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,206 @@
|
||||
package wgsync
|
||||
|
||||
// Group D — reconciler (Scenario D). Non-hollow: asserts the exact pushed payloads (full list,
|
||||
// deterministic order, absent-after-remove), retry-after-failure, and the no-mutation drift-
|
||||
// repair tick.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
type fakeSyncer struct {
|
||||
mu sync.Mutex
|
||||
payloads [][]byte
|
||||
err error
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func newFakeSyncer() *fakeSyncer { return &fakeSyncer{notify: make(chan struct{}, 16)} }
|
||||
|
||||
func (f *fakeSyncer) Push(ctx context.Context, payload []byte) error {
|
||||
f.mu.Lock()
|
||||
cp := append([]byte(nil), payload...)
|
||||
f.payloads = append(f.payloads, cp)
|
||||
err := f.err
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case f.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeSyncer) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.payloads)
|
||||
}
|
||||
|
||||
func (f *fakeSyncer) last() []byte {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.payloads) == 0 {
|
||||
return nil
|
||||
}
|
||||
return f.payloads[len(f.payloads)-1]
|
||||
}
|
||||
|
||||
func waitPush(t *testing.T, f *fakeSyncer) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.notify:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for a push")
|
||||
}
|
||||
}
|
||||
|
||||
func newWGStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
s, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
if err := s.SetWGEndpoint(&store.WGEndpoint{
|
||||
DNSName: "ep0.example", WGPort: 443, ServerPubkey: "SPK",
|
||||
TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetWGEndpoint: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestReconciler_TriggerPushesFullList(t *testing.T) {
|
||||
st := newWGStore(t)
|
||||
st.AddWGPeer("PKA", "", "")
|
||||
st.AddWGPeer("PKB", "", "")
|
||||
fake := newFakeSyncer()
|
||||
r := NewReconciler(st, fake, log.New(io.Discard, "", 0))
|
||||
r.interval = time.Hour // tick out of the picture
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go r.Run(ctx)
|
||||
|
||||
r.Trigger()
|
||||
waitPush(t, fake)
|
||||
if fake.count() != 1 {
|
||||
t.Fatalf("push count = %d, want exactly 1", fake.count())
|
||||
}
|
||||
var doc struct {
|
||||
Version int `json:"version"`
|
||||
Interface string `json:"interface"`
|
||||
Peers []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
AllowedIP string `json:"allowed_ip"`
|
||||
} `json:"peers"`
|
||||
}
|
||||
if err := json.Unmarshal(fake.last(), &doc); err != nil {
|
||||
t.Fatalf("payload not JSON: %v", err)
|
||||
}
|
||||
if doc.Version != 1 || doc.Interface != "wg0" || len(doc.Peers) != 2 {
|
||||
t.Fatalf("payload = %s", fake.last())
|
||||
}
|
||||
if doc.Peers[0].Pubkey != "PKA" || doc.Peers[0].AllowedIP != "10.77.0.2/32" ||
|
||||
doc.Peers[1].Pubkey != "PKB" || doc.Peers[1].AllowedIP != "10.77.0.3/32" {
|
||||
t.Errorf("peers = %+v (order/content)", doc.Peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciler_FailureRetriedOnTick(t *testing.T) {
|
||||
st := newWGStore(t)
|
||||
st.AddWGPeer("PKA", "", "")
|
||||
fake := newFakeSyncer()
|
||||
fake.err = context.DeadlineExceeded // every push fails
|
||||
r := NewReconciler(st, fake, log.New(io.Discard, "", 0))
|
||||
r.interval = 30 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go r.Run(ctx)
|
||||
|
||||
r.Trigger()
|
||||
waitPush(t, fake) // the failing triggered push
|
||||
waitPush(t, fake) // the tick retry — Run must not have exited on the error
|
||||
if fake.count() < 2 {
|
||||
t.Fatalf("push count = %d, want >=2 (retry after failure)", fake.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciler_TickPushesWithoutMutations(t *testing.T) {
|
||||
st := newWGStore(t) // zero peers, zero mutations
|
||||
fake := newFakeSyncer()
|
||||
r := NewReconciler(st, fake, log.New(io.Discard, "", 0))
|
||||
r.interval = 30 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go r.Run(ctx)
|
||||
|
||||
waitPush(t, fake) // pure tick — this IS the drift repair
|
||||
var doc struct {
|
||||
Peers []interface{} `json:"peers"`
|
||||
}
|
||||
if err := json.Unmarshal(fake.last(), &doc); err != nil {
|
||||
t.Fatalf("payload not JSON: %v", err)
|
||||
}
|
||||
if len(doc.Peers) != 0 {
|
||||
t.Errorf("empty registry must push an empty (not absent) peer list: %s", fake.last())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciler_RemovedPeerAbsentFromNextPayload(t *testing.T) {
|
||||
st := newWGStore(t)
|
||||
st.AddWGPeer("PKA", "", "")
|
||||
st.AddWGPeer("PKB", "", "")
|
||||
fake := newFakeSyncer()
|
||||
r := NewReconciler(st, fake, log.New(io.Discard, "", 0))
|
||||
|
||||
if err := r.SyncNow(context.Background()); err != nil {
|
||||
t.Fatalf("SyncNow: %v", err)
|
||||
}
|
||||
if err := st.RemoveWGPeer("PKA"); err != nil {
|
||||
t.Fatalf("RemoveWGPeer: %v", err)
|
||||
}
|
||||
if err := r.SyncNow(context.Background()); err != nil {
|
||||
t.Fatalf("SyncNow 2: %v", err)
|
||||
}
|
||||
last := string(fake.last())
|
||||
// Assert the NEGATIVE: the removed pubkey is gone from the pushed bytes.
|
||||
if json.Valid([]byte(last)) == false {
|
||||
t.Fatalf("payload not JSON: %s", last)
|
||||
}
|
||||
if contains := jsonContainsPubkey(t, []byte(last), "PKA"); contains {
|
||||
t.Errorf("removed peer PKA still in payload: %s", last)
|
||||
}
|
||||
if contains := jsonContainsPubkey(t, []byte(last), "PKB"); !contains {
|
||||
t.Errorf("surviving peer PKB missing from payload: %s", last)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonContainsPubkey(t *testing.T, payload []byte, pk string) bool {
|
||||
t.Helper()
|
||||
var doc struct {
|
||||
Peers []struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
} `json:"peers"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &doc); err != nil {
|
||||
t.Fatalf("payload parse: %v", err)
|
||||
}
|
||||
for _, p := range doc.Peers {
|
||||
if p.Pubkey == pk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user