eb227486d0
- internal/poke: pinned-host-key SSH poke sender (wgsync sibling) + fire-and-forget Notifier (PokeHost/PokeAllHosts). Poke refuses non-WG targets pre-dial; contentless via ep0 forced command to the box WG /32:51822. - wiring: Server.SetPoke; applyPBSDR pokes the host after each descriptor gen-bump; handleSetArtifacts (MinAgent floor) pokes all hosts. main.go env POKE_SSH_KEY_FILE (reuses peersync endpoint/hostkey). - scripts/felhom-poke.sh (non-root forced command) + offsite-endpoint.md §11; manifests/hub.yaml Secret/agent-poke + POKE_SSH_KEY_FILE (image tag bump follows the build).
101 lines
3.0 KiB
Go
101 lines
3.0 KiB
Go
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")
|