diff --git a/daemonapi/connection.go b/daemonapi/connection.go new file mode 100644 index 0000000..1987ce1 --- /dev/null +++ b/daemonapi/connection.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +// Connection is an opaque handle to a daemon stream connection. +// Plugins receive Connection values from DialConnection / Accept and +// pass them back to SendData / CloseConnection / NewConnReadWriter. +// The concrete *daemon.Connection in web4/pkg/daemon satisfies this +// via Go's structural typing — there are no methods on the interface +// itself; it's a marker token. +// +// Keeping Connection an opaque marker is deliberate: plugins like +// runtime treat connections as values they hand back to the daemon +// for any operation. They never inspect connection internals. +// Anything that ever does (writing bytes, reading bytes, closing) +// goes through ConnReadWriter — see below. +type Connection interface{} + +// ConnReadWriter is the read/write adapter plugins use when they +// need net.Conn-style I/O on a Connection. Construct one via +// Daemon.NewConnReadWriter(conn). +type ConnReadWriter interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Close() error +} + +// PortAllocator is an opaque handle to the daemon's port table. +// Plugins receive it via Daemon.Ports() and typically hand it to +// other plugins that need to bind well-known ports. Like Connection, +// it has no methods on this interface — concrete daemon types +// satisfy it via structural typing. +type PortAllocator interface{} + +// TunnelRegistry is an opaque handle to the daemon's tunnel table. +// Same opaque-marker shape as PortAllocator: plugins pass it +// around, the daemon owns the implementation. +type TunnelRegistry interface{} diff --git a/daemonapi/daemon.go b/daemonapi/daemon.go new file mode 100644 index 0000000..00fea96 --- /dev/null +++ b/daemonapi/daemon.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import ( + "context" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/protocol" + registry "github.com/pilot-protocol/common/registry/client" +) + +// Daemon is the dependency-free contract a daemon engine exposes to +// plugins. The concrete *daemon.Daemon in web4/pkg/daemon satisfies +// this interface via Go's structural typing — neither side needs to +// import the other. +// +// Every method here exists because at least one plugin (handshake, +// runtime, libpilot) calls it. Adding a method later is a backwards- +// compatible change as long as concrete daemon implementations grow +// the corresponding method first. +// +// Return types deliberately use: +// +// - Common-package concrete types (*crypto.Identity, *registry.Client, +// protocol.Addr) — those types already live in the dependency-free +// common module, so referencing them here doesn't create cycles. +// +// - daemonapi-local interfaces (Connection, PortAllocator, EventBus, +// etc.) — where the concrete return type lives inside the daemon +// engine. The opaque-marker interfaces keep plugins from poking +// at engine internals while still letting the daemon hand the +// value back to plugins as a typed token. +type Daemon interface { + // --- Lifecycle --------------------------------------------------- + + // Start brings the daemon online. Returns when the listeners are + // bound and the daemon is ready to accept traffic, or with an + // error if any step of bootstrap fails. + Start() error + + // Stop drains in-flight work and tears down the daemon. Idempotent; + // safe to call from a signal handler. + Stop() error + + // --- Identity --------------------------------------------------- + + // NodeID returns this daemon's stable 32-bit node ID. 0 when the + // identity has not been loaded yet. + NodeID() uint32 + + // Identity returns the daemon's Ed25519 keypair holder. Returns + // nil when the daemon was started without an identity file + // (in-memory tests). + Identity() *crypto.Identity + + // IdentityPath returns the on-disk path to the identity file. + // Empty when running in-memory. + IdentityPath() string + + // Sign signs the message with the local Ed25519 private key. + // Returns nil when no identity is loaded. + Sign(msg []byte) []byte + + // --- Configuration ---------------------------------------------- + + // AdminToken returns the local admin token used to authenticate + // privileged registry RPCs. Empty when not configured. + AdminToken() string + + // TrustAutoApprove reports whether the daemon was started with + // the auto-approve flag set. Plugins gating user-visible decisions + // on this flag (handshake auto-accept) read it once at Init. + TrustAutoApprove() bool + + // --- Network plumbing ------------------------------------------- + + // Addr returns the daemon's pilot-network address. Stable for + // the life of the daemon process. + Addr() protocol.Addr + + // DialConnection opens an outbound stream to (dstAddr, dstPort) + // and returns an opaque Connection handle. The handle is passed + // back to SendData, CloseConnection, NewConnReadWriter, etc. + DialConnection(dstAddr protocol.Addr, dstPort uint16) (Connection, error) + + // DialConnectionContext is DialConnection with a deadline. The + // context's Done channel cancels the dial. + DialConnectionContext(ctx context.Context, dstAddr protocol.Addr, dstPort uint16) (Connection, error) + + // SendData writes the byte slice to the stream connection. + // Blocks until the data is queued for transmission. + SendData(conn Connection, data []byte) error + + // SendDatagram sends an unconnected (UDP-shaped) payload to + // (dstAddr, dstPort). Best-effort, no retransmission. + SendDatagram(dstAddr protocol.Addr, dstPort uint16, data []byte) error + + // CloseConnection tears down the connection. Idempotent. + CloseConnection(conn Connection) + + // NewConnReadWriter wraps a stream Connection as a net.Conn-style + // adapter. Plugins that need read/write semantics use this; the + // daemon retains ownership of the underlying connection state. + NewConnReadWriter(conn Connection) ConnReadWriter + + // Ports returns the daemon's port allocator. Opaque to most + // plugins; usually handed off to other plugins (e.g. handshake) + // that bind well-known ports through it. + Ports() PortAllocator + + // Tunnels returns the daemon's tunnel registry. Opaque to most + // plugins. + Tunnels() TunnelRegistry + + // --- Registry --------------------------------------------------- + + // RegistryClient returns the L8 registry-side-channel client. + // nil when the daemon is running without a registry connection. + RegistryClient() *registry.Client + + // RegConnListNodes is the privileged list_nodes RPC, used by the + // policy plugin to enumerate per-network members. + RegConnListNodes(netID uint16, token string) (map[string]any, error) + + // SetMemberTags updates the local node's per-network tag list + // via the registry. + SetMemberTags(netID uint16, tags []string) + + // --- Events ----------------------------------------------------- + + // PublishEvent is the bus.Publish wrapper, exposed at top level + // because plugins commonly publish without holding a Bus reference. + PublishEvent(topic string, payload map[string]any) + + // Bus returns the in-process event bus for plugins that subscribe. + Bus() EventBus + + // --- Trust + handshake plugin coordination ---------------------- + + // GetTrustChecker returns the currently-registered trust checker + // (typically the trustedagents plugin). Returns nil when no + // checker is wired. + GetTrustChecker() TrustChecker + + // RegisterTrustChecker installs the given checker as the daemon's + // trust authority. Called once at startup by the trustedagents + // plugin via the runtime adapter. + RegisterTrustChecker(tc TrustChecker) + + // HandshakeService returns the currently-registered handshake + // service. Returns nil when no handshake plugin is wired (tests + // that bypass plugins). + HandshakeService() HandshakeService + + // RegisterHandshakeService installs the handshake plugin's + // service. Called once at startup via the runtime adapter. + RegisterHandshakeService(svc HandshakeService) + + // TrustedPeers proxies through to HandshakeService().TrustedPeers(). + // Returns nil when no handshake plugin is wired. + TrustedPeers() []HandshakeTrustRecord + + // HandshakeRevokeTrust proxies through to HandshakeService().RevokeTrust. + HandshakeRevokeTrust(nodeID uint32) error + + // HandshakeSendRequest proxies through to HandshakeService().SendRequest. + HandshakeSendRequest(nodeID uint32, reason string) error + + // --- Policy + webhook plugin coordination ----------------------- + + // RegisterPolicyManager installs the policy plugin's manager. + RegisterPolicyManager(pm PolicyManager) + + // SetWebhookURL hot-swaps the active webhook URL on the registered + // webhook plugin. No-op when no plugin is registered. + SetWebhookURL(url string) + + // RegisterWebhookManager installs the webhook plugin's manager. + RegisterWebhookManager(wm WebhookManager) +} diff --git a/daemonapi/doc.go b/daemonapi/doc.go new file mode 100644 index 0000000..b33fda9 --- /dev/null +++ b/daemonapi/doc.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package daemonapi is the dependency-free contract layer between the +// daemon engine and its plugins. +// +// The cycle problem: handshake / runtime / libpilot are daemon plugins +// that historically imported web4/pkg/daemon for the concrete Daemon +// struct, Config, Connection, Listener, and assorted services. The +// daemon binary in turn imported the plugins to compose itself. This +// is a real cycle (web4 ↔ plugins) that survives only because of +// `replace` directives during local dev. +// +// daemonapi breaks the cycle by hosting: +// +// 1. Pure-interface contracts (Daemon, Connection, Listener, +// TrustChecker, HandshakeService, PolicyManager, PolicyRunner, +// WebhookManager, ...). The concrete *daemon.Daemon and its +// members satisfy these via Go's structural typing — the daemon +// engine never imports daemonapi to register, and plugins never +// import the daemon engine. +// +// 2. A runtime plugin lifecycle (Plugin interface) and a process- +// global registry (RegisterPlugin, LoadAll). Plugins register +// themselves from an init() block in their own package; the +// daemon engine, with no compile-time knowledge of which plugins +// exist, iterates whatever the registry contains. +// +// "Not static" wiring means: +// +// - The daemon engine has zero hardcoded list of plugins. Adding or +// removing a plugin from a binary is a single blank-import line +// in cmd/daemon/main.go; the daemon, plugin, and other plugin +// packages all stay unchanged. +// +// - The Plugin contract is interface-based, so a plugin's source +// code is interchangeable: two `handshake` implementations satisfy +// the same Plugin interface, the daemon doesn't know the difference. +// +// - The registration mechanism is identical for in-process plugins +// (Go packages linked into the binary) and for true runtime +// plugins (Go plugin.Open of .so files). The .so's init() block +// calls RegisterPlugin the same way; the daemon engine then +// iterates the registry. +// +// What this package does NOT do: +// +// - It does not own daemon implementation code. Concrete types stay +// in web4/pkg/daemon (and plugins' own implementations stay in +// their own repos). Interfaces only. +// +// - It does not specify how the daemon engine starts or shuts down. +// That's the daemon's job. The Plugin lifecycle methods (Init, +// Shutdown) tell plugins when the daemon is ready and when it's +// stopping; the daemon decides the broader sequencing. +package daemonapi diff --git a/daemonapi/event.go b/daemonapi/event.go new file mode 100644 index 0000000..ba7702b --- /dev/null +++ b/daemonapi/event.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import "time" + +// Event is the payload structure carried over the daemon event bus. +// Mirrors the layout of the concrete event type in web4/pkg/daemon +// so subscriber channels receive identical-shaped values across the +// interface boundary. +type Event struct { + Topic string + NodeID uint32 + Time time.Time + Payload map[string]any +} + +// EventBus is the in-process pub/sub surface plugins consume. The +// concrete *inProcessBus in web4/pkg/daemon satisfies this; plugins +// retrieve it via Daemon.Bus() and Subscribe / Publish without +// knowing the underlying implementation. +type EventBus interface { + // Publish emits the topic + payload to every subscriber whose + // pattern matches the topic. Non-blocking; events may be dropped + // on per-subscriber backpressure. + Publish(topic string, payload map[string]any) + + // Subscribe registers a pattern-matching subscriber and returns + // the receive channel plus a cancellation closure. Calling the + // closure stops delivery and drains the buffer. + Subscribe(pattern string) (<-chan Event, func()) +} diff --git a/daemonapi/plugin.go b/daemonapi/plugin.go new file mode 100644 index 0000000..0681c3c --- /dev/null +++ b/daemonapi/plugin.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import "context" + +// Plugin is the lifecycle contract every daemon plugin implements. +// The daemon calls Init once after the engine is running and Shutdown +// once when the engine is stopping. Name returns a stable identifier +// used for registration, log lines, and metrics labels. +// +// A plugin holds the Daemon it was initialized with for the rest of +// its lifetime; the daemon engine will not be replaced under it. If +// the daemon shuts down, plugins receive Shutdown before the engine +// finishes its own teardown — they are guaranteed to be able to use +// the Daemon during Shutdown for any final cleanup (closing pending +// streams, dispatching final webhook deliveries, etc.). +type Plugin interface { + // Name returns the registration key of this plugin. Must be + // stable across releases of the plugin; the daemon engine may + // use it for keyed lookups, persistence, and operator-facing + // status output. + Name() string + + // Init wires the plugin to a running daemon engine. The plugin + // retains the Daemon for the rest of its lifetime. Init returns + // an error if the plugin cannot bootstrap; the daemon aborts + // startup on any plugin Init failure. + Init(d Daemon) error + + // Shutdown stops the plugin's background work and drains any + // in-flight requests. The Daemon passed to Init is still valid + // during Shutdown — plugins may use it for last-mile work — but + // the daemon engine will not accept new traffic. Shutdown should + // honor the context's deadline; the daemon will not wait + // indefinitely on a stuck plugin. + Shutdown(ctx context.Context) error +} + +// Factory builds a new instance of a plugin. Registered factories +// run when the daemon calls LoadAll; each call produces a fresh +// Plugin value, so plugins do not share state across daemon restarts. +type Factory func() Plugin diff --git a/daemonapi/registry.go b/daemonapi/registry.go new file mode 100644 index 0000000..fa78a45 --- /dev/null +++ b/daemonapi/registry.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import ( + "context" + "fmt" + "sort" + "sync" +) + +// The plugin pluginReg is a process-global, append-only map. Plugin +// packages call RegisterPlugin from their own init() block; the +// daemon engine later calls LoadAll to bring every registered plugin +// online against a specific Daemon. +// +// This decouples the daemon from compile-time knowledge of which +// plugins exist: cmd/daemon/main.go blank-imports the desired +// plugins, each plugin's init() registers itself, and the daemon +// iterates pluginReg contents at startup. Adding or removing a +// plugin from a binary is one blank-import line; no daemon or +// plugin code changes. +// +// The same mechanism works for in-process plugins (Go packages +// linked into the binary) and for runtime plugins (Go plugin.Open +// of .so files). The .so's init() block calls RegisterPlugin the +// same way, and LoadAll picks it up identically. + +var ( + pluginRegMu sync.Mutex + pluginReg = make(map[string]Factory) +) + +// RegisterPlugin records a factory under name. Typical use: +// +// func init() { +// daemonapi.RegisterPlugin("handshake", func() daemonapi.Plugin { +// return &handshakePlugin{} +// }) +// } +// +// Registering twice with the same name is a programming error and +// panics — two factories under one name would race in LoadAll. The +// panic surfaces during package init, not at runtime, which makes +// the conflict obvious in test output and at first daemon launch. +func RegisterPlugin(name string, f Factory) { + if name == "" { + panic("daemonapi: RegisterPlugin: empty name") + } + if f == nil { + panic("daemonapi: RegisterPlugin: nil factory for " + name) + } + pluginRegMu.Lock() + defer pluginRegMu.Unlock() + if _, exists := pluginReg[name]; exists { + panic("daemonapi: plugin already registered: " + name) + } + pluginReg[name] = f +} + +// Registered returns the names of every registered plugin, sorted. +// Useful for status output and for tests that want to verify a +// blank-import set wired the expected plugins in. +func Registered() []string { + pluginRegMu.Lock() + defer pluginRegMu.Unlock() + names := make([]string, 0, len(pluginReg)) + for n := range pluginReg { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// LoadAll instantiates every registered plugin against d and calls +// Init in sorted-by-name order. Returns the slice of loaded plugins +// so the caller can drive Shutdown later, plus the first error +// encountered (subsequent plugins are not started after a failure). +// +// Plugins are returned in registration-sorted order so Shutdown can +// run them in reverse and respect inter-plugin ordering by giving +// later-named plugins priority during teardown. This matches the +// common pattern where alphabetic name choice doubles as a startup- +// order hint (a-something starts before z-something). +func LoadAll(d Daemon) ([]Plugin, error) { + pluginRegMu.Lock() + names := make([]string, 0, len(pluginReg)) + for n := range pluginReg { + names = append(names, n) + } + sort.Strings(names) + factories := make([]Factory, len(names)) + for i, n := range names { + factories[i] = pluginReg[n] + } + pluginRegMu.Unlock() + + loaded := make([]Plugin, 0, len(names)) + for i, name := range names { + p := factories[i]() + if p == nil { + return loaded, fmt.Errorf("daemonapi: plugin %q factory returned nil", name) + } + if err := p.Init(d); err != nil { + return loaded, fmt.Errorf("daemonapi: plugin %q init: %w", name, err) + } + loaded = append(loaded, p) + } + return loaded, nil +} + +// ShutdownAll calls Shutdown on every plugin in REVERSE-sorted order +// (the inverse of LoadAll's startup order). Each Shutdown gets the +// same context — typically a deadline-bound context from the daemon's +// shutdown timeout. Errors are collected and returned as a wrapped +// multi-error so the daemon still attempts to shut down every plugin +// even if an earlier one returned an error. +func ShutdownAll(ctx context.Context, plugins []Plugin) error { + var errs []error + for i := len(plugins) - 1; i >= 0; i-- { + if err := plugins[i].Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("plugin %q shutdown: %w", plugins[i].Name(), err)) + } + } + if len(errs) == 0 { + return nil + } + // Join the errors; the daemon's caller can errors.Is / errors.As + // to inspect individual plugin failures. + msg := errs[0].Error() + for _, e := range errs[1:] { + msg += "; " + e.Error() + } + return fmt.Errorf("daemonapi: %d plugin shutdown error(s): %s", len(errs), msg) +} + +// resetRegistry clears the pluginReg. Tests only — no production code +// needs this. Kept package-private; callers that genuinely need to +// reset state in tests can declare their own helper using a build +// tag and a copy of this function. +func resetRegistry() { + pluginRegMu.Lock() + defer pluginRegMu.Unlock() + pluginReg = make(map[string]Factory) +} diff --git a/daemonapi/services.go b/daemonapi/services.go new file mode 100644 index 0000000..e45df59 --- /dev/null +++ b/daemonapi/services.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import "time" + +// The interfaces in this file are the plug-points where each +// canonical plugin slots into the daemon: TrustChecker (trustedagents), +// HandshakeService (handshake), PolicyManager + PolicyRunner (policy), +// WebhookManager (webhook). They originated in web4/pkg/daemon/ +// contract.go where the daemon engine declares them; this is the +// extraction to the dependency-free common package so plugins no +// longer need to import the daemon engine for the contract. +// +// All signatures are deliberately primitive (int, string, bool, +// time.Time, slices of records below). Plugin implementations +// satisfy these via Go's structural typing — no upward import from +// daemon to plugin. + +// PolicyEventType is the kind of protocol event a policy is +// evaluated against. Type alias to string so plugin signatures stay +// primitive end-to-end. +type PolicyEventType = string + +// PolicyEvent* are the event-type constants the daemon engine +// passes into PolicyManager / PolicyRunner. Match coreapi.PolicyEvent* +// values; both are aliases of string. +const ( + PolicyEventConnect = "connect" + PolicyEventDial = "dial" + PolicyEventDatagram = "datagram" + PolicyEventJoin = "join" + PolicyEventLeave = "leave" + PolicyEventCycle = "cycle" +) + +// TrustChecker is the daemon-facing surface of the trustedagents +// plugin. The handshake handler consults this for auto-accept. +type TrustChecker interface { + IsTrusted(nodeID uint32) (string, bool) +} + +// HandshakeService is the daemon-facing surface of the manual +// trust-handshake plugin (port 444). The plugin's *Manager satisfies +// this via Go's structural typing — the daemon engine never imports +// the handshake package. +// +// All trust-handshake operations route through this interface: IPC +// command dispatch, trust-gate checks on inbound SYN / datagrams, +// registry-relay polling, and trust-pair re-sync after reconnect. +type HandshakeService interface { + IsTrusted(nodeID uint32) bool + TrustedPeers() []HandshakeTrustRecord + PendingRequests() []HandshakePendingRecord + PendingCount() int + SendRequest(peerNodeID uint32, justification string) error + ApproveHandshake(peerNodeID uint32) error + RejectHandshake(peerNodeID uint32, reason string) error + RevokeTrust(peerNodeID uint32) error + + // WaitForTrust blocks until the peer transitions to trusted, or + // the timeout elapses. Returns true if trust was granted in + // time. Wired through the daemon so callers (typically pilotctl + // before a first send to a trusted-list peer) can block + // bidirectional operations on trust establishment instead of + // racing the data send against the handshake reply. + WaitForTrust(peerNodeID uint32, timeout time.Duration) bool + + // ProcessRelayedRequest / ProcessRelayedApproval / + // ProcessRelayedRejection are invoked from the daemon's relay + // poller after parsing the registry-inbox payload. + ProcessRelayedRequest(fromNodeID uint32, justification string) + ProcessRelayedApproval(fromNodeID uint32) + ProcessRelayedRejection(fromNodeID uint32) + + // Stop drains background RPCs and stops the replay reaper. + Stop() +} + +// HandshakeTrustRecord mirrors the handshake plugin's TrustRecord +// so the daemon-facing HandshakeService interface stays primitive- +// only (no upward import). Field set is identical to the plugin's +// TrustRecord — the plugin's adapter returns a converted +// []HandshakeTrustRecord built from its own TrustRecord values. +type HandshakeTrustRecord struct { + NodeID uint32 + PublicKey string + ApprovedAt time.Time + Mutual bool + Network uint16 +} + +// HandshakePendingRecord mirrors the handshake plugin's +// PendingHandshake for the same reason as HandshakeTrustRecord. +type HandshakePendingRecord struct { + NodeID uint32 + PublicKey string + Justification string + ReceivedAt time.Time +} + +// PolicyRunner is the daemon-facing surface of a single network's +// running policy. The plugin's concrete *PolicyRunner satisfies this +// via structural typing. +type PolicyRunner interface { + NetworkID() uint16 + HasMember(peerNodeID uint32) bool + + // EvaluatePortGate takes a string event-type ("connect", "dial", + // "datagram", ...). The plugin's EventType is a type alias to + // coreapi.PolicyEventType which is itself a type alias to string, + // so plugin signatures match this exactly. + EvaluatePortGate( + eventType string, + port uint16, + peerNodeID uint32, + payloadSize int, + direction string, + localTags, nodeInfoTags []string, + ) bool + + EvaluateActions(eventType string, ctx map[string]any) + Status() map[string]any + PeerList() []map[string]interface{} + ForceCycle() map[string]any + ReconcileNow() + PolicyJSON() ([]byte, error) + Stop() +} + +// PolicyManager owns the per-network registry of policy runners. +type PolicyManager interface { + Start(netID uint16, policyJSON []byte) (PolicyRunner, error) + Stop(netID uint16) + Get(netID uint16) PolicyRunner + All() []PolicyRunner + StopAll() + LoadPersisted() error +} + +// WebhookManager is the daemon-facing surface of the webhook plugin. +// The plugin owns the HTTP client; the daemon only needs to (a) +// hot-swap the URL when IPC's set-webhook fires and (b) read counters +// for the daemon info health snapshot. +type WebhookManager interface { + // SetURL hot-swaps the active webhook URL. Empty URL disables + // delivery (no-op until set again). + SetURL(url string) + + // Stats returns dispatcher counters for daemon Info. All-zero + // when no client is configured (nil-safe at the plugin level). + Stats() WebhookStats +} + +// WebhookStats is the daemon-facing mirror of the webhook plugin's +// Stats. Same shape, different package — the daemon engine can hold +// the value type without importing the plugin. +type WebhookStats struct { + Dropped uint64 + CircuitSkips uint64 +} diff --git a/daemonapi/zz_registry_test.go b/daemonapi/zz_registry_test.go new file mode 100644 index 0000000..e19ed6e --- /dev/null +++ b/daemonapi/zz_registry_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemonapi + +import ( + "context" + "crypto/ed25519" + "errors" + "testing" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/protocol" + registry "github.com/pilot-protocol/common/registry/client" +) + +// fakePlugin is a minimal Plugin implementation for registry tests. +type fakePlugin struct { + name string + initCount int + stopCount int + initErr error + shutErr error + gotDaemon Daemon +} + +func (p *fakePlugin) Name() string { return p.name } +func (p *fakePlugin) Init(d Daemon) error { p.initCount++; p.gotDaemon = d; return p.initErr } +func (p *fakePlugin) Shutdown(context.Context) error { p.stopCount++; return p.shutErr } + +// fakeDaemon is the smallest Daemon implementation that compiles — +// every method panics. Tests don't call any method; they only use it +// to verify that registry plumbing flows Daemon through Init. +type fakeDaemon struct{} + +func (fakeDaemon) Start() error { panic("not implemented") } +func (fakeDaemon) Stop() error { panic("not implemented") } +func (fakeDaemon) NodeID() uint32 { return 42 } +func (fakeDaemon) Identity() *crypto.Identity { return nil } +func (fakeDaemon) IdentityPath() string { return "" } +func (fakeDaemon) Sign([]byte) []byte { return nil } +func (fakeDaemon) AdminToken() string { return "" } +func (fakeDaemon) TrustAutoApprove() bool { return false } +func (fakeDaemon) Addr() protocol.Addr { return protocol.Addr{} } +func (fakeDaemon) DialConnection(protocol.Addr, uint16) (Connection, error) { panic("not implemented") } +func (fakeDaemon) DialConnectionContext(context.Context, protocol.Addr, uint16) (Connection, error) { panic("not implemented") } +func (fakeDaemon) SendData(Connection, []byte) error { panic("not implemented") } +func (fakeDaemon) SendDatagram(protocol.Addr, uint16, []byte) error { panic("not implemented") } +func (fakeDaemon) CloseConnection(Connection) { panic("not implemented") } +func (fakeDaemon) NewConnReadWriter(Connection) ConnReadWriter { panic("not implemented") } +func (fakeDaemon) Ports() PortAllocator { return nil } +func (fakeDaemon) Tunnels() TunnelRegistry { return nil } +func (fakeDaemon) RegistryClient() *registry.Client { return nil } +func (fakeDaemon) RegConnListNodes(uint16, string) (map[string]any, error) { panic("not implemented") } +func (fakeDaemon) SetMemberTags(uint16, []string) {} +func (fakeDaemon) PublishEvent(string, map[string]any) {} +func (fakeDaemon) Bus() EventBus { return nil } +func (fakeDaemon) GetTrustChecker() TrustChecker { return nil } +func (fakeDaemon) RegisterTrustChecker(TrustChecker) {} +func (fakeDaemon) HandshakeService() HandshakeService { return nil } +func (fakeDaemon) RegisterHandshakeService(HandshakeService) {} +func (fakeDaemon) TrustedPeers() []HandshakeTrustRecord { return nil } +func (fakeDaemon) HandshakeRevokeTrust(uint32) error { return nil } +func (fakeDaemon) HandshakeSendRequest(uint32, string) error { return nil } +func (fakeDaemon) RegisterPolicyManager(PolicyManager) {} +func (fakeDaemon) SetWebhookURL(string) {} +func (fakeDaemon) RegisterWebhookManager(WebhookManager) {} + +// Compile-time check that fakeDaemon satisfies Daemon — also a +// sanity check that the interface compiles together. +var _ Daemon = fakeDaemon{} + +// Compile-time check Ed25519 keys still type-check through Identity +// (catching accidental import-path regressions). +var _ ed25519.PublicKey = ed25519.PublicKey(nil) + +func TestRegisterAndLoadAll(t *testing.T) { + resetRegistry() + defer resetRegistry() + + p1 := &fakePlugin{name: "alpha"} + p2 := &fakePlugin{name: "beta"} + RegisterPlugin("alpha", func() Plugin { return p1 }) + RegisterPlugin("beta", func() Plugin { return p2 }) + + if got := Registered(); len(got) != 2 || got[0] != "alpha" || got[1] != "beta" { + t.Fatalf("Registered = %v, want [alpha beta]", got) + } + + d := fakeDaemon{} + loaded, err := LoadAll(d) + if err != nil { + t.Fatalf("LoadAll: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("loaded %d plugins, want 2", len(loaded)) + } + if p1.initCount != 1 || p2.initCount != 1 { + t.Errorf("Init counts: alpha=%d beta=%d, want 1 each", p1.initCount, p2.initCount) + } + if p1.gotDaemon != d || p2.gotDaemon != d { + t.Error("plugins did not receive the daemon") + } +} + +func TestRegisterDuplicatePanics(t *testing.T) { + resetRegistry() + defer resetRegistry() + RegisterPlugin("dup", func() Plugin { return &fakePlugin{name: "dup"} }) + + defer func() { + if recover() == nil { + t.Error("expected panic on duplicate registration") + } + }() + RegisterPlugin("dup", func() Plugin { return &fakePlugin{name: "dup2"} }) +} + +func TestLoadAllStopsOnFirstError(t *testing.T) { + resetRegistry() + defer resetRegistry() + good := &fakePlugin{name: "good"} + bad := &fakePlugin{name: "tango", initErr: errors.New("boom")} + RegisterPlugin("good", func() Plugin { return good }) + RegisterPlugin("tango", func() Plugin { return bad }) + + loaded, err := LoadAll(fakeDaemon{}) + if err == nil { + t.Fatal("expected error from failing Init") + } + if len(loaded) != 1 || loaded[0].Name() != "good" { + t.Errorf("partial-load result = %v, want [good]", loaded) + } +} + +func TestShutdownAllReverseOrder(t *testing.T) { + resetRegistry() + defer resetRegistry() + var order []string + RegisterPlugin("first", func() Plugin { + return &orderedPlugin{n: "first", order: &order} + }) + RegisterPlugin("second", func() Plugin { + return &orderedPlugin{n: "second", order: &order} + }) + RegisterPlugin("third", func() Plugin { + return &orderedPlugin{n: "third", order: &order} + }) + loaded, err := LoadAll(fakeDaemon{}) + if err != nil { + t.Fatalf("LoadAll: %v", err) + } + if err := ShutdownAll(context.Background(), loaded); err != nil { + t.Fatalf("ShutdownAll: %v", err) + } + if len(order) != 3 || order[0] != "third" || order[1] != "second" || order[2] != "first" { + t.Errorf("shutdown order = %v, want [third second first]", order) + } +} + +type orderedPlugin struct { + n string + order *[]string +} + +func (p *orderedPlugin) Name() string { return p.n } +func (p *orderedPlugin) Init(Daemon) error { return nil } +func (p *orderedPlugin) Shutdown(context.Context) error { *p.order = append(*p.order, p.n); return nil }