A taken tunnel's found configuration is retired once the take is proven (hq ADR 0119)

Kept on disk it was the take's fallback; once the mesh's interface is up in its place and a peer
has handshaken with it, it is an unmaintained way back onto the network, held for ever. It is now
removed from where its unit reads it, its kept original verified first and left as it is, and the
hold ends. Until proven — no handshake, or wg not answering — it is kept and the report says why.
The retirement is recorded apart from holds, so later applies, an undeclare, and a reassignment
find it retired rather than missing, and nothing writes it back.
This commit is contained in:
jochen
2026-09-27 00:47:57 +02:00
parent 23a4436499
commit b462f461c6
9 changed files with 628 additions and 14 deletions
+44 -1
View File
@@ -3,7 +3,9 @@
//
// On an adopted node that is the hub, the mesh's interface is raised with the found interface's
// private key, on its port, with its address and range, and every peer it had. The found interface
// is stopped, never flushed; its configuration stays on disk. What this package does is the
// is stopped, never flushed; its configuration stays on disk until the take is proven — a peer
// has handshaken with the mesh's interface — and is then retired (novox/hq ADR 0119). What this
// package does is the
// reading: which interface is there, what its file says, and what of that travels to the mesh —
// everything but the private key, which becomes the node's own overlay key and is stored the way
// that key is stored.
@@ -151,6 +153,47 @@ func Find(ctx context.Context, run Runner, named string) (Found, error) {
return found, nil
}
// Handshaken is how many peers of an interface have completed a handshake with it: the proof that
// the interface carries the tunnel, rather than merely being up (novox/hq ADR 0119).
//
// Asked of the running interface, since a handshake is a fact about the kernel's tunnel that no
// file records. A question that cannot be asked — no `wg` on the machine, no such interface, a
// permission refused — is an error and never a zero: "no peer has handshaken" retires nothing
// either, but it is a different thing to tell a person.
func Handshaken(ctx context.Context, run Runner, iface string) (int, error) {
out, err := run(ctx, "wg", "show", iface, "latest-handshakes")
if err != nil {
return 0, fmt.Errorf("cannot ask %s which peers have handshaken: %w", iface, err)
}
return ParseHandshakes(out)
}
// ParseHandshakes reads `wg show <interface> latest-handshakes`: one line per peer, its public key
// and the Unix time of its latest handshake, tab-separated — zero for a peer that never has. What
// is counted is the peers with a time. A line that is not a key and a time is refused rather than
// skipped: output this does not understand is not evidence of anything.
func ParseHandshakes(out string) (int, error) {
n := 0
for i, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 {
return 0, fmt.Errorf("line %d of the handshakes is not a peer and a time: %q", i+1, line)
}
at, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil || at < 0 {
return 0, fmt.Errorf("line %d of the handshakes does not end in a time: %q", i+1, line)
}
if at > 0 {
n++
}
}
return n, nil
}
func orNone(names []string) string {
if len(names) == 0 {
return "none"
+46
View File
@@ -194,3 +194,49 @@ func TestAFoundTunnelReadsItsMTU(t *testing.T) {
t.Fatalf("a config with no MTU must leave it zero; got %d", f2.MTU)
}
}
// novox/hq ADR 0119: a take is proven by a handshake on the mesh's interface, read from `wg show
// <interface> latest-handshakes` — as wg prints it, a key and a Unix time per peer, zero for never.
func TestAHandshakeIsAPeerWithATime(t *testing.T) {
cases := map[string]struct {
out string
want int
}{
"two peers, one handshaken": {"PEER-A=\t1790000000\nPEER-B=\t0\n", 1},
"every peer handshaken": {"PEER-A=\t1790000000\nPEER-B=\t1790000042\n", 2},
"no peer ever": {"PEER-A=\t0\nPEER-B=\t0\n", 0},
"an interface with no peer": {"", 0},
"spaces, a trailing line": {"PEER-A= 1790000000\n\n", 1},
}
for name, c := range cases {
got, err := ParseHandshakes(c.out)
if err != nil || got != c.want {
t.Errorf("%s: %d peer(s) handshaken (%v), want %d", name, got, err, c.want)
}
}
// Output that is not a key and a time is not evidence of anything, and not a zero either.
for _, nonsense := range []string{"PEER-A=\n", "PEER-A=\tyesterday\n", "PEER-A=\t-1\n", "a b c\n"} {
if _, err := ParseHandshakes(nonsense); err == nil {
t.Errorf("%q was read as handshakes", nonsense)
}
}
}
func TestHandshakesThatCannotBeAskedAreAnErrorNotAZero(t *testing.T) {
var asked string
ok := func(_ context.Context, name string, args ...string) (string, error) {
asked = name + " " + strings.Join(args, " ")
return "PEER-A=\t1790000000\n", nil
}
if n, err := Handshaken(context.Background(), ok, "mesh0"); err != nil || n != 1 ||
asked != "wg show mesh0 latest-handshakes" {
t.Fatalf("asked %q and read %d (%v)", asked, n, err)
}
missing := func(context.Context, string, ...string) (string, error) {
return "", errors.New(`exec: "wg": executable file not found in $PATH`)
}
if _, err := Handshaken(context.Background(), missing, "mesh0"); err == nil ||
!strings.Contains(err.Error(), "mesh0") {
t.Fatalf("a machine with no wg was read as one with no handshake: %v", err)
}
}