Merge pull request 'A machine joins through the tunnel: a token issued for its tunnel key (hq ADR 0169)' (#132) from feat/a-machine-joins-through-the-tunnel into main

This commit was merged in pull request #132.
This commit is contained in:
2026-10-08 08:22:44 +00:00
16 changed files with 436 additions and 2 deletions
@@ -0,0 +1,102 @@
package main
import (
"context"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// aMachineJoining is a machine with a live token issued for a tunnel key, given its address — what
// `token issue --overlay-key` records before it pushes the hub (novox/hq ADR 0169).
func aMachineJoining(t *testing.T, ctx context.Context, inv *inventory.Inventory, name string,
validFor time.Duration) string {
t.Helper()
record, err := inv.AddNode(ctx, name)
if err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(ctx, name, validFor); err != nil {
t.Fatal(err)
}
key := aPublicKey(t)
if err := inv.RecordOverlayKey(ctx, record.ID, key); err != nil {
t.Fatal(err)
}
if err := inv.BindTokenToKey(ctx, record.ID, key); err != nil {
t.Fatal(err)
}
cidr, err := overlayRange(ctx, inv)
if err != nil {
t.Fatal(err)
}
if _, err := inv.AssignAddress(ctx, record.ID, cidr); err != nil {
t.Fatal(err)
}
return key
}
// hubPeers are the keys the hub's composed tunnel carries.
func hubPeers(t *testing.T, ctx context.Context, inv *inventory.Inventory) map[string]bool {
t.Helper()
g, err := network(ctx, inv, map[string]bool{"anchor": true, "laptop": true}, nil)
if err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, p := range g.Graph()["anchor"] {
out[p.Key] = true
}
return out
}
// **A machine joining is a peer of the hub while its token can be used, and not after** (novox/hq
// ADR 0169): the hub's composed tunnel carries a machine whose token was issued for its key, so the
// tunnel answers the first time it knocks; a token that expired unused takes the peer with it at the
// hub's next composition.
func TestAMachineJoiningIsAPeerOfTheHubUntilItsTokenExpires(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
joining := aMachineJoining(t, ctx, inv, "joiner", time.Hour)
expired := aMachineJoining(t, ctx, inv, "late", 2*time.Second)
time.Sleep(2100 * time.Millisecond)
peers := hubPeers(t, ctx, inv)
if !peers[joining] {
t.Fatalf("the hub does not carry the machine its live token was issued for: %v", peers)
}
if peers[expired] {
t.Fatalf("the hub still carries a machine whose token expired unused: %v", peers)
}
// A token issued without a key gives the hub nothing to carry: there is no key to carry.
if _, err := inv.AddNode(ctx, "keyless"); err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(ctx, "keyless", time.Hour); err != nil {
t.Fatal(err)
}
if after := hubPeers(t, ctx, inv); len(after) != len(peers) {
t.Fatalf("a token issued without a key changed the hub's peers: %v, was %v", after, peers)
}
}
// **A tunnel key that is not one is refused before anything is recorded** (novox/hq ADR 0169): a
// token issued for it would be a tunnel the hub could never answer.
func TestATokenIsNotIssuedForSomethingThatIsNotATunnelKey(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
record, err := open.inventory.AddNode(ctx, "joiner")
if err != nil {
t.Fatal(err)
}
if _, _, err := throughTheTunnel(ctx, open, record, "not-a-key", "10.77.0.1:4222"); err == nil {
t.Fatal("a token was issued for something that is not a tunnel key")
}
if held := placementOf(t, ctx, open.inventory, "joiner"); held.Key != "" || held.Address != "" {
t.Fatalf("a refused key left a record behind: %+v", held)
}
}
+14 -1
View File
@@ -232,9 +232,22 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
if err != nil {
return nil, err
}
// **A machine joining is on the network before it is anything else** (novox/hq ADR 0169). Its
// token was issued for its tunnel key and gave it an address, so while that token can still be
// used the hub carries it as a peer: it brings its tunnel up from the token and enrols over it.
// When the token is spent the machine is on the network by what it runs, as every other is; when
// it expires unused, the peer goes with it at the hub's next composition.
joining, err := inv.NodesWithALiveToken(ctx)
if err != nil {
return nil, err
}
isJoining := map[string]bool{}
for _, name := range joining {
isJoining[name] = true
}
nodes := make([]overlay.Node, 0, len(places))
for _, p := range places {
if !on[p.Name] {
if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") {
continue
}
n := overlay.Node{
+86 -1
View File
@@ -2,15 +2,18 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"github.com/novox/mesh-controller/internal/conditions"
"net"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/token"
)
@@ -247,6 +250,8 @@ func tokenCommand(ctx context.Context, args []string) error {
validFor := set.Duration("for", time.Hour, "how long the token may be used")
adopted := set.Bool("adopted", false,
"the machine joining is in use: it is adopted, and keeps what is found on it")
tunnelKey := set.String("overlay-key", "",
"the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel")
if err := set.Parse(args[1:]); err != nil {
return err
}
@@ -300,6 +305,14 @@ func tokenCommand(ctx context.Context, args []string) error {
default:
return err
}
// **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the
// hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the
// machine knocks. The bus is then reached at its address on the private network.
if *tunnelKey != "" {
if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil {
return err
}
}
encoded, err := made.Encode()
if err != nil {
return err
@@ -324,6 +337,78 @@ func tokenCommand(ctx context.Context, args []string) error {
return nil
}
// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries
// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169).
//
// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a
// tunnel that does not answer, and a machine that cannot tell that from a bus that is down.
func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) (
*token.Tunnel, string, error) {
inv := open.inventory
key = strings.TrimSpace(key)
if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 {
return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+
"`nox-mesh-host key` prints it", key)
}
// The bus on the private network is its holder's address at the bus's own port, so the port must
// be known before anything is recorded.
_, port, err := net.SplitHostPort(busAt)
if err != nil || port == "" {
return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt)
}
places, err := inv.Overlays(ctx)
if err != nil {
return nil, "", err
}
var hub *inventory.Overlay
for i := range places {
if places[i].Hub {
hub = &places[i]
}
}
if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" {
return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " +
"dial, so there is no tunnel to join through: place one (`overlay place <node> --hub " +
"--endpoint <host>:<port>`), or issue the token without --overlay-key")
}
if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil {
return nil, "", err
}
if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil {
return nil, "", err
}
cidr, err := overlayRange(ctx, inv)
if err != nil {
return nil, "", err
}
address, err := inv.AssignAddress(ctx, node.ID, cidr)
if err != nil {
return nil, "", err
}
// The bus at its holder's address on the private network, reached through the hub like the rest
// of the range; the hub's own when no machine is recorded as holding it yet.
busAddress := hub.Address
if holders, err := seatHolders(ctx, inv); err != nil {
return nil, "", err
} else if h, held := holders[catalogue.BrokerSeat]; held {
for _, p := range places {
if p.Name == h.Node && p.Address != "" {
busAddress = p.Address
}
}
}
if err := sendTo(ctx, open, []string{hub.Name}); err != nil {
return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+
"it, so the tunnel would not answer — the token is not shown; issue it again once %s "+
"can be pushed: %w", node.Name, hub.Name, err)
}
// An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004).
return &token.Tunnel{
Key: key, Address: address + "/32", Range: cidr,
HubKey: hub.Key, HubEndpoint: hub.Endpoint,
}, net.JoinHostPort(busAddress, port), nil
}
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
// when the operator says so, and the one-time secret for it. The node in what it returns carries
// its mode, which is what the token says.
+20
View File
@@ -675,6 +675,26 @@ func (a *verbArguments) commandLine() ([]string, error) {
// Neither shape: the command says its usage, which names both, and that is the answer the
// caller needs.
return []string{"rotate"}, nil
case "token":
// `token issue` at a shell (novox/hq ADR 0169). Exactly one of node or new; the command
// refuses both or neither in its own words.
argv := []string{"token", "issue"}
if n := str("node"); n != "" {
argv = append(argv, "--node", n)
}
if n := str("new"); n != "" {
argv = append(argv, "--new", n)
}
if k := str("overlay_key"); k != "" {
argv = append(argv, "--overlay-key", k)
}
if d := str("for"); d != "" {
argv = append(argv, "--for", d)
}
if str("adopted") == "true" {
argv = append(argv, "--adopted")
}
return argv, nil
case "settings":
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
// because a tool has no file to hand the command; the command reads either.
@@ -270,6 +270,8 @@ var accountedFlags = map[string]map[string]string{
},
"builds": {"n": "=limit"},
"plans": {"n": "=limit", "what-if": "=repository"},
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
"token issue": {"overlay-key": "=overlay_key"},
"durations": {
"json": "set by the verb: the answer is data",
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
+8
View File
@@ -108,6 +108,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
}
}
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
t.Fatalf("token: %v %v", argv, err)
}
}
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
func TestSettingsSetsOrClearsALayer(t *testing.T) {
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})