main
go 260 lines 6.95 KB
Raw
1 package installer
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "time"
16
17 "golang.org/x/mod/semver"
18
19 "github.com/gosuda/portal-tunnel/v2/types"
20 "github.com/gosuda/portal-tunnel/v2/utils"
21 )
22
23 const updateCheckInterval = 24 * time.Hour
24
25 var updateCheckClient = utils.NewHTTPClient(
26 utils.WithHTTPTimeout(10*time.Second),
27 utils.WithHTTPCheckRedirect(func(req *http.Request, via []*http.Request) error {
28 return http.ErrUseLastResponse
29 }),
30 )
31
32 var updateDownloadClient = utils.NewHTTPClient(utils.WithHTTPTimeout(120 * time.Second))
33
34 func StartUpdateCheck(currentVersion string) {
35 binURL, _, ok := assetURLs("")
36 if !ok {
37 return
38 }
39
40 go func() {
41 for {
42 req, err := http.NewRequestWithContext(context.Background(), http.MethodHead, binURL, nil)
43 if err == nil {
44 resp, err := updateCheckClient.Do(req)
45 if err == nil {
46 location := resp.Header.Get("Location")
47 _ = resp.Body.Close()
48
49 if parsed, err := url.Parse(location); err == nil {
50 segments := strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/")
51 if len(segments) >= 5 {
52 version := segments[4]
53 current := strings.TrimSpace(currentVersion)
54 if current != "" && !strings.HasPrefix(current, "v") {
55 current = "v" + current
56 }
57 latestValid := strings.HasPrefix(version, "v") && semver.IsValid(version)
58 if latestValid && semver.IsValid(current) && semver.Compare(version, current) > 0 {
59 fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
60 }
61 }
62 }
63 }
64 }
65
66 time.Sleep(updateCheckInterval)
67 }
68 }()
69 }
70
71 func UpdateCurrentBinary(version string) error {
72 binURL, checksumURL, ok := assetURLs(version)
73 if !ok {
74 return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
75 }
76
77 execPath, err := os.Executable()
78 if err != nil {
79 return fmt.Errorf("failed to determine executable path: %w", err)
80 }
81 execPath, err = filepath.EvalSymlinks(execPath)
82 if err != nil {
83 return fmt.Errorf("failed to resolve executable path: %w", err)
84 }
85
86 tmpFile, err := os.CreateTemp("", "portal-update-*")
87 if err != nil {
88 return fmt.Errorf("failed to create temp file: %w", err)
89 }
90 defer func() { _ = os.Remove(tmpFile.Name()) }()
91
92 req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, binURL, nil)
93 if err != nil {
94 _ = tmpFile.Close()
95 return fmt.Errorf("failed to build binary request: %w", err)
96 }
97 resp, err := updateDownloadClient.Do(req)
98 if err != nil {
99 _ = tmpFile.Close()
100 return fmt.Errorf("failed to download binary: %w", err)
101 }
102 if resp.StatusCode != http.StatusOK {
103 _ = resp.Body.Close()
104 _ = tmpFile.Close()
105 return fmt.Errorf("failed to download binary: unexpected status %d", resp.StatusCode)
106 }
107 if _, err := io.Copy(tmpFile, resp.Body); err != nil {
108 _ = resp.Body.Close()
109 _ = tmpFile.Close()
110 return fmt.Errorf("failed to download binary: %w", err)
111 }
112 _ = resp.Body.Close()
113
114 if err := tmpFile.Sync(); err != nil {
115 _ = tmpFile.Close()
116 return fmt.Errorf("failed to sync downloaded binary: %w", err)
117 }
118 if err := tmpFile.Close(); err != nil {
119 return fmt.Errorf("failed to close downloaded binary: %w", err)
120 }
121
122 req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, checksumURL, nil)
123 if err != nil {
124 return fmt.Errorf("failed to build checksum request: %w", err)
125 }
126 resp, err = updateDownloadClient.Do(req)
127 if err != nil {
128 return fmt.Errorf("failed to download checksum: %w", err)
129 }
130 defer func() { _ = resp.Body.Close() }()
131
132 if resp.StatusCode != http.StatusOK {
133 return fmt.Errorf("checksum download returned status %d", resp.StatusCode)
134 }
135 body, err := io.ReadAll(resp.Body)
136 if err != nil {
137 return fmt.Errorf("failed to read checksum response: %w", err)
138 }
139
140 fields := strings.Fields(strings.TrimSpace(string(body)))
141 if len(fields) == 0 {
142 return fmt.Errorf("empty checksum response")
143 }
144 expectedHash := strings.ToLower(fields[0])
145 if len(expectedHash) != 64 {
146 return fmt.Errorf("invalid checksum format (expected 64 hex chars, got %d)", len(expectedHash))
147 }
148
149 f, err := os.Open(tmpFile.Name())
150 if err != nil {
151 return fmt.Errorf("failed to open downloaded file: %w", err)
152 }
153 defer func() { _ = f.Close() }()
154
155 h := sha256.New()
156 if _, err := io.Copy(h, f); err != nil {
157 return fmt.Errorf("failed to compute hash: %w", err)
158 }
159
160 actualHash := hex.EncodeToString(h.Sum(nil))
161 if actualHash != expectedHash {
162 return fmt.Errorf("hash mismatch: expected %s, got %s", expectedHash, actualHash)
163 }
164
165 if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
166 return fmt.Errorf("failed to replace binary: %w", err)
167 }
168
169 return nil
170 }
171
172 func assetURLs(version string) (binURL, checksumURL string, ok bool) {
173 slug := runtime.GOOS + "-" + runtime.GOARCH
174 filename, ok := AssetFilename(slug)
175 if !ok {
176 return "", "", false
177 }
178
179 baseURL := types.OfficialReleaseBaseURL + "/latest/download"
180 version = strings.TrimSpace(version)
181 if version != "" {
182 if !strings.HasPrefix(version, "v") {
183 version = "v" + version
184 }
185 baseURL = types.OfficialReleaseBaseURL + "/download/" + version
186 }
187
188 binURL = baseURL + "/" + filename
189 return binURL, binURL + ".sha256", true
190 }
191
192 func replaceBinary(srcPath, dstPath string) error {
193 if runtime.GOOS == "windows" {
194 return replaceBinaryWindows(srcPath, dstPath)
195 }
196 return replaceBinaryUnix(srcPath, dstPath)
197 }
198
199 func replaceBinaryUnix(srcPath, dstPath string) error {
200 if err := os.Chmod(srcPath, 0755); err != nil {
201 return fmt.Errorf("failed to set permissions: %w", err)
202 }
203
204 if err := os.Rename(srcPath, dstPath); err == nil {
205 return nil
206 }
207
208 src, err := os.Open(srcPath)
209 if err != nil {
210 return err
211 }
212 defer func() { _ = src.Close() }()
213
214 dstDir := filepath.Dir(dstPath)
215 tmp, err := os.CreateTemp(dstDir, "."+filepath.Base(dstPath)+".update-*")
216 if err != nil {
217 return fmt.Errorf("failed to create replacement file: %w", err)
218 }
219 tmpPath := tmp.Name()
220 defer func() { _ = os.Remove(tmpPath) }()
221
222 if _, err := io.Copy(tmp, src); err != nil {
223 _ = tmp.Close()
224 return fmt.Errorf("failed to copy binary: %w", err)
225 }
226 if err := tmp.Chmod(0755); err != nil {
227 _ = tmp.Close()
228 return fmt.Errorf("failed to set replacement permissions: %w", err)
229 }
230 if err := tmp.Sync(); err != nil {
231 _ = tmp.Close()
232 return fmt.Errorf("failed to sync replacement binary: %w", err)
233 }
234 if err := tmp.Close(); err != nil {
235 return fmt.Errorf("failed to close replacement binary: %w", err)
236 }
237 if err := os.Rename(tmpPath, dstPath); err != nil {
238 return fmt.Errorf("failed to replace destination: %w", err)
239 }
240 tmpPath = ""
241 return nil
242 }
243
244 func replaceBinaryWindows(srcPath, dstPath string) error {
245 oldPath := dstPath + ".old"
246
247 _ = os.Remove(oldPath)
248
249 if err := os.Rename(dstPath, oldPath); err != nil {
250 return fmt.Errorf("failed to rename old binary: %w", err)
251 }
252
253 if err := os.Rename(srcPath, dstPath); err != nil {
254 _ = os.Rename(oldPath, dstPath)
255 return fmt.Errorf("failed to place new binary: %w", err)
256 }
257
258 _ = os.Remove(oldPath)
259 return nil
260 }