Merge pull request 'Adopt the predecessor's tunnel in place: its range, its address, its peers (hq ADR 0105)' (#49) from feat/adopt-the-tunnel into main
This commit was merged in pull request #49.
This commit is contained in:
@@ -124,6 +124,29 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
|
||||
"%s's overlay key could not be recorded: %w", node.Name, err)
|
||||
}
|
||||
}
|
||||
// And the tunnel it found, whose key is the overlay key above (novox/hq ADR 0105). Recorded
|
||||
// before the token is spent for the same reason as the keys: the first declaration this node
|
||||
// receives is composed from it, and a hub enrolled without its tunnel would be placed at an
|
||||
// address of the mesh's choosing rather than the tunnel's.
|
||||
if request.Tunnel != nil {
|
||||
if request.Tunnel.PublicKey != request.OverlayKey {
|
||||
return EnrolReply{}, fmt.Errorf("%s presented a tunnel under key %s and an overlay key "+
|
||||
"that is not it; a tunnel is taken over with its own key or not at all", node.Name,
|
||||
request.Tunnel.PublicKey)
|
||||
}
|
||||
peers := make([]inventory.TunnelPeer, 0, len(request.Tunnel.Peers))
|
||||
for _, p := range request.Tunnel.Peers {
|
||||
peers = append(peers, inventory.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
if err := e.Inventory.RecordTunnel(ctx, node.ID, inventory.Tunnel{
|
||||
Interface: request.Tunnel.Interface, Unit: request.Tunnel.Unit,
|
||||
Config: request.Tunnel.Config, Port: request.Tunnel.Port,
|
||||
Address: request.Tunnel.Address, Range: request.Tunnel.Range,
|
||||
PublicKey: request.Tunnel.PublicKey, Peers: peers,
|
||||
}); err != nil {
|
||||
return EnrolReply{}, fmt.Errorf("%s's found tunnel could not be recorded: %w", node.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Spent once the node is complete in the store.
|
||||
if err := e.Inventory.Spend(ctx, secret, by); err != nil {
|
||||
@@ -158,6 +181,34 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// rekey applies a verified rekey: the node's overlay key and tunnel are recorded as enrolment
|
||||
// would have recorded them, and a hub moves to the tunnel's address.
|
||||
func (e Enrolment) rekey(ctx context.Context, node inventory.Node, r Rekey) error {
|
||||
if r.Tunnel == nil || r.OverlayKey == "" {
|
||||
return fmt.Errorf("%s sent a rekey naming no tunnel or no key; refused", node.Name)
|
||||
}
|
||||
if e.Identity == nil {
|
||||
return fmt.Errorf("%s sent a rekey and this mesh has no identity store to verify it against", node.Name)
|
||||
}
|
||||
if err := e.Identity.VerifyNode(ctx, node.ID,
|
||||
RekeyProof(node.Name, r.Previous, r.OverlayKey, r.Tunnel), r.Proof); err != nil {
|
||||
return fmt.Errorf("%s's rekey is not signed by %s's identity key; refused: %w", node.Name, node.Name, err)
|
||||
}
|
||||
peers := make([]inventory.TunnelPeer, 0, len(r.Tunnel.Peers))
|
||||
for _, p := range r.Tunnel.Peers {
|
||||
peers = append(peers, inventory.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
err := e.Inventory.Rekey(ctx, node.ID, r.Previous, r.OverlayKey, inventory.Tunnel{
|
||||
Interface: r.Tunnel.Interface, Unit: r.Tunnel.Unit, Config: r.Tunnel.Config, Port: r.Tunnel.Port,
|
||||
Address: r.Tunnel.Address, Range: r.Tunnel.Range, PublicKey: r.Tunnel.PublicKey, Peers: peers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s's rekey was not recorded: %w", node.Name, err)
|
||||
}
|
||||
log.Printf("%s took over the tunnel on %s: its overlay key is the tunnel's now", node.Name, r.Tunnel.Interface)
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimant names the key presenting a token, so a claim can be held for it alone.
|
||||
func claimant(public ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(public)
|
||||
@@ -219,6 +270,26 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
||||
if report.Tunnel != nil {
|
||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||
Interface: report.Tunnel.Interface, Port: report.Tunnel.Port, Range: report.Tunnel.Range,
|
||||
Peers: report.Tunnel.Peers, State: report.Tunnel.State, Note: report.Tunnel.Note,
|
||||
Kept: report.Tunnel.Kept,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A node taking a found tunnel's key after enrolment (novox/hq ADR 0105). Verified against the
|
||||
// node's live identity key before anything is written: the broker account authenticates the
|
||||
// connection, the signature proves the node itself said it. Refused outright when the proof
|
||||
// does not verify or is stale — a refusal, not "not now", so the node hears why.
|
||||
if report.Rekey != nil {
|
||||
if err := e.rekey(ctx, node, *report.Rekey); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Inventory.Seen(ctx, node.ID)
|
||||
}
|
||||
|
||||
// A bare word that a node is there is not an account of what the machine did or holds: it
|
||||
// moves last_seen and touches nothing else. This arrives every minute (link.AliveEvery),
|
||||
|
||||
@@ -8,6 +8,8 @@ package link
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -71,12 +73,39 @@ type EnrolRequest struct {
|
||||
// node's public key could replay the spent token (novox/hq issue 083, on review).
|
||||
Proof []byte `json:"proof,omitempty"`
|
||||
|
||||
// Tunnel is the tunnel this node found on its machine and whose key it took as its overlay
|
||||
// key (novox/hq ADR 0105): the interface, its port, address and range, and its peers. Presented
|
||||
// with the keys because it is one of them — OverlayKey above is this tunnel's public key when
|
||||
// it is set — and the mesh composes the hub's address, the range and every carried peer from
|
||||
// it. Nil from a node that found none, which is every converged one.
|
||||
Tunnel *Tunnel `json:"tunnel,omitempty"`
|
||||
|
||||
// Redelivered is set by the control plane, never sent: the broker handed this request over a
|
||||
// second time. Such a request does not finish an enrolment already spent — the first time may
|
||||
// have answered, and the node holds what it was told.
|
||||
Redelivered bool `json:"-"`
|
||||
}
|
||||
|
||||
// Tunnel is a found tunnel as a node presents it: everything but its private key, which the node
|
||||
// keeps as its own overlay key and never sends.
|
||||
type Tunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit"`
|
||||
Config string `json:"config"`
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Peers []TunnelPeer `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
// TunnelPeer is one peer of a found tunnel: its public key and the address the tunnel routed to
|
||||
// it.
|
||||
type TunnelPeer struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// Signed is a declaration and the signature over it.
|
||||
//
|
||||
// The signature is over Declaration exactly as it will arrive, bytes unchanged — a node verifies
|
||||
@@ -129,6 +158,60 @@ type Report struct {
|
||||
// Reachable is what can be reached on the machine now: every listening socket and every
|
||||
// published container port. Only an adopted node reports it; it is what converging previews.
|
||||
Reachable []Reach `json:"reachable,omitempty"`
|
||||
|
||||
// Tunnel is what an adopted node says about the tunnel it found and carried (novox/hq ADR
|
||||
// 0105): the interface, its port, range and peer count, whether the found interface is down
|
||||
// and the mesh's up in its place, and where the found configuration's original was kept.
|
||||
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
|
||||
|
||||
// Rekey is a node taking a found tunnel's key as its overlay key after enrolment (novox/hq
|
||||
// ADR 0105). A report carrying one is not an account of the machine: it moves the node's
|
||||
// overlay key and tunnel and nothing else.
|
||||
Rekey *Rekey `json:"rekey,omitempty"`
|
||||
}
|
||||
|
||||
// CarriedTunnel is a node's account of the tunnel it took over. State is "not-taken" (the found
|
||||
// interface still up, the mesh's not), "taken" (the found one down, the mesh's up with its key) or
|
||||
// "down" (the found one down and the mesh's not up: the peers reach nothing); Note is what the host
|
||||
// did about it.
|
||||
type CarriedTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Port int `json:"port"`
|
||||
Range string `json:"range"`
|
||||
Peers int `json:"peers"`
|
||||
State string `json:"state"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Kept string `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// Rekey is a node saying it took a found tunnel's key as its overlay key after enrolling (novox/hq
|
||||
// ADR 0105) — the path for a hub that enrolled before the mesh knew to take a tunnel over, since
|
||||
// re-enrolling would rotate every key the node holds. Carried in a report, on the node's own
|
||||
// authenticated connection, and signed with its identity key over RekeyProof, so a report forged
|
||||
// on a stolen broker account cannot move a node's overlay key.
|
||||
type Rekey struct {
|
||||
// Previous is the overlay key the node holds now, as the mesh records it. A rekey naming
|
||||
// another is stale — a replay, or made against a record that moved on — and is refused.
|
||||
Previous string `json:"previous"`
|
||||
OverlayKey string `json:"overlay_key"`
|
||||
Tunnel *Tunnel `json:"tunnel"`
|
||||
Proof []byte `json:"proof"`
|
||||
}
|
||||
|
||||
// RekeyProof is what a node signs when it rekeys: the node, the key it leaves, the key it takes
|
||||
// and the tunnel it took it from, so a proof cannot be moved to another node or another tunnel.
|
||||
func RekeyProof(node, previous, key string, tunnel *Tunnel) []byte {
|
||||
var t Tunnel
|
||||
if tunnel != nil {
|
||||
t = *tunnel
|
||||
}
|
||||
peers := make([]string, 0, len(t.Peers))
|
||||
for _, p := range t.Peers {
|
||||
peers = append(peers, p.PublicKey+"@"+p.Address)
|
||||
}
|
||||
return []byte("novox-mesh-rekey\x00" + node + "\x00" + previous + "\x00" + key + "\x00" +
|
||||
t.Interface + "\x00" + t.Unit + "\x00" + t.Config + "\x00" + strconv.Itoa(t.Port) + "\x00" +
|
||||
t.Address + "\x00" + t.Range + "\x00" + t.PublicKey + "\x00" + strings.Join(peers, ","))
|
||||
}
|
||||
|
||||
// Held is one file or container found on an adopted node and kept as it was.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package link_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: a hub that enrolled before the mesh knew to take a tunnel over rekeys onto the
|
||||
// found tunnel's key without re-enrolling — which would rotate every key it holds and remake every
|
||||
// credential the mesh sealed to it. The rekey rides in a report and is signed with the node's
|
||||
// identity key; the mesh verifies it against the key it recorded, and refuses one signed by
|
||||
// another key or one already applied.
|
||||
|
||||
const (
|
||||
ownKey = "THE-MESHS-OWN-KEY======================="
|
||||
tunnelKey = "TUNNEL-KEY-the-found-interfaces-public-key="
|
||||
)
|
||||
|
||||
func theTunnel() *link.Tunnel {
|
||||
return &link.Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf",
|
||||
Port: 51900, Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: tunnelKey,
|
||||
Peers: []link.TunnelPeer{{PublicKey: "PEER-A=", Address: "192.0.2.2/32"}}}
|
||||
}
|
||||
|
||||
// anEnrolledHub is a hub the way it stands before the feature: adopted, placed, its overlay key its
|
||||
// own, its identity key recorded — and a mesh holding both stores.
|
||||
func anEnrolledHub(t *testing.T) (link.Enrolment, inventory.Node, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
inv := inventory.ForTest(t)
|
||||
ident := identity.ForTest(t)
|
||||
ctx := t.Context()
|
||||
hub, err := inv.AddNodeAs(ctx, "anchor", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
public, private, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ident.RecordNodeKey(ctx, hub.ID, public); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordOverlayKey(ctx, hub.ID, ownKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetPlace(ctx, "anchor", "anchor.example:51900", "hosting", true, "10.42.0.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return link.Enrolment{Inventory: inv, Identity: ident}, hub, private
|
||||
}
|
||||
|
||||
func TestASignedRekeyMovesTheHubOntoItsTunnel(t *testing.T) {
|
||||
e, hub, private := anEnrolledHub(t)
|
||||
ctx := t.Context()
|
||||
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
||||
rekey.Proof = ed25519.Sign(private, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
||||
|
||||
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed, err := e.Inventory.Overlays(ctx)
|
||||
if err != nil || len(placed) != 1 {
|
||||
t.Fatal(placed, err)
|
||||
}
|
||||
if placed[0].Key != tunnelKey || placed[0].Address != "192.0.2.1" {
|
||||
t.Fatalf("the hub is not on the tunnel's key and address: %+v", placed[0])
|
||||
}
|
||||
tunnel, _, adopted, err := e.Inventory.AdoptedTunnel(ctx)
|
||||
if err != nil || !adopted || tunnel.Range != "192.0.2.0/24" || len(tunnel.Peers) != 1 {
|
||||
t.Fatalf("the tunnel is not adopted after the rekey: %+v %t %v", tunnel, adopted, err)
|
||||
}
|
||||
_ = hub
|
||||
|
||||
// Replayed, it is stale: the previous key it names is no longer the node's.
|
||||
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
||||
if err == nil || !strings.Contains(err.Error(), "previous overlay key") {
|
||||
t.Fatalf("a replayed rekey was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARekeySignedByAnotherKeyIsRefusedAndChangesNothing(t *testing.T) {
|
||||
e, _, _ := anEnrolledHub(t)
|
||||
ctx := t.Context()
|
||||
_, stranger, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
||||
rekey.Proof = ed25519.Sign(stranger, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
||||
|
||||
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
||||
if err == nil || !strings.Contains(err.Error(), "not signed by anchor's identity key") {
|
||||
t.Fatalf("a rekey signed by a stranger was accepted: %v", err)
|
||||
}
|
||||
placed, _ := e.Inventory.Overlays(ctx)
|
||||
if placed[0].Key != ownKey || placed[0].Address != "10.42.0.1" {
|
||||
t.Fatalf("a refused rekey changed the record: %+v", placed[0])
|
||||
}
|
||||
if _, _, adopted, _ := e.Inventory.AdoptedTunnel(ctx); adopted {
|
||||
t.Fatal("a refused rekey recorded a tunnel")
|
||||
}
|
||||
|
||||
// And a proof moved to another tunnel — the signature was over one tunnel, the message names
|
||||
// another — does not verify either.
|
||||
moved := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
||||
other := theTunnel()
|
||||
other.Port = 51820
|
||||
moved.Proof = ed25519.Sign(mustPrivate(t, e, "anchor"), link.RekeyProof("anchor", ownKey, tunnelKey, other))
|
||||
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: moved}); err == nil {
|
||||
t.Fatal("a proof over another tunnel was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// mustPrivate is a fresh key recorded as the node's live one, for signing in a test that needs
|
||||
// the node's own signature after the fixture's key is out of scope.
|
||||
func mustPrivate(t *testing.T, e link.Enrolment, node string) ed25519.PrivateKey {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := e.Inventory.NodeByName(t.Context(), node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.Identity.RecordNodeKey(t.Context(), n.ID, public); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return private
|
||||
}
|
||||
Reference in New Issue
Block a user