feat(tunnel): add PowerShell script support for Windows tunnel setup

snowmerak committed Dec 20, 2025 at 23:59 UTC fa3768d7702bd58b3ccb8c7304fcc138cc18972b
1 file changed +81 -7
cmd/relay-server/tunnel.go
+81 -7
@@ -61,6 +61,56 @@ echo "Starting portal-tunnel..." >&2
61 exec "$@"
62 `
63
64 +const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
65 +
66 +$BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
67 +$RelayUrl = if ($env:RELAY_URL) { $env:RELAY_URL } else { $BaseUrl }
68 +
69 +$Arch = $env:PROCESSOR_ARCHITECTURE
70 +if ($Arch -eq "AMD64") {
71 + $TunnelArch = "amd64"
72 +} elseif ($Arch -eq "ARM64") {
73 + $TunnelArch = "arm64"
74 +} else {
75 + Write-Error "Unsupported architecture: $Arch"
76 + exit 1
77 +}
78 +
79 +$BinUrl = if ($env:BIN_URL) { $env:BIN_URL } else { "$BaseUrl/tunnel/bin/windows-$TunnelArch" }
80 +
81 +$WorkDir = Join-Path $env:TEMP ("portal-tunnel-" + [Guid]::NewGuid().ToString())
82 +New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
83 +$BinPath = Join-Path $WorkDir "portal-tunnel.exe"
84 +
85 +try {
86 + Write-Host "Downloading portal-tunnel (windows/$TunnelArch)..."
87 + Invoke-WebRequest -Uri $BinUrl -OutFile $BinPath
88 +} catch {
89 + Write-Error "Failed to download portal-tunnel: $_"
90 + Remove-Item -Recurse -Force $WorkDir
91 + exit 1
92 +}
93 +
94 +$ArgsList = @("--relay", $RelayUrl)
95 +
96 +if ($env:HOST) { $ArgsList += "--host", $env:HOST } else { $ArgsList += "--host", "localhost:3000" }
97 +if ($env:NAME) { $ArgsList += "--name", $env:NAME }
98 +if ($env:DESCRIPTION) { $ArgsList += "--description", $env:DESCRIPTION }
99 +if ($env:TAGS) { $ArgsList += "--tags", $env:TAGS }
100 +if ($env:THUMBNAIL) { $ArgsList += "--thumbnail", $env:THUMBNAIL }
101 +if ($env:OWNER) { $ArgsList += "--owner", $env:OWNER }
102 +if ($env:HIDE -eq "1" -or $env:HIDE -eq "true") { $ArgsList += "--hide" }
103 +
104 +Write-Host "Starting portal-tunnel..."
105 +try {
106 + & $BinPath $ArgsList
107 +} finally {
108 + if (Test-Path $WorkDir) {
109 + Remove-Item -Recurse -Force $WorkDir
110 + }
111 +}
112 +`
113 +
114 func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
115 utils.SetCORSHeaders(w)
116 if r.Method != http.MethodGet && r.Method != http.MethodHead {
@@ -69,11 +119,33 @@ func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
119 return
120 }
121
72 - script := fmt.Sprintf(tunnelScriptTemplate, flagPortalURL)
122 + targetOS := r.URL.Query().Get("os")
123 + isWindows := false
124 + if targetOS != "" {
125 + isWindows = strings.EqualFold(targetOS, "windows")
126 + } else {
127 + // Fallback: check User-Agent
128 + ua := strings.ToLower(r.UserAgent())
129 + isWindows = strings.Contains(ua, "windows")
130 + }
131 +
132 + var script string
133 + var contentType string
134 + var filename string
135 +
136 + if isWindows {
137 + script = fmt.Sprintf(tunnelPowerShellScriptTemplate, flagPortalURL)
138 + contentType = "text/plain" // or application/x-powershell
139 + filename = "tunnel.ps1"
140 + } else {
141 + script = fmt.Sprintf(tunnelScriptTemplate, flagPortalURL)
142 + contentType = "text/x-shellscript"
143 + filename = "tunnel.sh"
144 + }
145
74 - w.Header().Set("Content-Type", "text/x-shellscript")
146 + w.Header().Set("Content-Type", contentType)
147 w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
76 - w.Header().Set("Content-Disposition", "inline; filename=\"tunnel.sh\"")
148 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
149 w.WriteHeader(http.StatusOK)
150 if r.Method == http.MethodGet {
151 w.Write([]byte(script))
@@ -91,10 +163,12 @@ func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
163 slug := strings.TrimPrefix(r.URL.Path, "/tunnel/bin/")
164 slug = strings.Trim(slug, "/")
165 path, ok := map[string]string{
94 - "linux-amd64": "dist/tunnel/portal-tunnel-linux-amd64",
95 - "linux-arm64": "dist/tunnel/portal-tunnel-linux-arm64",
96 - "darwin-amd64": "dist/tunnel/portal-tunnel-darwin-amd64",
97 - "darwin-arm64": "dist/tunnel/portal-tunnel-darwin-arm64",
166 + "linux-amd64": "dist/tunnel/portal-tunnel-linux-amd64",
167 + "linux-arm64": "dist/tunnel/portal-tunnel-linux-arm64",
168 + "darwin-amd64": "dist/tunnel/portal-tunnel-darwin-amd64",
169 + "darwin-arm64": "dist/tunnel/portal-tunnel-darwin-arm64",
170 + "windows-amd64": "dist/tunnel/portal-tunnel-windows-amd64.exe",
171 + "windows-arm64": "dist/tunnel/portal-tunnel-windows-arm64.exe",
172 }[slug]
173 if !ok {
174 http.NotFound(w, r)