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)