tunnel: implement official install script

Kim committed Mar 31, 2026 at 14:30 UTC 99a780b8974763a12ba980196515855983c24a8d
11 files changed +407 -275
.github/workflows/cd.yml
+3 -1
@@ -84,12 +84,14 @@ jobs:
84 make build-tunnel
85 mkdir -p release
86 cp cmd/relay-server/dist/tunnel/portal-* release/
87 + cp cmd/portal-tunnel/installer/install.sh release/install.sh
88 + cp cmd/portal-tunnel/installer/install.ps1 release/install.ps1
89
90 - name: Generate SHA256 checksums
91 shell: bash
92 run: |
93 cd release
92 - sha256sum * > checksums.txt
94 + sha256sum portal-* > checksums.txt
95 while read -r sum file; do
96 printf '%s %s\n' "$sum" "$file" > "${file}.sha256"
97 done < checksums.txt
README.md
+13 -1
@@ -57,7 +57,19 @@ For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
57
58 ### Expose Local Service via Tunnel
59
60 -For a local relay started with `docker compose up`:
60 +Install the tunnel from the official GitHub release assets:
61 +
62 +```bash
63 +curl -fsSL https://github.com/gosuda/portal/releases/latest/download/install.sh | bash
64 +portal expose 3000
65 +```
66 +
67 +```powershell
68 +irm https://github.com/gosuda/portal/releases/latest/download/install.ps1 | iex
69 +portal expose 3000
70 +```
71 +
72 +If you prefer a relay-local installer, or want the relay to provide the exact install command for its own host, use the relay installer instead:
73
74 ```bash
75 curl -ksSL https://localhost:4017/install.sh | bash
cmd/portal-tunnel/README.md
+16
@@ -4,6 +4,22 @@
4
5 ## Usage
6
7 +Install directly from the official GitHub release assets:
8 +
9 +```bash
10 +curl -fsSL https://github.com/gosuda/portal/releases/latest/download/install.sh | bash
11 +portal expose 3000
12 +portal list
13 +```
14 +
15 +```powershell
16 +irm https://github.com/gosuda/portal/releases/latest/download/install.ps1 | iex
17 +portal expose 3000
18 +portal list
19 +```
20 +
21 +If your relay publishes its own installer, you can use that instead:
22 +
23 ```bash
24 curl -sSL https://portal.example.com/install.sh | bash
25 portal expose 3000
cmd/portal-tunnel/installer/install.ps1 new
+77
@@ -0,0 +1,77 @@
1 +$ErrorActionPreference = "Stop"
2 +$BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "https://github.com/gosuda/portal/releases/latest/download" }
3 +$RelayUrl = if ($env:RELAY_URL) { $env:RELAY_URL } else { "https://your-relay.example.com" }
4 +$OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
5 +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
6 +$WorkDir = $null
7 +try {
8 + $Arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
9 + if ($Arch -eq "ARM64") {
10 + $PortalArch = "arm64"
11 + } elseif ($Arch -eq "AMD64" -or $Arch -eq "x86_64") {
12 + $PortalArch = "amd64"
13 + } else {
14 + throw "Unsupported architecture: $Arch"
15 + }
16 +
17 + $BinPathPrefix = if ($env:BIN_PATH_PREFIX) { $env:BIN_PATH_PREFIX.Trim() } else { "" }
18 + if ($env:BIN_URL) {
19 + $BinUrl = $env:BIN_URL
20 + } elseif ([string]::IsNullOrWhiteSpace($BinPathPrefix)) {
21 + $BinUrl = "$BaseUrl/portal-windows-$PortalArch.exe"
22 + } else {
23 + $BinUrl = "$BaseUrl/$BinPathPrefix/windows-$PortalArch"
24 + }
25 + $ChecksumUrl = if ($env:CHECKSUM_URL) { $env:CHECKSUM_URL } else { "$BinUrl.sha256" }
26 + $WorkDir = Join-Path $env:TEMP ("portal-install-" + [Guid]::NewGuid().ToString())
27 + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
28 + $BinPath = Join-Path $WorkDir "portal.exe"
29 +
30 + Write-Host "Downloading portal (windows/$PortalArch)..."
31 + Invoke-WebRequest -UseBasicParsing -Uri $BinUrl -OutFile $BinPath
32 +
33 + Write-Host "Verifying SHA256 checksum..."
34 + $ChecksumPayload = (Invoke-WebRequest -UseBasicParsing -Uri $ChecksumUrl).Content
35 + $ChecksumMatch = [regex]::Match($ChecksumPayload, '([A-Fa-f0-9]{64})')
36 + if (-not $ChecksumMatch.Success) {
37 + throw "Invalid checksum payload from $ChecksumUrl. Expected '<sha256> <filename>'."
38 + }
39 +
40 + $ExpectedHash = $ChecksumMatch.Groups[1].Value.ToLowerInvariant()
41 + $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $BinPath).Hash.ToLowerInvariant()
42 + if ($ActualHash -ne $ExpectedHash) {
43 + throw "Checksum mismatch for portal binary."
44 + }
45 +
46 + $InstallDir = Join-Path $env:LOCALAPPDATA "portal\bin"
47 + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
48 + $InstallPath = Join-Path $InstallDir "portal.exe"
49 + Copy-Item -Force $BinPath $InstallPath
50 + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
51 + $UserEntries = @()
52 + if (-not [string]::IsNullOrWhiteSpace($UserPath)) {
53 + $UserEntries = @($UserPath -split ';' | Where-Object { $_ -ne "" })
54 + }
55 + if (-not ($UserEntries -contains $InstallDir)) {
56 + $NewUserPath = if ([string]::IsNullOrWhiteSpace($UserPath)) {
57 + $InstallDir
58 + } else {
59 + "$InstallDir;$UserPath"
60 + }
61 + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
62 + }
63 +
64 + $SessionEntries = @($env:Path -split ';' | Where-Object { $_ -ne "" })
65 + if (-not ($SessionEntries -contains $InstallDir)) {
66 + $env:Path = "$InstallDir;$env:Path"
67 + }
68 +
69 + Write-Host "Installed portal to $InstallPath"
70 + Write-Host "Next step:"
71 + Write-Host " portal expose 3000 --relays $RelayUrl"
72 +} finally {
73 + [System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
74 + if ($WorkDir -and (Test-Path $WorkDir)) {
75 + Remove-Item -Recurse -Force $WorkDir
76 + }
77 +}
cmd/portal-tunnel/installer/install.sh new
+137
@@ -0,0 +1,137 @@
1 +#!/usr/bin/env sh
2 +set -eu
3 +
4 +OS="$(uname -s)"
5 +case "$OS" in
6 + Linux) PORTAL_OS="linux" ;;
7 + Darwin) PORTAL_OS="darwin" ;;
8 + *)
9 + echo "Unsupported OS: $OS" >&2
10 + exit 1
11 + ;;
12 +esac
13 +
14 +ARCH="$(uname -m)"
15 +case "$ARCH" in
16 + x86_64|amd64) PORTAL_ARCH="amd64" ;;
17 + arm64|aarch64) PORTAL_ARCH="arm64" ;;
18 + *)
19 + echo "Unsupported architecture: $ARCH" >&2
20 + exit 1
21 + ;;
22 +esac
23 +
24 +BASE_URL="${BASE_URL:-https://github.com/gosuda/portal/releases/latest/download}"
25 +BIN_PATH_PREFIX="${BIN_PATH_PREFIX:-}"
26 +BIN_SUFFIX="portal-$PORTAL_OS-$PORTAL_ARCH"
27 +if [ -n "$BIN_PATH_PREFIX" ]; then
28 + BIN_URL="${BIN_URL:-$BASE_URL/$BIN_PATH_PREFIX/$PORTAL_OS-$PORTAL_ARCH}"
29 +else
30 + BIN_URL="${BIN_URL:-$BASE_URL/$BIN_SUFFIX}"
31 +fi
32 +CHECKSUM_URL="${CHECKSUM_URL:-${BIN_URL}.sha256}"
33 +RELAY_URL="${RELAY_URL:-https://your-relay.example.com}"
34 +
35 +is_local_https_url() {
36 + case "$1" in
37 + https://localhost|https://localhost:*|https://127.0.0.1|https://127.0.0.1:*|https://[::1]|https://[::1]:*|https://*.localhost|https://*.localhost:*)
38 + return 0
39 + ;;
40 + esac
41 + return 1
42 +}
43 +
44 +download_url() {
45 + if is_local_https_url "$1"; then
46 + curl -k -fsSL "$1" -o "$2"
47 + return
48 + fi
49 + curl -fsSL "$1" -o "$2"
50 +}
51 +
52 +fetch_url() {
53 + if is_local_https_url "$1"; then
54 + curl -k -fsSL "$1"
55 + return
56 + fi
57 + curl -fsSL "$1"
58 +}
59 +
60 +TMPDIR="${TMPDIR:-/tmp}"
61 +WORKDIR="$(mktemp -d "$TMPDIR/portal-install.XXXXXX" 2>/dev/null || mktemp -d -t portal-install)"
62 +BIN_PATH="$WORKDIR/portal"
63 +cleanup() { rm -rf "$WORKDIR"; }
64 +trap cleanup EXIT INT TERM
65 +
66 +echo "Downloading portal ($PORTAL_OS/$PORTAL_ARCH)..." >&2
67 +download_url "$BIN_URL" "$BIN_PATH"
68 +
69 +echo "Verifying SHA256 checksum..." >&2
70 +CHECKSUM_PAYLOAD="$(fetch_url "$CHECKSUM_URL")" || {
71 + echo "Failed to download checksum from $CHECKSUM_URL. Aborting (fail-closed)." >&2
72 + exit 1
73 +}
74 +
75 +EXPECTED_SHA="$(printf '%s\n' "$CHECKSUM_PAYLOAD" | awk '{print $1}' | tr 'A-Z' 'a-z')"
76 +if ! printf '%s\n' "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{64}$'; then
77 + echo "Invalid checksum payload from $CHECKSUM_URL. Aborting (fail-closed)." >&2
78 + echo "Hint: expected SHA256 sidecar format '<sha256> <filename>'." >&2
79 + exit 1
80 +fi
81 +
82 +if command -v sha256sum >/dev/null 2>&1; then
83 + ACTUAL_SHA="$(sha256sum "$BIN_PATH" | awk '{print $1}')"
84 +elif command -v shasum >/dev/null 2>&1; then
85 + ACTUAL_SHA="$(shasum -a 256 "$BIN_PATH" | awk '{print $1}')"
86 +else
87 + echo "No SHA256 checksum tool found (need sha256sum or shasum)." >&2
88 + exit 1
89 +fi
90 +if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
91 + echo "Checksum mismatch for portal binary. Aborting (fail-closed)." >&2
92 + exit 1
93 +fi
94 +
95 +pick_install_path() {
96 + EXISTING="$(command -v portal 2>/dev/null || true)"
97 + if [ -n "$EXISTING" ]; then
98 + EXISTING_DIR="$(dirname "$EXISTING")"
99 + if [ -d "$EXISTING_DIR" ] && [ -w "$EXISTING_DIR" ]; then
100 + printf '%s\n' "$EXISTING"
101 + return 0
102 + fi
103 + fi
104 +
105 + if [ -n "${HOME:-}" ]; then
106 + for DIR in "$HOME/.local/bin" "$HOME/bin"; do
107 + mkdir -p "$DIR" 2>/dev/null || true
108 + if [ -d "$DIR" ] && [ -w "$DIR" ]; then
109 + printf '%s\n' "$DIR/portal"
110 + return 0
111 + fi
112 + done
113 + fi
114 +
115 + return 1
116 +}
117 +
118 +INSTALL_PATH="$(pick_install_path)" || {
119 + echo "No writable install directory found. Ensure an existing portal install is writable or create \$HOME/.local/bin or \$HOME/bin." >&2
120 + exit 1
121 +}
122 +
123 +cp "$BIN_PATH" "$INSTALL_PATH"
124 +chmod +x "$INSTALL_PATH"
125 +
126 +echo "Installed portal to $INSTALL_PATH" >&2
127 +
128 +INSTALL_DIR="$(dirname "$INSTALL_PATH")"
129 +case ":$PATH:" in
130 + *":$INSTALL_DIR:"*) ;;
131 + *)
132 + echo "Warning: $INSTALL_DIR is not on PATH. Add it before running 'portal expose 3000'." >&2
133 + ;;
134 +esac
135 +
136 +echo "Next step:" >&2
137 +echo " portal expose 3000 --relays $RELAY_URL" >&2
cmd/portal-tunnel/installer/installer.go new
+95
@@ -0,0 +1,95 @@
1 +package installer
2 +
3 +import (
4 + _ "embed"
5 + "errors"
6 + "strings"
7 +)
8 +
9 +const officialReleaseBaseURL = "https://github.com/gosuda/portal/releases/latest/download"
10 +
11 +//go:embed install.sh
12 +var installShellScript string
13 +
14 +//go:embed install.ps1
15 +var installPowerShellScript string
16 +
17 +func RelayScript(portalURL string, isWindows bool) (script, filename, contentType string, err error) {
18 + portalURL = strings.TrimSpace(portalURL)
19 + if portalURL == "" {
20 + return "", "", "", errors.New("portal url is required")
21 + }
22 +
23 + script, filename, contentType = scriptFor(isWindows)
24 + if isWindows {
25 + return relayPowerShellScript(portalURL, script), filename, contentType, nil
26 + }
27 + return relayShellScript(portalURL, script), filename, contentType, nil
28 +}
29 +
30 +func scriptFor(isWindows bool) (script, filename, contentType string) {
31 + if isWindows {
32 + return installPowerShellScript, "install.ps1", "text/plain; charset=utf-8"
33 + }
34 + return installShellScript, "install.sh", "text/x-shellscript"
35 +}
36 +
37 +func AssetFilename(slug string) (string, bool) {
38 + switch strings.TrimSpace(slug) {
39 + case "linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64":
40 + return "portal-" + slug, true
41 + case "windows-amd64", "windows-arm64":
42 + return "portal-" + slug + ".exe", true
43 + default:
44 + return "", false
45 + }
46 +}
47 +
48 +func OfficialAssetURL(slug string, checksum bool) (string, bool) {
49 + filename, ok := AssetFilename(slug)
50 + if !ok {
51 + return "", false
52 + }
53 + url := officialReleaseBaseURL + "/" + filename
54 + if checksum {
55 + url += ".sha256"
56 + }
57 + return url, true
58 +}
59 +
60 +func relayShellScript(portalURL, script string) string {
61 + overrides := strings.Join([]string{
62 + "BASE_URL=" + quoteShellValue(portalURL),
63 + "RELAY_URL=" + quoteShellValue(portalURL),
64 + "BIN_PATH_PREFIX='install/bin'",
65 + "",
66 + }, "\n")
67 + return insertAfterShebang(script, overrides)
68 +}
69 +
70 +func relayPowerShellScript(portalURL, script string) string {
71 + overrides := strings.Join([]string{
72 + "$env:BASE_URL = " + quotePowerShellValue(portalURL),
73 + "$env:RELAY_URL = " + quotePowerShellValue(portalURL),
74 + "$env:BIN_PATH_PREFIX = 'install/bin'",
75 + "",
76 + }, "\n")
77 + return overrides + script
78 +}
79 +
80 +func insertAfterShebang(script, prefix string) string {
81 + if strings.HasPrefix(script, "#!") {
82 + if newline := strings.IndexByte(script, '\n'); newline >= 0 {
83 + return script[:newline+1] + prefix + script[newline+1:]
84 + }
85 + }
86 + return prefix + script
87 +}
88 +
89 +func quoteShellValue(value string) string {
90 + return "'" + strings.ReplaceAll(value, "'", `'\"'\"'`) + "'"
91 +}
92 +
93 +func quotePowerShellValue(value string) string {
94 + return "'" + strings.ReplaceAll(value, "'", "''") + "'"
95 +}
cmd/relay-server/tunnel.go
+16 -218
@@ -7,199 +7,10 @@ import (
7 "net/http"
8 "strings"
9
10 + "github.com/gosuda/portal/v2/cmd/portal-tunnel/installer"
11 "github.com/gosuda/portal/v2/types"
12 )
13
13 -const installShellScriptTemplate = `#!/usr/bin/env sh
14 -set -eu
15 -
16 -OS="$(uname -s)"
17 -case "$OS" in
18 - Linux) PORTAL_OS="linux" ;;
19 - Darwin) PORTAL_OS="darwin" ;;
20 - *)
21 - echo "Unsupported OS: $OS" >&2
22 - exit 1
23 - ;;
24 -esac
25 -
26 -ARCH="$(uname -m)"
27 -case "$ARCH" in
28 - x86_64|amd64) PORTAL_ARCH="amd64" ;;
29 - arm64|aarch64) PORTAL_ARCH="arm64" ;;
30 - *)
31 - echo "Unsupported architecture: $ARCH" >&2
32 - exit 1
33 - ;;
34 -esac
35 -
36 -BASE_URL="${BASE_URL:-}"
37 -if [ -z "$BASE_URL" ]; then
38 - BASE_URL=%s
39 -fi
40 -BIN_URL="${BIN_URL:-$BASE_URL/install/bin/$PORTAL_OS-$PORTAL_ARCH}"
41 -CHECKSUM_URL="${BIN_URL}.sha256"
42 -CURL_INSECURE_FLAG=""
43 -
44 -case "$BASE_URL" in
45 - https://localhost|https://localhost:*|https://127.0.0.1|https://127.0.0.1:*|https://[::1]|https://[::1]:*|https://*.localhost|https://*.localhost:*)
46 - CURL_INSECURE_FLAG="-k"
47 - ;;
48 -esac
49 -
50 -TMPDIR="${TMPDIR:-/tmp}"
51 -WORKDIR="$(mktemp -d "$TMPDIR/portal-install.XXXXXX" 2>/dev/null || mktemp -d -t portal-install)"
52 -BIN_PATH="$WORKDIR/portal"
53 -cleanup() { rm -rf "$WORKDIR"; }
54 -trap cleanup EXIT INT TERM
55 -
56 -echo "Downloading portal ($PORTAL_OS/$PORTAL_ARCH)..." >&2
57 -curl $CURL_INSECURE_FLAG -fsSL "$BIN_URL" -o "$BIN_PATH"
58 -
59 -echo "Verifying SHA256 checksum..." >&2
60 -CHECKSUM_PAYLOAD="$(curl $CURL_INSECURE_FLAG -fsSL "$CHECKSUM_URL")" || {
61 - echo "Failed to download checksum from $CHECKSUM_URL. Aborting (fail-closed)." >&2
62 - echo "Hint: verify relay artifact publishing or CDN cache freshness." >&2
63 - exit 1
64 -}
65 -
66 -EXPECTED_SHA="$(printf '%%s\n' "$CHECKSUM_PAYLOAD" | awk '{print $1}' | tr 'A-Z' 'a-z')"
67 -if ! printf '%%s\n' "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{64}$'; then
68 - echo "Invalid checksum payload from $CHECKSUM_URL. Aborting (fail-closed)." >&2
69 - echo "Hint: expected SHA256 sidecar format '<sha256> <filename>'." >&2
70 - exit 1
71 -fi
72 -
73 -if command -v sha256sum >/dev/null 2>&1; then
74 - ACTUAL_SHA="$(sha256sum "$BIN_PATH" | awk '{print $1}')"
75 -elif command -v shasum >/dev/null 2>&1; then
76 - ACTUAL_SHA="$(shasum -a 256 "$BIN_PATH" | awk '{print $1}')"
77 -else
78 - echo "No SHA256 checksum tool found (need sha256sum or shasum)." >&2
79 - exit 1
80 -fi
81 -if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
82 - echo "Checksum mismatch for portal binary. Aborting (fail-closed)." >&2
83 - echo "Hint: relay artifact and checksum may be out of sync or cached stale." >&2
84 - exit 1
85 -fi
86 -
87 -pick_install_path() {
88 - EXISTING="$(command -v portal 2>/dev/null || true)"
89 - if [ -n "$EXISTING" ]; then
90 - EXISTING_DIR="$(dirname "$EXISTING")"
91 - if [ -d "$EXISTING_DIR" ] && [ -w "$EXISTING_DIR" ]; then
92 - printf '%%s\n' "$EXISTING"
93 - return 0
94 - fi
95 - fi
96 -
97 - if [ -n "${HOME:-}" ]; then
98 - for DIR in "$HOME/.local/bin" "$HOME/bin"; do
99 - mkdir -p "$DIR" 2>/dev/null || true
100 - if [ -d "$DIR" ] && [ -w "$DIR" ]; then
101 - printf '%%s\n' "$DIR/portal"
102 - return 0
103 - fi
104 - done
105 - fi
106 -
107 - return 1
108 -}
109 -
110 -INSTALL_PATH="$(pick_install_path)" || {
111 - echo "No writable install directory found. Ensure an existing portal install is writable or create \$HOME/.local/bin or \$HOME/bin." >&2
112 - exit 1
113 -}
114 -
115 -cp "$BIN_PATH" "$INSTALL_PATH"
116 -chmod +x "$INSTALL_PATH"
117 -
118 -echo "Installed portal to $INSTALL_PATH" >&2
119 -
120 -INSTALL_DIR="$(dirname "$INSTALL_PATH")"
121 -case ":$PATH:" in
122 - *":$INSTALL_DIR:"*) ;;
123 - *)
124 - echo "Warning: $INSTALL_DIR is not on PATH. Add it before running 'portal expose 3000'." >&2
125 - ;;
126 -esac
127 -
128 -echo "Next step:" >&2
129 -echo " portal expose 3000 --relays $BASE_URL" >&2
130 -`
131 -
132 -const installPowerShellTemplate = `$ErrorActionPreference = "Stop"
133 -$BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { %s }
134 -$OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
135 -[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
136 -$WorkDir = $null
137 -try {
138 - $Arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
139 - if ($Arch -eq "ARM64") {
140 - $PortalArch = "arm64"
141 - } elseif ($Arch -eq "AMD64" -or $Arch -eq "x86_64") {
142 - $PortalArch = "amd64"
143 - } else {
144 - throw "Unsupported architecture: $Arch"
145 - }
146 -
147 - $BinUrl = if ($env:BIN_URL) { $env:BIN_URL } else { "$BaseUrl/install/bin/windows-$PortalArch" }
148 - $ChecksumUrl = "$BinUrl.sha256"
149 - $WorkDir = Join-Path $env:TEMP ("portal-install-" + [Guid]::NewGuid().ToString())
150 - New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
151 - $BinPath = Join-Path $WorkDir "portal.exe"
152 -
153 - Write-Host "Downloading portal (windows/$PortalArch)..."
154 - Invoke-WebRequest -UseBasicParsing -Uri $BinUrl -OutFile $BinPath
155 -
156 - Write-Host "Verifying SHA256 checksum..."
157 - $ChecksumPayload = (Invoke-WebRequest -UseBasicParsing -Uri $ChecksumUrl).Content
158 - $ChecksumMatch = [regex]::Match($ChecksumPayload, '([A-Fa-f0-9]{64})')
159 - if (-not $ChecksumMatch.Success) {
160 - throw "Invalid checksum payload from $ChecksumUrl. Expected '<sha256> <filename>'."
161 - }
162 -
163 - $ExpectedHash = $ChecksumMatch.Groups[1].Value.ToLowerInvariant()
164 - $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $BinPath).Hash.ToLowerInvariant()
165 - if ($ActualHash -ne $ExpectedHash) {
166 - throw "Checksum mismatch for portal binary."
167 - }
168 -
169 - $InstallDir = Join-Path $env:LOCALAPPDATA "portal\bin"
170 - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
171 - $InstallPath = Join-Path $InstallDir "portal.exe"
172 - Copy-Item -Force $BinPath $InstallPath
173 - $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
174 - $UserEntries = @()
175 - if (-not [string]::IsNullOrWhiteSpace($UserPath)) {
176 - $UserEntries = @($UserPath -split ';' | Where-Object { $_ -ne "" })
177 - }
178 - if (-not ($UserEntries -contains $InstallDir)) {
179 - $NewUserPath = if ([string]::IsNullOrWhiteSpace($UserPath)) {
180 - $InstallDir
181 - } else {
182 - "$InstallDir;$UserPath"
183 - }
184 - [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
185 - }
186 -
187 - $SessionEntries = @($env:Path -split ';' | Where-Object { $_ -ne "" })
188 - if (-not ($SessionEntries -contains $InstallDir)) {
189 - $env:Path = "$InstallDir;$env:Path"
190 - }
191 -
192 - Write-Host "Installed portal to $InstallPath"
193 - Write-Host "Next step:"
194 - Write-Host " portal expose 3000 --relays $BaseUrl"
195 -} finally {
196 - [System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
197 - if ($WorkDir -and (Test-Path $WorkDir)) {
198 - Remove-Item -Recurse -Force $WorkDir
199 - }
200 -}
201 -`
202 -
14 func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
15 if r.Method != http.MethodGet && r.Method != http.MethodHead {
16 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
@@ -213,11 +24,21 @@ func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
24 slug = strings.TrimSuffix(slug, ".sha256")
25 }
26
216 - data, filename, ok := installBinaryBySlug(slug)
27 + filename, ok := installer.AssetFilename(slug)
28 if !ok {
29 http.NotFound(w, r)
30 return
31 }
32 + data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename)
33 + if err != nil {
34 + redirectURL, ok := installer.OfficialAssetURL(slug, checksumRequest)
35 + if !ok {
36 + http.NotFound(w, r)
37 + return
38 + }
39 + http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
40 + return
41 + }
42 sum := sha256.Sum256(data)
43 checksumHex := hex.EncodeToString(sum[:])
44
@@ -237,17 +58,6 @@ func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
58 }
59 }
60
240 -func installBinaryBySlug(slug string) ([]byte, string, bool) {
241 - filename := "portal-" + slug
242 - if strings.HasPrefix(slug, "windows-") {
243 - filename += ".exe"
244 - }
245 - if data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename); err == nil {
246 - return data, filename, true
247 - }
248 - return nil, "", false
249 -}
250 -
61 func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string, isWindows bool) {
62 if r.Method != http.MethodGet && r.Method != http.MethodHead {
63 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
@@ -255,12 +65,10 @@ func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string
65 return
66 }
67
258 - script := buildInstallScript(portalURL, isWindows)
259 - contentType := "text/x-shellscript"
260 - filename := "install.sh"
261 - if isWindows {
262 - contentType = "text/plain; charset=utf-8"
263 - filename = "install.ps1"
68 + script, filename, contentType, err := installer.RelayScript(portalURL, isWindows)
69 + if err != nil {
70 + http.Error(w, "failed to render install script", http.StatusInternalServerError)
71 + return
72 }
73
74 w.Header().Set("Content-Type", contentType)
@@ -269,13 +77,3 @@ func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string
77 _, _ = w.Write([]byte(script))
78 }
79 }
272 -
273 -func buildInstallScript(portalURL string, isWindows bool) string {
274 - if !isWindows {
275 - quotedPortalURL := "'" + strings.ReplaceAll(portalURL, "'", `'"'"'`) + "'"
276 - return fmt.Sprintf(installShellScriptTemplate, quotedPortalURL)
277 - }
278 -
279 - quotedPortalURL := "'" + strings.ReplaceAll(portalURL, "'", "''") + "'"
280 - return fmt.Sprintf(installPowerShellTemplate, quotedPortalURL)
281 -}
extensions/vscode/CHANGELOG.md
+1 -1
@@ -6,7 +6,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
6
7 ## [0.0.3]
8
9 -- Download the tunnel binary directly from the latest GitHub release assets before execution
9 +- Run the latest GitHub release installer script before execution
10
11 ## [0.0.2]
12
extensions/vscode/README.md
+4 -4
@@ -8,14 +8,14 @@ Expose your local service to the internet via a [Portal](https://github.com/gosu
8 - `Portal: Start Tunnel (Advanced)` prompts for host, optional service name, relay source, and optional thumbnail
9 - `Portal: Stop Tunnel` stops the active tunnel terminal
10 - Persisted settings for relay URLs, default local host, and default service name
11 -- Downloads the matching Portal tunnel binary from the latest GitHub release assets before starting the tunnel
11 +- Runs the latest Portal release installer script before starting the tunnel
12 - When no relay URL is configured, the extension can use the public registry at `https://raw.githubusercontent.com/gosuda/portal/main/registry.json`
13
14 ## Requirements
15
16 - A running [Portal relay server](https://github.com/gosuda/portal) with an `https://` URL
17 - `curl` on macOS/Linux, PowerShell on Windows
18 -- GitHub release asset access for `https://github.com/gosuda/portal/releases/latest/download/...`
18 +- GitHub release asset access for `https://github.com/gosuda/portal/releases/latest/download/install.sh` or `install.ps1`
19
20 ## Settings
21
@@ -44,7 +44,7 @@ Example `settings.json`:
44
45 Use `Portal: Start Tunnel (Advanced)` when you need a different host, custom relay selection, or a thumbnail URL.
46
47 -The extension downloads the platform-specific tunnel binary from the latest GitHub release assets and then runs `portal expose ...` with the selected relay settings.
47 +The extension runs the platform-appropriate installer script from the latest GitHub release assets and then runs `portal expose ...` with the selected relay settings.
48
49 To stop, run `Portal: Stop Tunnel` or close the `Portal Tunnel` terminal.
50
@@ -70,7 +70,7 @@ If you want Linux behavior from WSL, open the folder with `Remote - WSL` first s
70
71 ### 0.0.3
72
73 -- Download the tunnel binary directly from the latest GitHub release assets before execution
73 +- Run the latest GitHub release installer script before execution
74
75 ### 0.0.2
76
extensions/vscode/src/command.ts
+26 -33
@@ -10,7 +10,7 @@ export interface TunnelCommandOptions {
10 name: string;
11 relayList: string;
12 thumbnail: string;
13 - tunnelBinaryURL?: string;
13 + tunnelInstallerURL?: string;
14 }
15
16 export interface TunnelCommandRuntime {
@@ -51,27 +51,20 @@ export function defaultTunnelCommandRuntime(
51 };
52 }
53
54 -export function resolveTunnelBinaryURL(
54 +export function resolveTunnelInstallerURL(
55 platform = os.platform(),
56 arch = os.arch()
57 ): string | undefined {
58 - const platformName =
59 - platform === "darwin" || platform === "linux"
60 - ? platform
61 - : platform === "win32"
62 - ? "windows"
63 - : undefined;
64 - const archName =
65 - arch === "x64"
66 - ? "amd64"
67 - : arch === "arm64"
68 - ? "arm64"
69 - : undefined;
70 - if (!platformName || !archName) {
58 + if (arch !== "x64" && arch !== "arm64") {
59 return undefined;
60 }
73 - const extension = platformName === "windows" ? ".exe" : "";
74 - return `${defaultTunnelDownloadBaseURL}/portal-${platformName}-${archName}${extension}`;
61 + if (platform === "darwin" || platform === "linux") {
62 + return `${defaultTunnelDownloadBaseURL}/install.sh`;
63 + }
64 + if (platform === "win32") {
65 + return `${defaultTunnelDownloadBaseURL}/install.ps1`;
66 + }
67 + return undefined;
68 }
69
70 export function buildCommand(
@@ -80,10 +73,10 @@ export function buildCommand(
73 ): string {
74 const { host, name, relayList, thumbnail } = opts;
75 const target = runtime.shellTarget;
83 - const tunnelBinaryURL =
84 - opts.tunnelBinaryURL?.trim() ||
85 - resolveTunnelBinaryURL(runtime.platform, runtime.arch);
86 - if (!tunnelBinaryURL) {
76 + const tunnelInstallerURL =
77 + opts.tunnelInstallerURL?.trim() ||
78 + resolveTunnelInstallerURL(runtime.platform, runtime.arch);
79 + if (!tunnelInstallerURL) {
80 throw new Error(
81 `Unsupported platform ${runtime.platform}/${runtime.arch}. Portal supports macOS, Linux, and Windows on x64 or arm64.`
82 );
@@ -106,24 +99,24 @@ export function buildCommand(
99 if (target === "windows") {
100 const commandLines = [
101 `$ProgressPreference = 'SilentlyContinue'`,
109 - `$PortalDir = Join-Path $env:LOCALAPPDATA 'portal\\bin'`,
110 - `$PortalBin = Join-Path $PortalDir 'portal.exe'`,
111 - `$PortalTmp = Join-Path $PortalDir 'portal.exe.download'`,
112 - `New-Item -ItemType Directory -Force -Path $PortalDir | Out-Null`,
113 - `Invoke-WebRequest -Uri ${formatToken(tunnelBinaryURL, target)} -OutFile $PortalTmp`,
114 - `Move-Item -Force $PortalTmp $PortalBin`,
102 + `irm ${formatToken(tunnelInstallerURL, target)} | iex`,
103 + `$PortalBin = Join-Path $env:LOCALAPPDATA 'portal\\bin\\portal.exe'`,
104 + `if (-not (Test-Path $PortalBin)) { throw 'Portal install failed: portal.exe not found.' }`,
105 ];
106 commandLines.push(`& $PortalBin ${exposeCommand}`);
107 return commandLines.join("\n");
108 }
109
110 const commandLines = [
121 - `PORTAL_BIN="$HOME/.local/bin/portal"`,
122 - `PORTAL_TMP="$PORTAL_BIN.download"`,
123 - `mkdir -p "$(dirname "$PORTAL_BIN")"`,
124 - `curl -fsSL ${formatToken(tunnelBinaryURL, target)} -o "$PORTAL_TMP"`,
125 - `chmod +x "$PORTAL_TMP"`,
126 - `mv "$PORTAL_TMP" "$PORTAL_BIN"`,
111 + `set -e`,
112 + `PORTAL_INSTALLER="$(mktemp "${"$"}{TMPDIR:-/tmp}/portal-install.XXXXXX" 2>/dev/null || mktemp -t portal-install)"`,
113 + `curl -fsSL ${formatToken(tunnelInstallerURL, target)} -o "$PORTAL_INSTALLER"`,
114 + `sh "$PORTAL_INSTALLER"`,
115 + `rm -f "$PORTAL_INSTALLER"`,
116 + `PORTAL_BIN="$(command -v portal 2>/dev/null || true)"`,
117 + `if [ -z "$PORTAL_BIN" ] && [ -x "$HOME/.local/bin/portal" ]; then PORTAL_BIN="$HOME/.local/bin/portal"; fi`,
118 + `if [ -z "$PORTAL_BIN" ] && [ -x "$HOME/bin/portal" ]; then PORTAL_BIN="$HOME/bin/portal"; fi`,
119 + `if [ -z "$PORTAL_BIN" ]; then echo "Portal install failed: portal executable not found." >&2; exit 1; fi`,
120 `"${"$"}PORTAL_BIN" ${exposeCommand}`,
121 ];
122 return commandLines.join("\n");
extensions/vscode/src/test/extension.test.ts
+19 -17
@@ -2,7 +2,7 @@ import * as assert from "assert";
2
3 import {
4 buildCommand,
5 - resolveTunnelBinaryURL,
5 + resolveTunnelInstallerURL,
6 validateRelayUrl,
7 } from "../command";
8
@@ -13,33 +13,35 @@ suite("Extension Test Suite", () => {
13 assert.strictEqual(validateRelayUrl("not-a-url"), "Enter a valid https:// URL");
14 });
15
16 - test("resolveTunnelBinaryURL maps darwin amd64 assets to GitHub releases", () => {
16 + test("resolveTunnelInstallerURL maps supported platforms to release installers", () => {
17 assert.strictEqual(
18 - resolveTunnelBinaryURL("darwin", "x64"),
19 - "https://github.com/gosuda/portal/releases/latest/download/portal-darwin-amd64"
18 + resolveTunnelInstallerURL("darwin", "x64"),
19 + "https://github.com/gosuda/portal/releases/latest/download/install.sh"
20 );
21 assert.strictEqual(
22 - resolveTunnelBinaryURL("win32", "arm64"),
23 - "https://github.com/gosuda/portal/releases/latest/download/portal-windows-arm64.exe"
22 + resolveTunnelInstallerURL("win32", "arm64"),
23 + "https://github.com/gosuda/portal/releases/latest/download/install.ps1"
24 );
25 - assert.strictEqual(resolveTunnelBinaryURL("freebsd", "x64"), undefined);
25 + assert.strictEqual(resolveTunnelInstallerURL("linux", "ia32"), undefined);
26 + assert.strictEqual(resolveTunnelInstallerURL("freebsd", "x64"), undefined);
27 });
28
28 - test("buildCommand omits --name when empty and downloads the unix tunnel binary directly", () => {
29 + test("buildCommand omits --name when empty and runs the unix release installer", () => {
30 const command = buildCommand({
31 host: "localhost:3000",
32 name: "",
33 relayList: "https://relay.example.com",
34 thumbnail: "",
34 - tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-linux-amd64",
35 + tunnelInstallerURL: "https://github.com/gosuda/portal/releases/latest/download/install.sh",
36 }, {
37 shellTarget: "unix",
38 platform: "linux",
39 arch: "x64",
40 });
41
41 - assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-linux-amd64 -o "\$PORTAL_TMP"/);
42 - assert.match(command, /PORTAL_BIN="\$HOME\/\.local\/bin\/portal"/);
42 + assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/install\.sh -o "\$PORTAL_INSTALLER"/);
43 + assert.match(command, /sh "\$PORTAL_INSTALLER"/);
44 + assert.match(command, /PORTAL_BIN="\$\(command -v portal 2>\/dev\/null \|\| true\)"/);
45 assert.match(command, /"\$PORTAL_BIN" expose localhost:3000 --relays https:\/\/relay\.example\.com/);
46 assert.ok(!command.includes("--name"));
47 });
@@ -50,34 +52,34 @@ suite("Extension Test Suite", () => {
52 name: "",
53 relayList: "",
54 thumbnail: "",
53 - tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-linux-amd64",
55 + tunnelInstallerURL: "https://github.com/gosuda/portal/releases/latest/download/install.sh",
56 }, {
57 shellTarget: "unix",
58 platform: "linux",
59 arch: "x64",
60 });
61
60 - assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-linux-amd64 -o "\$PORTAL_TMP"/);
62 + assert.match(command, /curl -fsSL https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/install\.sh -o "\$PORTAL_INSTALLER"/);
63 assert.ok(!command.includes("--relays"));
64 assert.ok(!command.includes("--name"));
65 assert.match(command, /"\$PORTAL_BIN" expose localhost:3000/);
66 });
67
66 - test("buildCommand downloads portal.exe on windows", () => {
68 + test("buildCommand runs the PowerShell release installer on windows", () => {
69 const command = buildCommand({
70 host: "localhost:3000",
71 name: "my-app",
72 relayList: "https://relay.example.com",
73 thumbnail: "https://example.com/thumb.png",
72 - tunnelBinaryURL: "https://github.com/gosuda/portal/releases/latest/download/portal-windows-amd64.exe",
74 + tunnelInstallerURL: "https://github.com/gosuda/portal/releases/latest/download/install.ps1",
75 }, {
76 shellTarget: "windows",
77 platform: "win32",
78 arch: "x64",
79 });
80
79 - assert.match(command, /Invoke-WebRequest -Uri https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/portal-windows-amd64\.exe -OutFile \$PortalTmp/);
80 - assert.match(command, /\$PortalBin = Join-Path \$PortalDir 'portal\.exe'/);
81 + assert.match(command, /irm https:\/\/github\.com\/gosuda\/portal\/releases\/latest\/download\/install\.ps1 \| iex/);
82 + assert.match(command, /\$PortalBin = Join-Path \$env:LOCALAPPDATA 'portal\\bin\\portal\.exe'/);
83 assert.match(command, /& \$PortalBin expose localhost:3000 --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png/);
84 });
85 });