Compare commits

..
Author SHA1 Message Date
mesh-admin 1a44d281c2 Merge pull request 'node show cites ADR 0180 for a removed front end (hq ADR 0186)' (#224) from fix/a-ban-list-never-holds-a-neighbour into main 2026-10-02 16:47:25 +00:00
jschoubben 1c8fe65601 node show cites ADR 0180 for a removed front end (hq ADR 0186)
Another session took 0175 while that record was in review; the line printed on every converged
machine was pointing at an unrelated decision.
2026-10-02 18:42:16 +02:00
mesh-admin bea1a1c513 Merge pull request 'A control plane behind its seat's row serves what it can (hq ADR 0185)' (#222) from fix/a-service-asked-to-run-is-still-running into main 2026-10-02 16:21:55 +00:00
jschoubben 21d38c9b0e A control plane behind its seat's row serves what it can (hq ADR 0185)
One verb in the row that this binary cannot run aborted the start, and a stale push that put an
older control plane back took the whole mesh off the bus for ten minutes — recoverable only by a
person running the binary outside its service, because the push that repairs it is one of the verbs
that had stopped being served. Now the verbs it knows are served, the ones it does not answer the
reason, and the start names them once.
2026-10-02 18:16:24 +02:00
16 changed files with 99 additions and 364 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ func showFiltering(f inventory.Filtering, adopted bool) {
case fw.Active: case fw.Active:
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind) fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
case fw.RetiredBy == "removed": case fw.RetiredBy == "removed":
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0175)\n", fw.Kind) fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0180)\n", fw.Kind)
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh": case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind) fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
case fw.RetiredBy != "": case fw.RetiredBy != "":
+1 -14
View File
@@ -232,22 +232,9 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
if err != nil { if err != nil {
return nil, err 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)) nodes := make([]overlay.Node, 0, len(places))
for _, p := range places { for _, p := range places {
if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") { if !on[p.Name] {
continue continue
} }
n := overlay.Node{ n := overlay.Node{
-72
View File
@@ -2,11 +2,9 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
@@ -232,8 +230,6 @@ func tokenCommand(ctx context.Context, args []string) error {
validFor := set.Duration("for", time.Hour, "how long the token may be used") validFor := set.Duration("for", time.Hour, "how long the token may be used")
adopted := set.Bool("adopted", false, adopted := set.Bool("adopted", false,
"the machine joining is in use: it is adopted, and keeps what is found on it") "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 { if err := set.Parse(args[1:]); err != nil {
return err return err
} }
@@ -287,14 +283,6 @@ func tokenCommand(ctx context.Context, args []string) error {
default: default:
return err 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() encoded, err := made.Encode()
if err != nil { if err != nil {
return err return err
@@ -319,66 +307,6 @@ func tokenCommand(ctx context.Context, args []string) error {
return nil 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 the hub'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
}
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(hub.Address, port), nil
}
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted // 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 // 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. // its mode, which is what the token says.
+8 -1
View File
@@ -131,10 +131,17 @@ func serve(ctx context.Context) error {
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served // And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
// from the store's row, so what the seat declares is what is answered. // from the store's row, so what the seat declares is what is answered.
handlers, err := seatToolHandlers() handlers, behind, err := seatToolHandlers()
if err != nil { if err != nil {
return err return err
} }
if len(behind) > 0 {
// Said once, loudly, and then served anyway (novox/hq ADR 0185): the mesh keeps answering
// while whatever put an older control plane here is undone.
fmt.Printf("this control plane is behind the %s row: it cannot run %s. "+
"Those answer the reason when called; everything else is served as usual\n",
catalogue.ControllerSeatName, strings.Join(behind, ", "))
}
bus, isNATS := server.Bus().(link.OverNATS) bus, isNATS := server.Bus().(link.OverNATS)
if !isNATS { if !isNATS {
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it") return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
+28 -27
View File
@@ -144,26 +144,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
// Half of either shape: the command says its usage, which names both shapes, and that is // Half of either shape: the command says its usage, which names both shapes, and that is
// the answer the caller needs. // the answer the caller needs.
return []string{"rotate"}, nil 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": case "settings":
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument // `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. // because a tool has no file to hand the command; the command reads either.
@@ -250,13 +230,15 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
} }
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the // seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary // store's row, so a verb the row does not carry is not served. A verb it carries that this binary
// cannot run is said at start rather than at the first call. // cannot run is named at start and answers the reason when called — never a refusal to serve, which
func seatToolHandlers() (map[string]link.ToolHandler, error) { // would take the whole control plane down for one word (novox/hq ADR 0185).
func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName) seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
if !known { if !known {
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName) return nil, nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
} }
var behind []string
handlers := map[string]link.ToolHandler{} handlers := map[string]link.ToolHandler{}
for _, v := range seat.Serves { for _, v := range seat.Serves {
verb := v.Name verb := v.Name
@@ -267,8 +249,27 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
continue continue
} }
if _, err := argvFor(verb, sampleArguments(v)); err != nil { if _, err := argvFor(verb, sampleArguments(v)); err != nil {
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w", // **A row ahead of this binary is not a reason to go silent.**
catalogue.ControllerSeatName, verb, err) //
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
// this build does not know means the row was widened by a newer one — the ordinary
// state of a roll-out, and of a push that put an older control plane back. Refusing to
// serve at all made that transient fatal: on 2026-10-02 one unknown verb took the whole
// mesh off the bus for ten minutes, and the way back was a human running the binary by
// hand, because the thing that would have repaired it is the thing that was down
// (novox/hq 04-ISSUES/201, ADR 0185).
//
// So the verbs this binary knows are served, and this one answers the reason instead of
// nothing: a caller gets a sentence naming the fault, and everything else keeps working
// — including the push that replaces this binary with the one whose verb it is.
behind = append(behind, verb)
reason := err
handlers[verb] = func(context.Context, json.RawMessage) (any, error) {
return nil, fmt.Errorf("%s is in this mesh's %s row and the control plane running "+
"here cannot run it: %w. It is a verb of a newer build; this one is behind",
verb, catalogue.ControllerSeatName, reason)
}
continue
} }
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) { handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
args := map[string]any{} args := map[string]any{}
@@ -284,7 +285,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
return runVerb(ctx, argv) return runVerb(ctx, argv)
} }
} }
return handlers, nil return handlers, behind, nil
} }
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the // seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
+55 -9
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"strings" "strings"
"testing" "testing"
@@ -73,14 +74,6 @@ 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). // `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
func TestSettingsSetsOrClearsALayer(t *testing.T) { func TestSettingsSetsOrClearsALayer(t *testing.T) {
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"}) argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
@@ -138,10 +131,13 @@ func TestActsDoNotBlockTheCall(t *testing.T) {
// What `tools` answers is the seats' records, with each verb's schema. // What `tools` answers is the seats' records, with each verb's schema.
func TestToolsAnswersTheSeatsRecords(t *testing.T) { func TestToolsAnswersTheSeatsRecords(t *testing.T) {
handlers, err := seatToolHandlers() handlers, behind, err := seatToolHandlers()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(behind) != 0 {
t.Fatalf("this build cannot run %v of its own seat's verbs", behind)
}
if len(handlers) != len(catalogue.ControllerVerbs) { if len(handlers) != len(catalogue.ControllerVerbs) {
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs)) t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
} }
@@ -203,3 +199,53 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) {
t.Fatal("an unclosed quote was accepted") t.Fatal("an unclosed quote was accepted")
} }
} }
// A verb in the row that this binary cannot run does not take the control plane off the bus: the
// rest are served, the unknown one answers the reason, and the start-up names it (novox/hq ADR
// 0185). One unknown word cost the mesh ten minutes of silence on 2026-10-02, recoverable only by
// a person running the binary by hand — the push that would have repaired it needs the control
// plane that was down.
func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
if !known {
t.Fatal("no controller seat")
}
// The row as a newer control plane would have written it: every verb this build knows, and one
// it does not.
widened := seat
widened.Serves = append(append([]catalogue.Verb{}, seat.Serves...),
catalogue.Verb{Name: "teleport", Description: "a verb from a build that does not exist yet"})
rows := catalogue.DefaultSeats()
for i := range rows {
if rows[i].Name == catalogue.ControllerSeatName {
rows[i] = widened
}
}
catalogue.UseSeats(rows)
t.Cleanup(func() { catalogue.UseSeats(catalogue.DefaultSeats()) })
handlers, behind, err := seatToolHandlers()
if err != nil {
t.Fatalf("a row with one unknown verb refused to serve at all: %v", err)
}
if len(behind) != 1 || behind[0] != "teleport" {
t.Fatalf("the verbs this build cannot run were reported as %v", behind)
}
if len(handlers) != len(widened.Serves) {
t.Fatalf("%d handlers for %d verbs in the row", len(handlers), len(widened.Serves))
}
for _, known := range []string{"status", "nodes", "push"} {
if handlers[known] == nil {
t.Errorf("%s is not served although this build knows it", known)
}
}
_, err = handlers["teleport"](context.Background(), nil)
if err == nil {
t.Fatal("the unknown verb answered as though it had run")
}
for _, want := range []string{"teleport", "cannot run it", "behind"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the answer does not say %q: %v", want, err)
}
}
}
-10
View File
@@ -136,16 +136,6 @@ var ControllerVerbs = []Verb{
"node": "the machine that runs the module", "node": "the machine that runs the module",
"module": "the module's name", "module": "the module's name",
}, []string{"node", "module"})}, }, []string{"node", "module"})},
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
Input: schema(map[string]string{
"node": "a machine the mesh already has a record for",
"new": "or the name of a machine to create the record for",
"overlay_key": "the public half of the machine's tunnel key",
"for": "how long it may be used, as a duration (default 1h)",
"adopted": "\"true\" when the machine is in use and joins adopted",
}, nil)},
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " + {Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " + "or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
"at the next push. With clear, removes the layer and the module is back to what its definition says.", "at the next push. With clear, removes the layer and the module is back to what its definition says.",
@@ -1,7 +0,0 @@
-- A token issued for a tunnel key (novox/hq ADR 0169).
--
-- A machine that joins through the tunnel makes its key first, and the token is issued for it: the
-- hub is told the key before the token is shown. So enrolment must take that key and no other — a
-- different one is a machine the hub does not know, offering a tunnel that would never answer. Null
-- for a token issued without one, which enrols as before.
alter table enrolment_token add column overlay_key text;
-27
View File
@@ -356,33 +356,6 @@ func (i *Inventory) Claim(ctx context.Context, secret, by string, again bool) (N
return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id)) return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
} }
// BindTokenToKey records the tunnel key a node's live token was issued for (novox/hq ADR 0169), so
// enrolment takes that key and no other. Refused when the node has no live token to bind: a key
// recorded against nothing would be a promise nothing keeps.
func (i *Inventory) BindTokenToKey(ctx context.Context, node, key string) error {
tag, err := i.store.Pool().Exec(ctx,
`update enrolment_token set overlay_key = $2
where node = $1 and redeemed is null and expires > now()`, node, key)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("no live token to issue for the tunnel key")
}
return nil
}
// TokenKey is the tunnel key a token was issued for, or empty when it was issued without one.
func (i *Inventory) TokenKey(ctx context.Context, secret string) (string, error) {
var key *string
err := i.store.Pool().QueryRow(ctx,
`select overlay_key from enrolment_token where secret = $1`, hashSecret(secret)).Scan(&key)
if err != nil || key == nil {
return "", err
}
return *key, nil
}
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the // Spend makes a claimed token used, only for the presenter holding the claim. The last write to the
// store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent // store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent
// again by the same presenter is not an error: an answer lost after the first spend. // again by the same presenter is not an error: an answer lost after the first spend.
+6 -7
View File
@@ -9,13 +9,12 @@ import (
// the streams and the controller's consumers are asserted by Raise, before anything is served. // the streams and the controller's consumers are asserted by Raise, before anything is served.
func ConnectNats(js *broker.JetStream, enroller Enroller, listener Listener) *Server { func ConnectNats(js *broker.JetStream, enroller Enroller, listener Listener) *Server {
return &Server{ return &Server{
inbound: Nats(js), inbound: Nats(js),
bus: OverNATS{JS: js.Context(), Conn: js.Conn()}, bus: OverNATS{JS: js.Context(), Conn: js.Conn()},
js: js, js: js,
consumers: js, enroller: enroller,
enroller: enroller, listener: listener,
listener: listener, log: newLog(),
log: newLog(),
} }
} }
-51
View File
@@ -1,51 +0,0 @@
package link
import (
"context"
"encoding/json"
"io"
"log"
"testing"
"time"
"github.com/novox/mesh-controller/internal/broker"
)
type ensured struct{ consumers []broker.Consumer }
func (e *ensured) EnsureConsumer(c broker.Consumer) error {
e.consumers = append(e.consumers, c)
return nil
}
type acceptsAs string
func (n acceptsAs) Enrol(context.Context, EnrolRequest) (EnrolReply, error) {
return EnrolReply{Accepted: true, Node: string(n)}, nil
}
type anEnrolment struct{ body []byte }
func (m anEnrolment) Kind() string { return "enrol" }
func (m anEnrolment) Body() []byte { return m.body }
func (m anEnrolment) Redelivered() bool { return false }
func (m anEnrolment) HeldFor() time.Duration { return 0 }
func (m anEnrolment) Answer(context.Context, []byte) error { return nil }
func (m anEnrolment) Took() error { return nil }
func (m anEnrolment) Hold(time.Duration) error { return nil }
func (m anEnrolment) Drop() error { return nil }
// **A node that enrols can hear its declarations at once** (novox/hq 04-ISSUES/146): its consumer is
// made as it enrols, not only when the control plane next starts — the first machine of a mesh
// enrols after the control plane is up, and heard nothing.
func TestAnEnrolledNodeIsGivenHowItHearsItsDeclarations(t *testing.T) {
made := &ensured{}
s := &Server{enroller: acceptsAs("anchor"), consumers: made, log: log.New(io.Discard, "", 0)}
body, _ := json.Marshal(EnrolRequest{Node: "anchor"})
s.enrolling(context.Background(), anEnrolment{body: body})
want := broker.NodeConsumer("anchor")
if len(made.consumers) != 1 || made.consumers[0].Name != want.Name || made.consumers[0].Stream != want.Stream {
t.Fatalf("the enrolled node was given %v, want its own declaration consumer %v", made.consumers, want)
}
}
-37
View File
@@ -1,37 +0,0 @@
package link_test
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/link"
)
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub was
// sent the key before the token was shown, so another key is a machine it does not know.
func TestATokenIssuedForATunnelKeyTakesThatKeyAndNoOther(t *testing.T) {
inv, ident := aMeshReadyToEnrol(t)
ctx := context.Background()
secret, public := aTokenFor(t, inv, "joiner")
node, err := inv.NodeByName(ctx, "joiner")
if err != nil {
t.Fatal(err)
}
const issuedFor = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
if err := inv.BindTokenToKey(ctx, node.ID, issuedFor); err != nil {
t.Fatal(err)
}
e := link.Enrolment{Inventory: inv, Identity: ident}
_, err = e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
OverlayKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="})
if err == nil || !strings.Contains(err.Error(), "issued for the tunnel key") {
t.Fatalf("a token issued for one key took another: %v", err)
}
if _, err := e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
OverlayKey: issuedFor}); err != nil {
t.Fatalf("the key the token was issued for was refused: %v", err)
}
}
-12
View File
@@ -86,18 +86,6 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
if err != nil { if err != nil {
return EnrolReply{}, err return EnrolReply{}, err
} }
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub
// was told it before the token was shown; another key is a machine the hub does not know.
// Checked before anything is recorded, so a refusal changes nothing.
bound, err := e.Inventory.TokenKey(ctx, secret)
if err != nil {
return EnrolReply{}, err
}
if bound != "" && request.OverlayKey != bound {
return EnrolReply{}, fmt.Errorf("%s's token was issued for the tunnel key %s and the machine "+
"offered %q — the key it made with `nox-mesh-host key` is the one to issue for",
node.Name, bound, request.OverlayKey)
}
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil { if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err) return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err)
-21
View File
@@ -73,8 +73,6 @@ type Server struct {
inbound Inbound inbound Inbound
bus Bus bus Bus
js *broker.JetStream js *broker.JetStream
// consumers makes a node's declaration consumer as it enrols; the bus connection, or a stand-in.
consumers interface{ EnsureConsumer(broker.Consumer) error }
enroller Enroller enroller Enroller
listener Listener listener Listener
@@ -385,7 +383,6 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
default: default:
reply = accepted reply = accepted
s.log.Printf("enrolled %s", accepted.Node) s.log.Printf("enrolled %s", accepted.Node)
s.hearsItsDeclarations(accepted.Node)
} }
} }
@@ -405,24 +402,6 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
_ = m.Took() _ = m.Took()
} }
// hearsItsDeclarations makes the consumer a node reads its declarations through, as it enrols and
// before it is answered.
//
// **Created at enrolment, as the consumer's own doc has always said** (novox/hq 04-ISSUES/146). It
// was asserted only when the control plane started, so the first machine of a mesh — which enrols
// after the control plane is already up — joined and then heard nothing, its host retrying "consumer
// not found" for ever. Failing here is said and does not unspend the token: the next start of the
// control plane asserts it again.
func (s *Server) hearsItsDeclarations(node string) {
if s.consumers == nil {
return
}
if err := s.consumers.EnsureConsumer(broker.NodeConsumer(node)); err != nil {
s.log.Printf("%s enrolled, and how it hears its declarations could not be made — it will hear "+
"nothing until the control plane next starts: %v", node, err)
}
}
// wasBuilt keeps what a builder said, whichever way it went. // wasBuilt keeps what a builder said, whichever way it went.
// //
// This is for results nobody was waiting for. A build asked for with `build` is answered directly // This is for results nobody was waiting for. A build asked for with `build` is answered directly
-33
View File
@@ -55,26 +55,6 @@ type Token struct {
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall // that it speaks the firewall found on the machine, because an adopted node keeps that firewall
// in force. Absent for a converged node, so a converged token is byte for byte what it was. // in force. Absent for a converged node, so a converged token is byte for byte what it was.
Adopted bool `json:"adopted,omitempty"` Adopted bool `json:"adopted,omitempty"`
// Tunnel is the one peer a joining machine needs, when the token was issued for its tunnel key
// (novox/hq ADR 0169). The machine brings its tunnel up from this alone and reaches the bus over
// it, at an address on the private network — so the bus is never open to the internet. Absent
// on a token issued without a key, which then reads byte for byte as before.
Tunnel *Tunnel `json:"tunnel,omitempty"`
}
// Tunnel is the joining machine's side of its first tunnel: its own address and the hub to reach.
type Tunnel struct {
// Key is the public half of the key the machine made itself, which this token was issued for.
// The private half never left the machine (novox/hq ADR 0004).
Key string `json:"key"`
// Address is the machine's own address on the private network, with its prefix.
Address string `json:"address"`
// Range is the private network, routed through the hub until the machine is told more.
Range string `json:"range"`
// HubKey and HubEndpoint are the hub's tunnel key and where it is dialled.
HubKey string `json:"hub_key"`
HubEndpoint string `json:"hub_endpoint"`
} }
// Missing names the parts that are not filled in. // Missing names the parts that are not filled in.
@@ -103,19 +83,6 @@ func (t Token) Missing() []string {
if strings.TrimSpace(t.Secret) == "" { if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret — nothing to present") missing = append(missing, "the one-time secret — nothing to present")
} }
if t.Tunnel != nil {
for _, part := range []struct{ value, says string }{
{t.Tunnel.Key, "the machine's own tunnel key — the hub would not know it"},
{t.Tunnel.Address, "the machine's address on the private network"},
{t.Tunnel.Range, "the private network's range — nothing to route through the hub"},
{t.Tunnel.HubKey, "the hub's tunnel key — nothing to dial"},
{t.Tunnel.HubEndpoint, "where the hub's tunnel is dialled"},
} {
if strings.TrimSpace(part.value) == "" {
missing = append(missing, part.says)
}
}
}
return missing return missing
} }
-35
View File
@@ -172,38 +172,3 @@ func TestATokenWithNoNameIsRefused(t *testing.T) {
t.Fatalf("the refusal does not say what is missing: %v", without.Missing()) t.Fatalf("the refusal does not say what is missing: %v", without.Missing())
} }
} }
// A token issued for a tunnel key carries the one peer a joining machine needs (novox/hq ADR 0169),
// and says which part is missing rather than producing a tunnel that never answers.
func TestATokenThroughTheTunnelCarriesThePeerOrSaysWhatIsMissing(t *testing.T) {
whole := Token{Node: "n", Broker: "10.42.0.1:4222", Fingerprint: "sha256:x", Signer: make([]byte, 32), Secret: "s",
Tunnel: &Tunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}}
if !whole.Complete() {
t.Fatalf("a whole token through the tunnel reads as missing %v", whole.Missing())
}
encoded, err := whole.Encode()
if err != nil {
t.Fatal(err)
}
back, err := Decode(encoded)
if err != nil {
t.Fatal(err)
}
if back.Tunnel == nil || *back.Tunnel != *whole.Tunnel {
t.Fatalf("the tunnel did not survive the round trip: %+v", back.Tunnel)
}
part := whole
part.Tunnel = &Tunnel{Key: "k", Address: "10.42.0.9/32"}
if len(part.Missing()) != 3 {
t.Errorf("a tunnel without the hub and the range should name three missing parts: %v", part.Missing())
}
// And a token issued without a key carries no tunnel at all, byte for byte as before.
plain := whole
plain.Tunnel = nil
raw, _ := plain.Encode()
if strings.Contains(raw, "tunnel") {
t.Error("a token without a key mentions a tunnel")
}
}