Stop nothing the mesh cannot replace, give the tunnel back on failure, and take it over after enrolment
Review of the ADR 0105 build (hq ADR 0105). The takeover stopped the found unit and then found out whether the mesh's interface would do; a start that failed left the machine with no tunnel at all. Now nothing is stopped until the declared interface listens on the found port at the found address and the key file it names holds the found key — the refusal names the remedy — and a mesh interface that fails to start after the takeover has the found unit started again, with the account saying so. The account has three states (not taken, taken, down) and is given on every takeover, failure included. An interface raised by hand is looked at again for a moment and then refused naming `wg-quick down`. A found unit started again by hand beside the mesh's is said, not stopped: on the hub it cannot hold the port, and on a spoke two interfaces with one key would fight. `mesh-host overlay take --tunnel <iface>` is the path for a node that enrolled before the mesh knew to take a tunnel over: the found key becomes its overlay key — identity, sealing and serving keys untouched, so nothing sealed to the node is remade — and the mesh is told with a rekey signed by the identity key, over the key left, the key taken and the tunnel. Told first, written second, so a run again puts right whichever half did not happen.
This commit is contained in:
+86
-1
@@ -56,6 +56,8 @@ const usage = `mesh-host — the node host
|
||||
apply FILE make this machine match a declaration from a file
|
||||
reconcile make this machine match the declaration this host carries
|
||||
bundle show what this host carries
|
||||
overlay take take over the tunnel found here (novox/hq ADR 0105): its key becomes this
|
||||
node's overlay key and the mesh is told, signed; --tunnel <iface> when several are up
|
||||
owned what this host has applied and still owns
|
||||
version
|
||||
|
||||
@@ -147,6 +149,13 @@ func parseArgs(args []string) (string, options, error) {
|
||||
opts.file = positionals[0]
|
||||
return command, opts, nil
|
||||
}
|
||||
if command == "overlay" {
|
||||
if len(positionals) != 1 {
|
||||
return "", opts, errors.New("overlay take [--tunnel <iface>]")
|
||||
}
|
||||
opts.file = positionals[0]
|
||||
return command, opts, nil
|
||||
}
|
||||
// Anything left over was neither the command nor a flag. Refused rather than ignored: a
|
||||
// mistyped argument that changes nothing and reports success is worse than an error.
|
||||
if len(positionals) > 0 {
|
||||
@@ -234,6 +243,8 @@ func run(ctx context.Context, command string, opts options) error {
|
||||
|
||||
case "enrol", "enroll":
|
||||
return enrol(ctx, opts)
|
||||
case "overlay":
|
||||
return overlayCommand(ctx, opts)
|
||||
|
||||
case "run":
|
||||
return runLink(ctx, opts)
|
||||
@@ -608,6 +619,80 @@ func enrol(ctx context.Context, opts options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// overlayCommand is `mesh-host overlay take`: this node takes the tunnel found on its machine over
|
||||
// after it enrolled (novox/hq ADR 0105). For a node that enrolled before the mesh knew to take a
|
||||
// tunnel over — re-enrolling would rotate its identity, sealing and serving keys, and with them
|
||||
// every credential the mesh sealed to it.
|
||||
func overlayCommand(ctx context.Context, opts options) error {
|
||||
if opts.file != "take" {
|
||||
return errors.New("overlay take [--tunnel <iface>] — the one thing `overlay` does here")
|
||||
}
|
||||
identityPath := identity.Path(opts.state)
|
||||
mine, err := identity.Load(identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found, err := tunnel.Find(ctx, apply.ExecRunner, opts.tunnel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w. Nothing was changed", err)
|
||||
}
|
||||
taken, rekey, err := rekeyOnto(mine, found)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("taking over %s: this node's overlay key becomes the tunnel's, %s\n", found, taken.Overlay.Public)
|
||||
fmt.Printf(" identity, sealing and serving keys are untouched\n")
|
||||
|
||||
// Told first, then written: a mesh told and a machine not yet written is put right by running
|
||||
// this again (the mesh refuses the stale second rekey and changes nothing; the files are
|
||||
// rewritten the same). A machine written and a mesh not told would raise the mesh's interface
|
||||
// on a key the mesh does not know at the next restart.
|
||||
if err := link.Publish(ctx, link.Membership{
|
||||
Node: mine.Node, Broker: mine.Membership.Broker, Fingerprint: mine.Membership.Fingerprint,
|
||||
Password: mine.Membership.Password, Signer: mine.Membership.Signer,
|
||||
}, link.Report{Node: mine.Node, Rekey: &rekey}, opts.timeout); err != nil {
|
||||
return fmt.Errorf("the mesh could not be told; nothing was written here: %w", err)
|
||||
}
|
||||
fmt.Printf(" told the mesh signed rekey sent; `node show %s` on the controller says whether it took\n", mine.Node)
|
||||
|
||||
if err := os.WriteFile(identity.OverlayKeyPath(opts.state),
|
||||
[]byte(taken.Overlay.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("the mesh was told and this node's overlay key could not be written: %w — run this again", err)
|
||||
}
|
||||
if err := identity.Save(identityPath, taken); err != nil {
|
||||
return fmt.Errorf("the mesh was told and this node's identity could not be saved: %w — run this again", err)
|
||||
}
|
||||
fmt.Printf(" written %s and the identity; the mesh's interface reads the key when the next push restarts it\n",
|
||||
identity.OverlayKeyPath(opts.state))
|
||||
fmt.Printf(" next on the controller: `overlay place %s --hub --endpoint <host>:%d …`, `plan %s --json`, then push\n",
|
||||
mine.Node, found.Port, mine.Node)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rekeyOnto is the identity with the found tunnel's key as its overlay key, and the signed rekey
|
||||
// that tells the mesh. Pure, so it can be held to: node, sealing and serving keys are the same
|
||||
// bytes in and out; only the overlay key moves. Run again after a take, the previous key it names
|
||||
// is the one before the take, so the mesh can tell a repeat from a replay.
|
||||
func rekeyOnto(mine identity.Identity, found tunnel.Found) (identity.Identity, link.Rekey, error) {
|
||||
overlay, err := identity.OverlayKeyFrom(found.PrivateKey())
|
||||
if err != nil {
|
||||
return identity.Identity{}, link.Rekey{}, err
|
||||
}
|
||||
previous := mine.Overlay.Public
|
||||
if previous == overlay.Public && mine.OverlayBefore != "" {
|
||||
previous = mine.OverlayBefore
|
||||
}
|
||||
taken := mine
|
||||
taken.Overlay = overlay
|
||||
if previous != overlay.Public {
|
||||
taken.OverlayBefore = previous
|
||||
}
|
||||
presented := carried(found)
|
||||
rekey := link.Rekey{Previous: previous, OverlayKey: overlay.Public, Tunnel: presented}
|
||||
rekey.Proof = mine.Sign(link.RekeyProof(mine.Node, previous, overlay.Public, presented))
|
||||
return taken, rekey, nil
|
||||
}
|
||||
|
||||
// carried is a found tunnel as it is presented to the mesh: everything but its private key.
|
||||
func carried(t tunnel.Found) *link.Tunnel {
|
||||
out := &link.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port,
|
||||
@@ -934,7 +1019,7 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
// And the tunnel the private network took over, as this apply found it (novox/hq ADR 0105).
|
||||
if t := outcome.Tunnel; t != nil {
|
||||
report.Tunnel = &link.CarriedTunnel{Interface: t.Interface, Port: t.Port, Range: t.Range,
|
||||
Peers: t.Peers, Taken: t.Taken, Kept: t.Kept}
|
||||
Peers: t.Peers, State: t.State, Note: t.Note, Kept: t.Kept}
|
||||
}
|
||||
reached, err := reachable.Collect(ctx, apply.ExecRunner)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: `overlay take` moves this node's overlay key onto the found tunnel's and
|
||||
// nothing else — identity, sealing and serving keys stay as they were — and tells the mesh with a
|
||||
// proof signed by the identity key, over the previous key, the new one and the tunnel.
|
||||
|
||||
func anEnrolledNode(t *testing.T) identity.Identity {
|
||||
t.Helper()
|
||||
mine, err := identity.Generate("anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Membership = identity.Membership{Broker: "198.51.100.1:5671", Fingerprint: "sha256:aa",
|
||||
Signer: make([]byte, ed25519.PublicKeySize), Password: "p"}
|
||||
return mine
|
||||
}
|
||||
|
||||
func aFoundTunnel(t *testing.T) tunnel.Found {
|
||||
t.Helper()
|
||||
private, err := identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := tunnel.Parse([]byte("[Interface]\nPrivateKey = " + private.Private + "\nListenPort = 51900\n" +
|
||||
"Address = 192.0.2.1/24\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found.Interface, found.Unit, found.Config = "wg0", "wg-quick@wg0", "/etc/wireguard/wg0.conf"
|
||||
return found
|
||||
}
|
||||
|
||||
func TestTakingATunnelMovesOnlyTheOverlayKeyAndSignsForIt(t *testing.T) {
|
||||
mine := anEnrolledNode(t)
|
||||
found := aFoundTunnel(t)
|
||||
before := mine.Overlay.Public
|
||||
|
||||
taken, rekey, err := rekeyOnto(mine, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken.Overlay.Public != found.PublicKey || taken.Overlay.Private != found.PrivateKey() {
|
||||
t.Fatal("the overlay key is not the tunnel's")
|
||||
}
|
||||
if string(taken.Public) != string(mine.Public) || string(taken.Private) != string(mine.Private) ||
|
||||
taken.Node != mine.Node || taken.Membership.Password != mine.Membership.Password ||
|
||||
taken.Membership.Broker != mine.Membership.Broker {
|
||||
t.Fatal("something other than the overlay key moved")
|
||||
}
|
||||
if taken.OverlayBefore != before {
|
||||
t.Errorf("the key before the take was not kept: %q", taken.OverlayBefore)
|
||||
}
|
||||
if rekey.Previous != before || rekey.OverlayKey != found.PublicKey || rekey.Tunnel == nil ||
|
||||
rekey.Tunnel.PublicKey != found.PublicKey || len(rekey.Tunnel.Peers) != 1 {
|
||||
t.Fatalf("the rekey does not say what moved: %+v", rekey)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("anchor", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the rekey is not signed by this node's identity key over what it says")
|
||||
}
|
||||
if ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("laptop", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the proof is not bound to the node")
|
||||
}
|
||||
for _, said := range []string{rekey.Previous, rekey.OverlayKey, rekey.Tunnel.Interface} {
|
||||
if strings.Contains(said, found.PrivateKey()) {
|
||||
t.Fatal("the private key travels")
|
||||
}
|
||||
}
|
||||
|
||||
// Run again after the take — the mesh not yet told, or told and refused — the previous key it
|
||||
// names is still the one before the take, so the mesh can tell a repeat from a replay.
|
||||
again, second, err := rekeyOnto(taken, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Previous != before || again.OverlayBefore != before || again.Overlay.Public != found.PublicKey {
|
||||
t.Fatalf("a take run again does not name the key before the first: %+v", second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user