Skip to content

Commit 0f5305c

Browse files
committed
Privacy: hide peer IPs in pilotctl by default, gate network member counts
pilotctl peers/info/lookup now strip endpoint, real_addr, lan_addrs, public_addr, stun_addr, observed_addr from output unless --show-endpoints is passed. Search-by-endpoint substring requires the same opt-in so a search prompt cannot leak IP existence. Aggregate counters (peers, encrypted_peers) and identity fields preserved. Registry handleListNetworks now omits the per-network `members` count unless the caller presents a valid admin_token. Network identity (id, name, join_rule, enterprise) stays visible so daemons can still discover what to join. pilotctl `network list` shows "—" in the MEMBERS column when the count is hidden. Backwards-compat: ListNetworks is variadic on adminToken; callers that relied on always-public members field updated (8 in-repo sites).
1 parent ec04368 commit 0f5305c

7 files changed

Lines changed: 1461 additions & 167 deletions

File tree

cmd/pilotctl/main.go

Lines changed: 137 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -429,12 +429,13 @@ Service Agents:
429429
Diagnostic commands:
430430
pilotctl info
431431
pilotctl health
432-
pilotctl peers [--search <query>]
432+
pilotctl peers [--search <query>] [--show-endpoints]
433433
pilotctl ping <address|hostname> [--count <n>] [--timeout <dur>]
434434
pilotctl traceroute <address> [--timeout <dur>]
435435
pilotctl bench <address|hostname> [size_mb] [--timeout <dur>]
436436
pilotctl listen <port> [--count <n>] [--timeout <dur>]
437437
pilotctl broadcast <network_id> <message>
438+
pilotctl updates [--count <n>] [--scope <scope>] read https://teoslayer.github.io/pilot-changelog/feed.xml
438439
439440
Agent tool discovery:
440441
pilotctl context
@@ -483,6 +484,10 @@ func main() {
483484
fmt.Println(version)
484485
return
485486

487+
case "updates":
488+
cmdUpdates(cmdArgs)
489+
return
490+
486491
// Bootstrap
487492
case "init":
488493
cmdInit(cmdArgs)
@@ -764,7 +769,7 @@ func main() {
764769

765770
// Diagnostics
766771
case "info":
767-
cmdInfo()
772+
cmdInfo(cmdArgs)
768773
case "my-polo":
769774
cmdMyPolo()
770775
case "health":
@@ -1892,18 +1897,46 @@ func cmdRegister(args []string) {
18921897

18931898
func cmdLookup(args []string) {
18941899
if len(args) < 1 {
1895-
fatalCode("invalid_argument", "usage: pilotctl lookup <node_id>")
1900+
fatalCode("invalid_argument", "usage: pilotctl lookup <node_id> [--show-endpoints]")
18961901
}
1897-
nodeID := parseNodeID(args[0])
1902+
flags, pos := parseFlags(args)
1903+
if len(pos) < 1 {
1904+
fatalCode("invalid_argument", "usage: pilotctl lookup <node_id> [--show-endpoints]")
1905+
}
1906+
showEndpoints := flagBool(flags, "show-endpoints")
1907+
nodeID := parseNodeID(pos[0])
18981908
rc := connectRegistry()
18991909
defer rc.Close()
19001910
resp, err := rc.Lookup(nodeID)
19011911
if err != nil {
19021912
fatalCode("connection_failed", "lookup: %v", err)
19031913
}
1914+
if !showEndpoints {
1915+
redactPeerEndpoints(resp)
1916+
}
19041917
output(resp)
19051918
}
19061919

1920+
// redactPeerEndpoints walks a registry/daemon response and drops any
1921+
// IP-bearing fields. Operates in-place; safe to call on nil/non-map types.
1922+
// Removed keys: endpoint, real_addr, lan_addrs, public_addr, ip, addr.
1923+
// Recurses into nested maps and into peer_list / nodes / data sub-objects.
1924+
func redactPeerEndpoints(v interface{}) {
1925+
switch x := v.(type) {
1926+
case map[string]interface{}:
1927+
for _, k := range []string{"endpoint", "real_addr", "lan_addrs", "public_addr", "stun_addr", "observed_addr"} {
1928+
delete(x, k)
1929+
}
1930+
for _, vv := range x {
1931+
redactPeerEndpoints(vv)
1932+
}
1933+
case []interface{}:
1934+
for _, vv := range x {
1935+
redactPeerEndpoints(vv)
1936+
}
1937+
}
1938+
}
1939+
19071940
func cmdRotateKey(args []string) {
19081941
_ = args
19091942
d := connectDriver()
@@ -3773,7 +3806,10 @@ func cmdMyPolo() {
37733806
fmt.Printf("My polo score: %d\n", score)
37743807
}
37753808

3776-
func cmdInfo() {
3809+
func cmdInfo(args []string) {
3810+
flags, _ := parseFlags(args)
3811+
showEndpoints := flagBool(flags, "show-endpoints")
3812+
37773813
d := connectDriver()
37783814
defer d.Close()
37793815

@@ -3782,6 +3818,14 @@ func cmdInfo() {
37823818
fatalCode("connection_failed", "info: %v", err)
37833819
}
37843820

3821+
// Privacy: strip per-peer endpoints + STUN-discovered own addresses
3822+
// from the JSON dump unless the operator explicitly opts in via
3823+
// --show-endpoints. The summary counters (peers, encrypted_peers)
3824+
// stay; only the IP-bearing fields are redacted.
3825+
if !showEndpoints {
3826+
redactPeerEndpoints(info)
3827+
}
3828+
37853829
if jsonOutput {
37863830
output(info)
37873831
return
@@ -3926,6 +3970,10 @@ func cmdHealth() {
39263970
func cmdPeers(args []string) {
39273971
flags, _ := parseFlags(args)
39283972
search := flagString(flags, "search", "")
3973+
// Privacy default: peer real IPs are hidden. Opt in with --show-endpoints
3974+
// (ops/debug only). Search by node_id always works; search by endpoint
3975+
// fragment only works when endpoints are visible.
3976+
showEndpoints := flagBool(flags, "show-endpoints")
39293977

39303978
d := connectDriver()
39313979
defer d.Close()
@@ -3950,13 +3998,38 @@ func cmdPeers(args []string) {
39503998
peer := p.(map[string]interface{})
39513999
searchLower := strings.ToLower(search)
39524000
nodeIDStr := fmt.Sprintf("%d", int(peer["node_id"].(float64)))
3953-
endpoint, _ := peer["endpoint"].(string)
3954-
if strings.Contains(nodeIDStr, searchLower) ||
3955-
strings.Contains(strings.ToLower(endpoint), searchLower) {
4001+
match := strings.Contains(nodeIDStr, searchLower)
4002+
if !match && showEndpoints {
4003+
// only consult endpoint when the user has explicitly asked
4004+
// to see it; never let a search prompt leak IP existence.
4005+
endpoint, _ := peer["endpoint"].(string)
4006+
match = strings.Contains(strings.ToLower(endpoint), searchLower)
4007+
}
4008+
if match {
39564009
filtered = append(filtered, p)
39574010
}
39584011
}
39594012

4013+
// Redact endpoint unless the operator opted in.
4014+
if !showEndpoints {
4015+
redacted := make([]interface{}, 0, len(filtered))
4016+
for _, p := range filtered {
4017+
peer, _ := p.(map[string]interface{})
4018+
if peer == nil {
4019+
continue
4020+
}
4021+
cp := make(map[string]interface{}, len(peer))
4022+
for k, v := range peer {
4023+
if k == "endpoint" || k == "real_addr" || k == "lan_addrs" || k == "public_addr" {
4024+
continue
4025+
}
4026+
cp[k] = v
4027+
}
4028+
redacted = append(redacted, cp)
4029+
}
4030+
filtered = redacted
4031+
}
4032+
39604033
if jsonOutput {
39614034
output(map[string]interface{}{
39624035
"peers": filtered,
@@ -3976,7 +4049,11 @@ func cmdPeers(args []string) {
39764049
}
39774050

39784051
maxDisplay := 50
3979-
fmt.Printf("%-10s %-30s %-20s %s\n", "NODE ID", "ENDPOINT", "ENCRYPTED", "AUTH")
4052+
if showEndpoints {
4053+
fmt.Printf("%-10s %-30s %-20s %s\n", "NODE ID", "ENDPOINT", "ENCRYPTED", "AUTH")
4054+
} else {
4055+
fmt.Printf("%-10s %-20s %s\n", "NODE ID", "ENCRYPTED", "AUTH")
4056+
}
39804057
displayed := 0
39814058
for _, p := range filtered {
39824059
if displayed >= maxDisplay {
@@ -4001,7 +4078,11 @@ func cmdPeers(args []string) {
40014078
if authenticated {
40024079
authStr = "yes (Ed25519)"
40034080
}
4004-
fmt.Printf("%-10d %-30s %-20s %s\n", int(peer["node_id"].(float64)), peer["endpoint"], encStr, authStr)
4081+
if showEndpoints {
4082+
fmt.Printf("%-10d %-30s %-20s %s\n", int(peer["node_id"].(float64)), peer["endpoint"], encStr, authStr)
4083+
} else {
4084+
fmt.Printf("%-10d %-20s %s\n", int(peer["node_id"].(float64)), encStr, authStr)
4085+
}
40054086
}
40064087
}
40074088

@@ -4431,7 +4512,44 @@ func cmdListen(args []string) {
44314512
}
44324513

44334514
func cmdBroadcast(args []string) {
4434-
fatalCode("unavailable", "broadcast is not available yet — custom networks are WIP")
4515+
flags, positional := parseFlags(args)
4516+
if len(positional) < 2 {
4517+
fatalCode("usage", "usage: pilotctl broadcast <network_id> <message> [--port <port>]")
4518+
}
4519+
netID64, err := strconv.ParseUint(positional[0], 10, 16)
4520+
if err != nil {
4521+
fatalCode("usage", "invalid network_id: %v", err)
4522+
}
4523+
netID := uint16(netID64)
4524+
message := positional[1]
4525+
4526+
port := uint16(1000)
4527+
if v := flagString(flags, "port", ""); v != "" {
4528+
p, err := strconv.ParseUint(v, 10, 16)
4529+
if err != nil {
4530+
fatalCode("usage", "invalid --port: %v", err)
4531+
}
4532+
port = uint16(p)
4533+
}
4534+
4535+
token := requireAdminToken()
4536+
4537+
d := connectDriver()
4538+
defer d.Close()
4539+
4540+
if err := d.Broadcast(netID, port, []byte(message), token); err != nil {
4541+
fatalCode("broadcast_failed", "%v", err)
4542+
}
4543+
4544+
if jsonOutput {
4545+
output(map[string]interface{}{
4546+
"network_id": netID,
4547+
"port": port,
4548+
"bytes": len(message),
4549+
})
4550+
} else {
4551+
fmt.Printf("broadcast on network %d port %d (%d bytes)\n", netID, port, len(message))
4552+
}
44354553
}
44364554

44374555
// ===================== MAILBOX =====================
@@ -4656,19 +4774,23 @@ func cmdNetworkList() {
46564774
fmt.Println("no networks")
46574775
return
46584776
}
4777+
// Member counts are admin-only at the registry. Without admin_token
4778+
// the registry omits the `members` field; render "—" so the column
4779+
// stays aligned and it's clear the count is hidden by policy rather
4780+
// than broken.
46594781
fmt.Printf("%-8s %-30s %-10s %s\n", "ID", "NAME", "JOIN RULE", "MEMBERS")
46604782
for _, n := range nets {
46614783
nm, _ := n.(map[string]interface{})
46624784
id := uint16(nm["id"].(float64))
46634785
name, _ := nm["name"].(string)
46644786
rule, _ := nm["join_rule"].(string)
4665-
count := 0
4787+
memberStr := "—"
46664788
if members, ok := nm["members"].([]interface{}); ok {
4667-
count = len(members)
4789+
memberStr = fmt.Sprintf("%d", len(members))
46684790
} else if mc, ok := nm["members"].(float64); ok {
4669-
count = int(mc)
4791+
memberStr = fmt.Sprintf("%d", int(mc))
46704792
}
4671-
fmt.Printf("%-8d %-30s %-10s %d\n", id, name, rule, count)
4793+
fmt.Printf("%-8d %-30s %-10s %s\n", id, name, rule, memberStr)
46724794
}
46734795
}
46744796

cmd/pilotctl/redact_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"testing"
7+
)
8+
9+
// TestRedactPeerEndpointsRemovesIPFields pins the privacy guarantee on
10+
// the pilotctl side: peer real_addr / endpoint / lan_addrs / public_addr
11+
// are stripped from any response before display, recursively. Aggregate
12+
// counters (peers, encrypted_peers) and identity fields (node_id,
13+
// hostname, address) survive — the redaction is targeted at IP-bearing
14+
// keys only.
15+
func TestRedactPeerEndpointsRemovesIPFields(t *testing.T) {
16+
resp := map[string]interface{}{
17+
"node_id": 42,
18+
"address": "0:0000.0000.002A",
19+
"hostname": "agent-test",
20+
"endpoint": "203.0.113.5:4000", // must go
21+
"real_addr": "203.0.113.5:4000", // must go
22+
"public_addr": "203.0.113.5:4000", // must go
23+
"lan_addrs": []interface{}{"10.0.0.5:4000"}, // must go
24+
"observed_addr": "203.0.113.5:4000", // must go
25+
"stun_addr": "203.0.113.5:4000", // must go
26+
"peers": 7,
27+
"encrypted_peers": 7,
28+
"peer_list": []interface{}{
29+
map[string]interface{}{
30+
"node_id": 10,
31+
"endpoint": "198.51.100.10:4000",
32+
"real_addr": "198.51.100.10:4000",
33+
"encrypted": true,
34+
},
35+
map[string]interface{}{
36+
"node_id": 11,
37+
"endpoint": "198.51.100.11:4000",
38+
"encrypted": false,
39+
},
40+
},
41+
"data": map[string]interface{}{
42+
"node": map[string]interface{}{
43+
"node_id": 99,
44+
"endpoint": "192.0.2.99:4000", // nested under data.node
45+
"hostname": "deep",
46+
},
47+
},
48+
}
49+
50+
redactPeerEndpoints(resp)
51+
52+
// Top-level redacted
53+
for _, k := range []string{"endpoint", "real_addr", "public_addr", "lan_addrs", "observed_addr", "stun_addr"} {
54+
if _, ok := resp[k]; ok {
55+
t.Errorf("top-level %q should be redacted", k)
56+
}
57+
}
58+
// Top-level identity + counters preserved
59+
if resp["node_id"] != 42 {
60+
t.Errorf("node_id should be preserved")
61+
}
62+
if resp["hostname"] != "agent-test" {
63+
t.Errorf("hostname should be preserved")
64+
}
65+
if resp["peers"] != 7 || resp["encrypted_peers"] != 7 {
66+
t.Errorf("counters should be preserved")
67+
}
68+
69+
// Per-peer entries: endpoint stripped, identity + flags kept
70+
pl := resp["peer_list"].([]interface{})
71+
if len(pl) != 2 {
72+
t.Fatalf("peer_list length changed: %d", len(pl))
73+
}
74+
for i, p := range pl {
75+
peer := p.(map[string]interface{})
76+
if _, ok := peer["endpoint"]; ok {
77+
t.Errorf("peer %d endpoint should be redacted", i)
78+
}
79+
if _, ok := peer["real_addr"]; ok {
80+
t.Errorf("peer %d real_addr should be redacted", i)
81+
}
82+
if _, ok := peer["node_id"]; !ok {
83+
t.Errorf("peer %d node_id should be preserved", i)
84+
}
85+
}
86+
87+
// Recursion into data.node
88+
deep := resp["data"].(map[string]interface{})["node"].(map[string]interface{})
89+
if _, ok := deep["endpoint"]; ok {
90+
t.Errorf("nested data.node.endpoint should be redacted")
91+
}
92+
if deep["hostname"] != "deep" {
93+
t.Errorf("nested hostname should be preserved")
94+
}
95+
}
96+
97+
// TestRedactPeerEndpointsHandlesNonMap ensures the helper is safe to
98+
// call on nil, slices, and primitives without panic.
99+
func TestRedactPeerEndpointsHandlesNonMap(t *testing.T) {
100+
redactPeerEndpoints(nil)
101+
redactPeerEndpoints("string")
102+
redactPeerEndpoints(42)
103+
redactPeerEndpoints([]interface{}{1, "two", nil})
104+
// no assertions — just must not panic
105+
}

0 commit comments

Comments
 (0)