package poke import ( "context" "log" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // peerStore is the WG-peer lookup seam (satisfied by *store.Store; tests fake it). type peerStore interface { GetWGPeerForHost(hostID string) (*store.WGPeer, error) ListWGPeers() ([]store.WGPeer, error) } // pokeSender is the SSH send seam (satisfied by *Client; tests fake it). type pokeSender interface { Poke(ctx context.Context, boxWGIP string) error } // Notifier turns an operator-intent event (a host id, or "all hosts") into fire-and-forget pokes. // Every public method returns immediately and does the SSH work on a detached goroutine, so a poke // NEVER blocks or fails the hub save that triggered it — a lost poke is harmless (the 15-min cycle // reconciles). nil *Notifier is a safe no-op (poke disabled / not configured). type Notifier struct { store peerStore client pokeSender timeout time.Duration logger *log.Logger } // NewNotifier builds a notifier. timeout bounds one poke (default 10s). func NewNotifier(st peerStore, client pokeSender, logger *log.Logger) *Notifier { if logger == nil { logger = log.Default() } return &Notifier{store: st, client: client, timeout: 10 * time.Second, logger: logger} } // PokeHost fires a poke to one host's box (async, fire-and-forget). Safe on a nil receiver. func (n *Notifier) PokeHost(hostID string) { if n == nil { return } go func() { if err := n.pokeHost(context.Background(), hostID); err != nil { n.logger.Printf("[INFO] poke: host %s not nudged (%v) — the 15-min cycle still reconciles", hostID, err) } }() } // PokeAllHosts fires a poke to EVERY host with a WG peer (async, fire-and-forget). Used by a // fleet-wide agent-plane change (e.g. a MinAgent floor). Safe on a nil receiver. func (n *Notifier) PokeAllHosts() { if n == nil { return } go func() { peers, err := n.store.ListWGPeers() if err != nil { n.logger.Printf("[INFO] poke: fleet nudge skipped (peer list: %v) — the 15-min cycle still reconciles", err) return } nudged := 0 for _, p := range peers { if p.HostID == "" || p.AssignedIP == "" { continue } ctx, cancel := context.WithTimeout(context.Background(), n.timeout) if err := n.client.Poke(ctx, p.AssignedIP); err != nil { n.logger.Printf("[INFO] poke: host %s (%s) not nudged (%v)", p.HostID, p.AssignedIP, err) } else { nudged++ } cancel() } n.logger.Printf("[INFO] poke: fleet nudge sent to %d/%d WG peers", nudged, len(peers)) }() } // pokeHost is the synchronous body (test seam). Resolves the host's WG /32 and sends one poke. func (n *Notifier) pokeHost(ctx context.Context, hostID string) error { peer, err := n.store.GetWGPeerForHost(hostID) if err != nil { return err } if peer == nil || peer.AssignedIP == "" { return errNoPeer } tctx, cancel := context.WithTimeout(ctx, n.timeout) defer cancel() return n.client.Poke(tctx, peer.AssignedIP) } type pokeErr string func (e pokeErr) Error() string { return string(e) } const errNoPeer = pokeErr("host has no WG peer")