fbeeacb124
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
107 lines
3.2 KiB
Go
107 lines
3.2 KiB
Go
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()
|
|
}
|
|
}
|