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:
@@ -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 (+), " +
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<seat>.<verb>`.
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user