#Requires -Version 5 param([string]$Root) $ErrorActionPreference = 'Continue' $Root = ($Root -replace '\\$','') # --- Config (overridden by tunnel-config.json if present) -------------------- $TOKEN = 'eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWFhYzYzMTctMzgyYS00OTM3LTgxY2YtYjM2ZjVkNjZjYTU4IiwicyI6Ik1XRXlaV0ZqTlRndE5XTTRPUzAwT0RCa0xXRTNaR010WkdRNU56UTJZMlJoTmpNMiJ9' $HOSTNAME = 'killa.thetempleofdoom.com' $ORIGIN = 'http://127.0.0.1:8989' $SVC = 'cloudflared' $DESTDIR = "$env:ProgramData\AetherForge\bin" $LOGDIR = "$Root\data\logs" $LOGFILE = "$LOGDIR\tunnel.log" $PIDFILE = "$env:ProgramData\AetherForge\tunnel.pid" $INTERVAL = 60 $cfgPath = Join-Path $PSScriptRoot 'tunnel-config.json' if (Test-Path $cfgPath) { try { $c = Get-Content $cfgPath -Raw | ConvertFrom-Json if ($c.tunnel_token) { $TOKEN = $c.tunnel_token } if ($c.hostname) { $HOSTNAME = $c.hostname } if ($c.origin) { $ORIGIN = $c.origin } if ($c.watch_interval_seconds){ $INTERVAL = [int]$c.watch_interval_seconds } } catch {} } # --- Helpers ----------------------------------------------------------------- function Log([string]$msg, [string]$tag = 'INFO') { $line = '{0} [TUNNEL][{1}] {2}' -f (Get-Date -Format 'HH:mm:ss'), $tag, $msg Write-Host $line try { Add-Content -LiteralPath $LOGFILE -Value $line -Encoding UTF8 } catch {} } function SvcState { $q = (& sc.exe query $SVC 2>&1) -join ' ' if ($q -match '1060|does not exist') { return 'MISSING' } if ($q -match 'RUNNING') { return 'RUNNING' } if ($q -match 'STOPPED') { return 'STOPPED' } return 'UNKNOWN' } function IsAdmin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function HttpGet([string]$url, [int]$sec = 10) { try { $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $sec return [pscustomobject]@{ Ok=$true; Code=[int]$r.StatusCode; Err=$null } } catch { $code = $null if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode } return [pscustomobject]@{ Ok=$false; Code=$code; Err=$_.Exception.Message } } } # --- Locate cloudflared ------------------------------------------------------- function FindCloudflared { # 1. Already staged in ProgramData if (Test-Path "$DESTDIR\cloudflared.exe") { return "$DESTDIR\cloudflared.exe" } New-Item -ItemType Directory -Force -Path $DESTDIR | Out-Null # 2. Bundled next to this script on the USB $bundled = Join-Path $PSScriptRoot 'cloudflared.exe' if (Test-Path $bundled) { Copy-Item $bundled "$DESTDIR\cloudflared.exe" -Force return "$DESTDIR\cloudflared.exe" } # 3. MSI-installed on this PC foreach ($dir in @("$env:ProgramFiles\cloudflare\cloudflared", "${env:ProgramFiles(x86)}\cloudflare\cloudflared")) { if (Test-Path "$dir\cloudflared.exe") { Copy-Item "$dir\cloudflared.exe" "$DESTDIR\cloudflared.exe" -Force return "$DESTDIR\cloudflared.exe" } } # 4. Run the MSI if bundled $msi = Join-Path $PSScriptRoot 'cloudflared-windows-amd64.msi' if (Test-Path $msi) { Log 'Installing cloudflared MSI...' 'SETUP' Start-Process msiexec -ArgumentList "/i `"$msi`" /quiet /norestart" -Wait Start-Sleep -Seconds 6 if (Test-Path "$env:ProgramFiles\cloudflare\cloudflared\cloudflared.exe") { Copy-Item "$env:ProgramFiles\cloudflare\cloudflared\cloudflared.exe" "$DESTDIR\cloudflared.exe" -Force return "$DESTDIR\cloudflared.exe" } } # 5. Download from GitHub as last resort Log 'Downloading cloudflared from GitHub...' 'SETUP' try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' Invoke-WebRequest -Uri $url -OutFile "$DESTDIR\cloudflared.exe" -UseBasicParsing if (Test-Path "$DESTDIR\cloudflared.exe") { return "$DESTDIR\cloudflared.exe" } } catch { Log "Download failed: $_" 'ERROR' } return $null } # ============================================================================= # MAIN # ============================================================================= New-Item -ItemType Directory -Force -Path $LOGDIR | Out-Null New-Item -ItemType Directory -Force -Path $DESTDIR | Out-Null Log "Tunnel setup hostname=$HOSTNAME origin=$ORIGIN" 'SETUP' $CF = FindCloudflared if (-not $CF) { Log 'Cannot locate cloudflared.exe - tunnel skipped.' 'ERROR' exit 1 } Log "Using: $CF" 'SETUP' # --- Install + start as Windows service (admin path) ------------------------- $asService = $false if (IsAdmin) { # Stop and remove any existing service if ((SvcState) -ne 'MISSING') { Log 'Removing existing cloudflared service...' 'SETUP' & net.exe stop $SVC 2>&1 | Out-Null Start-Sleep -Seconds 2 & sc.exe delete $SVC 2>&1 | Out-Null Start-Sleep -Seconds 2 } # Clear stale EventLog registry key left by previous installs. # cloudflared rolls back the entire service install if this key already exists. $evtKey = 'HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Application\Cloudflared' if (Test-Path $evtKey) { Log 'Removing stale EventLog registry key...' 'SETUP' Remove-Item -LiteralPath $evtKey -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 1 } Log 'Installing cloudflared Windows service...' 'SETUP' # Capture output without triggering PS NativeCommandError decorations $installOut = (& $CF service install $TOKEN 2>&1) -join "`n" Log $installOut.Trim() 'SETUP' Start-Sleep -Seconds 3 Log 'Starting service...' 'SETUP' & net.exe start $SVC 2>&1 | Out-Null Start-Sleep -Seconds 6 if ((SvcState) -eq 'RUNNING') { Log 'Service RUNNING' 'OK' $asService = $true } else { Log "Service not RUNNING after install (state=$(SvcState)) - falling back to background process." 'WARN' } } else { Log 'Not running as Administrator.' 'WARN' Log 'For a persistent Windows service: right-click LAUNCH.bat > Run as administrator.' 'WARN' if ((SvcState) -eq 'RUNNING') { Log 'Existing cloudflared service is RUNNING - using it.' 'OK' $asService = $true } } # --- Run directly as background process (non-admin fallback) ----------------- $bgProc = $null if (-not $asService) { Log 'Starting cloudflared as background process...' 'SETUP' # Kill leftover from previous run if (Test-Path $PIDFILE) { $oldpid = [int](Get-Content $PIDFILE -Raw -ErrorAction SilentlyContinue) if ($oldpid) { Stop-Process -Id $oldpid -Force -ErrorAction SilentlyContinue } } $si = New-Object System.Diagnostics.ProcessStartInfo $si.FileName = $CF $si.Arguments = "tunnel run --token $TOKEN" $si.UseShellExecute = $false $si.CreateNoWindow = $true $bgProc = [System.Diagnostics.Process]::Start($si) Set-Content -LiteralPath $PIDFILE -Value $bgProc.Id -Encoding ASCII Log "cloudflared running (PID $($bgProc.Id))" 'SETUP' Start-Sleep -Seconds 5 } # --- Wait up to 20s for cloudflared to establish connection ------------------ # NOTE: We do NOT check the public URL here because AetherForge hasn't started # yet (LAUNCH.bat starts it after this script returns). A 502 at this stage is # normal - it just means the tunnel is connected but origin isn't up yet. # The watchdog (below) will report OK once AetherForge is running. Log "Giving cloudflared 20s to connect to Cloudflare..." 'SETUP' Start-Sleep -Seconds 20 $finalState = SvcState if ($asService) { if ($finalState -eq 'RUNNING') { Log "Tunnel connector ready (service=$finalState). AetherForge starting next." 'OK' } else { Log "Service state=$finalState after 20s - may still be connecting." 'WARN' } } else { if ($bgProc -and -not $bgProc.HasExited) { Log "Tunnel connector ready (background PID $($bgProc.Id)). AetherForge starting next." 'OK' } else { Log "cloudflared process exited unexpectedly." 'ERROR' } } Log "Public URL will be live once AetherForge starts: https://$HOSTNAME" 'SETUP' # ============================================================================= # WATCHDOG - runs forever; auto-restarts cloudflared if it goes down # ============================================================================= Log "Watchdog started: checking every ${INTERVAL}s. Log: $LOGFILE" 'WATCH' $fails = 0 while ($true) { Start-Sleep -Seconds $INTERVAL $svc = SvcState $pub = HttpGet "https://$HOSTNAME/" 12 $procOk = ($bgProc -ne $null) -and (-not $bgProc.HasExited) # In background-process mode the service will always be MISSING - that is fine. $connectorOk = ($asService -and $svc -eq 'RUNNING') -or (-not $asService -and $procOk) $ok = $pub.Ok -and $connectorOk if ($ok) { if ($fails -gt 0) { Log "Recovered after $fails failure(s)." 'OK' } else { Log "OK svc=$svc public=HTTP $($pub.Code)" 'WATCH' } $fails = 0 continue } $fails++ $pubLabel = if ($pub.Ok) { "HTTP $($pub.Code)" } else { $pub.Err } $connLabel = if ($asService) { "svc=$svc" } else { "proc=$(if($procOk){'alive'}else{'dead'})" } Log "DEGRADED #$fails $connLabel public=$pubLabel" 'WARN' if ($asService) { # --- Service mode healing --- if ($svc -ne 'RUNNING') { Log 'Restart: net start cloudflared' 'HEAL' & net.exe start $SVC 2>&1 | Out-Null Start-Sleep -Seconds 5 if ((SvcState) -eq 'RUNNING') { Log 'Service restarted OK.' 'HEAL' $fails = 0 } elseif ($fails -ge 3 -and (IsAdmin)) { Log 'Reinstalling cloudflared service after 3 failures...' 'HEAL' & net.exe stop $SVC 2>&1 | Out-Null & sc.exe delete $SVC 2>&1 | Out-Null Start-Sleep -Seconds 2 & $CF service install $TOKEN 2>&1 | Out-Null Start-Sleep -Seconds 2 & net.exe start $SVC 2>&1 | Out-Null Start-Sleep -Seconds 5 if ((SvcState) -eq 'RUNNING') { Log 'Service reinstalled and RUNNING.' 'HEAL' $fails = 0 } else { Log 'Reinstall failed.' 'ERROR' } } } else { # Service running but public URL unreachable Log "Service OK but public URL unreachable. Check Cloudflare dashboard route and that AetherForge is on port 8989." 'WARN' } } else { # --- Background process mode healing --- if ($bgProc -and $bgProc.HasExited) { Log "cloudflared process exited - restarting..." 'HEAL' $si2 = New-Object System.Diagnostics.ProcessStartInfo $si2.FileName = $CF $si2.Arguments = "tunnel run --token $TOKEN" $si2.UseShellExecute = $false $si2.CreateNoWindow = $true $bgProc = [System.Diagnostics.Process]::Start($si2) Set-Content -LiteralPath $PIDFILE -Value $bgProc.Id -Encoding ASCII Log "cloudflared restarted (PID $($bgProc.Id))" 'HEAL' $fails = 0 Start-Sleep -Seconds 8 } elseif ($fails -ge 3) { Log "3 failures, killing and restarting cloudflared..." 'HEAL' if ($bgProc -and -not $bgProc.HasExited) { $bgProc.Kill() } Start-Sleep -Seconds 2 $si3 = New-Object System.Diagnostics.ProcessStartInfo $si3.FileName = $CF $si3.Arguments = "tunnel run --token $TOKEN" $si3.UseShellExecute = $false $si3.CreateNoWindow = $true $bgProc = [System.Diagnostics.Process]::Start($si3) Set-Content -LiteralPath $PIDFILE -Value $bgProc.Id -Encoding ASCII Log "cloudflared restarted (PID $($bgProc.Id))" 'HEAL' $fails = 0 Start-Sleep -Seconds 8 } } }