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
207 lines
5.3 KiB
Go
207 lines
5.3 KiB
Go
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
|
|
}
|