Skip to content

Commit 6df120c

Browse files
committed
Enterprise Phase 3: security hardening, RBAC, policies, key lifecycle, observability
- Secure channel authentication: Ed25519 identity verification inside ECDH tunnel - Registry hardening: per-operation rate limits, connection limits, 64KB message cap, snapshot checksums - Peer resilience: endpoint caching with TTL, automatic reconnection on tunnel failure - Observability: /healthz endpoint on beacon, pilotctl health command, daemon uptime/stats - RBAC: owner/admin/member roles per network, role-gated mutations, legacy snapshot backfill - Protocol versioning: version field in handshake, compatibility negotiation - Network policies: max_members, allowed_ports, description with merge-on-update semantics - Key lifecycle: CreatedAt/RotatedAt/RotateCount/ExpiresAt metadata, set_key_expiry command - Invite consent fix: PollInvites non-destructive, RespondInvite verifies + consumes invite - Policy merge fix: partial updates preserve unset fields - Key age guard: zero-time check prevents bogus age for legacy nodes - 8 new test files, 43 new tests passing
1 parent 6a606d7 commit 6df120c

25 files changed

Lines changed: 5001 additions & 115 deletions

cmd/pilotctl/main.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ Mailbox:
395395
396396
Diagnostic commands:
397397
pilotctl info
398+
pilotctl health
398399
pilotctl peers [--search <query>]
399400
pilotctl ping <address|hostname> [--count <n>] [--timeout <dur>]
400401
pilotctl traceroute <address> [--timeout <dur>]
@@ -623,6 +624,8 @@ func main() {
623624
// Diagnostics
624625
case "info":
625626
cmdInfo()
627+
case "health":
628+
cmdHealth()
626629
case "peers":
627630
cmdPeers(cmdArgs)
628631
case "ping":
@@ -3536,6 +3539,37 @@ func cmdInfo() {
35363539
}
35373540
}
35383541

3542+
func cmdHealth() {
3543+
d := connectDriver()
3544+
defer d.Close()
3545+
3546+
health, err := d.Health()
3547+
if err != nil {
3548+
fatalCode("connection_failed", "health: %v", err)
3549+
}
3550+
3551+
if jsonOutput {
3552+
output(health)
3553+
return
3554+
}
3555+
3556+
uptime := int64(0)
3557+
if v, ok := health["uptime_seconds"].(float64); ok {
3558+
uptime = int64(v)
3559+
}
3560+
hours := uptime / 3600
3561+
mins := (uptime % 3600) / 60
3562+
secs := uptime % 60
3563+
3564+
fmt.Printf("Daemon Health\n")
3565+
fmt.Printf(" Status: %s\n", health["status"])
3566+
fmt.Printf(" Uptime: %02d:%02d:%02d\n", hours, mins, secs)
3567+
fmt.Printf(" Connections: %d\n", int(health["connections"].(float64)))
3568+
fmt.Printf(" Peers: %d\n", int(health["peers"].(float64)))
3569+
fmt.Printf(" Bytes Sent: %s\n", formatBytes(uint64(health["bytes_sent"].(float64))))
3570+
fmt.Printf(" Bytes Recv: %s\n", formatBytes(uint64(health["bytes_recv"].(float64))))
3571+
}
3572+
35393573
func cmdPeers(args []string) {
35403574
flags, _ := parseFlags(args)
35413575
search := flagString(flags, "search", "")

pkg/daemon/daemon.go

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ const (
9090
ZeroWinProbeMax = 30 * time.Second // max zero-window probe backoff
9191
)
9292

93+
// EndpointCacheTTL is how long a cached endpoint is considered fresh.
94+
// After this, the entry is stale but still usable as a fallback.
95+
const EndpointCacheTTL = 5 * time.Minute
96+
97+
// endpointEntry caches a resolved endpoint for a peer node.
98+
type endpointEntry struct {
99+
addr string // "host:port"
100+
cachedAt time.Time // when the entry was stored
101+
}
102+
93103
type Daemon struct {
94104
config Config
95105
addrMu sync.RWMutex // protects nodeID and addr (H6 fix)
@@ -107,6 +117,10 @@ type Daemon struct {
107117
stopCh chan struct{} // closed on Stop() to signal goroutines
108118
lanAddrs []string // LAN addresses for same-network peer detection
109119

120+
// Endpoint cache: nodeID -> last-known endpoint (peer resilience)
121+
epCacheMu sync.RWMutex
122+
epCache map[uint32]*endpointEntry
123+
110124
// SYN rate limiter (token bucket)
111125
synMu sync.Mutex
112126
synTokens int
@@ -177,6 +191,7 @@ func New(cfg Config) *Daemon {
177191
synTokens: cfg.synRateLimit(),
178192
synLastFill: time.Now(),
179193
perSrcSYN: make(map[uint32]*srcSYNBucket),
194+
epCache: make(map[uint32]*endpointEntry),
180195
}
181196
d.ipc = NewIPCServer(cfg.SocketPath, d)
182197
d.handshakes = NewHandshakeManager(d)
@@ -744,12 +759,15 @@ type DaemonInfo struct {
744759
Identity bool // true if identity is persisted
745760
PublicKey string // base64 Ed25519 public key (empty if no identity)
746761
Email string // email address for account identification and key recovery
747-
BytesSent uint64
748-
BytesRecv uint64
749-
PktsSent uint64
750-
PktsRecv uint64
751-
PeerList []PeerInfo
752-
ConnList []ConnectionInfo
762+
BytesSent uint64
763+
BytesRecv uint64
764+
PktsSent uint64
765+
PktsRecv uint64
766+
EncryptOK uint64
767+
EncryptFail uint64
768+
HandshakePendingCount int
769+
PeerList []PeerInfo
770+
ConnList []ConnectionInfo
753771
}
754772

755773
// Info returns current daemon status.
@@ -805,12 +823,15 @@ func (d *Daemon) Info() *DaemonInfo {
805823
Identity: hasIdentity,
806824
PublicKey: pubKeyStr,
807825
Email: d.config.Email,
808-
BytesSent: atomic.LoadUint64(&d.tunnels.BytesSent),
809-
BytesRecv: atomic.LoadUint64(&d.tunnels.BytesRecv),
810-
PktsSent: atomic.LoadUint64(&d.tunnels.PktsSent),
811-
PktsRecv: atomic.LoadUint64(&d.tunnels.PktsRecv),
812-
PeerList: peerList,
813-
ConnList: d.ports.ConnectionList(),
826+
BytesSent: atomic.LoadUint64(&d.tunnels.BytesSent),
827+
BytesRecv: atomic.LoadUint64(&d.tunnels.BytesRecv),
828+
PktsSent: atomic.LoadUint64(&d.tunnels.PktsSent),
829+
PktsRecv: atomic.LoadUint64(&d.tunnels.PktsRecv),
830+
EncryptOK: atomic.LoadUint64(&d.tunnels.EncryptOK),
831+
EncryptFail: atomic.LoadUint64(&d.tunnels.EncryptFail),
832+
HandshakePendingCount: d.handshakes.PendingCount(),
833+
PeerList: peerList,
834+
ConnList: d.ports.ConnectionList(),
814835
}
815836
}
816837

@@ -1822,8 +1843,45 @@ func (d *Daemon) broadcastDatagram(netID uint16, srcPort, dstPort uint16, data [
18221843
return nil
18231844
}
18241845

1846+
// cacheEndpoint stores a resolved endpoint in the cache.
1847+
func (d *Daemon) cacheEndpoint(nodeID uint32, addr string) {
1848+
d.epCacheMu.Lock()
1849+
d.epCache[nodeID] = &endpointEntry{addr: addr, cachedAt: time.Now()}
1850+
d.epCacheMu.Unlock()
1851+
}
1852+
1853+
// cachedEndpoint returns a previously cached endpoint, or empty string if none.
1854+
// The second return value is true if the entry exists (even if stale).
1855+
func (d *Daemon) cachedEndpoint(nodeID uint32) (string, bool) {
1856+
d.epCacheMu.RLock()
1857+
e, ok := d.epCache[nodeID]
1858+
d.epCacheMu.RUnlock()
1859+
if !ok {
1860+
return "", false
1861+
}
1862+
return e.addr, true
1863+
}
1864+
1865+
// isEndpointStale returns true if the cached entry is older than EndpointCacheTTL.
1866+
// Stale entries are still usable as fallback, but a fresh resolve is preferred.
1867+
func (d *Daemon) isEndpointStale(nodeID uint32) bool {
1868+
d.epCacheMu.RLock()
1869+
e, ok := d.epCache[nodeID]
1870+
d.epCacheMu.RUnlock()
1871+
if !ok {
1872+
return true
1873+
}
1874+
return time.Since(e.cachedAt) > EndpointCacheTTL
1875+
}
1876+
1877+
// CachedEndpoint returns a previously cached endpoint for a peer (exported for testing).
1878+
func (d *Daemon) CachedEndpoint(nodeID uint32) (string, bool) {
1879+
return d.cachedEndpoint(nodeID)
1880+
}
1881+
18251882
// ensureTunnel makes sure we have a route to the given node.
18261883
// Requests beacon hole-punching for NAT traversal when beacon is configured.
1884+
// Uses an endpoint cache as fallback when the registry is unreachable.
18271885
func (d *Daemon) ensureTunnel(nodeID uint32) error {
18281886
if d.tunnels.HasPeer(nodeID) {
18291887
return nil
@@ -1832,6 +1890,18 @@ func (d *Daemon) ensureTunnel(nodeID uint32) error {
18321890
// Resolve the node's real address from registry (requires our node ID)
18331891
resp, err := d.regConn.Resolve(nodeID, d.NodeID())
18341892
if err != nil {
1893+
// Registry unreachable — fall back to cached endpoint
1894+
if cached, ok := d.cachedEndpoint(nodeID); ok {
1895+
stale := d.isEndpointStale(nodeID)
1896+
slog.Warn("registry resolve failed, using cached endpoint",
1897+
"node_id", nodeID, "cached_addr", cached, "stale", stale, "error", err)
1898+
udpAddr, udpErr := net.ResolveUDPAddr("udp", cached)
1899+
if udpErr != nil {
1900+
return fmt.Errorf("resolve cached %s: %w", cached, udpErr)
1901+
}
1902+
d.tunnels.AddPeer(nodeID, udpAddr)
1903+
return nil
1904+
}
18351905
return fmt.Errorf("resolve node %d: %w", nodeID, err)
18361906
}
18371907

@@ -1849,6 +1919,9 @@ func (d *Daemon) ensureTunnel(nodeID uint32) error {
18491919
}
18501920
}
18511921

1922+
// Cache the resolved endpoint for fallback
1923+
d.cacheEndpoint(nodeID, targetAddr)
1924+
18521925
udpAddr, err := net.ResolveUDPAddr("udp", targetAddr)
18531926
if err != nil {
18541927
return fmt.Errorf("resolve %s: %w", targetAddr, err)

pkg/daemon/handshake.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,13 @@ func (hm *HandshakeManager) PendingRequests() []PendingHandshake {
812812
return list
813813
}
814814

815+
// PendingCount returns the number of pending handshake requests.
816+
func (hm *HandshakeManager) PendingCount() int {
817+
hm.mu.RLock()
818+
defer hm.mu.RUnlock()
819+
return len(hm.pending)
820+
}
821+
815822
// sendAcceptLocked sends an accept message (caller must hold hm.mu).
816823
func (hm *HandshakeManager) sendAcceptLocked(peerNodeID uint32) {
817824
hm.goRPC(func() {

pkg/daemon/ipc.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const (
4848
CmdSetTaskExecOK byte = 0x1E
4949
CmdNetwork byte = 0x1F
5050
CmdNetworkOK byte = 0x20
51+
CmdHealth byte = 0x21
52+
CmdHealthOK byte = 0x22
5153
)
5254

5355
// Network sub-commands (second byte of CmdNetwork payload)
@@ -223,6 +225,8 @@ func (s *IPCServer) handleClient(conn *ipcConn) {
223225
s.handleSetTaskExec(conn, payload)
224226
case CmdNetwork:
225227
s.handleNetwork(conn, payload)
228+
case CmdHealth:
229+
s.handleHealth(conn)
226230
default:
227231
s.sendError(conn, fmt.Sprintf("unknown command: 0x%02X", cmd))
228232
}
@@ -418,10 +422,13 @@ func (s *IPCServer) handleInfo(conn *ipcConn) {
418422
"email": info.Email,
419423
"bytes_sent": info.BytesSent,
420424
"bytes_recv": info.BytesRecv,
421-
"pkts_sent": info.PktsSent,
422-
"pkts_recv": info.PktsRecv,
423-
"peer_list": peers,
424-
"conn_list": conns,
425+
"pkts_sent": info.PktsSent,
426+
"pkts_recv": info.PktsRecv,
427+
"tunnel_encryption_success": info.EncryptOK,
428+
"tunnel_encryption_failure": info.EncryptFail,
429+
"handshake_pending_count": info.HandshakePendingCount,
430+
"peer_list": peers,
431+
"conn_list": conns,
425432
})
426433
if err != nil {
427434
s.sendError(conn, fmt.Sprintf("info marshal: %v", err))
@@ -435,6 +442,28 @@ func (s *IPCServer) handleInfo(conn *ipcConn) {
435442
}
436443
}
437444

445+
func (s *IPCServer) handleHealth(conn *ipcConn) {
446+
info := s.daemon.Info()
447+
data, err := json.Marshal(map[string]interface{}{
448+
"status": "ok",
449+
"uptime_seconds": int64(info.Uptime.Seconds()),
450+
"connections": info.Connections,
451+
"peers": info.Peers,
452+
"bytes_sent": info.BytesSent,
453+
"bytes_recv": info.BytesRecv,
454+
})
455+
if err != nil {
456+
s.sendError(conn, fmt.Sprintf("health marshal: %v", err))
457+
return
458+
}
459+
resp := make([]byte, 1+len(data))
460+
resp[0] = CmdHealthOK
461+
copy(resp[1:], data)
462+
if err := conn.ipcWrite(resp); err != nil {
463+
slog.Debug("IPC health reply failed", "err", err)
464+
}
465+
}
466+
438467
func (s *IPCServer) handleResolveHostname(conn *ipcConn, payload []byte) {
439468
hostname := string(payload)
440469
if hostname == "" {

pkg/daemon/tunnel.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@ type TunnelManager struct {
120120
webhook *WebhookClient
121121

122122
// Metrics
123-
BytesSent uint64
124-
BytesRecv uint64
125-
PktsSent uint64
126-
PktsRecv uint64
123+
BytesSent uint64
124+
BytesRecv uint64
125+
PktsSent uint64
126+
PktsRecv uint64
127+
EncryptOK uint64
128+
EncryptFail uint64
127129
}
128130

129131
type IncomingPacket struct {
@@ -633,6 +635,7 @@ func (tm *TunnelManager) handleEncrypted(data []byte, from *net.UDPAddr) {
633635

634636
plaintext, err := pc.aead.Open(nil, nonce, ciphertext, nil)
635637
if err != nil {
638+
atomic.AddUint64(&tm.EncryptFail, 1)
636639
slog.Error("tunnel decrypt error", "peer_node_id", peerNodeID, "error", err)
637640
// Undo the nonce record on decrypt failure — it was not a valid packet
638641
pc.replayMu.Lock()
@@ -800,6 +803,7 @@ func (tm *TunnelManager) encryptFrame(pc *peerCrypto, plaintext []byte) []byte {
800803
binary.BigEndian.PutUint64(nonce[pc.aead.NonceSize()-8:], counter)
801804

802805
ciphertext := pc.aead.Seal(nil, nonce, plaintext, nil)
806+
atomic.AddUint64(&tm.EncryptOK, 1)
803807

804808
frame := make([]byte, 4+4+len(nonce)+len(ciphertext))
805809
copy(frame[0:4], protocol.TunnelMagicSecure[:])

pkg/driver/driver.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ func (d *Driver) Info() (map[string]interface{}, error) {
142142
return d.jsonRPC([]byte{cmdInfo}, cmdInfoOK, "info")
143143
}
144144

145+
// Health returns a lightweight health check from the daemon.
146+
func (d *Driver) Health() (map[string]interface{}, error) {
147+
return d.jsonRPC([]byte{cmdHealth}, cmdHealthOK, "health")
148+
}
149+
145150
// Handshake sends a trust handshake request to a remote node.
146151
func (d *Driver) Handshake(nodeID uint32, justification string) (map[string]interface{}, error) {
147152
msg := make([]byte, 1+1+4+len(justification))

pkg/driver/ipc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const (
4444
cmdSetTaskExecOK byte = 0x1E
4545
cmdNetwork byte = 0x1F
4646
cmdNetworkOK byte = 0x20
47+
cmdHealth byte = 0x21
48+
cmdHealthOK byte = 0x22
4749
)
4850

4951
// Network sub-commands (must match daemon SubNetwork* constants)

pkg/protocol/packet.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ func Unmarshal(data []byte) (*Packet, error) {
9595
return nil, ErrChecksumMismatch
9696
}
9797

98+
// Validate protocol version.
99+
wireVersion := (data[0] >> 4) & 0x0F
100+
if wireVersion != Version {
101+
return nil, fmt.Errorf("unsupported protocol version %d (expected %d)", wireVersion, Version)
102+
}
103+
98104
p := &Packet{
99105
Version: (data[0] >> 4) & 0x0F,
100106
Flags: data[0] & 0x0F,

0 commit comments

Comments
 (0)