Repository navigation
Expand file tree
/
Copy pathupdate.go
More file actions
632 lines (587 loc) · 24.6 KB
/
Copy pathupdate.go
File metadata and controls
632 lines (587 loc) · 24.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"wanctl/internal/config"
"wanctl/internal/relayhttp"
wanrelease "wanctl/internal/release"
)
// buildVersion is set to an immutable vMAJOR.MINOR.PATCH by the release job.
// Development builds may update to a signed release but cannot claim a version.
var buildVersion = "dev"
// cmdUpdate replaces the running wanctl binary with the latest one served by
// the relay's /dl/<bin> endpoint. If the background daemon is running, it is
// stopped before the swap and restarted after — so users keep their session.
//
// On Unix the swap is a tempfile-in-same-dir + rename, which is atomic and safe
// even though the binary is currently executing (the kernel keeps the old inode
// alive for the running process). On Windows the running .exe can't be renamed,
// so we rename it to <name>.old first, then write the new file in place.
//
// When the binary lives in a root-owned dir (e.g. /usr/local/bin), we split the
// work in two: the user process stops the daemon, re-execs `sudo wanctl update
// --no-restart` (which only does download + swap as root), then the user
// process starts the daemon again — so the daemon process keeps running as the
// original user, not as root.
func cmdUpdate(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("update", flag.ExitOnError))
noRestart := fs.Bool("no-restart", false, "internal: skip daemon stop/start (used by the sudo-elevated phase)")
fetchAPK := fs.String("fetch-apk", "", "download and verify the Android APK into this directory, print its path, and exit;\n"+
"\tused by the Android app, which installs it through the system package installer")
fs.Parse(args)
if *fetchAPK != "" {
return fetchAndroidAPK(ctx, *fetchAPK)
}
self, err := selfPath()
if err != nil {
return fmt.Errorf("locate self: %w", err)
}
if real, err := filepath.EvalSymlinks(self); err == nil {
self = real
}
dir := filepath.Dir(self)
// Checked before the writability probe, which would otherwise send this
// down splitUpdateViaSudo and report a missing `sudo` — an answer to a
// question nobody asked.
if runningFromAPK(self) {
return errAPKSelfUpdate
}
if !canWriteDir(dir) {
return splitUpdateViaSudo(ctx, self)
}
bases, err := updateSources()
if err != nil {
return err
}
got, err := overSources(bases, func(base string) (updateFetch, error) {
fmt.Printf("正在验证 %s 的签名发布清单 …\n", base)
path, version, err := downloadSignedUpdate(ctx, base, dir, runtime.GOOS, runtime.GOARCH, buildVersion)
return updateFetch{path: path, version: version}, err
})
if err != nil {
return err
}
tmp, version := got.path, got.version
defer os.Remove(tmp) // safe no-op once Rename consumes it
if err := os.Chmod(tmp, 0o755); err != nil {
return fmt.Errorf("chmod new binary: %w", err)
}
// Read before the swap, never after: what this host was is a fact about the
// machine as the update found it, and the swap is the point after which it
// can no longer be observed.
plan := planUpdateRestart(*noRestart)
if err := applyUpdateStop(plan); err != nil {
return err
}
if err := replaceBinary(tmp, self); err != nil {
return fmt.Errorf("replace binary at %s: %w", self, err)
}
tmp = "" // consumed by Rename
fmt.Printf("✓ 已安装 wanctl %s: %s\n", version, self)
reportPATHShadow(self)
return applyUpdateRestart(ctx, self, plan)
}
var errAPKSelfUpdate = fmt.Errorf(
"这个 wanctl 由安卓 APK 分发,无法自我升级:APK 里的 lib 目录是系统只读的," +
"而 app 能写的目录 Android 一律禁止 exec。请在 wanctl 应用里点「检查更新」," +
"或从门户「下载安装」页下载新 APK 安装。")
// updateSources lists where signed release artifacts may be fetched from, in
// the order they should be tried: the build's release page (official builds
// point at the project's GitHub releases) first, then the relay's optional /dl
// mirror.
//
// Both are listed rather than only the first, because the first is the one that
// stops working. A device on a network that cannot reach the baked-in release
// page — the common case behind a corporate egress or the Great Firewall — has
// a relay it demonstrably reaches, since that is how it is controlled at all.
// The mirror serves the same manifest under the same signature, so falling back
// to it changes what is downloaded from where, never what is trusted.
func updateSources() ([]string, error) {
var bases []string
if base := config.ReleaseBase(); base != "" {
bases = append(bases, strings.TrimRight(base, "/"))
}
relay, relayErr := config.Relay()
if relayErr == nil {
mirror := strings.TrimRight(relay, "/") + "/dl"
if len(bases) == 0 || bases[0] != mirror {
bases = append(bases, mirror)
}
}
if len(bases) == 0 {
return nil, fmt.Errorf("no release source: set WANCTL_RELEASE_BASE, or configure a relay whose /dl mirror serves releases (%w)", relayErr)
}
return bases, nil
}
// updateFetch is what one source attempt yields: the release version it offers
// and, for an attempt that downloaded, the verified tempfile holding it.
type updateFetch struct {
path string
version string
}
// overSources runs attempt against each base until one answers.
//
// ErrUpToDate ends the walk as decisively as success does: a manifest that
// verified and simply has nothing newer is an answer, and asking a mirror the
// same question would only produce the same answer over a second round trip.
// Every other error moves on to the next base; the last one is reported when
// none of them work, because it is the one describing the source the caller is
// most likely to be able to fix.
func overSources(bases []string, attempt func(base string) (updateFetch, error)) (updateFetch, error) {
var last error
for _, base := range bases {
got, err := attempt(base)
if err == nil || errors.Is(err, wanrelease.ErrUpToDate) {
return got, err
}
last = err
}
if last == nil {
last = fmt.Errorf("no release source configured")
}
return updateFetch{}, last
}
// runningFromAPK reports whether this binary is the copy an installed Android
// app carries, as opposed to one someone pushed to /data/local/tmp or installed
// under Termux.
//
// The marker is the layout the package manager creates and only it creates:
// <somewhere under /data/app>/<package>-<suffix>/lib/<abi>/lib*.so. That
// directory is labelled apk_data_file — which is exactly why the binary can run
// from there at all — and it is owned by system:system with no write access for
// the app, so an update that tries to swap the file in place cannot succeed and
// should not be attempted.
func runningFromAPK(self string) bool {
return runtime.GOOS == "android" && isAPKPath(self)
}
// isAPKPath is the path shape alone, split from the GOOS check so it can be
// tested on the machine this is developed on rather than only on a phone.
func isAPKPath(self string) bool {
if !strings.HasPrefix(self, "/data/app/") {
return false
}
// .../lib/<abi>/libwanctl.so
abiDir := filepath.Dir(self)
return filepath.Base(filepath.Dir(abiDir)) == "lib"
}
// fetchAndroidAPK downloads the APK named by the signed release manifest,
// verifies it, and prints its path — nothing else on stdout, so the caller can
// use the output directly. Being already current is success with no path, not
// an error: the Android app shows "already up to date" for it, and an exit code
// would make that indistinguishable from a network failure.
func fetchAndroidAPK(ctx context.Context, dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("prepare %s: %w", dir, err)
}
bases, err := updateSources()
if err != nil {
return err
}
got, err := overSources(bases, func(base string) (updateFetch, error) {
fmt.Fprintf(os.Stderr, "正在验证 %s 的签名发布清单 …\n", base)
// The APK that carries this binary's own ABI: an arm64 app must not be
// handed the armeabi-v7a package, even though the device would install it.
path, version, err := downloadSignedUpdate(ctx, base, dir, "android", wanrelease.APKArch(runtime.GOARCH), buildVersion)
return updateFetch{path: path, version: version}, err
})
if err != nil {
if errors.Is(err, wanrelease.ErrUpToDate) {
fmt.Fprintf(os.Stderr, "已是最新版本 (%s)\n", buildVersion)
return nil
}
return err
}
tmp, version := got.path, got.version
// The package installer reads the file by path and reports the name it
// finds, so give it one that says which version the user is approving.
final := filepath.Join(dir, "wanctl-"+version+".apk")
if err := os.Rename(tmp, final); err != nil {
os.Remove(tmp)
return fmt.Errorf("place APK: %w", err)
}
if err := os.Chmod(final, 0o600); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "✓ 已下载并验签 wanctl %s\n", version)
fmt.Println(final)
return nil
}
// canWriteDir reports whether the current process can create files in dir
// (the most reliable permission check on POSIX: try it).
func canWriteDir(dir string) bool {
f, err := os.CreateTemp(dir, ".wanctl-probe-*")
if err != nil {
return false
}
name := f.Name()
f.Close()
os.Remove(name)
return true
}
// splitUpdateViaSudo handles the root-owned-dir case: stop daemon as user,
// re-exec `sudo wanctl update --no-restart` (root just swaps the binary),
// then restart the daemon as the original user. This keeps the long-running
// daemon owned by the user — running it as root would change file ownership of
// the config dir / pid file / logs.
func splitUpdateViaSudo(ctx context.Context, self string) error {
if runtime.GOOS == "windows" {
return fmt.Errorf("升级 %s 需要管理员权限。请用「以管理员身份运行」打开终端再跑 wanctl update", self)
}
sudo, err := exec.LookPath("sudo")
if err != nil {
return fmt.Errorf("升级 %s 需要 root 权限,但本机找不到 sudo。请用 root 身份直接跑: wanctl update", self)
}
plan := planUpdateRestart(false)
if err := applyUpdateStop(plan); err != nil {
return err
}
fmt.Printf("wanctl: %s 需要 sudo 才能替换,请在下方提示输入密码 …\n", filepath.Dir(self))
cmd := exec.CommandContext(ctx, sudo, self, "update", "--no-restart")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("sudo wanctl update: %w", err)
}
return applyUpdateRestart(ctx, self, plan)
}
type updateRestartPlan struct {
stopDetached bool
restartDetached bool
restartManagedPID int
}
// planUpdateRestart decides what this update owes the running agent, from the
// state of the machine before anything is swapped.
//
// An update replaces a binary. It may put back an agent it interrupted; it may
// never produce one that was not there, because starting an agent enrolls the
// machine as a controlled device — a row in the relay, a name in someone's
// portal, an entry other namespaces can be given. That is not something a
// command whose job is to copy a file gets to do, and undoing it means an
// administrator deleting a device record.
//
// So the answer is "restart" only when this machine was serving, and liveness
// comes from the agent lock, which exists exactly as long as an agent does. It
// used to come from the number in agent.pid, which is a label: a pid file left
// behind by a dead agent, plus the pid reuse that eventually follows, made an
// update stop a stranger and then *start* an agent on a machine that had never
// run one. That is how a controller-only PC registered itself as a controlled
// device (GitHub issue #66); bare `wanctl` enrolling on sight was the other
// half of it.
func planUpdateRestart(noRestart bool) updateRestartPlan {
pid, running := agentRunning()
return planUpdateRestartWithLiveness(noRestart, pid, running)
}
func planUpdateRestartWithLiveness(noRestart bool, pid int, alive bool) updateRestartPlan {
if noRestart || !alive {
return updateRestartPlan{}
}
// pid > 0 is not decoration. An agent holding the lock may have recorded no
// pid, and a host with no supervisor marker reads back 0 from ManagedPID:
// without this, those two zeros matched and the plan came out as "restart
// the supervised agent 0", which every caller correctly does nothing about.
// The result was an update that silently skipped the restart it owed — and
// a liveness check that could be removed without any test noticing.
if pid > 0 && config.ManagedPID() == pid {
return updateRestartPlan{restartManagedPID: pid}
}
return updateRestartPlan{stopDetached: true, restartDetached: true}
}
// applyUpdateStop and applyUpdateRestart carry out the plan. They are one pair
// rather than a block inlined at each entry point because there are two entry
// points — the ordinary update and the sudo-split one — and a rule about not
// starting agents that is written down twice is a rule that drifts. The empty
// plan, which is what a controller-only host produces, runs nothing in either.
func applyUpdateStop(plan updateRestartPlan) error {
if !plan.stopDetached {
return nil
}
fmt.Println("正在停止后台 agent …")
if err := cmdStop(); err != nil {
return fmt.Errorf("stop daemon: %w", err)
}
return nil
}
func applyUpdateRestart(ctx context.Context, self string, plan updateRestartPlan) error {
switch {
case plan.restartDetached:
fmt.Println("正在重启后台 agent …")
if err := cmdStart(ctx); err != nil {
return fmt.Errorf("restart daemon: %w", err)
}
return nil
case plan.restartManagedPID > 0:
fmt.Println("正在通过原 supervisor 重启 agent …")
return restartManagedAgent(self, plan.restartManagedPID)
default:
return nil
}
}
// How long to wait for the supervisor to put a new agent in place of the one we
// asked to exit. zyl's Scheduled Task wrapper sleeps 3s between runs; systemd
// units are usually faster.
const (
managedRestartAttempts = 20
managedRestartPoll = time.Second
)
type managedRestartResult int
const (
managedRestartReplaced managedRestartResult = iota // a different, live agent is registered
managedRestartStopped // the old agent is gone, nothing took over
managedRestartStuck // the old agent is still running
)
// restartManagedAgent asks the supervisor-owned agent to exit and confirms that
// something newer took its place.
//
// Reporting success without that confirmation hid a real upgrade failure: when
// the supervisor runs the agent under another account (a Scheduled Task as
// SYSTEM, a systemd unit as root), a user-owned `wanctl update` cannot terminate
// it. The kill happens in a detached helper whose error goes nowhere, so the
// update printed "正在通过原 supervisor 重启 agent …" and exited 0 while the old
// build kept serving — the new binary on disk made it look done.
func restartManagedAgent(self string, pid int) error {
if !canTerminatePID(pid) {
return fmt.Errorf(`新二进制已装好,但运行中的 agent (pid %d) 属于另一个账户(由 supervisor 托管),当前用户无权终止它 —— 旧版本仍在服务。
请以管理员/root 重启那个服务,例如:
Windows 计划任务 Stop-ScheduledTask -TaskName WanctlAgent; Start-ScheduledTask -TaskName WanctlAgent
systemd sudo systemctl restart <unit>
launchd sudo launchctl kickstart -k system/<label>`, pid)
}
if err := scheduleManagedRestart(self, pid); err != nil {
return fmt.Errorf("restart supervised agent: %w", err)
}
switch awaitManagedRestart(pid, processAlive, config.ReadPID, managedRestartAttempts, managedRestartPoll, time.Sleep) {
case managedRestartReplaced:
fmt.Println("✓ agent 已重启,新版本生效")
return nil
case managedRestartStopped:
return fmt.Errorf(`旧 agent (pid %d) 已停止,但 %s 内没有新 agent 接上 —— 这台机器现在没有 agent 在跑。
如果它由「登录时触发」的任务托管,重新登录或手动启动该服务;或者跑 wanctl start 先把它拉起来`,
pid, time.Duration(managedRestartAttempts)*managedRestartPoll)
default:
return fmt.Errorf(`旧 agent (pid %d) 在 %s 后仍在运行,新二进制没有生效。
手动重启它托管的服务,然后用 wanctl status 确认 pid 变了`,
pid, time.Duration(managedRestartAttempts)*managedRestartPoll)
}
}
// awaitManagedRestart polls until a live agent other than oldPID is registered,
// then reports what it saw. Clock and probes are injected so the decision table
// is testable without real processes.
func awaitManagedRestart(oldPID int, alive func(int) bool, currentPID func() int, attempts int, poll time.Duration, sleep func(time.Duration)) managedRestartResult {
for attempt := 0; ; attempt++ {
if pid := currentPID(); pid > 0 && pid != oldPID && alive(pid) {
return managedRestartReplaced
}
if attempt >= attempts {
break
}
sleep(poll)
}
if alive(oldPID) {
return managedRestartStuck
}
return managedRestartStopped
}
// scheduleManagedRestart starts the freshly installed binary outside the
// agent's process tree. The helper waits for the update command's output to be
// relayed, terminates the old agent, and lets its existing supervisor restart
// it with the original flags and identity.
func scheduleManagedRestart(self string, pid int) error {
cmd := exec.Command(self, "__restart-managed", strconv.Itoa(pid))
cmd.SysProcAttr = detachSysProcAttr()
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
func cmdRestartManaged(args []string) error {
if len(args) != 1 {
return fmt.Errorf("internal managed restart expects one pid")
}
pid, err := strconv.Atoi(args[0])
if err != nil || pid <= 0 {
return fmt.Errorf("invalid managed agent pid %q", args[0])
}
time.Sleep(time.Second)
// Same rule as cmdStop: never signal a pid unless an agent still holds the
// lock for this config dir.
live, running := agentRunning()
if !running || live != pid || config.ManagedPID() != pid {
return nil
}
return terminatePID(pid)
}
// pathShadow reports the wanctl that a bare `wanctl` resolves to, when that is
// not the copy this update just replaced. An empty result means there is
// nothing to say: either the updated copy is the one that wins, or no bare
// `wanctl` resolves at all because the user invokes it by path.
//
// This replaced a routine that walked $PATH and deleted every *other* file
// named `wanctl`. Deleting was wrong twice over. A basename match is not an
// identification — a wrapper script that sets WANCTL_RELAY, a developer's own
// build, or a distro-packaged file called `wanctl` was removed without ever
// being read. And the root-owned-directory case re-execs this command under
// sudo (see splitUpdateViaSudo), so the deletion ran as root, where the "it
// fails with permission denied and we merely print a hint" mitigation the old
// code leaned on can never fire: `sudo wanctl update` removed /usr/bin/wanctl
// outright and whatever put it there found out later.
//
// Shadowing is the only thing worth reporting, because it is the only thing
// that changes what the user gets. A copy that loses the PATH race is inert,
// and hunting down inert files is not this command's business.
func pathShadow(self string) string {
// LookPath is the resolution itself rather than a reimplementation of it:
// it applies the executable bit on Unix and PATHEXT on Windows, so its
// answer is the one the user's shell would give.
found, lookErr := exec.LookPath("wanctl")
// ErrDot still yields a usable path — PATH contains "." and a bare command
// name really would run that file.
if lookErr != nil && !errors.Is(lookErr, exec.ErrDot) {
return ""
}
// SameFile rather than string comparison, so a symlink, a hard link or a
// bind mount onto the updated binary is correctly read as "no shadow".
selfInfo, err := os.Stat(self)
if err != nil {
return ""
}
foundInfo, err := os.Stat(found)
if err != nil {
return ""
}
if os.SameFile(selfInfo, foundInfo) {
return ""
}
return found
}
// reportPATHShadow tells the user when the binary they just upgraded is not the
// one a bare `wanctl` will run, and stops there. It names the command that
// answers what the other copy is, because this cannot tell them itself.
func reportPATHShadow(self string) {
other := pathShadow(self)
if other == "" {
return
}
fmt.Fprintf(os.Stderr,
"提示: 升级的是 %s,但 PATH 上先命中的是 %s —— 直接敲 `wanctl` 仍会跑到那一个。\n"+
" 先看看它是什么: %s --version\n"+
" 确认是旧版后自行删除,或把 %s 排到 PATH 前面。\n",
self, other, other, filepath.Dir(self))
}
// selectSignedUpdate fetches base's manifest, verifies its signature against a
// trusted key, and picks the artifact for one platform. It stops short of
// downloading, so the auto-updater can learn what is on offer before deciding
// whether it is in a position to install it.
func selectSignedUpdate(ctx context.Context, base, goos, goarch, currentVersion string) (wanrelease.Manifest, wanrelease.Artifact, error) {
manifestRaw, err := fetchLimited(ctx, base+"/"+wanrelease.ManifestName, wanrelease.MaxManifestSize)
if err != nil {
return wanrelease.Manifest{}, wanrelease.Artifact{}, err
}
signatureRaw, err := fetchLimited(ctx, base+"/"+wanrelease.SignatureName, 4096)
if err != nil {
return wanrelease.Manifest{}, wanrelease.Artifact{}, err
}
manifest, err := wanrelease.VerifyManifest(manifestRaw, signatureRaw, wanrelease.TrustedPublicKeys)
if err != nil {
return wanrelease.Manifest{}, wanrelease.Artifact{}, fmt.Errorf("verify release manifest: %w", err)
}
artifact, err := wanrelease.Select(manifest, goos, goarch, currentVersion)
if err != nil {
return wanrelease.Manifest{}, wanrelease.Artifact{}, err
}
return manifest, artifact, nil
}
// checkSignedUpdate reports the version base offers for this platform, or
// wraps ErrUpToDate when the manifest verified and holds nothing newer.
func checkSignedUpdate(ctx context.Context, base, goos, goarch, currentVersion string) (string, error) {
manifest, _, err := selectSignedUpdate(ctx, base, goos, goarch, currentVersion)
if err != nil {
return "", err
}
return manifest.Version, nil
}
// downloadSignedUpdate fetches and verifies a release from base, a URL under
// which the artifacts live flat: a relay's /dl mirror or a GitHub release's
// download path — both serve manifest.json and the binaries side by side.
func downloadSignedUpdate(ctx context.Context, base, dir, goos, goarch, currentVersion string) (string, string, error) {
manifest, artifact, err := selectSignedUpdate(ctx, base, goos, goarch, currentVersion)
if err != nil {
return "", "", err
}
url := base + "/" + artifact.Name
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", "", err
}
// The relay transport, not a plain client: a binary is the largest thing
// wanctl ever downloads, and on links that shape TLS over TCP to the
// relay's CDN a plain client took longer than this timeout for it, so
// agents there never updated (2026-09-23).
cl := &http.Client{Transport: relayhttp.Shared(), Timeout: 5 * time.Minute}
resp, err := cl.Do(req)
if err != nil {
return "", "", fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("fetch %s: %s", url, resp.Status)
}
if resp.ContentLength > artifact.Size {
return "", "", fmt.Errorf("artifact content length %d exceeds signed size %d", resp.ContentLength, artifact.Size)
}
f, err := os.CreateTemp(dir, "wanctl-update-*.tmp")
if err != nil {
return "", "", fmt.Errorf("create tempfile in %s: %w", dir, err)
}
if err := wanrelease.VerifyArtifact(resp.Body, f, artifact); err != nil {
f.Close()
os.Remove(f.Name())
return "", "", fmt.Errorf("verify downloaded %s: %w", artifact.Name, err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(f.Name())
return "", "", fmt.Errorf("sync downloaded artifact: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return "", "", err
}
return f.Name(), manifest.Version, nil
}
func fetchLimited(ctx context.Context, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := (&http.Client{Transport: relayhttp.Shared(), Timeout: time.Minute}).Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s: %s", url, resp.Status)
}
if resp.ContentLength > limit {
return nil, fmt.Errorf("fetch %s: response too large", url)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("fetch %s: response too large", url)
}
return raw, nil
}