A machine makes its tunnel key first and joins through the tunnel
nox-mesh-host key makes the tunnel key, or reads the one made, and prints its public half for the token to be issued for. enrol with a token that carries a tunnel takes that key, refuses another, writes mesh0 with the hub as its one peer and starts it, then reaches the bus over it (novox/hq ADR 0169). Tokens without a tunnel enrol as before.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
|
||||
// Joining through the tunnel (novox/hq ADR 0169).
|
||||
//
|
||||
// **The bus is never open to the internet, so a joining machine reaches it over the tunnel.** It
|
||||
// makes its tunnel key first and prints the public half; the token is issued for that key, and the
|
||||
// hub is told the key before the token is shown; the token carries the one peer this machine needs.
|
||||
// So the tunnel can come up before the mesh has said anything else — the circle ADR 0004 broke by
|
||||
// carrying the bus's address in the token is broken here by carrying the hub's.
|
||||
|
||||
// tunnelConfigPath and tunnelUnit are where the mesh's own declaration puts the private network, so
|
||||
// the first tunnel is the same interface and unit the mesh takes over, not a second one beside it.
|
||||
var (
|
||||
tunnelConfigPath = "/etc/wireguard/mesh0.conf"
|
||||
tunnelUnit = "wg-quick@mesh0"
|
||||
// lookPath finds WireGuard's tools; a variable so a test needs none installed.
|
||||
lookPath = exec.LookPath
|
||||
)
|
||||
|
||||
// keyCommand makes this machine's tunnel key, or reads the one it already made, and prints the
|
||||
// public half: what the token is issued for. Making it twice would be a token issued for a key the
|
||||
// machine no longer has, so an existing key is kept.
|
||||
func keyCommand(opts options) error {
|
||||
path := identity.OverlayKeyPath(opts.state)
|
||||
if key, err := identity.LoadOverlayKey(path); err == nil {
|
||||
fmt.Println(key.Public)
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(identity.Path(opts.state)); err == nil {
|
||||
return fmt.Errorf("this machine has joined already (%s), and its tunnel key is its own; "+
|
||||
"there is no key to make", identity.Path(opts.state))
|
||||
}
|
||||
key, err := identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this machine's tunnel key: %w", err)
|
||||
}
|
||||
fmt.Println(key.Public)
|
||||
fmt.Fprintln(os.Stderr, "\nthis machine's tunnel key, made here; the private half stays in "+path+".\n"+
|
||||
"Issue the token for it — `token issue --new <name> --overlay-key <the line above>` — and enrol with that token.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// tunnelKeyFor is the key a token through the tunnel was issued for, read from where `key` left it.
|
||||
// Refused when there is none, or it is another: the hub knows only the key the token names.
|
||||
func tunnelKeyFor(t *identity.TokenTunnel, state string) (identity.OverlayKey, error) {
|
||||
path := identity.OverlayKeyPath(state)
|
||||
key, err := identity.LoadOverlayKey(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return identity.OverlayKey{}, fmt.Errorf("this token was issued for a tunnel key, and this " +
|
||||
"machine has none: run `nox-mesh-host key` here first and issue the token for the key it prints")
|
||||
}
|
||||
if err != nil {
|
||||
return identity.OverlayKey{}, err
|
||||
}
|
||||
if key.Public != t.Key {
|
||||
return identity.OverlayKey{}, fmt.Errorf("this token was issued for the tunnel key %s, and this "+
|
||||
"machine's is %s — it is another machine's token, or the key was made again; issue a new "+
|
||||
"token for %s", t.Key, key.Public, key.Public)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// tunnelConfig is the first tunnel: this machine's address, and the hub as its one peer, reaching the
|
||||
// whole private network through it. The private key is set from its file, as the mesh's own
|
||||
// declaration does it, so the file holds no secret.
|
||||
func tunnelConfig(t *identity.TokenTunnel, keyPath string) string {
|
||||
return fmt.Sprintf(`# Written by nox-mesh-host enrol: the one peer a joining machine needs (novox/hq ADR 0169).
|
||||
# The mesh's own declaration replaces this once the machine has joined.
|
||||
[Interface]
|
||||
Address = %s
|
||||
PostUp = wg set %%i private-key %s
|
||||
|
||||
[Peer]
|
||||
PublicKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = 25
|
||||
`, t.Address, keyPath, t.HubKey, t.HubEndpoint, t.Range)
|
||||
}
|
||||
|
||||
// bringTheTunnelUp writes the first tunnel and starts it, so the bus the token names can be reached.
|
||||
func bringTheTunnelUp(ctx context.Context, t *identity.TokenTunnel, keyPath string, run apply.Runner) error {
|
||||
if _, err := lookPath("wg-quick"); err != nil {
|
||||
return errors.New("joining through the tunnel needs WireGuard's tools on this machine " +
|
||||
"(wireguard-tools), and wg-quick is not here")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(tunnelConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tunnelConfigPath, []byte(tunnelConfig(t, keyPath)), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write the first tunnel: %w", err)
|
||||
}
|
||||
if out, err := run(ctx, "systemctl", "restart", tunnelUnit); err != nil {
|
||||
return fmt.Errorf("the first tunnel would not start (%s): %v %s", tunnelUnit, err, strings.TrimSpace(out))
|
||||
}
|
||||
fmt.Printf("the tunnel to the hub is up: %s, through %s\n", t.Address, t.HubEndpoint)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
|
||||
// `key` makes the tunnel key once and prints its public half; asked again it prints the same one,
|
||||
// because a token may already have been issued for it (novox/hq ADR 0169).
|
||||
func TestKeyMakesTheTunnelKeyOnceAndKeepsIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
first := captureStdout(t, func() {
|
||||
if err := keyCommand(opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
second := captureStdout(t, func() {
|
||||
if err := keyCommand(opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if strings.TrimSpace(first) == "" || strings.TrimSpace(first) != strings.TrimSpace(second) {
|
||||
t.Fatalf("the key changed between two asks: %q then %q", first, second)
|
||||
}
|
||||
info, err := os.Stat(identity.OverlayKeyPath(opts.state))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the private half is readable beyond root: %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// A token through the tunnel takes the key it was issued for, and says so when this machine has none
|
||||
// or another.
|
||||
func TestATokenThroughTheTunnelTakesItsOwnKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := filepath.Join(dir, "state.json")
|
||||
tt := &identity.TokenTunnel{Key: "x", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}
|
||||
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "nox-mesh-host key") {
|
||||
t.Fatalf("a machine with no key was not told to make one: %v", err)
|
||||
}
|
||||
captureStdout(t, func() { _ = keyCommand(options{state: state}) })
|
||||
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "issued for the tunnel key x") {
|
||||
t.Fatalf("another machine's token was taken: %v", err)
|
||||
}
|
||||
mine, _ := identity.LoadOverlayKey(identity.OverlayKeyPath(state))
|
||||
tt.Key = mine.Public
|
||||
if got, err := tunnelKeyFor(tt, state); err != nil || got.Public != mine.Public {
|
||||
t.Fatalf("this machine's own token was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The first tunnel is the mesh's interface and unit, with the hub as its one peer and no secret in
|
||||
// the file — the same shape the mesh's declaration replaces it with.
|
||||
func TestTheFirstTunnelIsTheMeshsInterfaceWithTheHubAsItsPeer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tunnelConfigPath = filepath.Join(dir, "wireguard", "mesh0.conf")
|
||||
lookPath = func(string) (string, error) { return "/usr/bin/wg-quick", nil }
|
||||
t.Cleanup(func() { tunnelConfigPath = "/etc/wireguard/mesh0.conf" })
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
tt := &identity.TokenTunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "HUBKEY", HubEndpoint: "198.51.100.1:51820"}
|
||||
captureStdout(t, func() {
|
||||
if err := bringTheTunnelUp(context.Background(), tt, "/var/lib/mesh-host/overlay.key", run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
raw, err := os.ReadFile(tunnelConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := string(raw)
|
||||
for _, want := range []string{"Address = 10.42.0.9/32", "PostUp = wg set %i private-key /var/lib/mesh-host/overlay.key",
|
||||
"PublicKey = HUBKEY", "Endpoint = 198.51.100.1:51820", "AllowedIPs = 10.42.0.0/16", "PersistentKeepalive = 25"} {
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Errorf("the first tunnel lacks %q:\n%s", want, conf)
|
||||
}
|
||||
}
|
||||
if strings.Contains(conf, "PrivateKey") {
|
||||
t.Error("the first tunnel's file holds the private key")
|
||||
}
|
||||
if len(ran) != 1 || ran[0] != "systemctl restart wg-quick@mesh0" {
|
||||
t.Errorf("the tunnel was started as %v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout is what fn printed to standard output.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := os.Stdout
|
||||
os.Stdout = w
|
||||
fn()
|
||||
os.Stdout = was
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
return string(out)
|
||||
}
|
||||
+18
-1
@@ -96,6 +96,9 @@ const usage = `mesh-host — the node host
|
||||
reconcile make this machine match what the mesh last told it — or, before any
|
||||
mesh has, the bundle this host carries
|
||||
bundle show what this host carries
|
||||
key make this machine's tunnel key, or read the one it made, and print the public
|
||||
half: what its join token is issued for (novox/hq ADR 0169)
|
||||
enrol --token T join the mesh — through the tunnel when the token was issued for a key
|
||||
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
|
||||
@@ -212,6 +215,9 @@ func parseArgs(args []string) (string, options, error) {
|
||||
func run(ctx context.Context, command string, opts options) error {
|
||||
jsonOut, timeout := opts.json, opts.timeout
|
||||
switch command {
|
||||
case "key":
|
||||
return keyCommand(opts)
|
||||
|
||||
case "profile":
|
||||
p := profile.Detect(ctx, profile.Default(nil), timeout)
|
||||
if jsonOut {
|
||||
@@ -788,7 +794,18 @@ func enrol(ctx context.Context, opts options) error {
|
||||
fmt.Printf("this node's overlay key is the found tunnel's (%s): %s\n", tun, mine.Overlay.Public)
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
switch {
|
||||
case found == nil && token.Tunnel != nil:
|
||||
// Through the tunnel (novox/hq ADR 0169): the key `key` made, which the token names, and the
|
||||
// tunnel brought up from the token before the bus is dialled — the bus is reached over it.
|
||||
mine.Overlay, err = tunnelKeyFor(token.Tunnel, opts.state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bringTheTunnelUp(ctx, token.Tunnel, identity.OverlayKeyPath(opts.state), apply.ExecRunner); err != nil {
|
||||
return err
|
||||
}
|
||||
case found == nil:
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -409,3 +409,26 @@ func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) {
|
||||
t.Fatalf("the name did not survive the token: %q", token.Node)
|
||||
}
|
||||
}
|
||||
|
||||
// A token through the tunnel carries the one peer, in the field names the control plane writes
|
||||
// (novox/hq ADR 0169), and an incomplete tunnel is refused naming what is missing.
|
||||
func TestATokenThroughTheTunnelParsesAndAPartOneIsRefused(t *testing.T) {
|
||||
whole := map[string]any{"v": 1, "node": "n", "broker": "10.42.0.1:4222", "fingerprint": "sha256:x",
|
||||
"signer": make([]byte, 32), "secret": "s",
|
||||
"tunnel": map[string]any{"key": "k", "address": "10.42.0.9/32", "range": "10.42.0.0/16",
|
||||
"hub_key": "h", "hub_endpoint": "198.51.100.1:51820"}}
|
||||
raw, _ := json.Marshal(whole)
|
||||
got, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Tunnel == nil || got.Tunnel.HubEndpoint != "198.51.100.1:51820" || got.Tunnel.Range != "10.42.0.0/16" {
|
||||
t.Fatalf("the tunnel was not read: %+v", got.Tunnel)
|
||||
}
|
||||
whole["tunnel"] = map[string]any{"key": "k"}
|
||||
raw, _ = json.Marshal(whole)
|
||||
if _, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw)); err == nil ||
|
||||
!strings.Contains(err.Error(), "the hub's tunnel key") {
|
||||
t.Fatalf("a token with half a tunnel was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The node's key on the private network, which is a different key from the one that says who it
|
||||
@@ -64,6 +66,19 @@ func OverlayKeyFrom(privateBase64 string) (OverlayKey, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadOverlayKey reads the key `key` made and left in its file (novox/hq ADR 0169).
|
||||
func LoadOverlayKey(path string) (OverlayKey, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return OverlayKey{}, err
|
||||
}
|
||||
key, err := OverlayKeyFrom(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("%s does not hold a tunnel key: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// OverlayKeyPath is where the private half lives: a file of its own, referenced by the interface
|
||||
// configuration rather than embedded in it.
|
||||
//
|
||||
|
||||
@@ -35,6 +35,21 @@ type Token struct {
|
||||
// firewall found here before enrolling, because an adopted node keeps that firewall in force.
|
||||
// Absent for a converged node.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
|
||||
// Tunnel is this machine's first tunnel, when the token was issued for the key it made with
|
||||
// `key` (novox/hq ADR 0169): its own address and the hub to reach. It brings the tunnel up from
|
||||
// this alone and reaches the bus over it, so the bus never has to face the internet.
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// TokenTunnel is the joining machine's side of its first tunnel. Field names are the wire format
|
||||
// the control plane writes.
|
||||
type TokenTunnel struct {
|
||||
Key string `json:"key"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
HubKey string `json:"hub_key"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
}
|
||||
|
||||
// ParseToken reads a token a person pasted.
|
||||
@@ -70,6 +85,17 @@ func ParseToken(encoded string) (Token, error) {
|
||||
if strings.TrimSpace(t.Secret) == "" {
|
||||
missing = append(missing, "the one-time secret")
|
||||
}
|
||||
if tt := t.Tunnel; tt != nil {
|
||||
for _, part := range []struct{ value, says string }{
|
||||
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
|
||||
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
|
||||
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
if strings.TrimSpace(part.value) == "" {
|
||||
missing = append(missing, part.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
// Refused whole rather than used partially. A token missing the fingerprint would have
|
||||
// this node connect to whatever answers at that address, and one missing the signing key
|
||||
|
||||
Reference in New Issue
Block a user