feat(cli): add `portal update` self-update subcommand
Add a new `portal update` command that updates the CLI binary to the latest GitHub release. The update flow mirrors install.sh: download the binary, verify its SHA256 checksum, and replace the running executable. - Detect latest version via HTTP HEAD redirect (no GitHub API needed) - SHA256 checksum verification (fail-closed on mismatch) - Cross-platform binary replacement (Unix: rename + copy fallback, Windows: rename-then-replace for locked .exe) - Pre-flight writable directory check before downloading - HTTP timeouts on all requests (10s HEAD/checksum, 120s download) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 16, 2026 at 19:51 UTC
a3716bb96b5b3c2b4ba2df05d3d9d56439982833
2 files changed
+253
cmd/portal-tunnel/main.go
+3
@@ -22,6 +22,7 @@ func main() {
22
if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{
23
"expose": runExposeCommand,
24
"list": runListCommand,
25
+ "update": runUpdateCommand,
26
"version": func(args []string) error {
27
fmt.Fprintln(os.Stdout, types.ReleaseVersion)
28
return nil
@@ -187,6 +188,7 @@ func printRootUsage(w io.Writer) {
188
"portal expose [flags] <target>",
189
"portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
190
"portal list [flags]",
191
+ "portal update",
192
"portal version",
193
},
194
[]string{
@@ -195,6 +197,7 @@ func printRootUsage(w io.Writer) {
197
"portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
198
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
199
"portal list",
200
+ "portal update",
201
"portal version",
202
},
203
)
cmd/portal-tunnel/update.go
new
+250
@@ -0,0 +1,250 @@
1
+package main
2
+
3
+import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "runtime"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
17
+ "github.com/gosuda/portal-tunnel/v2/types"
18
+)
19
+
20
+func runUpdateCommand(args []string) error {
21
+ slug := runtime.GOOS + "-" + runtime.GOARCH
22
+ if _, ok := installer.AssetFilename(slug); !ok {
23
+ return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
24
+ }
25
+
26
+ execPath, err := os.Executable()
27
+ if err != nil {
28
+ return fmt.Errorf("failed to determine executable path: %w", err)
29
+ }
30
+ execPath, err = filepath.EvalSymlinks(execPath)
31
+ if err != nil {
32
+ return fmt.Errorf("failed to resolve executable path: %w", err)
33
+ }
34
+
35
+ // Pre-check: verify that the binary's directory is writable before downloading.
36
+ if err := checkWritable(filepath.Dir(execPath)); err != nil {
37
+ return fmt.Errorf("cannot update %s: %w", execPath, err)
38
+ }
39
+
40
+ binURL, _ := installer.OfficialAssetURL(slug, false)
41
+
42
+ latestVersion, err := detectLatestVersion(binURL)
43
+ if err != nil {
44
+ return fmt.Errorf("failed to detect latest version: %w", err)
45
+ }
46
+
47
+ if latestVersion == types.ReleaseVersion {
48
+ fmt.Fprintf(os.Stderr, "Already up to date (%s).\n", types.ReleaseVersion)
49
+ return nil
50
+ }
51
+
52
+ fmt.Fprintf(os.Stderr, "Updating %s → %s ...\n", types.ReleaseVersion, latestVersion)
53
+
54
+ tmpFile, err := os.CreateTemp("", "portal-update-*")
55
+ if err != nil {
56
+ return fmt.Errorf("failed to create temp file: %w", err)
57
+ }
58
+ defer os.Remove(tmpFile.Name())
59
+
60
+ if err := downloadBinary(binURL, tmpFile); err != nil {
61
+ tmpFile.Close()
62
+ return fmt.Errorf("failed to download binary: %w", err)
63
+ }
64
+ if err := tmpFile.Sync(); err != nil {
65
+ tmpFile.Close()
66
+ return fmt.Errorf("failed to sync downloaded binary: %w", err)
67
+ }
68
+ tmpFile.Close()
69
+
70
+ checksumURL, _ := installer.OfficialAssetURL(slug, true)
71
+ if err := verifyChecksum(tmpFile.Name(), checksumURL); err != nil {
72
+ return fmt.Errorf("checksum verification failed: %w", err)
73
+ }
74
+
75
+ if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
76
+ return fmt.Errorf("failed to replace binary: %w", err)
77
+ }
78
+
79
+ fmt.Fprintf(os.Stderr, "Updated %s → %s\n", types.ReleaseVersion, latestVersion)
80
+ return nil
81
+}
82
+
83
+// detectLatestVersion sends a HEAD request to the GitHub releases latest URL
84
+// and extracts the version tag from the redirect Location header.
85
+func detectLatestVersion(latestURL string) (string, error) {
86
+ client := &http.Client{
87
+ Timeout: 10 * time.Second,
88
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
89
+ return http.ErrUseLastResponse
90
+ },
91
+ }
92
+
93
+ resp, err := client.Head(latestURL)
94
+ if err != nil {
95
+ return "", fmt.Errorf("HEAD request failed: %w", err)
96
+ }
97
+ resp.Body.Close()
98
+
99
+ location := resp.Header.Get("Location")
100
+ if location == "" {
101
+ return "", fmt.Errorf("no redirect location in response (status %d)", resp.StatusCode)
102
+ }
103
+
104
+ parsed, err := url.Parse(location)
105
+ if err != nil {
106
+ return "", fmt.Errorf("invalid redirect URL: %w", err)
107
+ }
108
+
109
+ // Expected path: /gosuda/portal-tunnel/releases/download/v2.2.0/portal-linux-amd64
110
+ segments := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
111
+ // segments: [gosuda, portal-tunnel, releases, download, v2.2.0, portal-linux-amd64]
112
+ if len(segments) < 6 || segments[3] != "download" {
113
+ return "", fmt.Errorf("unexpected redirect URL format: %s", location)
114
+ }
115
+
116
+ version := segments[4]
117
+ if !strings.HasPrefix(version, "v") {
118
+ return "", fmt.Errorf("unexpected version format in redirect URL: %s", version)
119
+ }
120
+
121
+ return version, nil
122
+}
123
+
124
+func downloadBinary(binURL string, dst *os.File) error {
125
+ client := &http.Client{Timeout: 120 * time.Second}
126
+
127
+ resp, err := client.Get(binURL)
128
+ if err != nil {
129
+ return err
130
+ }
131
+ defer resp.Body.Close()
132
+
133
+ if resp.StatusCode != http.StatusOK {
134
+ return fmt.Errorf("unexpected status %d", resp.StatusCode)
135
+ }
136
+
137
+ _, err = io.Copy(dst, resp.Body)
138
+ return err
139
+}
140
+
141
+func verifyChecksum(filePath, checksumURL string) error {
142
+ client := &http.Client{Timeout: 10 * time.Second}
143
+
144
+ resp, err := client.Get(checksumURL)
145
+ if err != nil {
146
+ return fmt.Errorf("failed to download checksum: %w", err)
147
+ }
148
+ defer resp.Body.Close()
149
+
150
+ if resp.StatusCode != http.StatusOK {
151
+ return fmt.Errorf("checksum download returned status %d", resp.StatusCode)
152
+ }
153
+
154
+ body, err := io.ReadAll(resp.Body)
155
+ if err != nil {
156
+ return fmt.Errorf("failed to read checksum response: %w", err)
157
+ }
158
+
159
+ expectedHash := strings.ToLower(strings.Fields(strings.TrimSpace(string(body)))[0])
160
+ if len(expectedHash) != 64 {
161
+ return fmt.Errorf("invalid checksum format (expected 64 hex chars, got %d)", len(expectedHash))
162
+ }
163
+
164
+ f, err := os.Open(filePath)
165
+ if err != nil {
166
+ return fmt.Errorf("failed to open downloaded file: %w", err)
167
+ }
168
+ defer f.Close()
169
+
170
+ h := sha256.New()
171
+ if _, err := io.Copy(h, f); err != nil {
172
+ return fmt.Errorf("failed to compute hash: %w", err)
173
+ }
174
+
175
+ actualHash := hex.EncodeToString(h.Sum(nil))
176
+ if actualHash != expectedHash {
177
+ return fmt.Errorf("hash mismatch: expected %s, got %s", expectedHash, actualHash)
178
+ }
179
+
180
+ return nil
181
+}
182
+
183
+func checkWritable(dir string) error {
184
+ f, err := os.CreateTemp(dir, ".portal-update-check-*")
185
+ if err != nil {
186
+ return fmt.Errorf("directory %s is not writable: %w", dir, err)
187
+ }
188
+ name := f.Name()
189
+ f.Close()
190
+ os.Remove(name)
191
+ return nil
192
+}
193
+
194
+func replaceBinary(srcPath, dstPath string) error {
195
+ if runtime.GOOS == "windows" {
196
+ return replaceBinaryWindows(srcPath, dstPath)
197
+ }
198
+ return replaceBinaryUnix(srcPath, dstPath)
199
+}
200
+
201
+func replaceBinaryUnix(srcPath, dstPath string) error {
202
+ if err := os.Chmod(srcPath, 0755); err != nil {
203
+ return fmt.Errorf("failed to set permissions: %w", err)
204
+ }
205
+
206
+ // Try atomic rename first (works when src and dst are on the same device).
207
+ if err := os.Rename(srcPath, dstPath); err == nil {
208
+ return nil
209
+ }
210
+
211
+ // Cross-device fallback: copy then remove temp.
212
+ src, err := os.Open(srcPath)
213
+ if err != nil {
214
+ return err
215
+ }
216
+ defer src.Close()
217
+
218
+ dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
219
+ if err != nil {
220
+ return fmt.Errorf("failed to open destination: %w", err)
221
+ }
222
+ defer dst.Close()
223
+
224
+ if _, err := io.Copy(dst, src); err != nil {
225
+ return fmt.Errorf("failed to copy binary: %w", err)
226
+ }
227
+ return nil
228
+}
229
+
230
+func replaceBinaryWindows(srcPath, dstPath string) error {
231
+ oldPath := dstPath + ".old"
232
+
233
+ // Remove leftover .old file from a previous update.
234
+ os.Remove(oldPath)
235
+
236
+ // Rename the running binary out of the way, then move the new one in.
237
+ if err := os.Rename(dstPath, oldPath); err != nil {
238
+ return fmt.Errorf("failed to rename old binary: %w", err)
239
+ }
240
+
241
+ if err := os.Rename(srcPath, dstPath); err != nil {
242
+ // Attempt to restore the old binary on failure.
243
+ os.Rename(oldPath, dstPath)
244
+ return fmt.Errorf("failed to place new binary: %w", err)
245
+ }
246
+
247
+ // Best-effort cleanup of the old binary.
248
+ os.Remove(oldPath)
249
+ return nil
250
+}