package wgsync import ( "context" "encoding/json" "fmt" "log" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // syncer is the push seam — satisfied by *Client; tests inject a fake to count calls and // capture payloads without SSH. type syncer interface { Push(ctx context.Context, payload []byte) error } // Reconciler keeps the endpoint's WG peer list converged on the hub DB (the source of truth). // DECLARATIVE: every push carries the FULL desired list — never deltas — so endpoint drift // (a manually-added peer, a missed earlier push) is erased on the next push, and a failed push // self-heals on the next tick. This full-list property is load-bearing (Scenario D drift // repair) — do not "optimize" it into deltas. type Reconciler struct { store *store.Store sync syncer trigger chan struct{} interval time.Duration // tick period; tests shrink it logger *log.Logger } // NewReconciler builds a Reconciler over the store and a syncer (the SSH client in production). func NewReconciler(st *store.Store, sync syncer, logger *log.Logger) *Reconciler { if logger == nil { logger = log.Default() } return &Reconciler{ store: st, sync: sync, trigger: make(chan struct{}, 1), interval: 5 * time.Minute, logger: logger, } } // Trigger requests an immediate sync from Run's loop. Non-blocking: a pending trigger already // covers this request (the full list is pushed either way). func (r *Reconciler) Trigger() { select { case r.trigger <- struct{}{}: default: } } // payload builds the versioned, deterministic sync document from the store. // {"version":1,"interface":"wg0","peers":[{"pubkey":"...","allowed_ip":"/32"}]} func (r *Reconciler) payload() ([]byte, error) { peers, err := r.store.ListWGPeers() if err != nil { return nil, fmt.Errorf("list peers: %w", err) } type wirePeer struct { Pubkey string `json:"pubkey"` AllowedIP string `json:"allowed_ip"` } doc := struct { Version int `json:"version"` Interface string `json:"interface"` Peers []wirePeer `json:"peers"` }{Version: 1, Interface: "wg0", Peers: make([]wirePeer, 0, len(peers))} for _, p := range peers { doc.Peers = append(doc.Peers, wirePeer{Pubkey: p.Pubkey, AllowedIP: p.AssignedIP + "/32"}) } return json.Marshal(doc) } // SyncNow builds the current full-list payload and pushes it. Called inline by the mutation // handlers (short ctx) and by Run's loop. func (r *Reconciler) SyncNow(ctx context.Context) error { payload, err := r.payload() if err != nil { return err } return r.sync.Push(ctx, payload) } // Run loops until ctx is done: an explicit Trigger OR the periodic tick pushes the full list. // The periodic push happens even with zero mutations — that is the drift repair. Errors are // logged and retried on the next signal; Run never exits early. func (r *Reconciler) Run(ctx context.Context) { ticker := time.NewTicker(r.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-r.trigger: case <-ticker.C: } syncCtx, cancel := context.WithTimeout(ctx, 60*time.Second) if err := r.SyncNow(syncCtx); err != nil { r.logger.Printf("[ERROR] wgsync reconcile: %v (will retry on next tick)", err) } cancel() } }