hub: S1 wgsync (pinned-SSH push + declarative reconciler) + /admin/wg API + env wiring
internal/wgsync: x/crypto/ssh client with ssh.FixedHostKey pin (no insecure fallback), forced-command exec, ok/applied response contract; Reconciler pushes the FULL peer list on Trigger or 5-min tick (drift repair by construction). internal/api/wg.go: PUT/GET /admin/wg/endpoint + POST/DELETE/GET /admin/wg/peers, global-key-only, pubkey in body (base64 vs URL), sync ok|deferred|disabled. main.go: WG_ENDPOINT_SSH_* env wiring, disabled-with-INFO when unconfigured. Groups B/C/D tests incl. in-process SSH server; red-proofs b/c/d run + reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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":"<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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user