feat: implement update check and binary replacement functionality

Kim committed Apr 27, 2026 at 11:42 UTC d5f6bc74c2222b835d8aeb00fd477e58608b7ba1
6 files changed +269 -420
cmd/portal-tunnel/installer/installer.go
-14
@@ -6,8 +6,6 @@ import (
6 "strings"
7 )
8
9 -const officialReleaseBaseURL = "https://github.com/gosuda/portal-tunnel/releases/latest/download"
10 -
9 //go:embed install.sh
10 var installShellScript string
11
@@ -45,18 +43,6 @@ func AssetFilename(slug string) (string, bool) {
43 }
44 }
45
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 -
46 func relayShellScript(portalURL, script string) string {
47 overrides := strings.Join([]string{
48 "BASE_URL=" + quoteShellValue(portalURL),
cmd/portal-tunnel/installer/update.go new
+224
@@ -0,0 +1,224 @@
1 +package installer
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/types"
17 +)
18 +
19 +const updateCheckInterval = 24 * time.Hour
20 +
21 +func StartUpdateCheck(currentVersion string) {
22 + binURL, _, ok := assetURLs("")
23 + if !ok {
24 + return
25 + }
26 +
27 + go func() {
28 + for {
29 + client := &http.Client{
30 + Timeout: 10 * time.Second,
31 + CheckRedirect: func(req *http.Request, via []*http.Request) error {
32 + return http.ErrUseLastResponse
33 + },
34 + }
35 +
36 + req, err := http.NewRequest(http.MethodHead, binURL, nil)
37 + if err == nil {
38 + resp, err := client.Do(req)
39 + if err == nil {
40 + location := resp.Header.Get("Location")
41 + _ = resp.Body.Close()
42 +
43 + if parsed, err := url.Parse(location); err == nil {
44 + segments := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
45 + if len(segments) >= 5 {
46 + version := segments[4]
47 + if strings.HasPrefix(version, "v") && version != currentVersion {
48 + fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
49 + }
50 + }
51 + }
52 + }
53 + }
54 +
55 + time.Sleep(updateCheckInterval)
56 + }
57 + }()
58 +}
59 +
60 +func UpdateCurrentBinary(version string) error {
61 + binURL, checksumURL, ok := assetURLs(version)
62 + if !ok {
63 + return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
64 + }
65 +
66 + execPath, err := os.Executable()
67 + if err != nil {
68 + return fmt.Errorf("failed to determine executable path: %w", err)
69 + }
70 + execPath, err = filepath.EvalSymlinks(execPath)
71 + if err != nil {
72 + return fmt.Errorf("failed to resolve executable path: %w", err)
73 + }
74 +
75 + tmpFile, err := os.CreateTemp("", "portal-update-*")
76 + if err != nil {
77 + return fmt.Errorf("failed to create temp file: %w", err)
78 + }
79 + defer func() { _ = os.Remove(tmpFile.Name()) }()
80 +
81 + client := &http.Client{Timeout: 120 * time.Second}
82 +
83 + resp, err := client.Get(binURL)
84 + if err != nil {
85 + _ = tmpFile.Close()
86 + return fmt.Errorf("failed to download binary: %w", err)
87 + }
88 + if resp.StatusCode != http.StatusOK {
89 + _ = resp.Body.Close()
90 + _ = tmpFile.Close()
91 + return fmt.Errorf("failed to download binary: unexpected status %d", resp.StatusCode)
92 + }
93 + if _, err := io.Copy(tmpFile, resp.Body); err != nil {
94 + _ = resp.Body.Close()
95 + _ = tmpFile.Close()
96 + return fmt.Errorf("failed to download binary: %w", err)
97 + }
98 + _ = resp.Body.Close()
99 +
100 + if err := tmpFile.Sync(); err != nil {
101 + _ = tmpFile.Close()
102 + return fmt.Errorf("failed to sync downloaded binary: %w", err)
103 + }
104 + if err := tmpFile.Close(); err != nil {
105 + return fmt.Errorf("failed to close downloaded binary: %w", err)
106 + }
107 +
108 + resp, err = client.Get(checksumURL)
109 + if err != nil {
110 + return fmt.Errorf("failed to download checksum: %w", err)
111 + }
112 + defer func() { _ = resp.Body.Close() }()
113 +
114 + if resp.StatusCode != http.StatusOK {
115 + return fmt.Errorf("checksum download returned status %d", resp.StatusCode)
116 + }
117 + body, err := io.ReadAll(resp.Body)
118 + if err != nil {
119 + return fmt.Errorf("failed to read checksum response: %w", err)
120 + }
121 +
122 + fields := strings.Fields(strings.TrimSpace(string(body)))
123 + if len(fields) == 0 {
124 + return fmt.Errorf("empty checksum response")
125 + }
126 + expectedHash := strings.ToLower(fields[0])
127 + if len(expectedHash) != 64 {
128 + return fmt.Errorf("invalid checksum format (expected 64 hex chars, got %d)", len(expectedHash))
129 + }
130 +
131 + f, err := os.Open(tmpFile.Name())
132 + if err != nil {
133 + return fmt.Errorf("failed to open downloaded file: %w", err)
134 + }
135 + defer func() { _ = f.Close() }()
136 +
137 + h := sha256.New()
138 + if _, err := io.Copy(h, f); err != nil {
139 + return fmt.Errorf("failed to compute hash: %w", err)
140 + }
141 +
142 + actualHash := hex.EncodeToString(h.Sum(nil))
143 + if actualHash != expectedHash {
144 + return fmt.Errorf("hash mismatch: expected %s, got %s", expectedHash, actualHash)
145 + }
146 +
147 + if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
148 + return fmt.Errorf("failed to replace binary: %w", err)
149 + }
150 +
151 + return nil
152 +}
153 +
154 +func assetURLs(version string) (binURL, checksumURL string, ok bool) {
155 + slug := runtime.GOOS + "-" + runtime.GOARCH
156 + filename, ok := AssetFilename(slug)
157 + if !ok {
158 + return "", "", false
159 + }
160 +
161 + baseURL := types.OfficialReleaseBaseURL
162 + version = strings.TrimSpace(version)
163 + if version != "" {
164 + if !strings.HasPrefix(version, "v") {
165 + version = "v" + version
166 + }
167 + baseURL = types.OfficialReleaseDownloadURL + "/" + version
168 + }
169 +
170 + binURL = baseURL + "/" + filename
171 + return binURL, binURL + ".sha256", true
172 +}
173 +
174 +func replaceBinary(srcPath, dstPath string) error {
175 + if runtime.GOOS == "windows" {
176 + return replaceBinaryWindows(srcPath, dstPath)
177 + }
178 + return replaceBinaryUnix(srcPath, dstPath)
179 +}
180 +
181 +func replaceBinaryUnix(srcPath, dstPath string) error {
182 + if err := os.Chmod(srcPath, 0755); err != nil {
183 + return fmt.Errorf("failed to set permissions: %w", err)
184 + }
185 +
186 + if err := os.Rename(srcPath, dstPath); err == nil {
187 + return nil
188 + }
189 +
190 + src, err := os.Open(srcPath)
191 + if err != nil {
192 + return err
193 + }
194 + defer func() { _ = src.Close() }()
195 +
196 + dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
197 + if err != nil {
198 + return fmt.Errorf("failed to open destination: %w", err)
199 + }
200 + defer func() { _ = dst.Close() }()
201 +
202 + if _, err := io.Copy(dst, src); err != nil {
203 + return fmt.Errorf("failed to copy binary: %w", err)
204 + }
205 + return nil
206 +}
207 +
208 +func replaceBinaryWindows(srcPath, dstPath string) error {
209 + oldPath := dstPath + ".old"
210 +
211 + _ = os.Remove(oldPath)
212 +
213 + if err := os.Rename(dstPath, oldPath); err != nil {
214 + return fmt.Errorf("failed to rename old binary: %w", err)
215 + }
216 +
217 + if err := os.Rename(srcPath, dstPath); err != nil {
218 + _ = os.Rename(oldPath, dstPath)
219 + return fmt.Errorf("failed to place new binary: %w", err)
220 + }
221 +
222 + _ = os.Remove(oldPath)
223 + return nil
224 +}
cmd/portal-tunnel/main.go
+36 -65
@@ -7,8 +7,6 @@ import (
7 "fmt"
8 "io"
9 "os"
10 - "path/filepath"
11 - "runtime"
10 "strings"
11 "sync"
12 "time"
@@ -35,6 +33,7 @@ func main() {
33 "help": utils.MakeHelpCommand(printRootUsage, []utils.HelpTopic{
34 {Name: "expose", Usage: printExposeUsage},
35 {Name: "list", Usage: printListUsage},
36 + {Name: "update", Usage: printUpdateUsage},
37 }),
38 }); err != nil {
39 log.Error().Err(err).Msg("portal tunnel exited with error")
@@ -65,7 +64,7 @@ type exposeFlags struct {
64 }
65
66 func runExposeCommand(args []string) error {
68 - updateCh := startUpdateCheck()
67 + installer.StartUpdateCheck(types.ReleaseVersion)
68
69 flags := exposeFlags{}
70 fs := utils.NewFlagSet("expose", printExposeUsage)
@@ -142,7 +141,6 @@ func runExposeCommand(args []string) error {
141 if err != nil {
142 return fmt.Errorf("failed to start relays: %w", err)
143 }
145 - printUpdateHint(updateCh)
144 if len(flags.httpRoutes) > 0 {
145 httpRoutes := make([]sdk.HTTPRoute, 0, len(flags.httpRoutes))
146 for _, raw := range flags.httpRoutes {
@@ -162,77 +160,37 @@ func runExposeCommand(args []string) error {
160 return proxyExposure(ctx, exposure)
161 }
162
165 -type listFlags struct {
166 - relayCSV string
167 - defaultRelays bool
168 -}
169 -
163 func runUpdateCommand(args []string) error {
171 - slug := runtime.GOOS + "-" + runtime.GOARCH
172 - if _, ok := installer.AssetFilename(slug); !ok {
173 - return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
174 - }
175 -
176 - execPath, err := os.Executable()
177 - if err != nil {
178 - return fmt.Errorf("failed to determine executable path: %w", err)
179 - }
180 - execPath, err = filepath.EvalSymlinks(execPath)
181 - if err != nil {
182 - return fmt.Errorf("failed to resolve executable path: %w", err)
183 - }
184 -
185 - // Pre-check: verify that the binary's directory is writable before downloading.
186 - if err := checkWritable(filepath.Dir(execPath)); err != nil {
187 - return fmt.Errorf("cannot update %s: %w", execPath, err)
188 - }
189 -
190 - binURL, _ := installer.OfficialAssetURL(slug, false)
191 -
192 - latestVersion, err := detectLatestVersion(binURL)
193 - if err != nil {
194 - return fmt.Errorf("failed to detect latest version: %w", err)
195 - }
196 -
197 - if latestVersion == types.ReleaseVersion {
198 - fmt.Fprintf(os.Stderr, "Already up to date (%s).\n", types.ReleaseVersion)
199 - return nil
200 - }
201 -
202 - fmt.Fprintf(os.Stderr, "Updating %s → %s ...\n", types.ReleaseVersion, latestVersion)
203 -
204 - tmpFile, err := os.CreateTemp("", "portal-update-*")
205 - if err != nil {
206 - return fmt.Errorf("failed to create temp file: %w", err)
207 - }
208 - defer func() { _ = os.Remove(tmpFile.Name()) }()
164 + var version string
165 + fs := utils.NewFlagSet("update", printUpdateUsage)
166 + utils.StringFlag(fs, &version, "version", "", "Release version to install; defaults to latest")
167
210 - if err := downloadBinary(binURL, tmpFile); err != nil {
211 - _ = tmpFile.Close()
212 - return fmt.Errorf("failed to download binary: %w", err)
213 - }
214 - if err := tmpFile.Sync(); err != nil {
215 - _ = tmpFile.Close()
216 - return fmt.Errorf("failed to sync downloaded binary: %w", err)
168 + if err := utils.ParseFlagSet(fs, args, printUpdateUsage); err != nil {
169 + if errors.Is(err, flag.ErrHelp) {
170 + return nil
171 + }
172 + return err
173 }
218 - _ = tmpFile.Close()
219 -
220 - checksumURL, _ := installer.OfficialAssetURL(slug, true)
221 - if err := verifyChecksum(tmpFile.Name(), checksumURL); err != nil {
222 - return fmt.Errorf("checksum verification failed: %w", err)
174 + if err := utils.RequireNoArgs(fs.Args(), "update"); err != nil {
175 + printUpdateUsage(os.Stderr)
176 + return err
177 }
178
225 - if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
226 - return fmt.Errorf("failed to replace binary: %w", err)
179 + if err := installer.UpdateCurrentBinary(version); err != nil {
180 + return err
181 }
182
229 - fmt.Fprintf(os.Stderr, "Updated %s → %s\n", types.ReleaseVersion, latestVersion)
183 + fmt.Fprintln(os.Stderr, "Updated portal.")
184 return nil
185 }
186
187 +type listFlags struct {
188 + relayCSV string
189 + defaultRelays bool
190 +}
191 +
192 func runListCommand(args []string) error {
234 - updateCh := startUpdateCheck()
235 - defer printUpdateHint(updateCh)
193 + installer.StartUpdateCheck(types.ReleaseVersion)
194
195 flags := listFlags{}
196 fs := utils.NewFlagSet("list", printListUsage)
@@ -291,7 +249,7 @@ func printRootUsage(w io.Writer) {
249 "portal expose [flags] <target>",
250 "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
251 "portal list [flags]",
294 - "portal update",
252 + "portal update [flags]",
253 "portal version",
254 },
255 []string{
@@ -301,6 +259,7 @@ func printRootUsage(w io.Writer) {
259 "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
260 "portal list",
261 "portal update",
262 + "portal update --version v2.1.7",
263 "portal version",
264 },
265 )
@@ -336,3 +295,15 @@ func printListUsage(w io.Writer) {
295 },
296 )
297 }
298 +
299 +func printUpdateUsage(w io.Writer) {
300 + utils.WriteCommandUsage(w,
301 + []string{
302 + "portal update [flags]",
303 + },
304 + []string{
305 + "portal update",
306 + "portal update --version v2.1.7",
307 + },
308 + )
309 +}
cmd/portal-tunnel/update.go deleted
-333
@@ -1,333 +0,0 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "crypto/sha256"
6 - "encoding/hex"
7 - "encoding/json"
8 - "fmt"
9 - "io"
10 - "net/http"
11 - "net/url"
12 - "os"
13 - "path/filepath"
14 - "runtime"
15 - "strings"
16 - "time"
17 -
18 - "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
19 - "github.com/gosuda/portal-tunnel/v2/types"
20 -)
21 -
22 -const updateCheckTTL = 24 * time.Hour
23 -
24 -type updateCache struct {
25 - CheckedAt time.Time `json:"checked_at"`
26 - LatestVersion string `json:"latest_version"`
27 -}
28 -
29 -// startUpdateCheck begins a background goroutine that checks for a newer
30 -// release. It returns a buffered channel that will receive the latest version
31 -// string when a newer version exists, or an empty string otherwise. The check
32 -// is skipped when a valid cache entry exists within the TTL.
33 -func startUpdateCheck() <-chan string {
34 - ch := make(chan string, 1)
35 -
36 - slug := runtime.GOOS + "-" + runtime.GOARCH
37 - binURL, ok := installer.OfficialAssetURL(slug, false)
38 - if !ok {
39 - ch <- ""
40 - return ch
41 - }
42 -
43 - go func() {
44 - ch <- checkForUpdate(binURL)
45 - }()
46 -
47 - return ch
48 -}
49 -
50 -func checkForUpdate(binURL string) string {
51 - cacheDir, err := updateCacheDir()
52 - if err != nil {
53 - return ""
54 - }
55 - cachePath := filepath.Join(cacheDir, "update_check.json")
56 -
57 - // Try reading the cache first.
58 - if cached, err := readUpdateCache(cachePath); err == nil {
59 - if time.Since(cached.CheckedAt) < updateCheckTTL {
60 - if cached.LatestVersion != types.ReleaseVersion {
61 - return cached.LatestVersion
62 - }
63 - return ""
64 - }
65 - }
66 -
67 - // Cache is missing or stale — check the network.
68 - latestVersion, err := detectLatestVersion(binURL)
69 - if err != nil {
70 - return ""
71 - }
72 -
73 - // Write cache atomically.
74 - writeUpdateCache(cachePath, updateCache{
75 - CheckedAt: time.Now(),
76 - LatestVersion: latestVersion,
77 - })
78 -
79 - if latestVersion != types.ReleaseVersion {
80 - return latestVersion
81 - }
82 - return ""
83 -}
84 -
85 -// printUpdateHint collects from the channel with a short timeout and prints
86 -// a hint to stderr if a newer version is available.
87 -func printUpdateHint(ch <-chan string) {
88 - if ch == nil {
89 - return
90 - }
91 - select {
92 - case version := <-ch:
93 - if version != "" {
94 - fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
95 - }
96 - case <-time.After(500 * time.Millisecond):
97 - }
98 -}
99 -
100 -// detectLatestVersion sends a HEAD request to the GitHub releases latest URL
101 -// and extracts the version tag from the redirect Location header.
102 -func detectLatestVersion(latestURL string) (string, error) {
103 - client := &http.Client{
104 - Timeout: 10 * time.Second,
105 - CheckRedirect: func(req *http.Request, via []*http.Request) error {
106 - return http.ErrUseLastResponse
107 - },
108 - }
109 -
110 - req, err := http.NewRequestWithContext(context.Background(), http.MethodHead, latestURL, nil)
111 - if err != nil {
112 - return "", fmt.Errorf("failed to create request: %w", err)
113 - }
114 -
115 - resp, err := client.Do(req)
116 - if err != nil {
117 - return "", fmt.Errorf("HEAD request failed: %w", err)
118 - }
119 - _ = resp.Body.Close()
120 -
121 - location := resp.Header.Get("Location")
122 - if location == "" {
123 - return "", fmt.Errorf("no redirect location in response (status %d)", resp.StatusCode)
124 - }
125 -
126 - parsed, err := url.Parse(location)
127 - if err != nil {
128 - return "", fmt.Errorf("invalid redirect URL: %w", err)
129 - }
130 -
131 - // Expected path: /gosuda/portal-tunnel/releases/download/v2.2.0/portal-linux-amd64
132 - segments := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
133 - // segments: [gosuda, portal-tunnel, releases, download, v2.2.0, portal-linux-amd64]
134 - if len(segments) < 6 || segments[3] != "download" {
135 - return "", fmt.Errorf("unexpected redirect URL format: %s", location)
136 - }
137 -
138 - version := segments[4]
139 - if !strings.HasPrefix(version, "v") {
140 - return "", fmt.Errorf("unexpected version format in redirect URL: %s", version)
141 - }
142 -
143 - return version, nil
144 -}
145 -
146 -func downloadBinary(binURL string, dst *os.File) error {
147 - client := &http.Client{Timeout: 120 * time.Second}
148 -
149 - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, binURL, nil)
150 - if err != nil {
151 - return fmt.Errorf("failed to create request: %w", err)
152 - }
153 -
154 - resp, err := client.Do(req)
155 - if err != nil {
156 - return err
157 - }
158 - defer func() { _ = resp.Body.Close() }()
159 -
160 - if resp.StatusCode != http.StatusOK {
161 - return fmt.Errorf("unexpected status %d", resp.StatusCode)
162 - }
163 -
164 - _, err = io.Copy(dst, resp.Body)
165 - return err
166 -}
167 -
168 -func verifyChecksum(filePath, checksumURL string) error {
169 - client := &http.Client{Timeout: 10 * time.Second}
170 -
171 - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, checksumURL, nil)
172 - if err != nil {
173 - return fmt.Errorf("failed to create request: %w", err)
174 - }
175 -
176 - resp, err := client.Do(req)
177 - if err != nil {
178 - return fmt.Errorf("failed to download checksum: %w", err)
179 - }
180 - defer func() { _ = resp.Body.Close() }()
181 -
182 - if resp.StatusCode != http.StatusOK {
183 - return fmt.Errorf("checksum download returned status %d", resp.StatusCode)
184 - }
185 -
186 - body, err := io.ReadAll(resp.Body)
187 - if err != nil {
188 - return fmt.Errorf("failed to read checksum response: %w", err)
189 - }
190 -
191 - fields := strings.Fields(strings.TrimSpace(string(body)))
192 - if len(fields) == 0 {
193 - return fmt.Errorf("empty checksum response")
194 - }
195 - expectedHash := strings.ToLower(fields[0])
196 - if len(expectedHash) != 64 {
197 - return fmt.Errorf("invalid checksum format (expected 64 hex chars, got %d)", len(expectedHash))
198 - }
199 -
200 - f, err := os.Open(filePath)
201 - if err != nil {
202 - return fmt.Errorf("failed to open downloaded file: %w", err)
203 - }
204 - defer func() { _ = f.Close() }()
205 -
206 - h := sha256.New()
207 - if _, err := io.Copy(h, f); err != nil {
208 - return fmt.Errorf("failed to compute hash: %w", err)
209 - }
210 -
211 - actualHash := hex.EncodeToString(h.Sum(nil))
212 - if actualHash != expectedHash {
213 - return fmt.Errorf("hash mismatch: expected %s, got %s", expectedHash, actualHash)
214 - }
215 -
216 - return nil
217 -}
218 -
219 -func checkWritable(dir string) error {
220 - f, err := os.CreateTemp(dir, ".portal-update-check-*")
221 - if err != nil {
222 - return fmt.Errorf("directory %s is not writable: %w", dir, err)
223 - }
224 - name := f.Name()
225 - _ = f.Close()
226 - _ = os.Remove(name)
227 - return nil
228 -}
229 -
230 -func replaceBinary(srcPath, dstPath string) error {
231 - if runtime.GOOS == "windows" {
232 - return replaceBinaryWindows(srcPath, dstPath)
233 - }
234 - return replaceBinaryUnix(srcPath, dstPath)
235 -}
236 -
237 -func replaceBinaryUnix(srcPath, dstPath string) error {
238 - if err := os.Chmod(srcPath, 0755); err != nil {
239 - return fmt.Errorf("failed to set permissions: %w", err)
240 - }
241 -
242 - // Try atomic rename first (works when src and dst are on the same device).
243 - if err := os.Rename(srcPath, dstPath); err == nil {
244 - return nil
245 - }
246 -
247 - // Cross-device fallback: copy then remove temp.
248 - src, err := os.Open(srcPath)
249 - if err != nil {
250 - return err
251 - }
252 - defer func() { _ = src.Close() }()
253 -
254 - dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
255 - if err != nil {
256 - return fmt.Errorf("failed to open destination: %w", err)
257 - }
258 - defer func() { _ = dst.Close() }()
259 -
260 - if _, err := io.Copy(dst, src); err != nil {
261 - return fmt.Errorf("failed to copy binary: %w", err)
262 - }
263 - return nil
264 -}
265 -
266 -func replaceBinaryWindows(srcPath, dstPath string) error {
267 - oldPath := dstPath + ".old"
268 -
269 - // Remove leftover .old file from a previous update.
270 - _ = os.Remove(oldPath)
271 -
272 - // Rename the running binary out of the way, then move the new one in.
273 - if err := os.Rename(dstPath, oldPath); err != nil {
274 - return fmt.Errorf("failed to rename old binary: %w", err)
275 - }
276 -
277 - if err := os.Rename(srcPath, dstPath); err != nil {
278 - // Attempt to restore the old binary on failure.
279 - _ = os.Rename(oldPath, dstPath)
280 - return fmt.Errorf("failed to place new binary: %w", err)
281 - }
282 -
283 - // Best-effort cleanup of the old binary.
284 - _ = os.Remove(oldPath)
285 - return nil
286 -}
287 -
288 -func updateCacheDir() (string, error) {
289 - base, err := os.UserCacheDir()
290 - if err != nil {
291 - return "", err
292 - }
293 - dir := filepath.Join(base, "portal-tunnel")
294 - if err := os.MkdirAll(dir, 0755); err != nil {
295 - return "", err
296 - }
297 - return dir, nil
298 -}
299 -
300 -func readUpdateCache(path string) (updateCache, error) {
301 - data, err := os.ReadFile(path)
302 - if err != nil {
303 - return updateCache{}, err
304 - }
305 - var c updateCache
306 - if err := json.Unmarshal(data, &c); err != nil {
307 - return updateCache{}, err
308 - }
309 - return c, nil
310 -}
311 -
312 -func writeUpdateCache(path string, c updateCache) {
313 - data, err := json.Marshal(c)
314 - if err != nil {
315 - return
316 - }
317 -
318 - dir := filepath.Dir(path)
319 - tmp, err := os.CreateTemp(dir, ".update_check-*.json")
320 - if err != nil {
321 - return
322 - }
323 - tmpName := tmp.Name()
324 -
325 - if _, err := tmp.Write(data); err != nil {
326 - _ = tmp.Close()
327 - _ = os.Remove(tmpName)
328 - return
329 - }
330 - _ = tmp.Close()
331 -
332 - _ = os.Rename(tmpName, path)
333 -}
cmd/relay-server/frontend.go
+3 -4
@@ -395,10 +395,9 @@ func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
395 }
396 data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename)
397 if err != nil {
398 - redirectURL, ok := installer.OfficialAssetURL(slug, checksumRequest)
399 - if !ok {
400 - http.NotFound(w, r)
401 - return
398 + redirectURL := types.OfficialReleaseBaseURL + "/" + filename
399 + if checksumRequest {
400 + redirectURL += ".sha256"
401 }
402 http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
403 return
types/types.go
+6 -4
@@ -1,10 +1,12 @@
1 package types
2
3 const (
4 - ReleaseVersion = "v2.1.7"
5 - SDKVersion = "6"
6 - DiscoveryVersion = "7"
7 - PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"
4 + ReleaseVersion = "v2.1.7"
5 + SDKVersion = "6"
6 + DiscoveryVersion = "7"
7 + PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"
8 + OfficialReleaseBaseURL = "https://github.com/gosuda/portal-tunnel/releases/latest/download"
9 + OfficialReleaseDownloadURL = "https://github.com/gosuda/portal-tunnel/releases/download"
10
11 HeaderAccessToken = "X-Portal-Access-Token"
12 MarkerKeepalive = byte(0x00)