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
});