From 594ddb77a369a2e76ec593888a4de18f41a293cd Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 2 Oct 2026 13:33:42 +0200 Subject: [PATCH] A machine makes its tunnel key first and joins through the tunnel nox-mesh-host key makes the tunnel key, or reads the one made, and prints its public half for the token to be issued for. enrol with a token that carries a tunnel takes that key, refuses another, writes mesh0 with the hub as its one peer and starts it, then reaches the bus over it (novox/hq ADR 0169). Tokens without a tunnel enrol as before. --- cmd/mesh-host/join_tunnel.go | 119 +++++++++++++++++++++++++++++ cmd/mesh-host/join_tunnel_test.go | 112 +++++++++++++++++++++++++++ cmd/mesh-host/main.go | 19 ++++- internal/identity/identity_test.go | 23 ++++++ internal/identity/overlay.go | 15 ++++ internal/identity/token.go | 26 +++++++ 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-host/join_tunnel.go create mode 100644 cmd/mesh-host/join_tunnel_test.go diff --git a/cmd/mesh-host/join_tunnel.go b/cmd/mesh-host/join_tunnel.go new file mode 100644 index 0000000..d72c64a --- /dev/null +++ b/cmd/mesh-host/join_tunnel.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/identity" +) + +// Joining through the tunnel (novox/hq ADR 0169). +// +// **The bus is never open to the internet, so a joining machine reaches it over the tunnel.** It +// makes its tunnel key first and prints the public half; the token is issued for that key, and the +// hub is told the key before the token is shown; the token carries the one peer this machine needs. +// So the tunnel can come up before the mesh has said anything else — the circle ADR 0004 broke by +// carrying the bus's address in the token is broken here by carrying the hub's. + +// tunnelConfigPath and tunnelUnit are where the mesh's own declaration puts the private network, so +// the first tunnel is the same interface and unit the mesh takes over, not a second one beside it. +var ( + tunnelConfigPath = "/etc/wireguard/mesh0.conf" + tunnelUnit = "wg-quick@mesh0" + // lookPath finds WireGuard's tools; a variable so a test needs none installed. + lookPath = exec.LookPath +) + +// keyCommand makes this machine's tunnel key, or reads the one it already made, and prints the +// public half: what the token is issued for. Making it twice would be a token issued for a key the +// machine no longer has, so an existing key is kept. +func keyCommand(opts options) error { + path := identity.OverlayKeyPath(opts.state) + if key, err := identity.LoadOverlayKey(path); err == nil { + fmt.Println(key.Public) + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if _, err := os.Stat(identity.Path(opts.state)); err == nil { + return fmt.Errorf("this machine has joined already (%s), and its tunnel key is its own; "+ + "there is no key to make", identity.Path(opts.state)) + } + key, err := identity.GenerateOverlayKey() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil { + return fmt.Errorf("cannot write this machine's tunnel key: %w", err) + } + fmt.Println(key.Public) + fmt.Fprintln(os.Stderr, "\nthis machine's tunnel key, made here; the private half stays in "+path+".\n"+ + "Issue the token for it — `token issue --new --overlay-key ` — and enrol with that token.") + return nil +} + +// tunnelKeyFor is the key a token through the tunnel was issued for, read from where `key` left it. +// Refused when there is none, or it is another: the hub knows only the key the token names. +func tunnelKeyFor(t *identity.TokenTunnel, state string) (identity.OverlayKey, error) { + path := identity.OverlayKeyPath(state) + key, err := identity.LoadOverlayKey(path) + if errors.Is(err, os.ErrNotExist) { + return identity.OverlayKey{}, fmt.Errorf("this token was issued for a tunnel key, and this " + + "machine has none: run `nox-mesh-host key` here first and issue the token for the key it prints") + } + if err != nil { + return identity.OverlayKey{}, err + } + if key.Public != t.Key { + return identity.OverlayKey{}, fmt.Errorf("this token was issued for the tunnel key %s, and this "+ + "machine's is %s — it is another machine's token, or the key was made again; issue a new "+ + "token for %s", t.Key, key.Public, key.Public) + } + return key, nil +} + +// tunnelConfig is the first tunnel: this machine's address, and the hub as its one peer, reaching the +// whole private network through it. The private key is set from its file, as the mesh's own +// declaration does it, so the file holds no secret. +func tunnelConfig(t *identity.TokenTunnel, keyPath string) string { + return fmt.Sprintf(`# Written by nox-mesh-host enrol: the one peer a joining machine needs (novox/hq ADR 0169). +# The mesh's own declaration replaces this once the machine has joined. +[Interface] +Address = %s +PostUp = wg set %%i private-key %s + +[Peer] +PublicKey = %s +Endpoint = %s +AllowedIPs = %s +PersistentKeepalive = 25 +`, t.Address, keyPath, t.HubKey, t.HubEndpoint, t.Range) +} + +// bringTheTunnelUp writes the first tunnel and starts it, so the bus the token names can be reached. +func bringTheTunnelUp(ctx context.Context, t *identity.TokenTunnel, keyPath string, run apply.Runner) error { + if _, err := lookPath("wg-quick"); err != nil { + return errors.New("joining through the tunnel needs WireGuard's tools on this machine " + + "(wireguard-tools), and wg-quick is not here") + } + if err := os.MkdirAll(filepath.Dir(tunnelConfigPath), 0o700); err != nil { + return err + } + if err := os.WriteFile(tunnelConfigPath, []byte(tunnelConfig(t, keyPath)), 0o600); err != nil { + return fmt.Errorf("cannot write the first tunnel: %w", err) + } + if out, err := run(ctx, "systemctl", "restart", tunnelUnit); err != nil { + return fmt.Errorf("the first tunnel would not start (%s): %v %s", tunnelUnit, err, strings.TrimSpace(out)) + } + fmt.Printf("the tunnel to the hub is up: %s, through %s\n", t.Address, t.HubEndpoint) + return nil +} diff --git a/cmd/mesh-host/join_tunnel_test.go b/cmd/mesh-host/join_tunnel_test.go new file mode 100644 index 0000000..146fc7e --- /dev/null +++ b/cmd/mesh-host/join_tunnel_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/identity" +) + +// `key` makes the tunnel key once and prints its public half; asked again it prints the same one, +// because a token may already have been issued for it (novox/hq ADR 0169). +func TestKeyMakesTheTunnelKeyOnceAndKeepsIt(t *testing.T) { + dir := t.TempDir() + opts := options{state: filepath.Join(dir, "state.json")} + first := captureStdout(t, func() { + if err := keyCommand(opts); err != nil { + t.Fatal(err) + } + }) + second := captureStdout(t, func() { + if err := keyCommand(opts); err != nil { + t.Fatal(err) + } + }) + if strings.TrimSpace(first) == "" || strings.TrimSpace(first) != strings.TrimSpace(second) { + t.Fatalf("the key changed between two asks: %q then %q", first, second) + } + info, err := os.Stat(identity.OverlayKeyPath(opts.state)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("the private half is readable beyond root: %v", info.Mode().Perm()) + } +} + +// A token through the tunnel takes the key it was issued for, and says so when this machine has none +// or another. +func TestATokenThroughTheTunnelTakesItsOwnKey(t *testing.T) { + dir := t.TempDir() + state := filepath.Join(dir, "state.json") + tt := &identity.TokenTunnel{Key: "x", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"} + if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "nox-mesh-host key") { + t.Fatalf("a machine with no key was not told to make one: %v", err) + } + captureStdout(t, func() { _ = keyCommand(options{state: state}) }) + if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "issued for the tunnel key x") { + t.Fatalf("another machine's token was taken: %v", err) + } + mine, _ := identity.LoadOverlayKey(identity.OverlayKeyPath(state)) + tt.Key = mine.Public + if got, err := tunnelKeyFor(tt, state); err != nil || got.Public != mine.Public { + t.Fatalf("this machine's own token was refused: %v", err) + } +} + +// The first tunnel is the mesh's interface and unit, with the hub as its one peer and no secret in +// the file — the same shape the mesh's declaration replaces it with. +func TestTheFirstTunnelIsTheMeshsInterfaceWithTheHubAsItsPeer(t *testing.T) { + dir := t.TempDir() + tunnelConfigPath = filepath.Join(dir, "wireguard", "mesh0.conf") + lookPath = func(string) (string, error) { return "/usr/bin/wg-quick", nil } + t.Cleanup(func() { tunnelConfigPath = "/etc/wireguard/mesh0.conf" }) + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + return "", nil + } + tt := &identity.TokenTunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "HUBKEY", HubEndpoint: "198.51.100.1:51820"} + captureStdout(t, func() { + if err := bringTheTunnelUp(context.Background(), tt, "/var/lib/mesh-host/overlay.key", run); err != nil { + t.Fatal(err) + } + }) + raw, err := os.ReadFile(tunnelConfigPath) + if err != nil { + t.Fatal(err) + } + conf := string(raw) + for _, want := range []string{"Address = 10.42.0.9/32", "PostUp = wg set %i private-key /var/lib/mesh-host/overlay.key", + "PublicKey = HUBKEY", "Endpoint = 198.51.100.1:51820", "AllowedIPs = 10.42.0.0/16", "PersistentKeepalive = 25"} { + if !strings.Contains(conf, want) { + t.Errorf("the first tunnel lacks %q:\n%s", want, conf) + } + } + if strings.Contains(conf, "PrivateKey") { + t.Error("the first tunnel's file holds the private key") + } + if len(ran) != 1 || ran[0] != "systemctl restart wg-quick@mesh0" { + t.Errorf("the tunnel was started as %v", ran) + } +} + +// captureStdout is what fn printed to standard output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + was := os.Stdout + os.Stdout = w + fn() + os.Stdout = was + w.Close() + out, _ := io.ReadAll(r) + return string(out) +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 65c620a..2a46ba5 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -96,6 +96,9 @@ const usage = `mesh-host — the node host reconcile make this machine match what the mesh last told it — or, before any mesh has, the bundle this host carries bundle show what this host carries + key make this machine's tunnel key, or read the one it made, and print the public + half: what its join token is issued for (novox/hq ADR 0169) + enrol --token T join the mesh — through the tunnel when the token was issued for a key overlay take take over the tunnel found here (novox/hq ADR 0105): its key becomes this node's overlay key and the mesh is told, signed; --tunnel when several are up owned what this host has applied and still owns @@ -212,6 +215,9 @@ func parseArgs(args []string) (string, options, error) { func run(ctx context.Context, command string, opts options) error { jsonOut, timeout := opts.json, opts.timeout switch command { + case "key": + return keyCommand(opts) + case "profile": p := profile.Detect(ctx, profile.Default(nil), timeout) if jsonOut { @@ -788,7 +794,18 @@ func enrol(ctx context.Context, opts options) error { fmt.Printf("this node's overlay key is the found tunnel's (%s): %s\n", tun, mine.Overlay.Public) } } - if found == nil { + switch { + case found == nil && token.Tunnel != nil: + // Through the tunnel (novox/hq ADR 0169): the key `key` made, which the token names, and the + // tunnel brought up from the token before the bus is dialled — the bus is reached over it. + mine.Overlay, err = tunnelKeyFor(token.Tunnel, opts.state) + if err != nil { + return err + } + if err := bringTheTunnelUp(ctx, token.Tunnel, identity.OverlayKeyPath(opts.state), apply.ExecRunner); err != nil { + return err + } + case found == nil: mine.Overlay, err = identity.GenerateOverlayKey() if err != nil { return err diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index fcc0b2a..eac84ec 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -409,3 +409,26 @@ func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) { t.Fatalf("the name did not survive the token: %q", token.Node) } } + +// A token through the tunnel carries the one peer, in the field names the control plane writes +// (novox/hq ADR 0169), and an incomplete tunnel is refused naming what is missing. +func TestATokenThroughTheTunnelParsesAndAPartOneIsRefused(t *testing.T) { + whole := map[string]any{"v": 1, "node": "n", "broker": "10.42.0.1:4222", "fingerprint": "sha256:x", + "signer": make([]byte, 32), "secret": "s", + "tunnel": map[string]any{"key": "k", "address": "10.42.0.9/32", "range": "10.42.0.0/16", + "hub_key": "h", "hub_endpoint": "198.51.100.1:51820"}} + raw, _ := json.Marshal(whole) + got, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw)) + if err != nil { + t.Fatal(err) + } + if got.Tunnel == nil || got.Tunnel.HubEndpoint != "198.51.100.1:51820" || got.Tunnel.Range != "10.42.0.0/16" { + t.Fatalf("the tunnel was not read: %+v", got.Tunnel) + } + whole["tunnel"] = map[string]any{"key": "k"} + raw, _ = json.Marshal(whole) + if _, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw)); err == nil || + !strings.Contains(err.Error(), "the hub's tunnel key") { + t.Fatalf("a token with half a tunnel was taken: %v", err) + } +} diff --git a/internal/identity/overlay.go b/internal/identity/overlay.go index bc1342c..d05ff3d 100644 --- a/internal/identity/overlay.go +++ b/internal/identity/overlay.go @@ -5,6 +5,8 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "os" + "strings" ) // The node's key on the private network, which is a different key from the one that says who it @@ -64,6 +66,19 @@ func OverlayKeyFrom(privateBase64 string) (OverlayKey, error) { }, nil } +// LoadOverlayKey reads the key `key` made and left in its file (novox/hq ADR 0169). +func LoadOverlayKey(path string) (OverlayKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return OverlayKey{}, err + } + key, err := OverlayKeyFrom(strings.TrimSpace(string(raw))) + if err != nil { + return OverlayKey{}, fmt.Errorf("%s does not hold a tunnel key: %w", path, err) + } + return key, nil +} + // OverlayKeyPath is where the private half lives: a file of its own, referenced by the interface // configuration rather than embedded in it. // diff --git a/internal/identity/token.go b/internal/identity/token.go index 4fbaf46..6f0c200 100644 --- a/internal/identity/token.go +++ b/internal/identity/token.go @@ -35,6 +35,21 @@ type Token struct { // firewall found here before enrolling, because an adopted node keeps that firewall in force. // Absent for a converged node. Adopted bool `json:"adopted,omitempty"` + + // Tunnel is this machine's first tunnel, when the token was issued for the key it made with + // `key` (novox/hq ADR 0169): its own address and the hub to reach. It brings the tunnel up from + // this alone and reaches the bus over it, so the bus never has to face the internet. + Tunnel *TokenTunnel `json:"tunnel,omitempty"` +} + +// TokenTunnel is the joining machine's side of its first tunnel. Field names are the wire format +// the control plane writes. +type TokenTunnel struct { + Key string `json:"key"` + Address string `json:"address"` + Range string `json:"range"` + HubKey string `json:"hub_key"` + HubEndpoint string `json:"hub_endpoint"` } // ParseToken reads a token a person pasted. @@ -70,6 +85,17 @@ func ParseToken(encoded string) (Token, error) { if strings.TrimSpace(t.Secret) == "" { missing = append(missing, "the one-time secret") } + if tt := t.Tunnel; tt != nil { + for _, part := range []struct{ value, says string }{ + {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, + {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, + {tt.HubEndpoint, "where the hub's tunnel is dialled"}, + } { + if strings.TrimSpace(part.value) == "" { + missing = append(missing, part.says) + } + } + } if len(missing) > 0 { // Refused whole rather than used partially. A token missing the fingerprint would have // this node connect to whatever answers at that address, and one missing the signing key