-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathmain.go
More file actions
7604 lines (6839 loc) · 227 KB
/
Copy pathmain.go
File metadata and controls
7604 lines (6839 loc) · 227 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
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"bufio"
cryptorand "crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/pilot-protocol/common/consent"
"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/common/protocol"
registry "github.com/pilot-protocol/common/registry/client"
"github.com/pilot-protocol/dataexchange"
"github.com/pilot-protocol/eventstream"
"github.com/pilot-protocol/policy/policylang"
"github.com/pilot-protocol/trustedagents"
)
var version = "dev"
// Global flags
var jsonOutput bool
var verbose bool
// Config paths
const (
defaultConfigDir = ".pilot"
defaultConfigFile = "config.json"
defaultPIDFile = "pilot.pid"
defaultLogFile = "pilot.log"
)
func defaultSocket() string {
return driver.DefaultSocketPath()
}
func configDir() string {
home, _ := os.UserHomeDir()
return home + "/" + defaultConfigDir
}
func configPath() string { return configDir() + "/" + defaultConfigFile }
func pidFilePath() string { return configDir() + "/" + defaultPIDFile }
func logFilePath() string { return configDir() + "/" + defaultLogFile }
func featureFlagsPath() string { return configDir() + "/feature-flags.json" }
// featureFlags is the in-process cache of ~/.pilot/feature-flags.json.
// Loaded once at startup via loadFeatureFlags(); never mutated after that.
var featureFlags map[string]bool
// loadFeatureFlags reads ~/.pilot/feature-flags.json if it exists. Missing
// file is not an error. Precedence (highest→lowest): CLI flag → env var →
// feature-flags.json → code default (false).
func loadFeatureFlags() {
featureFlags = map[string]bool{}
b, err := os.ReadFile(featureFlagsPath())
if err != nil {
return // file absent or unreadable — silent, defaults apply
}
var raw map[string]interface{}
if err := json.Unmarshal(b, &raw); err != nil {
return
}
for k, v := range raw {
switch val := v.(type) {
case bool:
featureFlags[k] = val
case float64:
featureFlags[k] = val != 0
case string:
featureFlags[k] = val == "true" || val == "1"
}
}
}
// featureEnabled returns true when the named flag is on. Checks (in order):
// 1. Non-empty env var named PILOT_FLAG_<UPPER_SNAKE> (e.g. PILOT_FLAG_PING_REUSE_CONN)
// 2. ~/.pilot/feature-flags.json key
//
// CLI flags take precedence at call sites before this function is consulted.
func featureEnabled(name string) bool {
envKey := "PILOT_FLAG_" + strings.ToUpper(strings.NewReplacer(".", "_", "-", "_").Replace(name))
if v := os.Getenv(envKey); v != "" {
return v != "0" && v != "false"
}
return featureFlags[name]
}
// --- Output helpers ---
func output(data interface{}) {
if jsonOutput {
envelope := map[string]interface{}{"status": "ok", "data": data}
if importantUpdate != "" {
envelope["important_update"] = importantUpdate
}
b, _ := json.Marshal(envelope)
fmt.Println(string(b))
} else {
switch v := data.(type) {
case map[string]interface{}:
b, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(b))
default:
fmt.Println(v)
}
}
}
func outputOK(fields map[string]interface{}) {
if fields == nil {
fields = map[string]interface{}{}
}
output(fields)
}
func fatalCode(code string, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if jsonOutput {
env := map[string]string{
"status": "error",
"code": code,
"message": msg,
"error": msg,
}
if importantUpdate != "" {
env["important_update"] = importantUpdate
}
b, _ := json.Marshal(env)
fmt.Fprintln(os.Stderr, string(b))
} else {
fmt.Fprintf(os.Stderr, "error: %s\n", msg)
}
os.Exit(1)
}
// classifyDaemonError inspects an error string from the daemon and, when it
// recognizes a transient or operator-friendly failure mode, returns a more
// actionable hint to the user. Returns "" if no specific guidance applies —
// callers fall back to whatever default hint they had.
//
// Currently recognized patterns:
// - "pending queue full ... key exchange pending" — the encrypted tunnel
// to the peer is mid-handshake. The packet was queued; waiting a moment
// and retrying typically succeeds.
// - "dial timeout"/"dial: daemon: dial timeout" — the SYN went out but no
// SYN-ACK came back. Often means the relay path is mid-convergence
// after a beacon roll, or the peer is offline.
func classifyDaemonError(err error) string {
if err == nil {
return ""
}
s := err.Error()
switch {
case strings.Contains(s, "pending queue full") || strings.Contains(s, "key exchange pending"):
return "tunnel handshake in progress — the packet was queued. Wait a few seconds and retry; the SYN will go out as soon as the encrypted session keys are derived."
case strings.Contains(s, "dial timeout"):
return "no reply from peer. Check reachability with `pilotctl ping <peer>`; if relay is fresh after a beacon roll it can take ~30s for endpoints to converge."
}
return ""
}
// fatalHint is like fatalCode but adds an actionable hint telling the user what to do next.
func fatalHint(code, hint, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if jsonOutput {
env := map[string]string{
"status": "error",
"code": code,
"message": msg,
"error": msg,
"hint": hint,
}
if importantUpdate != "" {
env["important_update"] = importantUpdate
}
b, _ := json.Marshal(env)
fmt.Fprintln(os.Stderr, string(b))
} else {
fmt.Fprintf(os.Stderr, "error: %s\nhint: %s\n", msg, hint)
}
os.Exit(1)
}
func fatal(format string, args ...interface{}) {
fatalCode("internal", format, args...)
}
// parseNodeID parses a string as a uint32 node ID or exits with an error (M18 fix).
func parseNodeID(s string) uint32 {
v, err := strconv.ParseUint(s, 10, 32)
if err != nil {
fatalCode("invalid_argument", "invalid node_id %q: %v", s, err)
}
return uint32(v)
}
// parseUint16 parses a string as a uint16 or exits with an error (M18 fix).
func parseUint16(s, label string) uint16 {
v, err := strconv.ParseUint(s, 10, 16)
if err != nil {
fatalCode("invalid_argument", "invalid %s %q: %v", label, s, err)
}
return uint16(v)
}
func formatBytes(b uint64) string {
switch {
case b >= 1024*1024*1024:
return fmt.Sprintf("%.1f GB", float64(b)/1024/1024/1024)
case b >= 1024*1024:
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
case b >= 1024:
return fmt.Sprintf("%.1f KB", float64(b)/1024)
default:
return fmt.Sprintf("%d B", b)
}
}
// fmtCount renders a large count compactly: 1234 → "1.2K", 5400000 → "5.4M".
func fmtCount(n uint64) string {
switch {
case n >= 1_000_000_000:
return fmt.Sprintf("%.1fB", float64(n)/1e9)
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1e6)
case n >= 10_000:
return fmt.Sprintf("%.1fK", float64(n)/1e3)
default:
return fmt.Sprintf("%d", n)
}
}
// --- Env / config helpers ---
func getSocket() string {
if v := os.Getenv("PILOT_SOCKET"); v != "" {
return v
}
cfg := loadConfig()
if s, ok := cfg["socket"].(string); ok && s != "" {
return s
}
return defaultSocket()
}
func getRegistry() string {
if v := os.Getenv("PILOT_REGISTRY"); v != "" {
return v
}
cfg := loadConfig()
if s, ok := cfg["registry"].(string); ok && s != "" {
return s
}
return "34.71.57.205:9000"
}
func loadConfig() map[string]interface{} {
f, err := os.Open(configPath())
if err != nil {
return map[string]interface{}{}
}
defer f.Close()
var cfg map[string]interface{}
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return map[string]interface{}{}
}
return cfg
}
func getAdminToken() string {
if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" {
return v
}
cfg := loadConfig()
if s, ok := cfg["admin_token"].(string); ok && s != "" {
return s
}
return ""
}
func requireAdminToken() string {
token := getAdminToken()
if token == "" {
fatalHint("auth_required",
"set PILOT_ADMIN_TOKEN env var or admin_token in ~/.pilot/config.json",
"admin token required for this operation")
}
return token
}
func saveConfig(cfg map[string]interface{}) error {
dir := configDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.Create(configPath())
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(cfg)
}
// --- Arg parsing helpers ---
// parseFlags extracts --key=value and --flag from args, returns remaining positional args.
func parseFlags(args []string) (map[string]string, []string) {
flags := map[string]string{}
var pos []string
for i := 0; i < len(args); i++ {
a := args[i]
var key string
if strings.HasPrefix(a, "--") {
key = a[2:]
} else if strings.HasPrefix(a, "-") && len(a) > 1 && !isNumericFlag(a[1:]) {
// Accept single-dash long flags (e.g. -email) so users aren't
// silently bitten after reading the daemon binary's own -flag help.
key = a[1:]
}
if key != "" {
if idx := strings.Index(key, "="); idx >= 0 {
flags[key[:idx]] = key[idx+1:]
} else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
flags[key] = args[i+1]
i++
} else {
flags[key] = "true"
}
} else {
pos = append(pos, a)
}
}
return flags, pos
}
// isNumericFlag reports whether s looks like a bare number (e.g. "1", "3.14"),
// so that negative number positional args like "-1" are not treated as flags.
func isNumericFlag(s string) bool {
if len(s) == 0 {
return false
}
for _, c := range s {
if c != '.' && (c < '0' || c > '9') {
return false
}
}
return true
}
func flagDuration(flags map[string]string, key string, def time.Duration) time.Duration {
v, ok := flags[key]
if !ok {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
// Try as seconds
secs, err2 := strconv.ParseFloat(v, 64)
if err2 != nil {
fatalCode("invalid_argument", "invalid duration for --%s: %v", key, err)
}
return time.Duration(secs * float64(time.Second))
}
return d
}
func flagInt(flags map[string]string, key string, def int) int {
v, ok := flags[key]
if !ok {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
fatalCode("invalid_argument", "invalid integer for --%s: %v", key, err)
}
return n
}
func flagString(flags map[string]string, key string, def string) string {
if v, ok := flags[key]; ok {
return v
}
return def
}
func flagBool(flags map[string]string, key string) bool {
v, ok := flags[key]
return ok && (v == "true" || v == "1" || v == "")
}
// flagNetID returns --net as a uint16 network ID, refusing to silently
// truncate values that don't fit. Without the bounds check, `--net 70000`
// becomes uint16(70000 & 0xFFFF)=4464 and the command operates on the
// wrong network — caught by CodeQL "Incorrect conversion between integer
// types" (alerts #21, #23, #24, #25, #32).
func flagNetID(flags map[string]string) uint16 {
n := flagInt(flags, "net", 0)
if n < 0 || n > 0xFFFF {
fatalCode("invalid_argument", "--net must be in 0..65535, got %d", n)
}
return uint16(n)
}
// fmtDuration formats a duration as a compact human-readable string
// ("3s", "2m5s", "1h4m", "2d3h") for --trace output.
func fmtDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
m := int(d.Minutes())
s := int(d.Seconds()) % 60
if s == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dm%ds", m, s)
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh%dm", h, m)
}
days := int(d.Hours()) / 24
h := int(d.Hours()) % 24
if h == 0 {
return fmt.Sprintf("%dd", days)
}
return fmt.Sprintf("%dd%dh", days, h)
}
// --- Connection helpers ---
func connectDriver() *driver.Driver {
sock := getSocket()
if verbose {
fmt.Fprintf(os.Stderr, "connecting to daemon socket %s\n", sock)
}
d, err := driver.Connect(sock)
if err != nil {
fatalHint("not_running",
fmt.Sprintf("start the daemon with: pilotctl daemon start (tried %s)", sock),
"daemon is not running")
}
return d
}
// nodeIDFromDaemon returns the daemon's registered node ID for use in
// telemetry events. Returns 0 silently if the daemon is unreachable.
func nodeIDFromDaemon() int64 {
d, err := driver.Connect(getSocket())
if err != nil {
return 0
}
defer d.Close()
info, err := d.Info()
if err != nil {
return 0
}
nid, _ := info["node_id"].(float64)
return int64(nid)
}
func connectRegistry() *registry.Client {
addr := getRegistry()
rc, err := registry.Dial(addr)
if err != nil {
fatalHint("connection_failed",
fmt.Sprintf("check that the registry is running at %s, or set PILOT_REGISTRY", addr),
"cannot reach registry at %s", addr)
}
return rc
}
func resolveHostnameToAddr(d *driver.Driver, hostname string) (protocol.Addr, uint32, error) {
result, err := d.ResolveHostname(hostname)
if err != nil {
return protocol.Addr{}, 0, err
}
nodeIDVal, ok := result["node_id"].(float64)
if !ok {
return protocol.Addr{}, 0, fmt.Errorf("missing node_id in resolve response")
}
nodeID := uint32(nodeIDVal)
addrStr, ok := result["address"].(string)
if !ok {
return protocol.Addr{}, 0, fmt.Errorf("missing address in resolve response")
}
addr, err := protocol.ParseAddr(addrStr)
if err != nil {
return protocol.Addr{}, 0, fmt.Errorf("parse address: %w", err)
}
return addr, nodeID, nil
}
// maybeAutoHandshake gates any client-initiated tunnel operation
// (send-message, send-file, connect, ping, publish, etc.)
// based on the peer's visibility and our trust state. Three branches:
//
// 1. **Already trusted**: pass silently (one IPC fast-path, ~5ms).
// 2. **Peer in embedded `internal/trustedagents` list** (curated service
// agents): print "establishing handshake with Trusted Agent <name>",
// fire Driver.Handshake, then block on Driver.WaitForTrust until the
// peer's accept arrives or 5s elapses. Trust formation on cold first
// contact takes ~700–2400ms (request dial + accept dial back); blocking
// here serialises trust-then-data so service agents don't drop our
// payload before peer-side trust is finalised.
// 3. **Otherwise**: registry lookup. If the peer is **Public**, allow the
// tunnel — public daemons accept all comers by design. If the peer is
// **Private**, refuse client-side with `trust_required`: a private
// peer's daemon will silently drop our SYN anyway (daemon.go:2223),
// so starting a tunnel just produces a timeout. Refusing upfront
// gives the user a clear, immediate signal and a handshake hint.
//
// Skip with --no-auto-handshake. Pre-#99 daemons don't have SubHandshakeWait;
// we treat the IPC error as "wait unsupported" and proceed best-effort.
func maybeAutoHandshake(d *driver.Driver, addr protocol.Addr, skip bool) {
if skip {
return
}
// Branch 1 — already trusted. Fast path: one IPC roundtrip, no network.
if resp, err := d.WaitForTrust(addr.Node, 0); err == nil {
if trusted, _ := resp["trusted"].(bool); trusted {
return
}
}
// Branch 2 — peer is in the embedded trusted-agents allowlist.
if name, ok := trustedagents.IsTrusted(addr.Node); ok {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "establishing handshake with Trusted Agent %s (%s)...\n", name, addr)
}
if _, err := d.Handshake(addr.Node, "auto-handshake: trusted agent "+name); err != nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, " handshake send failed: %v — continuing\n", err)
}
return
}
resp, err := d.WaitForTrust(addr.Node, 5000)
if err != nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, " wait-for-trust unsupported on this daemon; continuing\n")
}
return
}
if trusted, _ := resp["trusted"].(bool); trusted {
if !jsonOutput {
fmt.Fprintf(os.Stderr, " trust established with %s\n", name)
}
} else {
if !jsonOutput {
fmt.Fprintf(os.Stderr, " trust not established within 5s — continuing (peer may drop)\n")
}
}
return
}
// Branch 3 — unknown peer, not trusted. Refuse if private.
rc, err := registry.Dial(getRegistry())
if err != nil {
// Registry unreachable — be conservative and refuse rather than
// silently let an untrusted tunnel attempt go through to a peer
// we can't characterise.
fatalHint("trust_required",
fmt.Sprintf("run: pilotctl handshake %s", addr),
"cannot verify peer visibility (registry unreachable: %v); refusing tunnel to untrusted node", err)
}
defer rc.Close()
info, lookupErr := rc.Lookup(addr.Node)
if lookupErr != nil {
fatalHint("trust_required",
fmt.Sprintf("run: pilotctl handshake %s", addr),
"cannot look up peer %s (%v); refusing tunnel to untrusted node", addr, lookupErr)
}
pub, _ := info["public"].(bool)
if !pub {
fatalHint("trust_required",
fmt.Sprintf("run: pilotctl handshake %s", addr),
"refusing tunnel to private node %s without trust", addr)
}
// Public peer — best-effort handshake so replies survive our local
// trust gate. Without this, a request/reply pattern (e.g. send-message
// --wait) gets its ACK back but the responder's reply SYN is silently
// rejected by our daemon (daemon.go SYN-trust-gate). The peer is
// public and the embedded trustedagents allowlist may not cover every
// public hostname (entries can become stale when a public agent is
// re-deployed under a new node ID), so we treat any registry-resolved
// public peer the same as a Trusted Agent. Auto-approve peers (most
// public agents are) finalise mutual trust in ~700-2400 ms; non-auto-
// approve peers still see the request in pending and the tunnel
// proceeds best-effort.
if _, err := d.Handshake(addr.Node, "auto-handshake: public peer"); err != nil {
// Handshake send failure is non-fatal — fall through to send.
return
}
if resp, err := d.WaitForTrust(addr.Node, 5000); err == nil {
if trusted, _ := resp["trusted"].(bool); trusted {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "auto-handshake established trust with public peer %s\n", addr)
}
return
}
// PILOT-220: on fresh daemon starts the handshake ACK path
// may not be fully routed within the initial 5 s window.
// When the local trust gate drops the reply SYN because
// trust isn't finalised, send-message --wait times out.
// Poll WaitForTrust while a pending handshake exists (up
// to 16 s extra) so trust finalises before the first user
// query hits the wire.
} else {
// WaitForTrust not supported (old daemon); proceed best-effort.
return
}
for poll := 0; poll < 8; poll++ {
time.Sleep(2 * time.Second)
pending, err := d.PendingHandshakes()
if err != nil {
break
}
hasPending := false
if list, ok := pending["pending"].([]interface{}); ok {
for _, item := range list {
m, okMap := item.(map[string]interface{})
if !okMap {
continue
}
if nid, okNid := m["node_id"].(float64); okNid && uint32(nid) == addr.Node {
hasPending = true
break
}
}
}
if !hasPending {
break // handshake resolved (accepted or rejected); stop polling
}
if resp, err := d.WaitForTrust(addr.Node, 0); err == nil {
if trusted, _ := resp["trusted"].(bool); trusted {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "auto-handshake established trust with public peer %s\n", addr)
}
break
}
}
}
}
func parseAddrOrHostname(d *driver.Driver, arg string) (protocol.Addr, error) {
// Try full address (e.g. "0:0000.0000.000B")
addr, err := protocol.ParseAddr(arg)
if err == nil {
return addr, nil
}
// Try bare node ID (e.g. "11" → backbone address 0:0000.0000.000B)
if id, numErr := strconv.ParseUint(arg, 10, 32); numErr == nil {
return protocol.Addr{Network: 0, Node: uint32(id)}, nil
}
// Try hostname resolution
resolved, _, resolveErr := resolveHostnameToAddr(d, arg)
if resolveErr != nil {
return protocol.Addr{}, fmt.Errorf("cannot resolve %q — is the hostname correct and is there mutual trust? (see: pilotctl handshake)", arg)
}
if !jsonOutput {
fmt.Fprintf(os.Stderr, "resolved %q → %s\n", arg, resolved)
}
return resolved, nil
}
// resolveToNodeID resolves any of: numeric node ID, pilot address
// (N:NNNN.HHHH.LLLL), or hostname — to a uint32 node ID.
// Prints a resolution line to stderr (text mode) so the user can see
// what was matched. d may be nil only when the arg is numeric or an address.
func resolveToNodeID(d *driver.Driver, arg string) uint32 {
if id, err := strconv.ParseUint(arg, 10, 32); err == nil {
return uint32(id)
}
if addr, err := protocol.ParseAddr(arg); err == nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "parsed address %s → node %d\n", arg, addr.Node)
}
return addr.Node
}
if d == nil {
fatalCode("invalid_argument", "cannot resolve hostname without a daemon connection: %q", arg)
}
_, nodeID, err := resolveHostnameToAddr(d, arg)
if err != nil {
fatalCode("not_found", "cannot resolve %q — check the hostname and that mutual trust exists", arg)
}
if !jsonOutput {
fmt.Fprintf(os.Stderr, "resolved %q → node %d\n", arg, nodeID)
}
return nodeID
}
// resolveNetworkNodeArg resolves a node ID, address, or hostname for network
// subcommands that talk directly to the registry (no daemon connection kept open).
// Opens a daemon connection only when the arg is a hostname.
func resolveNetworkNodeArg(arg string) uint32 {
if id, err := strconv.ParseUint(arg, 10, 32); err == nil {
return uint32(id)
}
if addr, err := protocol.ParseAddr(arg); err == nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "parsed address %s → node %d\n", arg, addr.Node)
}
return addr.Node
}
d := connectDriver()
defer d.Close()
return resolveToNodeID(d, arg)
}
// hasHelpFlag returns true when args contains -h or --help.
func hasHelpFlag(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" {
return true
}
}
return false
}
// commandHelp holds concise usage text for each command. Looked up by
// printCommandHelp when the user passes -h / --help after a command name.
var commandHelp = map[string]string{
"send-message": `Usage: pilotctl send-message <address|hostname> --data <text> [flags]
Send a message to a remote agent and optionally wait for the reply.
Flags:
--data <text> message payload (required)
--type text|json|binary payload encoding (default: text)
--count <n> send N times (default: 1)
--reuse-conn reuse the connection across --count sends (saves ~1 RTT)
--wait [<dur>] wait for a reply in the inbox (default timeout: 30s)
--trace print per-step timing breakdown to stderr
--no-auto-handshake skip automatic trust handshake with known agents
Examples:
pilotctl send-message list-agents --data '/data {"search":"weather","limit":5}'
pilotctl send-message my-peer --data "hello" --wait
pilotctl send-message 0:0000.0000.400E --data "ping" --trace
`,
"ping": `Usage: pilotctl ping <address|hostname> [flags]
Send echo packets and report round-trip latency.
Flags:
--count <n> number of packets to send (default: 4)
--timeout <dur> per-ping deadline (default: 5s)
--reuse-conn reuse tunnel connection across packets
--trace print per-step timing breakdown to stderr
Examples:
pilotctl ping my-agent
pilotctl ping 0:0000.0000.400E --count 10 --trace
`,
"bench": `Usage: pilotctl bench <address|hostname> [size_mb] [flags]
Measure throughput to a remote node via the echo port.
Flags:
--timeout <dur> overall deadline (default: 30s)
Examples:
pilotctl bench my-agent
pilotctl bench my-agent 10
`,
"traceroute": `Usage: pilotctl traceroute <address|hostname> [flags]
Probe the hop-by-hop path to a remote node.
Flags:
--timeout <dur> overall deadline (default: 30s)
`,
"connect": `Usage: pilotctl connect <address|hostname> [port] [flags]
Open an interactive tunnel to a remote node.
Flags:
--message <msg> send a message on connect
--timeout <dur> connection timeout (default: 30s)
`,
"send": `Usage: pilotctl send <address|hostname> <port> --data <msg> [flags]
Send a raw message to a specific port on a remote node.
Flags:
--data <msg> message payload (required)
--timeout <dur> timeout (default: 30s)
`,
"recv": `Usage: pilotctl recv <port> [flags]
Listen on a port and print incoming messages.
Flags:
--count <n> stop after N messages (default: unlimited)
--timeout <dur> idle timeout (default: unlimited)
`,
"listen": `Usage: pilotctl listen <port> [flags]
Listen for datagrams on the given port and print them.
Flags:
--count <n> stop after N messages
--timeout <dur> idle timeout
`,
"find": `Usage: pilotctl find <hostname>
Look up a hostname in the registry and print its pilot address.
`,
"handshake": `Usage: pilotctl handshake <node_id|hostname> [justification]
Initiate a trust handshake with another node.
The remote node must approve the request before messages can flow.
See also: pilotctl approve, pilotctl pending, pilotctl trust
`,
"peers": `Usage: pilotctl peers [flags]
Summarize currently connected peers. Shows a one-line breakdown
(encrypted+authenticated / relay / direct) and then only the exceptions —
peers that are unencrypted or unauthenticated.
PATH=relay means a peer is behind symmetric NAT and traffic goes through
the beacon. It's normal but adds ~50-150ms latency vs a direct path.
Flags:
--all full peer table instead of exceptions only
--limit <n> rows to show (default 20, 0 = all)
--search <query> filter by node ID substring
See also: pilotctl ping <node_id> — measure RTT to a specific peer
`,
"inbox": `Usage: pilotctl inbox [read <id>] [flags]
Show messages received from other agents — newest first, 10 by default.
Subcommands:
read <id> print the full body of one message
Flags:
--latest full body of the single newest message (after filters)
--limit <n> how many to show (default 10, 0 = all)
--from <peer> only messages from this address or hostname
--since <dur|ts> only messages newer than a duration (5m, 1h) or RFC3339 time
--full full bodies instead of one-line previews
--clear delete the matched messages (all if no filters)
--before <dur> with --clear: only delete messages older than this
Agent patterns:
pilotctl --json inbox --latest # newest reply, full body
pilotctl --json inbox --from list-agents --limit 3 # last 3 from one peer
pilotctl inbox --clear --before 24h # keep today, purge older
Tip: send-message --wait already returns the matching reply inline; the
inbox is for replies that arrive later or that you want to re-read.
`,
"info": `Usage: pilotctl info [flags]
Print full daemon state, grouped:
identity hostname, address, node ID, key fingerprint, email
network peers (encrypted/relay/direct), pending handshakes,
beacon, connections, ports
traffic cumulative bytes and packet counts since start
skills agent tools with the pilot skill installed
See also: pilotctl health — lightweight status check
pilotctl peers — per-peer detail
pilotctl connections — per-connection detail
`,
"health": `Usage: pilotctl health
Quick daemon health check. Shows uptime, peers (encrypted/relay breakdown),
pending handshakes, traffic, and any queue drops or webhook failures.
Queue Drops > 0 means the daemon's accept queue is overflowing — connections
are being dropped before they're processed. Usually indicates CPU saturation
or a misconfigured system file-descriptor limit.
See also: pilotctl info — full daemon state with connection list
`,
"daemon start": `Usage: pilotctl daemon start [flags]
Flags:
--config <path> path to config file (JSON)
--registry <addr> registry address (default: $PILOT_REGISTRY or 34.71.57.205:9000)
--beacon <addr> beacon address (default: $PILOT_BEACON or 34.71.57.205:9001)
--listen <addr> UDP listen address (default: :0)
--socket <path> Unix socket path (default: /tmp/pilot.sock)
--identity <path> Ed25519 identity file path
--email <addr> email for account identification and key recovery
--hostname <name> discovery hostname (lowercase alphanumeric + hyphens)
--endpoint <host:port> fixed public endpoint — skips STUN (for cloud VMs)
--public make this node publicly visible
--webhook <url> HTTP(S) endpoint for event notifications
--admin-token <token> admin token for network operations
--networks <ids> comma-separated network IDs to auto-join
--trust-auto-approve automatically approve all incoming trust handshakes
--log-level <level> log level: debug, info, warn, error (default: info)
--log-format <fmt> log format: text, json (default: text)
--no-encrypt disable tunnel encryption
--foreground run in foreground (no fork; for systemd / shell wrappers)
--wait <duration> how long to wait for daemon to become ready (default: 15s)
--motd-feed-url <url> message-of-the-day feed (empty to disable; env PILOT_MOTD_URL)
--motd-interval <duration> message-of-the-day poll interval (default: 15m)
`,
"daemon stop": `Usage: pilotctl daemon stop
Stop the running daemon gracefully. Sends SIGTERM; daemon closes active
connections and deregisters from the beacon before exiting.
`,
"daemon status": `Usage: pilotctl daemon status [flags]
Show daemon status: running/stopped, node ID, address, uptime, peer count
(with encrypted and relay breakdown), active connections, and any pending
trust handshakes.
Flags:
--check silent health check — exits 0 if daemon is responsive, 1 otherwise
useful in scripts: pilotctl daemon status --check || pilotctl daemon start
`,
"init": `Usage: pilotctl init --registry <addr> [flags]
Initialize the local pilot config at ~/.pilot/config.json.
Flags:
--registry <addr> registry address (required)
--beacon <addr> beacon address
--hostname <name> hostname to register
`,
"network": `Usage: pilotctl network <subcommand> [flags]
Manage overlay networks.
Subcommands:
list list networks this node belongs to
create <name> create a new network
join <id> join a network
leave <id> leave a network
members <id> list members of a network
invite <id> <node> invite a node to a network
invites list pending invitations
accept <id> accept an invitation
delete <id> delete a network (owner only)
rename <id> <name> rename a network (owner only)
promote <id> <node> promote a member to admin
demote <id> <node> demote an admin to member
kick <id> <node> remove a member from a network
policy <id> show or set network policy
`,
// Trust
"pending": `Usage: pilotctl pending
List inbound trust handshake requests waiting for your approval.
Each row shows the requesting node ID, their justification, and when they asked.
To approve: pilotctl approve <node_id|address|hostname>
To reject: pilotctl reject <node_id|address|hostname> [reason]
Note: nodes in the embedded trusted-agents list are auto-approved on first contact.
`,
"trust": `Usage: pilotctl trust [flags]
List the nodes this daemon currently trusts, newest first (20 by default).
Mutual trust is the norm and prints nothing; rows tagged "one-way" mean
you approved them but they haven't approved you yet, or vice versa.
Flags:
--limit <n> rows to show (default 20, 0 = all)
--search <substr> filter by node ID (or hostname when known)
See also: pilotctl pending — incoming requests waiting for your approval
pilotctl handshake — initiate trust with a new peer