← Back to Files
start-kaichat.ps1
param(
[int]$HttpPort = 5200,
[int]$HttpsPort = 5201,
[int]$TailnetPort = 443,
[string]$BackendHost = '127.0.0.1',
[switch]$NoServe,
[switch]$SkipServeStart,
[switch]$KeepServe,
[switch]$EnableFunnel,
[switch]$DisableFunnel,
[switch]$KeepFunnel
)
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $PSCommandPath
$publishDir = Join-Path $scriptDir 'publish'
$serverDll = Join-Path $publishDir 'KaiChat.dll'
$projectPath = Join-Path $scriptDir 'KaiChat.csproj'
$baseRoot = (Resolve-Path (Join-Path $scriptDir '..')).ProviderPath
$certPath = Join-Path $baseRoot '.kaichat-lan.pfx'
$certScript = Join-Path $scriptDir 'new-lan-certificate.ps1'
function Write-Step
{
param([string]$Message, [string]$Color = 'White')
Write-Host "[KaiChat] $Message" -ForegroundColor $Color
}
function Fail([string]$message)
{
Write-Step $message Red
exit 1
}
function Invoke-TailscaleCommand
{
param([string[]]$Arguments)
$previousAction = $ErrorActionPreference
try
{
$ErrorActionPreference = 'Continue'
$output = & tailscale @Arguments 2>&1
return @{ ExitCode = $LASTEXITCODE; Output = $output }
}
finally
{
$ErrorActionPreference = $previousAction
}
}
function Get-TailscaleStatusJson
{
try
{
$result = Invoke-TailscaleCommand -Arguments @('status', '--json')
if ($result.ExitCode -ne 0) { return $null }
$statusText = [string]::Join('', $result.Output)
if ([string]::IsNullOrWhiteSpace($statusText)) { return $null }
return $statusText | ConvertFrom-Json
}
catch
{
return $null
}
}
function Get-TailnetDnsName
{
$json = Get-TailscaleStatusJson
if ($null -eq $json -or $null -eq $json.Self -or [string]::IsNullOrWhiteSpace($json.Self.DNSName))
{
$null
}
else
{
$json.Self.DNSName.TrimEnd('.')
}
}
function Get-HttpStatusCode([string]$Uri)
{
try
{
return [int](& curl.exe -s -o NUL -w "%{http_code}" -k --max-time 2 $Uri)
}
catch
{
return 0
}
}
function Get-TailscaleFunnelMode
{
$result = Invoke-TailscaleCommand -Arguments @('funnel', 'status')
$text = if ($null -eq $result.Output) { '' } else { [string]::Join("`n", $result.Output) }
if ([string]::IsNullOrWhiteSpace($text) -or $result.ExitCode -ne 0)
{
return 'unknown'
}
if ($text -match 'No serve config')
{
return 'none'
}
if ($text -match '\(tailnet only\)')
{
return 'tailnet-only'
}
if ($text -match 'https://')
{
return 'public'
}
return 'unknown'
}
function Wait-ForLocalEndpoint
{
param([string]$Uri, [int]$TimeoutSeconds = 30)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline)
{
if ((Get-HttpStatusCode -Uri $Uri) -eq 200)
{
return $true
}
Start-Sleep -Milliseconds 500
}
return $false
}
function Stop-KaiChatProcesses
{
try
{
$portOwners = Get-NetTCPConnection -State Listen -LocalPort $HttpPort -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique
foreach ($pid in @($portOwners))
{
try
{
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($null -ne $proc -and $proc.ProcessName -eq 'dotnet')
{
Write-Step "Stopping process $($proc.Id) because it is listening on port $HttpPort"
Stop-Process -Id $proc.Id -Force
}
}
catch
{
}
}
}
catch
{
}
try
{
$existing = Get-CimInstance Win32_Process -Filter "Name = 'dotnet.exe'" |
Where-Object {
$_.CommandLine -and
($_.CommandLine -match 'KaiChat\.dll' -or $_.CommandLine -match 'KaiChat\.csproj')
}
foreach ($proc in @($existing))
{
try
{
Write-Step "Stopping residual KaiChat process $($proc.ProcessId)"
Stop-Process -Id $proc.ProcessId -Force
}
catch
{
}
}
}
catch
{
}
Start-Sleep -Milliseconds 600
}
function Ensure-TailscaleService
{
param([int]$Port, [string]$Target)
# If a previous manual/tailnet launch created a foreground listener process,
# kill it so bg mapping can be reconfigured cleanly.
try
{
$stale = Get-CimInstance Win32_Process -Filter "Name = 'tailscale.exe'" |
Where-Object { $_.CommandLine -like "*serve*--https=$Port*" }
if ($null -ne $stale -and @($stale).Count -gt 0)
{
$staleCount = @($stale).Count
Write-Step "Stopping $staleCount stale foreground tailscale serve process(es) for port $Port."
@($stale) | ForEach-Object {
try { Stop-Process -Id $_.ProcessId -Force } catch {}
}
Start-Sleep -Milliseconds 500
}
}
catch
{
}
# Clear any stale foreground listener or prior mapping for this port before re-adding.
Try
{
$null = Invoke-TailscaleCommand -Arguments @('serve', "--https=$Port", 'off')
}
catch
{
}
$attempt = 1
while ($attempt -le 2)
{
$response = Invoke-TailscaleCommand -Arguments @('serve', '--bg', '--yes', "--https=$Port", $Target)
$code = $response.ExitCode
$result = $response.Output
if ($code -eq 0)
{
return $true
}
$text = ($result | Out-String).Trim()
if ($text -match 'already exists|foreground listener already exists|updating config')
{
Try { $null = Invoke-TailscaleCommand -Arguments @('serve', "--https=$Port", 'off') } catch {}
Start-Sleep -Milliseconds 750
$attempt++
continue
}
Write-Step "tailscale serve failed to bind port ${Port}: $text" Yellow
return $false
}
Write-Step "Unable to configure tailscale serve on port $Port after retry." Yellow
return $false
}
function Ensure-TailscaleFunnel
{
param([int]$Port, [string]$Target)
Write-Step "Configuring tailscale funnel: $Port -> $Target"
# If any funnel mapping exists, clear it and re-apply cleanly.
Try
{
$null = Invoke-TailscaleCommand -Arguments @('funnel', 'reset')
}
catch
{
}
$response = Invoke-TailscaleCommand -Arguments @('funnel', '--bg', '--yes', "--https=$Port", $Target)
$code = $response.ExitCode
if ($code -eq 0)
{
return $true
}
$text = ($response.Output | Out-String).Trim()
Write-Step "tailscale funnel failed to bind port ${Port}: $text" Yellow
return $false
}
function Wait-ForServerExitOrCancel([System.Diagnostics.Process]$Process)
{
while ($true)
{
if ($null -eq $Process)
{
Start-Sleep -Seconds 5
continue
}
if ($Process.HasExited)
{
Fail 'KaiChat backend stopped unexpectedly.'
}
Start-Sleep -Seconds 5
}
}
Write-Step '================================================'
Write-Step 'KaiChat Remote launcher'
Write-Step '================================================'
$effectiveEnableFunnel = -not $DisableFunnel.IsPresent
if ($EnableFunnel -and $DisableFunnel)
{
Write-Step 'Both -EnableFunnel and -DisableFunnel were set; defaulting to disabled for safety.' Yellow
$effectiveEnableFunnel = $false
}
if (-not (Test-Path $certPath))
{
Write-Step 'Local HTTPS certificate not found, generating one now...' Yellow
if (-not (Test-Path $certScript))
{
Fail 'Unable to find new-lan-certificate.ps1. Keep this helper in KaiChat directory.'
}
& powershell -NoProfile -ExecutionPolicy Bypass -File $certScript -TrustCurrentUser
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $certPath))
{
Fail 'Certificate generation failed or certificate still missing.'
}
}
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$targetUrl = "http://$BackendHost`:$HttpPort/api/app/version"
Stop-KaiChatProcesses
$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1
$env:ASPNETCORE_URLS = ''
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'dotnet'
$psi.Arguments = "run --project `"$projectPath`" --urls http://$BackendHost`:$HttpPort"
$psi.WorkingDirectory = $scriptDir
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $false
$psi.RedirectStandardOutput = $false
$psi.RedirectStandardError = $false
$server = [System.Diagnostics.Process]::Start($psi)
if ($null -eq $server)
{
Fail 'Failed to start KaiChat server process.'
}
Write-Step "Started KaiChat process: $($server.Id)"
if (-not (Wait-ForLocalEndpoint -Uri $targetUrl -TimeoutSeconds 90))
{
if (-not $server.HasExited)
{
$server.Kill()
}
Fail "KaiChat did not become ready at $targetUrl after 30 seconds."
}
if ($server.HasExited)
{
Fail 'KaiChat process exited during startup. Check build logs above.'
}
Write-Step "KaiChat is online at $targetUrl"
Write-Step "Local TLS cert present: $certPath"
Write-Step "Remote access should be available at https://<this-device>.<tailnet>.ts.net/"
if ($NoServe -or $SkipServeStart)
{
Write-Step 'Skipping tailscale serve start as requested. Keeping backend running.'
Write-Step 'Press Ctrl+C in this window to stop.' Yellow
try
{
Wait-ForServerExitOrCancel -Process $server
}
finally
{
if ($null -ne $server -and -not $server.HasExited)
{
$server.Kill()
}
}
return
}
$tailscale = Get-Command tailscale -ErrorAction SilentlyContinue
if ($null -eq $tailscale)
{
Write-Step 'tailscale executable not found on PATH. Keeping backend running only.' Yellow
Write-Step 'Press Ctrl+C in this window to stop.' Yellow
try
{
Wait-ForServerExitOrCancel -Process $server
}
finally
{
if ($null -ne $server -and -not $server.HasExited)
{
$server.Kill()
}
}
return
}
Write-Step "Configuring tailscale serve: $TailnetPort -> $BackendHost`:$HttpPort"
$serveTarget = "http://$BackendHost`:$HttpPort"
$serveStarted = Ensure-TailscaleService -Port $TailnetPort -Target $serveTarget
if (-not $serveStarted)
{
Write-Step 'Could not configure tailscale serve. Keeping local server alive; check this manually with: tailscale serve status' Yellow
Write-Step 'Press Ctrl+C in this window to stop.' Yellow
try
{
Wait-ForServerExitOrCancel -Process $server
}
finally
{
if ($null -ne $server -and -not $server.HasExited)
{
$server.Kill()
}
}
return
}
$funnelStarted = $false
if ($effectiveEnableFunnel)
{
$funnelStarted = Ensure-TailscaleFunnel -Port $TailnetPort -Target $serveTarget
if (-not $funnelStarted)
{
Write-Step 'Could not configure tailscale funnel. For WAN access, enable Funnel in the Tailscale admin console.' Yellow
Write-Step 'Visit: https://login.tailscale.com/f/funnel?node=' + $(Get-TailscaleStatusJson).Self.NodeID Yellow
Write-Step 'Then restart this launcher (default WAN is enabled).' Yellow
}
else
{
Write-Step "Public WAN URL: https://$($(Get-TailnetDnsName))"
}
}
Start-Sleep -Milliseconds 900
$tailscaleDns = Get-TailnetDnsName
if ([string]::IsNullOrWhiteSpace($tailscaleDns))
{
Write-Step 'Could not read tailnet DNS from status output. Check `tailscale status`.' Yellow
}
else
{
$tailUrl = "https://$tailscaleDns"
Write-Step "Tailnet URL: $tailUrl"
$funnelMode = Get-TailscaleFunnelMode
if ($funnelMode -eq 'tailnet-only')
{
Write-Step 'Tailnet exposure is active, but funnel is tailnet-only.' Yellow
Write-Step 'Mobile users on cellular/Wi-Fi without Tailscale will time out. Open the Tailscale app and verify they are on the same tailnet.' Yellow
Write-Step 'To enable direct internet access, enable Funnel in the Tailscale admin console and restart this launcher.' Yellow
}
elseif ($funnelMode -ne 'public')
{
Write-Step 'Could not confirm funnel exposure mode. Check this manually with: tailscale funnel status' Yellow
}
Write-Step 'Waiting for tailnet proxy probe...'
$proxyReady = $false
for ($i = 1; $i -le 25; $i++)
{
$code = Get-HttpStatusCode -Uri "$tailUrl/api/app/version"
if ($code -eq 200)
{
Write-Step "Tailnet proxy OK (HTTP $code)." Green
$proxyReady = $true
break
}
Write-Step "Tailnet probe attempt $i returned HTTP $code." Yellow
Start-Sleep -Milliseconds 800
}
if (-not $proxyReady)
{
Write-Step 'Proxy still not responding with HTTP 200 yet. If this continues, try:' Yellow
Write-Step " tailscale serve status" Yellow
Write-Step ' tailscale serve --https=443 off' Yellow
Write-Step ' Restart this launcher' Yellow
}
}
Write-Step 'Press Ctrl+C in this window to stop everything.'
try
{
Wait-ForServerExitOrCancel -Process $server
}
finally
{
if (-not $KeepServe)
{
Write-Step 'Removing tailscale serve mapping...' Yellow
Try
{
$null = Invoke-TailscaleCommand -Arguments @('serve', "--https=$TailnetPort", 'off')
}
catch
{
}
}
if ($effectiveEnableFunnel -and -not $KeepFunnel)
{
Write-Step 'Removing tailscale funnel mapping...' Yellow
Try
{
$null = Invoke-TailscaleCommand -Arguments @('funnel', 'reset')
}
catch
{
}
}
if ($null -ne $server -and -not $server.HasExited)
{
$server.Kill()
}
}