658 lines
24 KiB
Go
658 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.zx2c4.com/wireguard/device"
|
|
)
|
|
|
|
func TestCandidateList(t *testing.T) {
|
|
got := candidateList([]string{" [2604:2dc0:100:589b::36]:51820 ", "203.0.113.10:52054", ""}, "203.0.113.10:52054")
|
|
want := []string{"[2604:2dc0:100:589b::36]:51820", "203.0.113.10:52054"}
|
|
if !sameList(got, want) {
|
|
t.Fatalf("got %v want %v", got, want)
|
|
}
|
|
// An older host sends no list: the single endpoint is the list.
|
|
if got := candidateList(nil, "203.0.113.10:52054"); !sameList(got, []string{"203.0.113.10:52054"}) {
|
|
t.Fatalf("single: %v", got)
|
|
}
|
|
// The v4 entry is appended when the list omits it, never dropped.
|
|
if got := candidateList([]string{"[2604::1]:51820"}, "1.2.3.4:52001"); !sameList(got, []string{"[2604::1]:51820", "1.2.3.4:52001"}) {
|
|
t.Fatalf("append: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestParseRotateTarget(t *testing.T) {
|
|
// New host: ordered list.
|
|
c, pk := parseRotateTarget([]byte(`{"ok":true,"rotate_endpoint":{"endpoint":"1.2.3.4:52054","endpoints":["[2604::36]:51820","1.2.3.4:52054"],"endpoint_pubkey":"PK="}}`))
|
|
if pk != "PK=" || !sameList(c, []string{"[2604::36]:51820", "1.2.3.4:52054"}) {
|
|
t.Fatalf("new host: %v %q", c, pk)
|
|
}
|
|
// Old host: only endpoint.
|
|
c, pk = parseRotateTarget([]byte(`{"rotate_endpoint":{"endpoint":"1.2.3.4:52054","endpoint_pubkey":"PK="}}`))
|
|
if pk != "PK=" || !sameList(c, []string{"1.2.3.4:52054"}) {
|
|
t.Fatalf("old host: %v %q", c, pk)
|
|
}
|
|
// No directive.
|
|
if c, _ := parseRotateTarget([]byte(`{"ok":true}`)); len(c) != 0 {
|
|
t.Fatalf("no directive: %v", c)
|
|
}
|
|
}
|
|
|
|
type fakeDev struct{ dump string }
|
|
|
|
func (f fakeDev) IpcGet() (string, error) { return f.dump, nil }
|
|
|
|
func TestLastHandshake(t *testing.T) {
|
|
if !lastHandshake(fakeDev{"private_key=00\npublic_key=11\nlast_handshake_time_sec=0\nlast_handshake_time_nsec=0\n"}).IsZero() {
|
|
t.Fatal("zero handshake should be zero time")
|
|
}
|
|
got := lastHandshake(fakeDev{"public_key=11\nendpoint=[::1]:1\nlast_handshake_time_sec=1700000000\nlast_handshake_time_nsec=5\n"})
|
|
if !got.Equal(time.Unix(1700000000, 0)) {
|
|
t.Fatalf("got %v", got)
|
|
}
|
|
if !lastHandshake(fakeDev{"public_key=11\n"}).IsZero() {
|
|
t.Fatal("no field should be zero time")
|
|
}
|
|
}
|
|
|
|
func TestFailedRecently(t *testing.T) {
|
|
s := rotateState{failed: map[string]probeFailure{}}
|
|
if s.failedRecently("a") {
|
|
t.Fatal("fresh state")
|
|
}
|
|
s.markFailed("a")
|
|
if !s.failedRecently("a") {
|
|
t.Fatal("just failed")
|
|
}
|
|
s.failed["a"] = probeFailure{at: time.Now().Add(-rotateRetryAfter - time.Second), count: 1}
|
|
if s.failedRecently("a") {
|
|
t.Fatal("expired failure still skipped")
|
|
}
|
|
// A FIRST failure is probably-transient and is retried in minutes, not parked for
|
|
// an hour (during a mass pairing every failed v6 probe succeeded on its next
|
|
// attempt once the burst was over).
|
|
s.failed["a"] = probeFailure{at: time.Now().Add(-90 * time.Second), count: 1}
|
|
if !s.failedRecently("a") {
|
|
t.Fatal("a 90s-old first failure is still inside the short grace")
|
|
}
|
|
s.failed["a"] = probeFailure{at: time.Now().Add(-3 * time.Minute), count: 1}
|
|
if s.failedRecently("a") {
|
|
t.Fatal("a first failure must be retried within minutes, not held for an hour")
|
|
}
|
|
// Repeated failures back off: the third failure is skipped for 2 h, so a failure
|
|
// 61 min old is still recent.
|
|
s.markFailed("a")
|
|
s.failed["a"] = probeFailure{at: time.Now().Add(-61 * time.Minute), count: 3}
|
|
if !s.failedRecently("a") {
|
|
t.Fatal("third failure should back off past an hour")
|
|
}
|
|
s.markFailed("b")
|
|
s.clear("b")
|
|
if s.failedRecently("b") {
|
|
t.Fatal("cleared failure still skipped")
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterBacksOffAndCaps(t *testing.T) {
|
|
// First failure: the short grace. Then the hour, doubling.
|
|
if retryAfter(1) != rotateRetryFirst || retryAfter(0) != rotateRetryFirst {
|
|
t.Fatalf("first: %v %v", retryAfter(1), retryAfter(0))
|
|
}
|
|
if retryAfter(2) != time.Hour || retryAfter(3) != 2*time.Hour || retryAfter(4) != 4*time.Hour {
|
|
t.Fatalf("backoff: %v %v %v", retryAfter(2), retryAfter(3), retryAfter(4))
|
|
}
|
|
// Capped within a day, not a week: a candidate that comes back is picked up the
|
|
// same day, while a candidate that never answers costs one disruptive probe a day
|
|
// rather than four (each probe swaps the live peer for up to rotateProbeTimeout).
|
|
if retryAfter(20) != rotateRetryMax || rotateRetryMax > 24*time.Hour {
|
|
t.Fatalf("cap: %v (max %v)", retryAfter(20), rotateRetryMax)
|
|
}
|
|
}
|
|
|
|
// The route guard. A candidate on an address family this machine does
|
|
// not have must be refused from the ROUTING TABLE, before probeAndCommit swaps the
|
|
// live peer — the failure it replaces cost a real tunnel outage per retry on every
|
|
// IPv4-only bastion.
|
|
func TestEndpointRoutableRefusesWhenTheKernelHasNoRoute(t *testing.T) {
|
|
orig := dialForRouteCheck
|
|
defer func() { dialForRouteCheck = orig }()
|
|
|
|
// The kernel's answer on a machine with no IPv6 path at all.
|
|
dialForRouteCheck = func(network, address string, timeout time.Duration) (net.Conn, error) {
|
|
if network != "udp" {
|
|
t.Fatalf("route check must use udp (it must not open a stream): %q", network)
|
|
}
|
|
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connect: network is unreachable")}
|
|
}
|
|
err := endpointRoutable("[2604:2dc0:100:589b::4a]:51820")
|
|
if !errors.Is(err, errNoLocalPath) {
|
|
t.Fatalf("want errNoLocalPath, got %v", err)
|
|
}
|
|
// The underlying reason survives into the message: a person reading the log has
|
|
// to be able to tell "no IPv6 here" from "that name would not resolve".
|
|
if !strings.Contains(err.Error(), "network is unreachable") {
|
|
t.Fatalf("underlying cause dropped: %v", err)
|
|
}
|
|
|
|
// A route exists: the guard steps aside and the real handshake probe decides.
|
|
// net.Pipe stands in for the connected socket; only Close is used.
|
|
dialForRouteCheck = func(network, address string, timeout time.Duration) (net.Conn, error) {
|
|
c, _ := net.Pipe()
|
|
return c, nil
|
|
}
|
|
if err := endpointRoutable("15.204.198.155:20074"); err != nil {
|
|
t.Fatalf("routable endpoint refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A standing local condition is stated once, not once a minute.
|
|
func TestNotePathlessReportsOnlyTheFirstTime(t *testing.T) {
|
|
s := rotateState{failed: map[string]probeFailure{}} // noPath deliberately nil, as the package var is
|
|
ep := "[2604:2dc0:100:589b::4a]:51820"
|
|
if !s.notePathless(ep) {
|
|
t.Fatal("the first sighting is the one worth logging")
|
|
}
|
|
if s.notePathless(ep) {
|
|
t.Fatal("every later beat must stay quiet")
|
|
}
|
|
// If the path comes back and is lost again, that is news again.
|
|
s.clearPathless(ep)
|
|
if !s.notePathless(ep) {
|
|
t.Fatal("a path lost a second time must be reported again")
|
|
}
|
|
// An unroutable candidate is NOT a probe failure: it must not consume the
|
|
// backoff, or a machine that gains IPv6 waits out a day before using it.
|
|
if s.failedRecently(ep) {
|
|
t.Fatal("the route guard must not mark the candidate failed")
|
|
}
|
|
}
|
|
|
|
// failuresRecordedFor lists the probe targets with a failure recorded for the
|
|
// entry ep, whatever the key.
|
|
func failuresRecordedFor(ep string) []string {
|
|
rotation.mu.Lock()
|
|
defer rotation.mu.Unlock()
|
|
var out []string
|
|
for k := range rotation.failed {
|
|
if strings.HasPrefix(k, ep+" ") {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The property the guard exists for: it runs BEFORE the live peer is touched, and
|
|
// what it refuses is not recorded as a probe failure. Both are invisible to the
|
|
// two tests above — a mutation pass deleted the guard call from
|
|
// probeAndCommit and they stayed green, which is what this test closes.
|
|
//
|
|
// dev is nil ON PURPOSE. swapPeer dereferences it, so if a refactor ever moves
|
|
// the guard after the swap this panics instead of quietly passing.
|
|
func TestProbeAndCommitRefusesUnroutableBeforeTouchingTheLivePeer(t *testing.T) {
|
|
orig := dialForRouteCheck
|
|
ep := "[2604:2dc0:100:589b::4a]:51820"
|
|
defer func() {
|
|
dialForRouteCheck = orig
|
|
rotation.clearPathless(ep)
|
|
for _, k := range failuresRecordedFor(ep) {
|
|
rotation.clear(k)
|
|
}
|
|
}()
|
|
dialForRouteCheck = func(network, address string, timeout time.Duration) (net.Conn, error) {
|
|
return nil, &net.OpError{Op: "dial", Net: network, Err: errors.New("connect: network is unreachable")}
|
|
}
|
|
|
|
cfg := &Config{Endpoint: "15.204.198.155:20074", EndpointPubKey: "OLD="}
|
|
err := probeAndCommit(cfg, nil, ep, "NEW=", []string{ep, cfg.Endpoint})
|
|
if !errors.Is(err, errNoLocalPath) {
|
|
t.Fatalf("want errNoLocalPath before any peer swap, got %v", err)
|
|
}
|
|
// Not a probe failure: "no route from here" costs nothing to re-test, and
|
|
// consuming the backoff would park a machine that gains IPv6 for up to a day.
|
|
// Checked under every key, not only the one probed: a failure recorded for
|
|
// this entry under the key the machine runs would park it just the same.
|
|
if got := failuresRecordedFor(ep); len(got) != 0 {
|
|
t.Fatalf("an unroutable candidate must not consume the probe backoff, recorded %q", got)
|
|
}
|
|
// The live endpoint is untouched: nothing was committed.
|
|
if cfg.Endpoint != "15.204.198.155:20074" || cfg.EndpointPubKey != "OLD=" {
|
|
t.Fatalf("config moved on a refused candidate: %s %s", cfg.Endpoint, cfg.EndpointPubKey)
|
|
}
|
|
}
|
|
|
|
// ── a host moving the machine to a new Hub key on the same entries ───────────
|
|
//
|
|
// A host can change the key a machine pins without changing where it dials: its
|
|
// Hub answers the old key and the new one side by side while its machines move,
|
|
// and the heartbeat directive names the same entries with the new key. These
|
|
// tests drive the probe and failover paths with the live-device steps stubbed:
|
|
// probeStub says which (entry, key) pairs complete a handshake.
|
|
|
|
const (
|
|
kmV4 = "203.0.113.10:52054"
|
|
kmV6 = "[2001:db8::4a]:51820"
|
|
)
|
|
|
|
type probeStub struct {
|
|
answers map[string]bool // entry + " " + key -> a handshake completes
|
|
lastHS time.Time // what liveLastHandshake reports
|
|
on string // the (entry, key) the device was last swapped to
|
|
}
|
|
|
|
func stubLiveProbe(t *testing.T, s *probeStub) {
|
|
t.Helper()
|
|
swap, hs, last, dial := livePeerSwap, liveHandshake, liveLastHandshake, dialForRouteCheck
|
|
livePeerSwap = func(_ *Config, _ *device.Device, endpoint, pubkey string) error {
|
|
s.on = endpoint + " " + pubkey
|
|
return nil
|
|
}
|
|
liveHandshake = func(*device.Device, time.Time, time.Duration) bool { return s.answers[s.on] }
|
|
liveLastHandshake = func(*device.Device) time.Time { return s.lastHS }
|
|
dialForRouteCheck = func(string, string, time.Duration) (net.Conn, error) {
|
|
a, b := net.Pipe()
|
|
_ = b.Close()
|
|
return a, nil
|
|
}
|
|
rotation.mu.Lock()
|
|
failed, noPath := rotation.failed, rotation.noPath
|
|
rotation.failed, rotation.noPath = map[string]probeFailure{}, nil
|
|
rotation.mu.Unlock()
|
|
t.Cleanup(func() {
|
|
livePeerSwap, liveHandshake, liveLastHandshake, dialForRouteCheck = swap, hs, last, dial
|
|
rotation.mu.Lock()
|
|
rotation.failed, rotation.noPath = failed, noPath
|
|
rotation.mu.Unlock()
|
|
})
|
|
}
|
|
|
|
// pairedOn writes a pairing file for a machine on endpoint with key, and returns
|
|
// the running copy of it.
|
|
func pairedOn(t *testing.T, endpoint, key string, endpoints ...string) *Config {
|
|
t.Helper()
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
cfg := &Config{HostName: "member.host.example", WGPrivateKey: "k",
|
|
Endpoint: endpoint, EndpointPubKey: key, Endpoints: endpoints}
|
|
pd, err := pairingsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.Marshal(cfg)
|
|
if err := os.WriteFile(filepath.Join(pd, cfg.HostName+".json"), b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func pairingFile(t *testing.T, cfg *Config) string {
|
|
t.Helper()
|
|
pd, err := pairingsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return filepath.Join(pd, cfg.HostName+".json")
|
|
}
|
|
|
|
func onDisk(t *testing.T, cfg *Config) Config {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(pairingFile(t, cfg))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var d Config
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func directive(endpoint string, endpoints []string, key string) []byte {
|
|
b, _ := json.Marshal(map[string]any{"rotate_endpoint": map[string]any{
|
|
"endpoint": endpoint, "endpoints": endpoints, "endpoint_pubkey": key}})
|
|
return b
|
|
}
|
|
|
|
// A dead spell on the key the machine runs must not hold up a move to a new key.
|
|
// Failover marked every entry failed under the OLD key; under the new key nothing
|
|
// has been tried, so the move goes ahead as soon as the host asks.
|
|
func TestAKeyMoveIsNotHeldByAPathFailureUnderTheOldKey(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4, kmV6)
|
|
s := &probeStub{answers: map[string]bool{}}
|
|
stubLiveProbe(t, s)
|
|
|
|
maybeFailover(cfg, nil, time.Now().Add(-time.Hour)) // no handshake for an hour: every entry fails
|
|
if cfg.Endpoint != kmV4 || cfg.EndpointPubKey != "OLD=" {
|
|
t.Fatalf("failover moved with nothing answering: %s %s", cfg.Endpoint, cfg.EndpointPubKey)
|
|
}
|
|
|
|
s.answers[kmV6+" NEW="], s.answers[kmV4+" NEW="] = true, true
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV6, kmV4}, "NEW="), cfg, nil)
|
|
if cfg.EndpointPubKey != "NEW=" {
|
|
t.Fatalf("key move held back by failures recorded under the old key: still on %s %s",
|
|
cfg.Endpoint, cfg.EndpointPubKey)
|
|
}
|
|
if d := onDisk(t, cfg); d.EndpointPubKey != "NEW=" {
|
|
t.Fatalf("moved but not saved: disk has %q", d.EndpointPubKey)
|
|
}
|
|
}
|
|
|
|
// A probe of a key the Hub does not answer yet says nothing about the entry under
|
|
// the key the machine runs. It must not keep the machine off its preferred entry.
|
|
func TestAFailedKeyProbeDoesNotHoldTheMachineOffItsPreferredEntry(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV6, kmV4)
|
|
s := &probeStub{answers: map[string]bool{kmV6 + " OLD=": true, kmV4 + " OLD=": true}}
|
|
stubLiveProbe(t, s)
|
|
|
|
// The host asks for a key its Hub does not answer yet: both probes fail.
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV6, kmV4}, "NEW="), cfg, nil)
|
|
if cfg.Endpoint != kmV4 || cfg.EndpointPubKey != "OLD=" {
|
|
t.Fatalf("committed a key nothing answered: %s %s", cfg.Endpoint, cfg.EndpointPubKey)
|
|
}
|
|
// It goes back to asking for the preferred entry on the key the machine runs.
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV6, kmV4}, "OLD="), cfg, nil)
|
|
if cfg.Endpoint != kmV6 {
|
|
t.Fatalf("held off the preferred entry by a failed probe of another key: on %s", cfg.Endpoint)
|
|
}
|
|
}
|
|
|
|
// A committed move whose save failed must reach disk on a later beat. Until it
|
|
// does, a restart comes up on the old key, and once the host retires that key the
|
|
// machine is stranded: the heartbeat that could correct it rides the dead tunnel.
|
|
func TestAMoveWhoseSaveFailedIsSavedOnALaterBeat(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4)
|
|
s := &probeStub{answers: map[string]bool{kmV4 + " NEW=": true}, lastHS: time.Now()}
|
|
stubLiveProbe(t, s)
|
|
path := pairingFile(t, cfg)
|
|
good, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The save fails: the file cannot be read back for its read-modify-write.
|
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV4}, "NEW="), cfg, nil)
|
|
if cfg.EndpointPubKey != "NEW=" {
|
|
t.Fatalf("the live move should have committed: %s", cfg.EndpointPubKey)
|
|
}
|
|
|
|
// The cause clears. The file still names the old key.
|
|
if err := os.WriteFile(path, good, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The next beat: failover's check (the path is alive) and the same directive.
|
|
maybeFailover(cfg, nil, time.Now().Add(-time.Hour))
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV4}, "NEW="), cfg, nil)
|
|
if d := onDisk(t, cfg); d.EndpointPubKey != "NEW=" || d.Endpoint != kmV4 {
|
|
t.Fatalf("a restart would come up on %s %s, not the key the tunnel runs on",
|
|
d.Endpoint, d.EndpointPubKey)
|
|
}
|
|
}
|
|
|
|
// An unpaired host's file is gone; a pending save must not bring it back or be
|
|
// retried forever.
|
|
func TestAPendingSaveForAnUnpairedHostIsDropped(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4)
|
|
stubLiveProbe(t, &probeStub{lastHS: time.Now()})
|
|
rotation.setUnsaved(cfg, true)
|
|
if err := os.Remove(pairingFile(t, cfg)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
maybeFailover(cfg, nil, time.Now())
|
|
if _, err := os.Stat(pairingFile(t, cfg)); !os.IsNotExist(err) {
|
|
t.Fatalf("a retried save recreated an unpaired host's file: %v", err)
|
|
}
|
|
if rotation.isUnsaved(cfg) {
|
|
t.Fatal("a save for an unpaired host is still pending")
|
|
}
|
|
}
|
|
|
|
// A host paired again (`pair --replace`, or unpair then pair) gets a new file
|
|
// with its own WireGuard key, and a daemon already running keeps the copy it
|
|
// started with. A save that copy left pending must not write its entry and Hub
|
|
// key into the new pairing: they were pinned for the key that was replaced, and
|
|
// the new pairing would come up on them at its next start.
|
|
func TestAPendingSaveNeverWritesIntoAPairingMadeAgain(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4)
|
|
stubLiveProbe(t, &probeStub{answers: map[string]bool{kmV4 + " NEW=": true}, lastHS: time.Now()})
|
|
path := pairingFile(t, cfg)
|
|
|
|
// The move commits; its save fails.
|
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV4}, "NEW="), cfg, nil)
|
|
if cfg.EndpointPubKey != "NEW=" || !rotation.isUnsaved(cfg) {
|
|
t.Fatalf("setup: want a committed move with its save pending, got %s pending=%v",
|
|
cfg.EndpointPubKey, rotation.isUnsaved(cfg))
|
|
}
|
|
|
|
// The host is paired again while the daemon runs.
|
|
again := Config{HostName: cfg.HostName, WGPrivateKey: "k-again",
|
|
Endpoint: kmV6, EndpointPubKey: "AGAIN=", Endpoints: []string{kmV6, kmV4}}
|
|
b, _ := json.Marshal(again)
|
|
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
maybeFailover(cfg, nil, time.Now())
|
|
if d := onDisk(t, cfg); d.Endpoint != again.Endpoint || d.EndpointPubKey != again.EndpointPubKey ||
|
|
!sameList(d.Endpoints, again.Endpoints) {
|
|
t.Fatalf("the old pairing's pending save wrote %s %s %v into the new one",
|
|
d.Endpoint, d.EndpointPubKey, d.Endpoints)
|
|
}
|
|
if rotation.isUnsaved(cfg) {
|
|
t.Fatal("a save for a pairing that was made again is still pending")
|
|
}
|
|
}
|
|
|
|
// One process serves several pairings, each beating on its own goroutine
|
|
// (run.go). Two of them move to their own host's new key at the same time,
|
|
// and one of the two cannot save at first: its retry lands on a later beat,
|
|
// and neither pairing's save, retry or failure reaches the other's file or
|
|
// memory. Run with -race, this is the concurrent case the tests above cannot
|
|
// show.
|
|
func TestTwoPairingsMoveTheirKeysAtOnce(t *testing.T) {
|
|
stubLiveProbe(t, &probeStub{})
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
pd, err := pairingsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
type pairing struct {
|
|
cfg *Config
|
|
dev *device.Device // identity only: the stubs never call it
|
|
v6, v4, old, new string
|
|
}
|
|
ps := []*pairing{
|
|
{v6: "[2001:db8::a]:51820", v4: "203.0.113.10:52001", old: "OLD-A=", new: "NEW-A="},
|
|
{v6: "[2001:db8::b]:51820", v4: "203.0.113.10:52002", old: "OLD-B=", new: "NEW-B="},
|
|
}
|
|
answers := map[string]bool{}
|
|
for i, p := range ps {
|
|
p.dev = new(device.Device)
|
|
p.cfg = &Config{HostName: []string{"a.host.example", "b.host.example"}[i],
|
|
WGPrivateKey: "k" + p.old, Endpoint: p.v4, EndpointPubKey: p.old, Endpoints: []string{p.v6, p.v4}}
|
|
b, _ := json.Marshal(p.cfg)
|
|
if err := os.WriteFile(filepath.Join(pd, p.cfg.HostName+".json"), b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answers[p.v6+" "+p.new] = true
|
|
}
|
|
var mu sync.Mutex
|
|
on := map[*device.Device]string{}
|
|
livePeerSwap = func(_ *Config, dev *device.Device, endpoint, pubkey string) error {
|
|
mu.Lock()
|
|
on[dev] = endpoint + " " + pubkey
|
|
mu.Unlock()
|
|
return nil
|
|
}
|
|
liveHandshake = func(dev *device.Device, _ time.Time, _ time.Duration) bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return answers[on[dev]]
|
|
}
|
|
liveLastHandshake = func(*device.Device) time.Time { return time.Now() }
|
|
|
|
beat := func() {
|
|
var wg sync.WaitGroup
|
|
for _, p := range ps {
|
|
wg.Add(1)
|
|
go func(p *pairing) {
|
|
defer wg.Done()
|
|
maybeFailover(p.cfg, p.dev, time.Now())
|
|
maybeRotateEndpoint(directive(p.v4, []string{p.v6, p.v4}, p.new), p.cfg, p.dev)
|
|
}(p)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
aFile := filepath.Join(pd, ps[0].cfg.HostName+".json")
|
|
good, err := os.ReadFile(aFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(aFile, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beat() // both move; A's save fails
|
|
if !rotation.isUnsaved(ps[0].cfg) || rotation.isUnsaved(ps[1].cfg) {
|
|
t.Fatalf("want only A's save pending, got A=%v B=%v",
|
|
rotation.isUnsaved(ps[0].cfg), rotation.isUnsaved(ps[1].cfg))
|
|
}
|
|
if err := os.WriteFile(aFile, good, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beat() // A's retry lands; B has nothing to do
|
|
for _, p := range ps {
|
|
var d Config
|
|
raw, err := os.ReadFile(filepath.Join(pd, p.cfg.HostName+".json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.Endpoint != p.v6 || d.EndpointPubKey != p.new || d.WGPrivateKey != "k"+p.old {
|
|
t.Errorf("%s on disk: %s %s (key %s); want %s %s", p.cfg.HostName,
|
|
d.Endpoint, d.EndpointPubKey, d.WGPrivateKey, p.v6, p.new)
|
|
}
|
|
if p.cfg.Endpoint != p.v6 || p.cfg.EndpointPubKey != p.new {
|
|
t.Errorf("%s in memory: %s %s", p.cfg.HostName, p.cfg.Endpoint, p.cfg.EndpointPubKey)
|
|
}
|
|
if rotation.isUnsaved(p.cfg) {
|
|
t.Errorf("%s: a save is still pending", p.cfg.HostName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The commit's log line says a failed save will be retried only when it will
|
|
// be: not for a pairing whose file is gone.
|
|
func TestTheCommitLineSaysRetriedOnlyWhenItIs(t *testing.T) {
|
|
commitLine := func(t *testing.T, breakFile func(path string)) string {
|
|
t.Helper()
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4)
|
|
stubLiveProbe(t, &probeStub{answers: map[string]bool{kmV4 + " NEW=": true}, lastHS: time.Now()})
|
|
breakFile(pairingFile(t, cfg))
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stdout := os.Stdout
|
|
os.Stdout = w
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV4}, "NEW="), cfg, nil)
|
|
os.Stdout = stdout
|
|
_ = w.Close()
|
|
out, _ := io.ReadAll(r)
|
|
return string(out)
|
|
}
|
|
if got := commitLine(t, func(p string) { _ = os.WriteFile(p, []byte("{"), 0o600) }); !strings.Contains(got, "retried every beat") {
|
|
t.Errorf("a save that will be retried must say so: %q", got)
|
|
}
|
|
if got := commitLine(t, func(p string) { _ = os.Remove(p) }); !strings.Contains(got, "config save failed") ||
|
|
strings.Contains(got, "retried") {
|
|
t.Errorf("a save for a gone pairing is not retried and must not say it is: %q", got)
|
|
}
|
|
}
|
|
|
|
// pendingOnTheBeat reads endpoint_save_pending off the body a beat of cfg's
|
|
// run would send. A beat that observes agents resolves the remote inference
|
|
// hosts (resolveLLMIPs); the cache is filled first, so the test sends no DNS
|
|
// query off the machine.
|
|
func pendingOnTheBeat(t *testing.T, cfg *Config) any {
|
|
t.Helper()
|
|
llmIPMu.Lock()
|
|
prev, prevAt := llmIPCache, llmIPCacheAt
|
|
llmIPCache, llmIPCacheAt = map[string]string{}, time.Now()
|
|
llmIPMu.Unlock()
|
|
defer func() {
|
|
llmIPMu.Lock()
|
|
llmIPCache, llmIPCacheAt = prev, prevAt
|
|
llmIPMu.Unlock()
|
|
}()
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(heartbeatPayload(cfg, fakeDev{}, "box", "pub")), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v, ok := m["endpoint_save_pending"]
|
|
if !ok {
|
|
t.Fatal("the beat carries no endpoint_save_pending")
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Each beat says whether its pairing runs on a move its file does not hold
|
|
// yet. The host retires its old Hub key from the Hub's handshake view, which
|
|
// cannot see a machine's disk; a machine running on the new key whose save
|
|
// keeps failing would restart onto the retired one and be stranded. So the
|
|
// beat says so from the failed save until a later beat saves the move.
|
|
func TestTheBeatSaysWhileAMoveIsNotSaved(t *testing.T) {
|
|
cfg := pairedOn(t, kmV4, "OLD=", kmV4)
|
|
stubLiveProbe(t, &probeStub{answers: map[string]bool{kmV4 + " NEW=": true}, lastHS: time.Now()})
|
|
path := pairingFile(t, cfg)
|
|
good, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := pendingOnTheBeat(t, cfg); v != false {
|
|
t.Fatalf("before any move: %v", v)
|
|
}
|
|
|
|
// The move commits and its save fails.
|
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
maybeRotateEndpoint(directive(kmV4, []string{kmV4}, "NEW="), cfg, nil)
|
|
if v := pendingOnTheBeat(t, cfg); v != true {
|
|
t.Fatalf("a committed move whose save failed: %v", v)
|
|
}
|
|
|
|
// A run started again from the file runs on what the file holds, so it
|
|
// has nothing pending, whatever the run before it left unsaved.
|
|
if err := os.WriteFile(path, good, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := pendingOnTheBeat(t, loadedPairing(t, cfg.HostName)); v != false {
|
|
t.Fatalf("a run started from its file reports a save it does not have: %v", v)
|
|
}
|
|
|
|
// The next beat's retry saves the move.
|
|
maybeFailover(cfg, nil, time.Now())
|
|
if d := onDisk(t, cfg); d.EndpointPubKey != "NEW=" {
|
|
t.Fatalf("setup: the retry should have saved the move: %s", d.EndpointPubKey)
|
|
}
|
|
if v := pendingOnTheBeat(t, cfg); v != false {
|
|
t.Fatalf("after a later beat saved the move: %v", v)
|
|
}
|
|
}
|