Size: 13.7 KB Modified: 6/07/2026 2:28 PM
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()
    }
}
Offline