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 +}