diff --git a/cmd/mesh-controller/join_through_the_tunnel_test.go b/cmd/mesh-controller/join_through_the_tunnel_test.go new file mode 100644 index 00000000..5782644b --- /dev/null +++ b/cmd/mesh-controller/join_through_the_tunnel_test.go @@ -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) + } +} diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 8e612e1d..49cf5d76 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -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{ diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 8c63a064..d784e574 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -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 --hub " + + "--endpoint :`), 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. diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 80545b0a..1eb0ccfa 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -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. diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 6e0289ad..b0f2243e 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -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", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index a5e47ea2..bbb29096 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -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"}) diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 590885f3..def34097 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -228,6 +228,17 @@ var ControllerVerbs = []Verb{ "node": "the machine that runs the module", "module": "the module's name", }, []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, "adopted"), + Replaces: []string{"mesh-controller token issue", "wg set"}}, {Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " + "mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it; " + "with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " + diff --git a/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql b/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql new file mode 100644 index 00000000..6faeae69 --- /dev/null +++ b/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql @@ -0,0 +1,7 @@ +-- 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; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 0e1dbc66..4c13ad05 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -361,6 +361,33 @@ 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)) } +// 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 // 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. diff --git a/internal/link/calls.go b/internal/link/calls.go index 588b9e78..56101e31 100644 --- a/internal/link/calls.go +++ b/internal/link/calls.go @@ -302,9 +302,26 @@ func (l *CallLog) finish(c *Call, answer []byte, failed, answeredAlready bool) { } else { c.State = CallAnswered } + if shownOnce[c.Seat+"."+c.Verb] { + // **A secret shown once is never kept on the bus** (novox/hq ADR 0169): a join token is the + // one-time right to become a machine of the mesh, and `calls` answers anyone who may call the + // seat. Its caller was sent it; kept in this process's memory only when its caller has not + // had it yet — told the call was still running — and then until a restart, never beyond. + withheld, _ := json.Marshal(map[string]any{"not kept": "a secret shown once, to its caller"}) + onTheBus := *c + onTheBus.Answer = withheld + if !answeredAlready { + c.Answer = withheld + } + l.keep(onTheBus) + return + } l.keep(*c) } +// shownOnce are the verbs whose answer is a secret shown once to its caller, as `.`. +var shownOnce = map[string]bool{"mesh-controller.token": true} + // Recent is the kept calls, newest first, as copies: this process's from memory, and, when they are // kept durably, every other the bus holds — a call a controller before this one served included. // Memory wins for a call in both, being the newer word on it. A bus that cannot be read is said in diff --git a/internal/link/calls_test.go b/internal/link/calls_test.go index faa5d589..b06ee147 100644 --- a/internal/link/calls_test.go +++ b/internal/link/calls_test.go @@ -199,3 +199,27 @@ func TestAHandoverRefusalIsMarkedRetryable(t *testing.T) { t.Fatal("an ordinary refusal was marked to be asked again") } } + +// **A join token is shown to its caller and kept nowhere** (novox/hq ADR 0169): `calls` answers anyone +// who may call the seat, and a token is the one-time right to become a machine of the mesh. +func TestAJoinTokenIsShownToItsCallerAndNotKept(t *testing.T) { + l, a := NewCallLog(), newAnswers(t) + writes := make(chan Call, 4) + l.writes = writes + l.serveCall("mesh-controller", "token", json.RawMessage(`{"new":"laptop"}`), "_INBOX.x.1", + func(context.Context, json.RawMessage) (any, error) { return "token for laptop: s3cret-join", nil }, + a.respond, nil) + if got, _ := a.only()["result"].(string); !strings.Contains(got, "s3cret-join") { + t.Fatalf("its caller was not shown the token: %v", got) + } + recent, _ := l.Recent() + if len(recent) != 1 || strings.Contains(string(recent[0].Answer), "s3cret-join") { + t.Fatalf("the token was kept in memory: %+v", recent) + } + close(writes) + for c := range writes { + if strings.Contains(string(c.Answer), "s3cret-join") { + t.Fatalf("the token was sent to be kept on the bus: %s", c.Answer) + } + } +} diff --git a/internal/link/enrol_tunnel_key_test.go b/internal/link/enrol_tunnel_key_test.go new file mode 100644 index 00000000..f5604f42 --- /dev/null +++ b/internal/link/enrol_tunnel_key_test.go @@ -0,0 +1,37 @@ +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) + } +} diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 45d00ab2..6c3cab6b 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -87,6 +87,18 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol if err != nil { 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 { return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err) diff --git a/internal/token/token.go b/internal/token/token.go index ee7ab491..b97e7118 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -55,6 +55,26 @@ type Token struct { // 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. 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. @@ -83,6 +103,19 @@ func (t Token) Missing() []string { if strings.TrimSpace(t.Secret) == "" { 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 } diff --git a/internal/token/token_test.go b/internal/token/token_test.go index ecf3f785..c4d656f5 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -172,3 +172,38 @@ func TestATokenWithNoNameIsRefused(t *testing.T) { 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") + } +} diff --git a/module.json b/module.json index 4a940723..0ee497d0 100644 --- a/module.json +++ b/module.json @@ -49,6 +49,7 @@ "push", "rotate", "issue", + "token", "settings", "command", "queue",