Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
1442 lines (1392 loc) · 50 KB
/
Copy pathmain.go
File metadata and controls
1442 lines (1392 loc) · 50 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
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Command wanctl is a cross-internet remote-control CLI. The same binary runs as
// the relay (`wanctl relay`), the controlled device
// (`wanctl agent`), and the controller (`wanctl exec/push/pull`). Endpoints meet
// through the relay's WebSocket broker and speak end-to-end mutual TLS, so the
// relay only sees ciphertext.
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"wanctl/internal/agent"
"wanctl/internal/catalog"
// Android has no /etc/resolv.conf, so a CGO_ENABLED=0 binary resolves
// nothing until this package's init points the Go resolver somewhere real.
// Imported for that side effect; it compiles to nothing elsewhere.
_ "wanctl/internal/androiddns"
"wanctl/internal/client"
"wanctl/internal/config"
"wanctl/internal/desktop"
"wanctl/internal/eventlog"
"wanctl/internal/limits"
mcppkg "wanctl/internal/mcp"
"wanctl/internal/policy"
"wanctl/internal/portal"
"wanctl/internal/protocol"
"wanctl/internal/relay"
"wanctl/internal/script"
"wanctl/internal/serverlog"
"wanctl/internal/transport"
"wanctl/internal/webfetch"
)
// usage is the short index bare `wanctl` prints. It used to be sixty lines of
// every flag of every subcommand, which is the same as printing nothing: the
// reader who needed one command scrolled past it. The index names each command
// in one line and points at `wanctl help <command>`, which renders that
// command's full entry from internal/catalog — the same text the MCP server
// registers as its tool description.
var usage = catalog.Index(defaultRelay, defaultPortal)
// withHelp points a FlagSet's -h at its catalog entry instead of Go's raw flag
// dump. A subcommand whose FlagSet is named after it ("exec") resolves
// directly; a nested one ("docs ls") falls back to its parent's entry, which is
// where the subcommand's own syntax is written.
func withHelp(fs *flag.FlagSet) *flag.FlagSet {
name := fs.Name()
c, ok := catalog.Lookup(name)
if !ok {
if i := strings.Index(name, " "); i > 0 {
c, ok = catalog.Lookup(name[:i])
}
}
if !ok {
return fs
}
fs.Usage = func() { fmt.Fprint(fs.Output(), catalog.Entry(c)) }
return fs
}
// isHelpFlag reports whether an argument is a request for help rather than
// input. `help` itself is deliberately absent: it is a plausible thing to run
// on a device, and `wanctl exec help` must stay a command.
func isHelpFlag(arg string) bool {
return arg == "-h" || arg == "-help" || arg == "--help"
}
// cmdHelp renders the contract: the index, one command's entry, or the whole
// catalog as Markdown (which is what docs/contract.md is generated from).
// Either spelling resolves, so an AI that only knows the MCP tool name can run
// `wanctl help wanctl_read`.
func cmdHelp(args []string) error {
if len(args) > 0 && (args[0] == "--markdown" || args[0] == "-markdown") {
fmt.Print(catalog.Markdown())
return nil
}
// The same text an MCP host is handed in its initialize response. Printing
// it here is how anything that is not an MCP client — the discovery page, a
// person deciding what this thing will do to their machine — reads the
// instructions the agent is working from, without a second copy existing.
if len(args) > 0 && (args[0] == "--instructions" || args[0] == "-instructions") {
fmt.Print(catalog.Instructions())
return nil
}
if len(args) == 0 {
fmt.Print(usage)
return nil
}
name := strings.Join(args, " ")
c, ok := catalog.Lookup(name)
if !ok {
// Exiting non-zero matters: `wanctl help typo` in a script should fail
// rather than look like it documented something.
fmt.Fprintf(os.Stderr, "wanctl: no such command %q\n\n%s", name, usage)
os.Exit(2)
}
fmt.Print(catalog.Entry(c))
return nil
}
// Deployment defaults live in internal/config so they can be injected with
// -ldflags while environment variables still take precedence at runtime.
var (
defaultRelay = settingValue("relay")
defaultPortal = settingValue("portal")
)
const defaultTransport = config.DefaultTransport
func configuredDisplay(value, empty string) string {
if value == "" {
return empty
}
return value
}
func main() {
if len(os.Args) == 3 && os.Args[1] == "__desktop-pipe" {
os.Exit(desktop.PipeHelperMain(os.Args[2]))
}
if len(os.Args) == 2 && os.Args[1] == "__desktop" {
os.Exit(desktop.HelperMain(os.Stdin, os.Stdout))
}
if len(os.Args) < 2 {
// Bare `wanctl` explains itself and does nothing else. It used to
// enroll and start an agent, so someone on a controller-only machine
// who ran it to see what it does turned that machine into a controlled
// device. `wanctl start` is the device command; `wanctl login` is the
// controller one.
fmt.Print(usage)
fmt.Println(localStatusLine())
return
}
ctx := context.Background()
var err error
// `wanctl exec -h` is a question about wanctl, not a use of it, so it is
// answered before the relay gate below: a binary that does not yet know
// which instance it talks to must still be able to explain itself. It also
// reaches the commands that read a subcommand before any FlagSet exists,
// where a -h would otherwise land as a bad argument.
if len(os.Args) == 3 && isHelpFlag(os.Args[2]) {
if _, ok := catalog.Lookup(os.Args[1]); ok {
if err := cmdHelp(os.Args[1:2]); err != nil {
fmt.Fprintln(os.Stderr, "wanctl: "+err.Error())
os.Exit(1)
}
return
}
}
if relayCommands[os.Args[1]] {
// The first-run question happens before the command's own work, so a
// binary that does not know where to connect asks instead of failing
// deep inside a dial (GitHub issue #11).
if gateErr := ensureRelayConfigured(""); gateErr != nil {
fmt.Fprintln(os.Stderr, "wanctl: "+gateErr.Error())
os.Exit(1)
}
}
switch os.Args[1] {
case "relay":
err = cmdRelay(os.Args[2:])
case "portal":
err = cmdPortal(os.Args[2:])
case "agent":
err = cmdAgent(ctx, os.Args[2:])
case "exec":
err = cmdExec(ctx, os.Args[2:])
case "workspace":
err = cmdWorkspace(ctx, os.Args[2:])
case "screenshot":
err = cmdScreenshot(ctx, os.Args[2:])
case "act":
err = cmdAct(ctx, os.Args[2:])
case "push":
err = cmdPush(ctx, os.Args[2:])
case "pull":
err = cmdPull(ctx, os.Args[2:])
case "read":
err = cmdRead(ctx, os.Args[2:])
case "edit":
err = cmdEdit(ctx, os.Args[2:])
case "write":
err = cmdWrite(ctx, os.Args[2:])
case "peers":
err = cmdPeers(ctx)
case "id":
err = cmdID()
case "pair":
err = cmdPair(ctx, os.Args[2:])
case "trust":
err = cmdTrust(os.Args[2:])
case "portal-admins":
err = cmdPortalAdmins(os.Args[2:])
case "rules":
err = cmdRules(os.Args[2:])
case "logs":
err = cmdLogs(ctx, os.Args[2:])
case "label":
err = cmdLabel(os.Args[2:])
case "login":
err = cmdLogin(ctx, os.Args[2:])
case "config":
err = cmdConfig(os.Args[2:])
case "docs":
err = cmdDocs(ctx, os.Args[2:])
case "friends":
err = cmdFriends(ctx, os.Args[2:])
case "share":
err = cmdShare(ctx, os.Args[2:])
case "start":
err = cmdStart(ctx)
case "stop":
err = cmdStop()
case "status":
err = cmdStatus(ctx, os.Args[2:])
case "logout":
err = cmdLogout()
case "update":
err = cmdUpdate(ctx, os.Args[2:])
case "__restart-managed":
err = cmdRestartManaged(os.Args[2:])
case "__supervise":
err = cmdSupervise(ctx, os.Args[2:])
case "version":
fmt.Println(buildVersion)
return
case "admin":
err = cmdAdmin(os.Args[2:])
case "service":
err = cmdService(ctx, os.Args[2:])
case "mcp":
err = cmdMCP(ctx, os.Args[2:])
case "-h", "--help", "help":
err = cmdHelp(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "wanctl: "+errorText(err))
os.Exit(1)
}
}
// relayCommands cannot do anything without a relay, and take no --relay of
// their own, so the first-run gate runs for them before dispatch. Deliberately
// absent: `update` (an official build bakes a release page and needs no
// relay), `logs` (reads the local device log when no target is given),
// `service` (has its own --relay, and status/uninstall need none), `status`
// (a diagnostic that reports the missing relay itself), and the servers
// `relay`/`portal`. `agent` runs the same gate itself, after parsing --relay.
var relayCommands = map[string]bool{
"start": true, "login": true,
"exec": true, "screenshot": true, "act": true, "push": true, "pull": true,
"read": true, "edit": true, "write": true, "workspace": true,
"peers": true, "pair": true, "friends": true, "share": true,
"docs": true, "admin": true,
}
// settingValue is config.Setting without the source, for flag defaults and
// display lines.
func settingValue(key string) string {
v, _ := config.Setting(key)
return v
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func cmdRelay(args []string) error {
fs := withHelp(flag.NewFlagSet("relay", flag.ExitOnError))
addr := fs.String("addr", ":8080", "listen address")
fs.Parse(args)
logs := serverlog.NewDefault()
log.SetOutput(io.MultiWriter(os.Stderr, logs))
log.SetFlags(log.LstdFlags)
adminSecret := os.Getenv("WANCTL_ADMIN_SECRET")
if err := validateAdminSecret(adminSecret); err != nil {
return err
}
var r *relay.Relay
var pgStore *relay.PGStore
if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
pg, err := relay.OpenPG(dsn)
if err != nil {
return fmt.Errorf("postgres: %w", err)
}
r = relay.New(pg)
pgStore = pg
r.SetACL(pg)
r.SetAuditor(pg)
r.SetAdmin(pg)
r.SetDocs(pg)
log.Print("wanctl relay: token store = postgres (hashed tokens + ACL + audit)")
} else {
spec := os.Getenv("WANCTL_TOKENS")
upstream := os.Getenv("WANCTL_UPSTREAM_RELAY")
var stores relay.ChainTokenStore
if spec != "" {
stores = append(stores, relay.EnvTokenStore(spec))
}
if upstream != "" {
sec := adminSecret
if sec == "" {
return fmt.Errorf("WANCTL_UPSTREAM_RELAY needs WANCTL_ADMIN_SECRET (shared with the upstream relay)")
}
stores = append(stores, relay.NewUpstreamTokenStore(strings.TrimRight(upstream, "/"), sec))
}
if len(stores) == 0 {
return fmt.Errorf("set DATABASE_URL (postgres), WANCTL_TOKENS=\"token:namespace,...\", or WANCTL_UPSTREAM_RELAY")
}
r = relay.New(stores)
if upstream != "" {
log.Printf("wanctl relay: token store = env + upstream (%s)", upstream)
} else {
log.Print("wanctl relay: token store = env (WANCTL_TOKENS)")
}
}
// The admin secret gates /admin/* (portal access + satellite-relay token
// resolution). Set it regardless of the token-store backend: a satellite
// relay may itself be asked to resolve for another one, and the resolve
// endpoint only needs the token store.
if adminSecret != "" {
r.SetAdminSecret(adminSecret)
log.Print("wanctl relay: admin API enabled (secret-gated)")
}
r.SetLogBuffer(logs)
if pns := os.Getenv("WANCTL_PORTAL_NS"); pns != "" {
r.SetPortalNS(pns)
}
if seedHex := os.Getenv("WANCTL_MCP_SEED"); seedHex != "" {
seed, err := hex.DecodeString(seedHex)
if err != nil {
return fmt.Errorf("WANCTL_MCP_SEED must be hex-encoded: %w", err)
}
// The hosted endpoint authenticates with OAuth only (v0.19.0, ADR 0008),
// and OAuth needs three things: somewhere durable to keep clients and
// refresh tokens, a public origin to publish as the issuer, and a portal
// to host the consent page. Without all three nobody could get in, so
// /mcp says why instead of serving sessions no one can use.
missing := ""
switch {
case pgStore == nil:
missing = "DATABASE_URL (for OAuth clients and refresh tokens)"
case os.Getenv("WANCTL_PUBLIC_ORIGIN") == "":
missing = "WANCTL_PUBLIC_ORIGIN (the OAuth issuer)"
case os.Getenv("WANCTL_PORTAL") == "":
missing = "WANCTL_PORTAL (hosts the OAuth consent page)"
}
if missing != "" {
reason := "hosted MCP is off on this relay: it authenticates with OAuth only, which needs " + missing +
". AI hosts on your own machines can run the local stdio server, `wanctl mcp`."
log.Print("wanctl relay: " + reason)
r.SetMCPHandler(mcppkg.Unavailable(reason))
} else {
r.SetMCPOAuth(seed, pgStore)
h, err := mcppkg.Handler(mcppkg.Options{
Seed: seed,
EndpointPath: "/mcp",
OAuth: &mcppkg.OAuthConfig{
ResourceMetadataURL: strings.TrimRight(os.Getenv("WANCTL_PUBLIC_ORIGIN"), "/") +
"/.well-known/oauth-protected-resource",
Live: r.ResolveOAuthToken,
Revoke: r.RevokeOAuthRelayToken,
},
})
if err != nil {
return fmt.Errorf("mcp handler: %w", err)
}
r.SetMCPHandler(h)
// The MCP server keeps its pinned device identities in this process's
// memory. Unbinding a device has to reach them, the same way it reaches
// the portal's own store (ADR 0002).
r.SetPinForgetter(mcppkg.ForgetPinnedDevice)
log.Print("wanctl relay: MCP server enabled at /mcp (alias /wanctl-mcp, Streamable HTTP, OAuth; authorize on the portal, tokens at /oauth/token)")
}
}
if seedHex := os.Getenv("WANCTL_WEBFETCH_SEED"); seedHex != "" {
if pgStore == nil {
return fmt.Errorf("WebFetch requires DATABASE_URL for durable delegation and job records")
}
seed, err := hex.DecodeString(seedHex)
if err != nil {
return fmt.Errorf("WANCTL_WEBFETCH_SEED must be hex-encoded")
}
publicOrigin := os.Getenv("WANCTL_PUBLIC_ORIGIN")
relayURL := os.Getenv("WANCTL_WEBFETCH_RELAY_URL")
if relayURL == "" {
relayURL = publicOrigin
}
h, err := webfetch.New(webfetch.Config{
Store: pgStore, Jobs: pgStore, Seed: seed,
PublicOrigin: publicOrigin, RelayURL: relayURL,
PortalOrigin: os.Getenv("WANCTL_WEBFETCH_PORTAL_ORIGIN"),
})
if err != nil {
return fmt.Errorf("webfetch: %w", err)
}
defer h.Close()
r.SetWebFetchHandler(h)
log.Print("wanctl relay: WebFetch enabled at /webfetch (owner-approved device delegation)")
}
log.Printf("wanctl relay listening on %s", *addr)
return limits.HTTPServer(*addr, r.Handler()).ListenAndServe()
}
func validateAdminSecret(secret string) error {
if secret != "" && len(secret) < 32 {
return fmt.Errorf("WANCTL_ADMIN_SECRET must be at least 32 bytes when set (have %d)", len(secret))
}
return nil
}
func cmdPortal(args []string) error {
fs := withHelp(flag.NewFlagSet("portal", flag.ExitOnError))
addr := fs.String("addr", ":8080", "listen address")
fs.Parse(args)
logs := serverlog.NewDefault()
log.SetOutput(io.MultiWriter(os.Stderr, logs))
log.SetFlags(log.LstdFlags)
id, err := transport.LoadOrCreateIdentity()
if err != nil {
return err
}
known, err := transport.OpenStore("known_servers.json")
if err != nil {
return err
}
ghClientID := os.Getenv("WANCTL_GITHUB_CLIENT_ID")
sessionSecret := os.Getenv("WANCTL_SESSION_SECRET")
if ghClientID != "" {
// The two login modes must not coexist: with OAuth active the identity
// header is ignored, and a deployment that sets both is confused about
// which proxy it trusts.
if os.Getenv("PORTAL_USER_HEADER") != "" {
return fmt.Errorf("set either WANCTL_GITHUB_CLIENT_ID (OAuth login) or PORTAL_USER_HEADER (trusted proxy), not both")
}
if os.Getenv("WANCTL_GITHUB_CLIENT_SECRET") == "" {
return fmt.Errorf("WANCTL_GITHUB_CLIENT_ID is set but WANCTL_GITHUB_CLIENT_SECRET is empty")
}
if len(sessionSecret) < 32 {
return fmt.Errorf("WANCTL_SESSION_SECRET must be at least 32 bytes when OAuth login is enabled (have %d)", len(sessionSecret))
}
}
githubTransport, err := portal.GitHubProxyTransport(os.Getenv("WANCTL_GITHUB_PROXY"))
if err != nil {
return err
}
p := portal.New(portal.Config{
SMTPAddr: os.Getenv("WANCTL_SMTP_ADDR"),
SMTPUser: os.Getenv("WANCTL_SMTP_USER"),
SMTPPassword: os.Getenv("WANCTL_SMTP_PASSWORD"),
MailFrom: os.Getenv("WANCTL_MAIL_FROM"),
GitHubTransport: githubTransport,
RelayAdminURL: os.Getenv("RELAY_ADMIN_URL"),
AdminSecret: os.Getenv("WANCTL_ADMIN_SECRET"),
UserHeader: os.Getenv("PORTAL_USER_HEADER"),
GitHubClientID: ghClientID,
GitHubClientSecret: os.Getenv("WANCTL_GITHUB_CLIENT_SECRET"),
SessionSecret: sessionSecret,
GitHubAuthBase: os.Getenv("WANCTL_GITHUB_AUTH_BASE"),
GitHubAPIBase: os.Getenv("WANCTL_GITHUB_API_BASE"),
RelayDialURL: config.EnvOr("WANCTL_RELAY", config.DefaultRelay),
PortalToken: os.Getenv("WANCTL_PORTAL_TOKEN"),
Transport: envOr("WANCTL_TRANSPORT", "http"),
Identity: id,
Known: known,
PublicOrigin: os.Getenv("PORTAL_PUBLIC_ORIGIN"),
DebugWhoami: os.Getenv("PORTAL_DEBUG_WHOAMI") == "1",
})
p.SetLogBuffer(logs)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
p.Start(ctx)
defer p.Close()
loginMode := "header " + strconv.Quote(envOr("PORTAL_USER_HEADER", "X-Auth-Request-Email"))
if ghClientID != "" {
loginMode = "github oauth (client " + ghClientID + ")"
}
log.Printf("wanctl portal on %s\n identity: %s\n login: %s\n relay(admin): %q\n relay(dial): %q",
*addr, id.Fingerprint, loginMode,
os.Getenv("RELAY_ADMIN_URL"), os.Getenv("WANCTL_RELAY"))
server := limits.HTTPServer(*addr, p.Handler())
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func cmdAgent(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("agent", flag.ExitOnError))
name := fs.String("name", "", "display name (default hostname; does not change device ID)")
relayURL := fs.String("relay", settingValue("relay"), "relay ws(s) URL")
token := fs.String("token", envOr("WANCTL_TOKEN", config.StoredToken()), "access/registration token")
shell := fs.String("shell", "", "shell (default powershell on Windows, /bin/sh elsewhere)")
yes := fs.Bool("yes", false, "auto-trust new controllers (unattended)")
tr := fs.String("transport", config.Transport(), "transport: ws or http (http is proxy-agnostic)")
mode := fs.String("mode", "", "policy mode: normal (prompt on miss) or bypass (auto-allow, DANGEROUS). Empty = keep the last persisted mode (default normal).")
managed := fs.Bool("managed", false, "agent is owned by an external supervisor")
portalFPS := fs.String("portal-fps", config.PortalFingerprintsEnv(), "comma-separated portal admin fingerprints to seed locally")
portalPK := fs.String("portal-pk", "", "deprecated alias for one --portal-fps entry")
approvalsStdio := fs.Bool("approvals-stdio", false, "for the wanctl Android app, which runs the agent as its child: stdout carries approval cards (wanctl-approval lines) and stdin carries the owner's decisions")
fs.Parse(args)
portalRaw := *portalFPS
if *portalPK != "" {
if portalRaw != "" {
portalRaw += ","
}
portalRaw += *portalPK
}
parsedPortalFPs, err := config.ParsePortalFingerprints(portalRaw)
if err != nil {
return fmt.Errorf("portal fingerprints: %w", err)
}
if *token == "" {
return fmt.Errorf("provide --token (or WANCTL_TOKEN)")
}
if err := ensureRelayConfigured(*relayURL); err != nil {
return err
}
if *relayURL == "" {
if *relayURL, err = config.Relay(); err != nil {
return err
}
}
ag, err := agent.New(agent.Options{RelayURL: *relayURL, Token: *token, Name: *name, Shell: *shell, AutoYes: *yes, Transport: *tr, Mode: policy.Mode(*mode), PortalFPs: parsedPortalFPs, Version: buildVersion, ApprovalsStdio: *approvalsStdio})
if err != nil {
return err
}
// Warn on the EFFECTIVE mode (which may be a persisted bypass, not just an
// explicit --mode bypass flag).
if ag.Mode() == policy.ModeBypass {
fmt.Fprintln(os.Stderr, "wanctl: BYPASS mode — every command and file op is auto-allowed. Use only on trusted, isolated devices.")
}
lock, err := awaitAgentLock(config.AcquireAgentLock, agentLockAttempts, agentLockPoll, time.Sleep)
if err != nil {
if config.IsAgentLockHeld(err) {
fmt.Fprintln(os.Stderr, "wanctl: "+lockHeldMessage(config.ReadPID(), os.Getpid()))
return nil
}
return err
}
defer lock.Close()
// Self-register the pid so `wanctl status`/`stop` see this agent no matter how
// it was launched (bare `wanctl`, a keeper task, a systemd/launchd service),
// not just the child that `wanctl start` spawns.
pid := os.Getpid()
_ = config.WritePID(pid)
if *managed {
_ = config.WriteManagedPID(pid)
} else {
_ = config.RemoveManagedPID(config.ManagedPID())
}
// handedOver is set when a successor process — the one an auto-update
// started — already owns these files. Removing them then would leave the
// new agent invisible to `wanctl status` and `wanctl stop`.
handedOver := false
defer func() {
if handedOver {
return
}
_ = config.RemoveManagedPID(pid)
_ = config.RemovePID()
}()
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
// Said before the relay is dialled, so a log that starts with a connection
// failure still records which build produced it.
fmt.Printf("wanctl agent %s 已启动\n", buildVersion)
// The updater stops this agent by cancelling its context; Run returns, and
// the restart below hands the device to the binary now on disk.
runCtx, stopRun := context.WithCancel(ctx)
defer stopRun()
updater := startAutoUpdate(runCtx, buildVersion, ag.Busy, stopRun)
runErr := ag.Run(runCtx)
if updater != nil && updater.updated() {
over, err := restartAgentForUpdate(updater.binaryPath(), os.Args, lock)
if err != nil {
return err
}
handedOver = over
return nil
}
return runErr
}
// agentLockAttempts/agentLockPoll bound the wait for a predecessor to let go of
// the config-dir lock. An agent started by `wanctl start` (or by the restart
// half of `wanctl update`) can arrive while the agent it replaces is still
// shutting down: the parent signalled it and did not wait. Exiting immediately
// then left the machine with no agent at all, after the parent had already told
// the user one was running. Five seconds covers an ordinary shutdown; beyond
// that something is wrong and saying so beats waiting.
const (
agentLockAttempts = 50
agentLockPoll = 100 * time.Millisecond
)
// awaitAgentLock retries acquisition while the lock is merely held, and returns
// any other error at once. The clock is injected so the retry is testable.
func awaitAgentLock(acquire func() (*config.AgentLock, error), attempts int, poll time.Duration, sleep func(time.Duration)) (*config.AgentLock, error) {
lock, err := acquire()
for attempt := 0; attempt < attempts && err != nil && config.IsAgentLockHeld(err); attempt++ {
sleep(poll)
lock, err = acquire()
}
return lock, err
}
// lockHeldMessage explains a lock this agent could not take. The pid file is
// not proof of who holds it: `wanctl start` records the pid of the child it
// spawned before that child has locked anything, so an agent that lost this
// race read its *own* pid there and reported "another agent is already running
// (pid <itself>)" -- which sent the reader looking for a process that was the
// one printing the message.
func lockHeldMessage(recordedPID, self int) string {
if recordedPID == self || recordedPID <= 0 {
return "the previous agent has not released this config dir yet; exiting. Run `wanctl start` once it has stopped"
}
return fmt.Sprintf("another agent is already running for this config dir (pid %d); exiting", recordedPID)
}
func cmdExec(ctx context.Context, args []string) error {
// Legacy exec forwards Ctrl-C to the device. Workspace exec only stops
// waiting: its device-owned request can be polled or explicitly cancelled.
// Both controller paths use the shell's conventional 128+SIGINT exit code.
// SIGTERM is treated the same way: it is what a tool that times a command
// out sends, and without it the device went on running the command and the
// relay held the abandoned session until its sweeper noticed.
ctx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
fs := withHelp(flag.NewFlagSet("exec", flag.ExitOnError))
target := fs.String("target", "", "device ID or unique name (NS/DEV or DEV)")
workspace := workspaceFlag(fs)
requestID := fs.String("request-id", "", "workspace command ID; reuse unchanged after an uncertain result")
async := fs.Bool("async", false, "workspace only: return JSON immediately; collect with workspace poll")
oneShot := fs.Bool("oneshot", false, "fresh shell, no session state")
cwd := fs.String("cwd", "", "working directory on the device (also the policy scope)")
scriptPath := fs.String("script", "", "run a local script file on the device instead of a command string;\n"+
"\tno shell quoting or encoding hazards — the file is sent base64-encoded.\n"+
"\tInterpreter comes from the extension (.ps1 -> PowerShell, .sh/none -> sh)")
interp := fs.String("interp", "", "override the -script interpreter: powershell | sh")
elevateFlag := fs.Bool("elevate", false, "run with elevated privilege on the device (Android: root or the\n"+
"\tdevice's own adbd — whichever is available). Its own policy class:\n"+
"\tneeds an approval or an exec-elevated rule, unless the device is in\n"+
"\tbypass mode AND has its elevation channel switched on.")
via := fs.String("via", "", "pin the elevation channel: su | adb.\n"+
"\tFails if that channel is unavailable rather than falling back.")
fs.Parse(args)
if *via != "" && !*elevateFlag {
// -via without -elevate would otherwise be silently ignored, and the
// command would run unprivileged while looking like it asked not to.
*elevateFlag = true
}
commandArgs := fs.Args()
ref, routeErr := workspaceRoute(*target, *workspace)
if routeErr != nil {
return routeErr
}
if ref.ID == "" && (*requestID != "" || *async) {
return fmt.Errorf("--request-id and --async require a workspace")
}
var c *client.Client
var err error
if ref.ID == "" && *target == "" && len(commandArgs) > 0 {
c, err = client.New()
if err != nil {
return err
}
aliases, err := c.PeerAliases(ctx)
if err != nil {
return err
}
*target, commandArgs = inferExecTarget(*target, commandArgs, aliases)
}
command := strings.TrimSpace(strings.Join(commandArgs, " "))
if *scriptPath != "" {
if command != "" {
return fmt.Errorf("give either -script or a command, not both")
}
if ref.ID == "" {
var err error
if command, err = buildScriptCommand(*scriptPath, *interp); err != nil {
return err
}
}
}
if command == "" && *scriptPath == "" {
return fmt.Errorf("no command given (pass a command string, or -script <file>)")
}
// The command is source for the device's shell, so it gets parsed there
// before anything else runs. A nested `powershell -Command "...$x..."` is
// therefore parsed twice and loses its variables to the outer pass — a
// failure that surfaces as a bogus "term is not recognized" from the inner
// script. Say so at the moment it would happen rather than in the docs.
if *scriptPath == "" && script.NestedPowerShellExpansion(command) {
fmt.Fprintln(os.Stderr, "wanctl: warning: this command nests `powershell -Command \"...\"` with a $ inside the double quotes.")
fmt.Fprintln(os.Stderr, " The device's shell expands those variables before the inner PowerShell sees them.")
fmt.Fprintln(os.Stderr, " Use single quotes for the inner script, or `wanctl exec -script <file.ps1>`.")
}
warnPOSIXShellQuoteLoss(os.Stderr, *scriptPath, commandArgs)
if c == nil {
c, err = client.New()
if err != nil {
return err
}
}
stdout, flushOut := deviceOutput(os.Stdout)
stderr, flushErr := deviceOutput(os.Stderr)
if ref.ID != "" {
code, err := execWorkspace(ctx, c, ref, protocol.Message{
Command: command, RequestID: *requestID, Cwd: *cwd,
OneShot: *oneShot, Elevate: *elevateFlag, Via: *via,
}, *scriptPath, *interp, *async, stdout, stderr)
flushOut()
flushErr()
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "wanctl: stopped waiting; the remote command may still be running. Use workspace poll with the request_id above, or workspace cancel to stop it")
os.Exit(130)
}
if err != nil {
return err
}
os.Exit(code)
}
code, err := c.ExecTo(ctx, client.ExecRequest{
Target: *target, Command: command, OneShot: *oneShot, Cwd: *cwd,
Elevate: *elevateFlag, Via: *via,
}, stdout, stderr)
flushOut()
flushErr()
if err != nil {
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "wanctl: interrupted — sent a cancel to the device")
os.Exit(130) // 128 + SIGINT, what a shell reports for an interrupted command
}
return err
}
os.Exit(code)
return nil
}
func firstBytes(b []byte, n int) string {
if len(b) > n {
b = b[:n]
}
return strings.ToValidUTF8(string(b), "?")
}
func warnPOSIXShellQuoteLoss(w io.Writer, scriptPath string, args []string) {
if scriptPath != "" || !script.POSIXShellQuoteLoss(args) {
return
}
fmt.Fprintln(w, "wanctl: warning: this looks like `sh -c '<script>'`, but your local shell already removed the quotes,")
fmt.Fprintln(w, " so the device receives separate words and may run only the first one.")
fmt.Fprintln(w, " Use `wanctl exec -script <file.sh>`; it is sent base64-encoded and cannot be re-split.")
}
// inferExecTarget supports the conventional `exec DEVICE COMMAND` spelling.
// An explicit -target is authoritative and disables positional inference, which
// is also the escape hatch for a command whose name matches an online device.
func inferExecTarget(target string, args, peerAliases []string) (string, []string) {
if target != "" || len(args) == 0 {
return target, args
}
for _, alias := range peerAliases {
if args[0] == alias {
return alias, args[1:]
}
}
return target, args
}
// buildScriptCommand turns a local script file into a command string that
// carries the script without exposing it to shell parsing. See execscript.go for
// why this exists.
func buildScriptCommand(path, interpFlag string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("-script expects a local file path; cannot read %q: %w", path, err)
}
var in script.Interp
if interpFlag != "" {
in, err = script.ParseInterp(interpFlag)
} else {
in, err = script.ForPath(path)
}
if err != nil {
return "", err
}
return script.Command(in, data)
}
func cmdPush(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("push", flag.ExitOnError))
target := fs.String("target", "", "device")
fs.Parse(args)
if fs.NArg() != 2 {
return fmt.Errorf("usage: wanctl push <local> <remote>")
}
c, err := client.New()
if err != nil {
return err
}
// push is byte-exact on purpose, so this warns rather than rewrites: a
// BOM-less UTF-8 .ps1 is read by Windows PowerShell 5.1 as the ANSI code
// page, which mangles non-ASCII text and can eat the closing quote of a
// string literal — at which point PowerShell echoes the script instead of
// running it.
if data, err := os.ReadFile(fs.Arg(0)); err == nil && script.BomlessNonASCIIPowerShell(fs.Arg(1), data) {
fmt.Fprintln(os.Stderr, "wanctl: warning: "+fs.Arg(0)+" is a .ps1 with non-ASCII text and no UTF-8 BOM.")
fmt.Fprintln(os.Stderr, " Windows PowerShell 5.1 will read it as the ANSI code page and mangle that text.")
fmt.Fprintln(os.Stderr, " Add a BOM before pushing, or run it with `wanctl exec -script` instead.")
}
return c.Push(ctx, *target, fs.Arg(0), fs.Arg(1))
}
func cmdPull(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("pull", flag.ExitOnError))
target := fs.String("target", "", "device")
fs.Parse(args)
if fs.NArg() != 2 {
return fmt.Errorf("usage: wanctl pull <remote> <local>")
}
c, err := client.New()
if err != nil {
return err
}
return c.Pull(ctx, *target, fs.Arg(0), fs.Arg(1))
}
// cmdRead prints a line range of a remote text file. The content goes to
// stdout so it can be piped or redirected byte for byte; everything about the
// read — which lines these are, how many there are in total, the file's hash —
// goes to stderr, where it does not contaminate that.
func cmdRead(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("read", flag.ExitOnError))
target := fs.String("target", "", "device ID or unique name (NS/DEV or DEV)")
workspace := workspaceFlag(fs)
offset := fs.Int("offset", 0, "1-based line number to start at (default 1)")
limit := fs.Int("limit", 0, "maximum number of lines to return (default 2000)")
rest := parseAroundPositionals(fs, args)
if len(rest) != 1 {
return fmt.Errorf("usage: wanctl read [--target NS/DEV] <path> [--offset N] [--limit N]")
}
path := rest[0]
ref, err := workspaceRoute(*target, *workspace)
if err != nil {
return err
}
c, err := client.New()
if err != nil {
return err
}
res, err := c.ReadFile(ctx, client.ReadRequest{
Target: ref.Target, WorkspaceID: ref.ID, Path: path, Offset: *offset, Limit: *limit,
})
if err != nil {
return err
}
stdout, flushOut := deviceOutput(os.Stdout)
_, err = io.WriteString(stdout, res.Content)
flushOut()
if err != nil {
return err
}
truncated := "no"
if res.Truncated {
truncated = "yes"
}
stderr, flushErr := deviceOutput(os.Stderr)
defer flushErr()
fmt.Fprintf(stderr, "lines %d-%d of %d, sha256 %s, truncated=%s\n",
res.FirstLine, res.LastLine, res.TotalLines, res.SHA256, truncated)
switch {
case res.LongLine != 0:
// Asking again from the next line would return this same line forever.
fmt.Fprintf(os.Stderr, "line %d is larger than %d KiB; only its first part is shown — use exec with sed/cut to inspect it\n",
res.LongLine, protocol.MaxReadBytes>>10)
case res.Truncated:
fmt.Fprintf(os.Stderr, "continue with --offset %d\n", res.LastLine+1)
}
return nil
}
// cmdEdit replaces a string inside a remote file. --old/--new take the text
// directly; --old-file/--new-file read it from a local file, which is how you
// pass a multi-line block without fighting the shell over quoting.
func cmdEdit(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("edit", flag.ExitOnError))
target := fs.String("target", "", "device ID or unique name (NS/DEV or DEV)")
workspace := workspaceFlag(fs)
old := fs.String("old", "", "the exact text to find; must match once unless -all")
oldFile := fs.String("old-file", "", "read the text to find from this local file instead of -old")
newText := fs.String("new", "", "the text to put in its place (empty deletes)")
newFile := fs.String("new-file", "", "read the replacement from this local file instead of -new")
all := fs.Bool("all", false, "replace every occurrence instead of refusing when there is more than one")
sha := fs.String("sha", "", "refuse the edit unless the file still has this sha256 (from wanctl read)")
rest := parseAroundPositionals(fs, args)
if len(rest) != 1 {
return fmt.Errorf("usage: wanctl edit [--target NS/DEV] <path> (--old STR | --old-file F) (--new STR | --new-file F) [--all] [--sha SHA256]")
}
oldText, err := editText("old", *old, *oldFile)
if err != nil {
return err
}
replacement, err := editText("new", *newText, *newFile)
if err != nil {
return err
}
ref, err := workspaceRoute(*target, *workspace)
if err != nil {
return err
}
c, err := client.New()
if err != nil {
return err
}
res, err := c.EditFile(ctx, client.EditRequest{
Target: ref.Target, WorkspaceID: ref.ID, Path: rest[0],
Old: oldText, New: replacement, All: *all, ExpectedSHA: *sha,
})
if err != nil {
return err
}
out, flush := deviceOutput(os.Stdout)
defer flush()
fmt.Fprintf(out, "replaced %d occurrence(s), sha256 %s\n", res.Replaced, res.SHA256)
return nil
}
// cmdWrite creates a remote file or replaces one end to end. --content takes
// the text directly; --content-file reads it from a local file, which is how a
// whole config or script gets through without the shell touching it.
func cmdWrite(ctx context.Context, args []string) error {
fs := withHelp(flag.NewFlagSet("write", flag.ExitOnError))
target := fs.String("target", "", "device ID or unique name (NS/DEV or DEV)")
workspace := workspaceFlag(fs)
content := fs.String("content", "", "the whole new text of the file")
contentFile := fs.String("content-file", "", "read the content from this local file instead of -content")
rest := parseAroundPositionals(fs, args)
if len(rest) != 1 {
return fmt.Errorf("usage: wanctl write [--target NS/DEV] <path> (--content STR | --content-file F)")
}
text, err := editText("content", *content, *contentFile)
if err != nil {
return err
}
ref, err := workspaceRoute(*target, *workspace)
if err != nil {
return err
}
c, err := client.New()
if err != nil {
return err
}
res, err := c.WriteFile(ctx, client.WriteRequest{Target: ref.Target, WorkspaceID: ref.ID, Path: rest[0], Content: text})
if err != nil {
return err
}
verb := "overwrote"
if res.Created {
verb = "created"
}
out, flush := deviceOutput(os.Stdout)
defer flush()
fmt.Fprintf(out, "%s %s (%d bytes, sha256 %s)\n", verb, rest[0], res.SizeBytes, res.SHA256)
return nil
}