diff --git a/driver/driver.go b/driver/driver.go index e83385f..8eca2d4 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -282,6 +282,29 @@ func (d *Driver) RevokeTrust(nodeID uint32) (map[string]interface{}, error) { return d.jsonRPC(msg, cmdHandshakeOK, "revoke") } +// PreferDirect asks the daemon to drop the existing tunnel to the peer +// and any sticky routing state (cached endpoint, cached resolve, unpinned +// relay flag), then re-resolve from the registry and prefer a direct UDP +// path on the next dial. Returns the new routing state the daemon arrived +// at — typically {"node_id": N, "relay_active": false, "pinned": false, +// "real_addr": "..."} when a direct path was found, or relay_active=true +// when the registry's relay_only flag is authoritative or the punch +// failed. +// +// Useful when stream traffic (pilotctl send-file) is failing on a relay +// path while small UDP (pilotctl ping) still works — typical symptom of +// a beacon-mediated tunnel that established once and then stuck. +// +// Backward compatibility: an old daemon (no CmdPreferDirect) returns an +// "unknown command" error — callers should treat that as "best-effort +// hint" and proceed with the normal dial, not abort the operation. +func (d *Driver) PreferDirect(nodeID uint32) (map[string]interface{}, error) { + msg := make([]byte, 5) + msg[0] = cmdPreferDirect + binary.BigEndian.PutUint32(msg[1:5], nodeID) + return d.jsonRPC(msg, cmdPreferDirectOK, "prefer_direct") +} + // ResolveHostname resolves a hostname to node info via the daemon. func (d *Driver) ResolveHostname(hostname string) (map[string]interface{}, error) { msg := make([]byte, 1+len(hostname)) diff --git a/driver/ipc.go b/driver/ipc.go index 457a458..38cd06d 100644 --- a/driver/ipc.go +++ b/driver/ipc.go @@ -53,6 +53,13 @@ const ( cmdRotateKeyOK byte = 0x26 cmdBroadcast byte = 0x29 cmdBroadcastOK byte = 0x2A + // cmdPreferDirect asks the daemon to drop the existing tunnel to a + // peer (and any cached endpoint / sticky relay flag) and re-resolve + // + redial fresh — preferring a direct UDP path. Useful when a peer + // got stuck on the beacon relay after an unlucky punch and stream + // traffic (send-file) is failing while small messages (ping) work. + cmdPreferDirect byte = 0x2D + cmdPreferDirectOK byte = 0x2E ) // Network sub-commands (must match daemon SubNetwork* constants) @@ -184,7 +191,8 @@ func (c *ipcClient) readLoop() { case cmdBindOK, cmdDialOK, cmdError, cmdInfoOK, cmdHandshakeOK, cmdResolveHostnameOK, cmdSetHostnameOK, cmdSetVisibilityOK, cmdDeregisterOK, cmdSetTagsOK, cmdSetWebhookOK, cmdNetworkOK, - cmdHealthOK, cmdManagedOK, cmdRotateKeyOK, cmdBroadcastOK: + cmdHealthOK, cmdManagedOK, cmdRotateKeyOK, cmdBroadcastOK, + cmdPreferDirectOK: // Known response cmds: route to pending for the in-flight sendAndWait. select { case c.pending <- &pendingResponse{cmd: cmd, payload: append([]byte(nil), payload...)}: diff --git a/driver/zz_driver_simple_ops_test.go b/driver/zz_driver_simple_ops_test.go index d2e82b5..cf0832e 100644 --- a/driver/zz_driver_simple_ops_test.go +++ b/driver/zz_driver_simple_ops_test.go @@ -3,6 +3,7 @@ package driver import ( + "encoding/binary" "testing" ) @@ -105,6 +106,55 @@ func TestDriverWaitForTrust(t *testing.T) { } } +// TestDriverPreferDirect covers PreferDirect's JSON-RPC roundtrip: +// - the request frame is exactly [cmdPreferDirect(0x2D)][big-endian uint32 nodeID] (5 bytes), +// - the cmdPreferDirectOK (0x2E) reply is routed/accepted by readLoop (not dropped) — +// proven by the happy path returning a non-nil result and nil error, which only +// happens if the OK frame reaches the in-flight sendAndWait via c.pending, +// - the daemon's returned routing state is unmarshalled and surfaced. +func TestDriverPreferDirect(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + defer d.close() + + const nodeID uint32 = 0xDEADBEEF + + d.onCmd(cmdPreferDirect, func(frame []byte) [][]byte { + // Frame is [cmd][payload]; assert the exact 5-byte wire shape. + if len(frame) != 5 { + t.Errorf("PreferDirect frame len = %d, want 5", len(frame)) + return [][]byte{{cmdError, 0, 0, 'l', 'e', 'n'}} + } + if frame[0] != cmdPreferDirect { + t.Errorf("PreferDirect opcode = 0x%02X, want 0x%02X", frame[0], cmdPreferDirect) + } + if got := binary.BigEndian.Uint32(frame[1:5]); got != nodeID { + t.Errorf("PreferDirect nodeID = 0x%08X, want 0x%08X", got, nodeID) + } + body := []byte(`{"node_id":3735928559,"relay_active":false,"pinned":false,"real_addr":"1.2.3.4:9000"}`) + return [][]byte{append([]byte{cmdPreferDirectOK}, body...)} + }) + + drv, err := Connect(d.path) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer drv.Close() + + result, err := drv.PreferDirect(nodeID) + if err != nil { + t.Fatalf("PreferDirect: %v", err) + } + if result == nil { + t.Fatalf("PreferDirect result is nil") + } + // The cmdPreferDirectOK payload must have been routed (not dropped) and + // unmarshalled — check a field round-tripped. + if ra, ok := result["relay_active"].(bool); !ok || ra { + t.Errorf("relay_active = %v (ok=%v), want false", result["relay_active"], ok) + } +} + // TestDriverRotateKey covers RotateKey's JSON-RPC roundtrip. func TestDriverRotateKey(t *testing.T) { t.Parallel()