Take over the found tunnel: its key, its port, its peers; stop it, never flush

On an adopted machine the private network takes the predecessor's tunnel
over in place (hq ADR 0105). Genesis finds the one interface up besides the
mesh's own, settles the hub's port and the mesh's range on it, and skips
ADR 0100's non-overlap check for a range that is now the tunnel's; a
--hub-port or --overlay-range that disagrees is refused naming the tunnel's.

At enrolment the found interface's private key becomes this node's overlay
key — the one credential the mesh takes rather than mints — stored where a
generated one is stored, never printed and never sent; the tunnel (port,
address, range, peers) travels with the keys so the mesh composes from it
before the first declaration.

The interface's service may say what it takes over. Before the mesh's unit
starts, the found configuration is kept like any held file and the found
unit is stopped and disabled; nothing is flushed, and an interface still up
after its unit stopped refuses the takeover rather than half-working. The
report says what was carried: interface, port, range, peer count, taken or
not, and where the original was kept.
This commit is contained in:
2026-09-23 23:26:35 +02:00
parent 9176aea6c4
commit 7283924a35
18 changed files with 1182 additions and 13 deletions
+26
View File
@@ -36,6 +36,7 @@ import (
"time"
"github.com/novox/mesh-host/internal/firewall"
"github.com/novox/mesh-host/internal/tunnel"
)
// Step names one stage. A failure says which one, because "the bootstrap failed" is a sentence
@@ -201,6 +202,11 @@ type Options struct {
// until each module is taken, its firewall stays in force, and the mesh guards its own ports
// in a table that only refuses. Without it, a machine in use is refused.
Adopted bool
// Tunnel names the found tunnel's interface an adopted hub takes over (novox/hq ADR 0105), when
// more than one is up and the machine cannot say which. Empty finds the one that is up. Once
// found, the tunnel's port is the hub's and its range the private network's; --hub-port and
// --overlay-range may agree with it or be left unsaid.
Tunnel string
}
// pivots reports whether this run goes past the foundation.
@@ -311,6 +317,9 @@ type Result struct {
// Filter is the packet filter chosen for when the node converges; an adopted genesis loads
// none, and the flip assigns this one.
Filter string `json:"filter-on-converge,omitempty"`
// Tunnel is the found tunnel an adopted genesis takes over (novox/hq ADR 0105): what was read
// from it, never its key.
Tunnel *tunnel.Found `json:"tunnel,omitempty"`
}
// Run performs the bootstrap, saying what it is doing as it goes.
@@ -394,6 +403,23 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
}
result.Firewall = string(kind)
say(" adopted what is on this machine is kept; its firewall (" + string(kind) + ") stays in force")
// The tunnel the predecessor left, which the private network takes over (novox/hq ADR
// 0105): its port is the hub's and its range is the mesh's from here on, so both are
// settled before the ports are checked free and the bundle rewritten.
found, err := TakeTheTunnel(&o, d.Run)
if err != nil {
return result, failed(StepPreflight, err)
}
if found != nil {
result.Tunnel = found
result.Ports = o.Ports
say(fmt.Sprintf(" tunnel %s — the private network takes it over: its port %d is "+
"the hub's, its range %s the mesh's, and its %d peer(s) are carried until they enrol",
found.Interface, found.Port, found.Range, len(found.Peers)))
} else {
say(" tunnel none up on this machine; the private network is raised on its own port and range")
}
}
sys, err := WorkOutSystem(ctx, d.Run, o.System)
+8 -1
View File
@@ -112,7 +112,14 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
return out, err
}
joining, cancel := context.WithTimeout(ctx, o.Wait)
joined, err := control.run(joining, o.Host, "enrol", "--token", token, "--state", o.State)
args := []string{"enrol", "--token", token, "--state", o.State}
if o.Tunnel != "" {
// The found tunnel's key becomes this node's overlay key, and the tunnel travels with
// the enrolment (novox/hq ADR 0105). Named, so the host takes the one genesis settled
// its ports and range on and not another that came up since.
args = append(args, "--tunnel", o.Tunnel)
}
joined, err := control.run(joining, o.Host, args...)
cancel()
if err != nil {
return out, fmt.Errorf(
+47 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sort"
@@ -13,6 +14,7 @@ import (
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/tunnel"
)
// FoundationPorts are the machine's ports the foundation binds (novox/hq ADR 0100).
@@ -321,6 +323,36 @@ func PortsFree(ctx context.Context, run Runner, p FoundationPorts, ours func(rea
return nil
}
// TakeTheTunnel finds the tunnel an adopted machine's private network takes over (novox/hq ADR
// 0105) and settles the options on it: the hub's port is the tunnel's, the mesh's range is the
// tunnel's, and the interface is named for the enrolment that takes its key. Nil when no tunnel is
// up, which is an ordinary machine. A --hub-port or --overlay-range that disagrees with the
// tunnel is refused: the peers dial the tunnel's port and live in its range, and a mesh raised
// beside them on other numbers is the two-tunnel shape the record rejects.
func TakeTheTunnel(o *Options, run Runner) (*tunnel.Found, error) {
found, err := tunnel.Find(context.Background(), tunnel.Runner(run), o.Tunnel)
if errors.Is(err, tunnel.ErrNone) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("%w. An adopted hub takes over the tunnel it finds; nothing was changed", err)
}
if o.Ports.Hub != 0 && o.Ports.Hub != DefaultPorts().Hub && o.Ports.Hub != found.Port {
return nil, fmt.Errorf("--hub-port %d disagrees with the tunnel %s, which listens on %d: the "+
"private network takes over that tunnel on its own port, so leave --hub-port unsaid or "+
"say %d", o.Ports.Hub, found.Interface, found.Port, found.Port)
}
if o.OverlayRange != "" && o.OverlayRange != DefaultOverlayRange && o.OverlayRange != found.Range {
return nil, fmt.Errorf("--overlay-range %s disagrees with the tunnel %s, whose range is %s: the "+
"private network takes over that tunnel with its range, so leave --overlay-range unsaid "+
"or say %s", o.OverlayRange, found.Interface, found.Range, found.Range)
}
o.Tunnel = found.Interface
o.Ports.Hub = found.Port
o.OverlayRange = found.Range
return &found, nil
}
// OverlayClear refuses a private-network range that overlaps an address or a route the machine
// already has — a predecessor's tunnel still running — naming the interface. The mesh's own
// interface is not counted.
@@ -459,12 +491,26 @@ func CheckTheMachine(ctx context.Context, o Options, run Runner, bundle *declara
}
return false
}
if o.Tunnel != "" {
// The hub's port is the found tunnel's, held by that tunnel until the mesh's interface
// takes it over (novox/hq ADR 0105): held by design, not by something else.
inner := ours
ours = func(r reachable.Reach) bool {
return inner(r) || (r.Protocol == "udp" && r.Port == p.Hub)
}
}
if err := PortsFree(ctx, run, p, ours); err != nil {
return err
}
say(fmt.Sprintf(" ports free store %d, bus %d, amqp %d, management %d, registry %d, packages %d, hub %d/udp",
p.Store, p.Bus, p.AMQP, p.Management, p.Registry, p.Packages, p.Hub))
if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
if o.Tunnel != "" {
// One tunnel and one range: the mesh's range IS the found tunnel's, so the rule that the
// two must not overlap applies only where a found tunnel is left running beside the mesh's
// (ADR 0100, narrowed by ADR 0105).
say(fmt.Sprintf(" range %s is the tunnel %s's, taken over; not checked against it",
o.OverlayRange, o.Tunnel))
} else if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
return err
}
if err := NamesFree(ctx, run, names, known); err != nil {
+82 -3
View File
@@ -2,6 +2,9 @@ package bootstrap
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"os"
"path/filepath"
@@ -12,6 +15,7 @@ import (
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/tunnel"
)
// Defends novox/hq ADR 0100: the foundation's ports are the node's — inputs to genesis, checked free,
@@ -130,9 +134,9 @@ func TestTwoThingsOnOnePortAreRefused(t *testing.T) {
// machineRunner answers ss, docker ps, docker inspect and ip from fixtures.
type machineRunner struct {
ss, ps, addrs, routes string
unlabelled map[string]bool
labelled map[string]bool
ss, ps, addrs, routes, wg string
unlabelled map[string]bool
labelled map[string]bool
}
func (m machineRunner) run(_ context.Context, name string, args ...string) (string, error) {
@@ -154,6 +158,8 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
return m.addrs, nil
case name == "ip" && args[1] == "route":
return m.routes, nil
case name == "wg":
return m.wg, nil
}
return "", nil
}
@@ -295,3 +301,76 @@ func TestARerunOfGenesisFindsItsOwnPackageRegistry(t *testing.T) {
t.Error("a container under the package registry's name on a fresh machine was not refused")
}
}
// novox/hq ADR 0105: an adopted genesis takes over the tunnel it finds — its port is the hub's,
// its range the mesh's, and neither is refused for being held by it.
func TestAnAdoptedGenesisSettlesOnTheTunnelItFinds(t *testing.T) {
private, err := aFoundKey()
if err != nil {
t.Fatal(err)
}
conf := "[Interface]\nPrivateKey = " + private + "\nListenPort = 51900\nAddress = 192.0.2.1/24\n" +
"[Peer]\nPublicKey = PEER=\nAllowedIPs = 192.0.2.2/32\n"
tunnel.ReadFile = func(path string) ([]byte, error) {
if path == tunnel.ConfigDir+"/wg0.conf" {
return []byte(conf), nil
}
return nil, errors.New("no such file")
}
t.Cleanup(func() { tunnel.ReadFile = os.ReadFile })
m := machineRunner{
wg: "wg0\n",
ss: "udp UNCONN 0 0 0.0.0.0:51900 0.0.0.0:*\n",
addrs: "5: wg0 inet 192.0.2.1/24 scope global wg0\n",
routes: "192.0.2.0/24 dev wg0 proto kernel scope link src 192.0.2.1\n",
}
o := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange, State: filepath.Join(t.TempDir(), "state.json")}
found, err := TakeTheTunnel(&o, m.run)
if err != nil || found == nil {
t.Fatalf("the tunnel was not found and taken: %+v %v", found, err)
}
if o.Tunnel != "wg0" || o.Ports.Hub != 51900 || o.OverlayRange != "192.0.2.0/24" {
t.Fatalf("genesis did not settle on the tunnel's port and range: %+v", o)
}
// Its port is held by the tunnel and its range overlaps the tunnel's — by design, not refused.
if err := CheckTheMachine(context.Background(), o, m.run, producedBundle(t).Declaration, func(string) {}); err != nil {
t.Fatalf("the machine was refused for the tunnel it takes over: %v", err)
}
// Whereas the same machine not taking it over is refused on both counts (ADR 0100).
plain := o
plain.Tunnel = ""
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
!strings.Contains(err.Error(), "51900") {
t.Fatalf("a tunnel not taken over stopped being refused for holding the hub's port: %v", err)
}
plain.Ports.Hub = 51821
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
!strings.Contains(err.Error(), "wg0") {
t.Fatalf("a tunnel not taken over stopped being refused for overlapping the range: %v", err)
}
// Numbers that disagree with the tunnel are refused, naming the tunnel's.
for name, given := range map[string]Options{
"--hub-port": {Adopted: true, Ports: FoundationPorts{Hub: 51821}, OverlayRange: DefaultOverlayRange},
"--overlay-range": {Adopted: true, Ports: DefaultPorts(), OverlayRange: "10.77.0.0/16"},
} {
if _, err := TakeTheTunnel(&given, m.run); err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("a %s disagreeing with the tunnel was accepted: %v", name, err)
}
}
// And no tunnel up is an ordinary machine.
m.wg = "mesh0\n"
none := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange}
if found, err := TakeTheTunnel(&none, m.run); err != nil || found != nil || none.Ports.Hub != DefaultPorts().Hub {
t.Errorf("a machine with no tunnel was not left as it was: %+v %v", found, err)
}
}
func aFoundKey() (string, error) {
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(k.Bytes()), nil
}