Take over the found tunnel: its key, its port, its peers; stop it, never flush (hq ADR 0105) #24
@@ -140,6 +140,10 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
||||
firewall stay as they are, the foundation's filter is not loaded and
|
||||
the mesh guards its own ports instead, and each module is taken on it
|
||||
one at a time. Without it, a machine in use is refused
|
||||
--tunnel adopted: the interface of the tunnel the private network takes over
|
||||
(its key, port, range and peers); found by itself when one is up, and
|
||||
needed only when several are. --hub-port and --overlay-range then
|
||||
follow the tunnel
|
||||
|
||||
The installer carries a builder, not a control plane. What raises a mesh is therefore
|
||||
the same thing that will maintain it, and the control plane a mesh ends up running is
|
||||
@@ -334,6 +338,8 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
|
||||
"raise this machine adopted: keep what it runs and its firewall until each module is taken")
|
||||
set.StringVar(&opts.OverlayRange, "overlay-range", opts.OverlayRange,
|
||||
"the private network's address range; must not overlap a tunnel the machine already runs")
|
||||
set.StringVar(&opts.Tunnel, "tunnel", "",
|
||||
"adopted: the found tunnel's interface the private network takes over; found by itself when one is up")
|
||||
if opts.Answers == nil {
|
||||
opts.Answers = map[string]string{}
|
||||
}
|
||||
|
||||
+51
-6
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
"github.com/novox/mesh-host/internal/upgrade"
|
||||
)
|
||||
|
||||
@@ -88,6 +89,7 @@ type options struct {
|
||||
state string
|
||||
token string
|
||||
nodeName string
|
||||
tunnel string
|
||||
dryRun bool
|
||||
file string
|
||||
}
|
||||
@@ -116,6 +118,8 @@ func parseArgs(args []string) (string, options, error) {
|
||||
set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing")
|
||||
set.StringVar(&opts.token, "token", "", "enrol: the one-time token, carried here by a person")
|
||||
set.StringVar(&opts.nodeName, "name", "", "enrol: override the name the token carries")
|
||||
set.StringVar(&opts.tunnel, "tunnel", "", "enrol, adopted: the found tunnel's interface whose key "+
|
||||
"this node takes as its own; found by itself when one is up")
|
||||
|
||||
// Parsed in a loop, because the standard library stops at the FIRST non-flag argument.
|
||||
// `mesh-host inventory --json` hit that once, and taking the subcommand off the front
|
||||
@@ -478,14 +482,39 @@ func enrol(ctx context.Context, opts options) error {
|
||||
}
|
||||
fmt.Printf("generated this node's identity: %s\n", mine.PublicBase64())
|
||||
|
||||
// Its key on the private network, generated here and now for the same reason: the private
|
||||
// Its key on the private network. Generated here and now, for the same reason: the private
|
||||
// half must never have been anywhere else. The mesh receives only the public half and uses it
|
||||
// to compute a graph it cannot impersonate.
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
return err
|
||||
//
|
||||
// **Except on an adopted node with a tunnel** (novox/hq ADR 0105): the found interface's key
|
||||
// becomes this node's, so the peers that know the tunnel by that key keep reaching it once
|
||||
// the mesh's interface takes the tunnel over. The one case where the mesh takes a credential
|
||||
// it did not mint — read from the found configuration, written where a generated one is
|
||||
// written, never printed, never sent.
|
||||
var found *link.Tunnel
|
||||
if token.Adopted {
|
||||
tun, err := tunnel.Find(ctx, apply.ExecRunner, opts.tunnel)
|
||||
switch {
|
||||
case errors.Is(err, tunnel.ErrNone):
|
||||
fmt.Println("no tunnel is up on this machine; the private network's key is generated")
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
mine.Overlay, err = identity.OverlayKeyFrom(tun.PrivateKey())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found = carried(tun)
|
||||
fmt.Printf("this node's overlay key is the found tunnel's (%s): %s\n", tun, mine.Overlay.Public)
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("generated this node's overlay key: %s\n", mine.Overlay.Public)
|
||||
}
|
||||
fmt.Printf("generated this node's overlay key: %s\n", mine.Overlay.Public)
|
||||
|
||||
// And the key secrets are sealed to. Here, with the others, because the mesh cannot seal
|
||||
// anything to a key it has not been told about — a key made later would leave a node that
|
||||
@@ -519,7 +548,8 @@ func enrol(ctx context.Context, opts options) error {
|
||||
proof := mine.Sign(link.EnrolProof(token.Secret, mine.Public, mine.Overlay.Public,
|
||||
sealing.Public, serving.Public))
|
||||
reply, err := link.Enrol(ctx, token.Broker, token.Fingerprint, *name, token.Secret,
|
||||
mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, proof, opts.timeout)
|
||||
mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, proof, found,
|
||||
opts.timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -578,6 +608,16 @@ func enrol(ctx context.Context, opts options) error {
|
||||
return 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,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, p := range t.Peers {
|
||||
out.Peers = append(out.Peers, link.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
@@ -891,6 +931,11 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
if updated.Firewall != nil {
|
||||
report.Firewall = updated.Firewall.Kind
|
||||
}
|
||||
// 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}
|
||||
}
|
||||
reached, err := reachable.Collect(ctx, apply.ExecRunner)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not read what is reachable here: %v\n", err)
|
||||
|
||||
@@ -57,6 +57,9 @@ type Outcome struct {
|
||||
// Report is what an apply did, in the order it did it.
|
||||
type Report struct {
|
||||
Outcomes []Outcome `json:"outcomes"`
|
||||
// Tunnel is what this apply says about the tunnel the private network took over, when the
|
||||
// declaration names one (novox/hq ADR 0105).
|
||||
Tunnel *TakenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Changed reports whether anything about the machine actually moved. An apply that changed
|
||||
@@ -153,6 +156,11 @@ func ApplyKeeping(
|
||||
for _, r := range d.Resources {
|
||||
declared[r.Identity()] = true
|
||||
}
|
||||
if svc := takesOver(d); svc != nil {
|
||||
// The found tunnel's configuration is held under an id of its own, declared for as long
|
||||
// as the service that took it over is (novox/hq ADR 0105).
|
||||
declared[takeOverID(svc)] = true
|
||||
}
|
||||
|
||||
// Which firewall is found here, before anything else, since an unsupported one refuses the
|
||||
// whole declaration (novox/hq ADR 0100). Nothing for a converged node.
|
||||
@@ -327,6 +335,29 @@ func ApplyKeeping(
|
||||
}
|
||||
}
|
||||
|
||||
// The private network takes over the tunnel it found, ahead of the service that replaces
|
||||
// it (novox/hq ADR 0105): its configuration kept, its unit stopped and disabled, never
|
||||
// flushed. A failure here fails the service too — the mesh's interface is not started on a
|
||||
// port the found one still holds.
|
||||
if svc, ok := resource.(*declaration.Service); ok && svc.TakesOver != nil {
|
||||
var outcome Outcome
|
||||
var facts TakenTunnel
|
||||
var err error
|
||||
if d.Adoption == nil {
|
||||
err = errNotAdopted
|
||||
} else {
|
||||
outcome, facts, err = takeOver(ctx, sys, svc, d, &known, run, keep, time.Now().UTC())
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, &Error{Resource: svc.Identity(), Err: err, Done: report})
|
||||
log(fmt.Sprintf(" failed %s (%s): %v", svc.Identity(), svc.Unit, err))
|
||||
continue
|
||||
}
|
||||
report.Outcomes = append(report.Outcomes, outcome)
|
||||
report.Tunnel = &facts
|
||||
log(fmt.Sprintf(" held %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail))
|
||||
}
|
||||
|
||||
was, _ := known.Find(resource.Identity())
|
||||
var outcome Outcome
|
||||
var err error
|
||||
@@ -393,6 +424,11 @@ func ApplyKeeping(
|
||||
known.Release(held.ID)
|
||||
outcome.Detail = takenDetail(held)
|
||||
}
|
||||
if svc, ok := resource.(*declaration.Service); ok && svc.TakesOver != nil && report.Tunnel != nil {
|
||||
// The found interface is down and the mesh's is up in its place: the tunnel changed
|
||||
// hands (novox/hq ADR 0105).
|
||||
report.Tunnel.Taken = true
|
||||
}
|
||||
report.Outcomes = append(report.Outcomes, outcome)
|
||||
if outcome.Action != "unchanged" {
|
||||
changed[resource.Identity()] = true
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
type machine struct {
|
||||
containers map[string]*fakeContainer
|
||||
asked []string
|
||||
// wgUp is what `wg show interfaces` answers: the tunnels up on the machine.
|
||||
wgUp string
|
||||
|
||||
// units are service units by name, as systemd would report them; volumes are the runtime's
|
||||
// named volumes.
|
||||
@@ -94,6 +96,9 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
|
||||
if name == "systemctl" {
|
||||
return m.systemctl(args)
|
||||
}
|
||||
if name == "wg" {
|
||||
return m.wgUp, nil
|
||||
}
|
||||
if name == "getent" {
|
||||
if m.users[args[len(args)-1]] {
|
||||
return args[len(args)-1] + ":x:1500:1500::/home/" + args[len(args)-1] + ":/bin/bash\n", nil
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// The private network takes over the tunnel it found (novox/hq ADR 0105).
|
||||
//
|
||||
// The controller says so on the interface's service: `takes-over` names the found interface, the
|
||||
// unit that raised it and its configuration file. Before the mesh's unit is started, the host keeps
|
||||
// that file like any held file — the original recorded before anything else happens to it — and
|
||||
// stops and disables the found unit. Never a flush: `wg set … peer … remove` is never run, the
|
||||
// file is never written, and the found interface goes down the way its own unit takes it down.
|
||||
// Then the mesh's interface comes up, with the found key the node took at enrolment, on the found
|
||||
// port, with the found peers in its list — and a peer of the tunnel cannot tell it changed hands.
|
||||
//
|
||||
// Every apply, not once: a found unit somebody starts again would take the port back from the
|
||||
// mesh's interface, so it is stopped again and said so. That is the one place an adopted node
|
||||
// undoes something done by hand, and it is because the tunnel is the mesh's now.
|
||||
|
||||
// TakenTunnel is what an apply says about a tunnel it took over, for the node's report.
|
||||
type TakenTunnel struct {
|
||||
Interface string
|
||||
Port int
|
||||
Range string
|
||||
Peers int
|
||||
// Taken is whether the found interface is down and disabled and the mesh's up in its place.
|
||||
Taken bool
|
||||
Kept string
|
||||
}
|
||||
|
||||
// takeOverID is the held record's id for the found configuration: the service's own with a suffix,
|
||||
// so it is declared for as long as the service is and never mistaken for the service itself.
|
||||
func takeOverID(svc *declaration.Service) string { return svc.ID + ".takes-over" }
|
||||
|
||||
// takeOver keeps the found tunnel's configuration and stops its unit, ahead of the service that
|
||||
// replaces it. Returned is the hold's outcome, and what was found for the report.
|
||||
func takeOver(ctx context.Context, sys system.System, svc *declaration.Service, d *declaration.Declaration,
|
||||
known *store.State, run Runner, keep Keep, now time.Time) (Outcome, TakenTunnel, error) {
|
||||
t := svc.TakesOver
|
||||
id := takeOverID(svc)
|
||||
module, _ := d.Adoption.UntakenModuleOf(svc.ID)
|
||||
if module == "" {
|
||||
module = "the private network"
|
||||
}
|
||||
facts := TakenTunnel{Interface: t.Interface}
|
||||
|
||||
// 1. The configuration, kept like any held file. A synthetic file resource stands for it, so
|
||||
// the same code keeps its original, digests it and notices it changing.
|
||||
file := &declaration.File{ID: id, Type: declaration.TypeFile, Path: t.Config}
|
||||
was, already := known.HeldAt(id)
|
||||
out, held, err := hold(ctx, sys, file, module, was, already,
|
||||
"the configuration of the tunnel "+t.Interface+", taken over by "+svc.Unit, run, keep, now)
|
||||
if err != nil {
|
||||
return begin(file), facts, fmt.Errorf("keeping the found tunnel's configuration: %w", err)
|
||||
}
|
||||
known.RecordHeld(held)
|
||||
facts.Kept = held.Kept
|
||||
// What the file says, for the report: read from the machine, or from the kept original when
|
||||
// the machine's copy is gone. The private key stays in the file; nothing here keeps it.
|
||||
unread := ""
|
||||
raw, err := os.ReadFile(t.Config)
|
||||
if err != nil && held.Kept != "" {
|
||||
raw, err = os.ReadFile(held.Kept)
|
||||
}
|
||||
if err == nil {
|
||||
if found, perr := tunnel.Parse(raw); perr == nil {
|
||||
facts.Port, facts.Range, facts.Peers = found.Port, found.Range, len(found.Peers)
|
||||
} else {
|
||||
unread = perr.Error()
|
||||
}
|
||||
} else {
|
||||
unread = err.Error()
|
||||
}
|
||||
|
||||
// 2. The found unit: stopped if it runs, disabled if it starts at boot. A unit that is not
|
||||
// there is not an error — the interface may have been raised another way, which the check
|
||||
// below catches — and neither is one already down.
|
||||
var did []string
|
||||
state, err := sys.ServiceState(ctx, run, t.Unit)
|
||||
switch {
|
||||
case err != nil:
|
||||
did = append(did, t.Unit+" is not a unit here")
|
||||
case state == "running":
|
||||
if err := sys.SetServiceState(ctx, run, t.Unit, "stopped"); err != nil {
|
||||
return out, facts, fmt.Errorf("stopping the found %s: %w", t.Unit, err)
|
||||
}
|
||||
after, err := sys.ServiceState(ctx, run, t.Unit)
|
||||
if err != nil {
|
||||
return out, facts, err
|
||||
}
|
||||
if after != "stopped" {
|
||||
return out, facts, fmt.Errorf("%s was asked to stop and is %s", t.Unit, after)
|
||||
}
|
||||
did = append(did, "stopped "+t.Unit)
|
||||
}
|
||||
if err == nil {
|
||||
if boot, err := sys.ServiceBoot(ctx, run, t.Unit); err == nil && boot == "enabled" {
|
||||
if err := sys.SetServiceBoot(ctx, run, t.Unit, "disabled"); err != nil {
|
||||
return out, facts, fmt.Errorf("disabling the found %s at boot: %w", t.Unit, err)
|
||||
}
|
||||
did = append(did, "disabled it at boot")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The interface is gone. If it is still up, something other than its unit raised it, and
|
||||
// starting the mesh's on the same port and address would fail or, worse, half work.
|
||||
if up, err := run(ctx, "wg", "show", "interfaces"); err == nil {
|
||||
for _, iface := range strings.Fields(up) {
|
||||
if iface == t.Interface {
|
||||
return out, facts, fmt.Errorf("%s is still up after its unit %s was stopped: something other "+
|
||||
"than that unit raises it, and the mesh's interface cannot take its port and address "+
|
||||
"while it does. Nothing was flushed", t.Interface, t.Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.Detail = "the tunnel " + t.Interface + "'s configuration, kept as found"
|
||||
if held.Kept != "" {
|
||||
out.Detail += " (original at " + held.Kept + ")"
|
||||
}
|
||||
if len(did) > 0 {
|
||||
out.Detail += "; " + strings.Join(did, ", ") + " — never flushed"
|
||||
}
|
||||
if held.Changed != "" {
|
||||
out.Detail += "; " + held.Changed + " by something other than the mesh since it was found"
|
||||
}
|
||||
if unread != "" {
|
||||
// Said, not swallowed: the report would otherwise say a tunnel with no port and no
|
||||
// peers was carried, which reads as a tunnel that was not one.
|
||||
out.Detail += "; what it says could not be read as a tunnel's: " + unread
|
||||
}
|
||||
return out, facts, nil
|
||||
}
|
||||
|
||||
// takesOver is the service in a declaration that takes over a tunnel, if any: one per node, since
|
||||
// a machine has one private network.
|
||||
func takesOver(d *declaration.Declaration) *declaration.Service {
|
||||
for _, r := range d.Resources {
|
||||
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil {
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errNotAdopted is a takeover on a declaration that does not say the node is adopted, which the
|
||||
// parser refuses already; kept as a second line of defence at the point of acting.
|
||||
var errNotAdopted = errors.New("a tunnel is taken over on an adopted node only")
|
||||
@@ -0,0 +1,165 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: the host raises the mesh's interface with the found key and peers, stops the
|
||||
// found interface without flushing it, and keeps its configuration.
|
||||
|
||||
// foundConf is the predecessor's configuration, with a real key made once per run: the key is
|
||||
// what the takeover must never print or copy, so it had better be one.
|
||||
var foundConf = func() string {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "[Interface]\nPrivateKey = " + base64.StdEncoding.EncodeToString(k.Bytes()) + "\n" +
|
||||
"ListenPort = 51900\nAddress = 192.0.2.1/24\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n" +
|
||||
"\n[Peer]\nPublicKey = PEER-B=\nAllowedIPs = 192.0.2.3/32\n"
|
||||
}()
|
||||
|
||||
// aTakeover is the private network's declaration for an adopted hub whose interface takes over
|
||||
// the found tunnel: the mesh's configuration — with the found key set from the node's own key file
|
||||
// and the found peers in its list — and the interface's service naming what it replaces.
|
||||
func aTakeover(t *testing.T, config, mesh string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
return adopted(t,
|
||||
`{"taken":[],"untaken":{"mesh-wireguard":["mesh-wireguard.overlay-config","mesh-wireguard.overlay-up"]}}`,
|
||||
`{"id":"mesh-wireguard.overlay-config","type":"file","path":"`+mesh+`","mode":"0600",
|
||||
"content":"[Interface]\nAddress = 192.0.2.1/32\nListenPort = 51900\nPostUp = wg set %i private-key /var/lib/mesh-host/overlay.key\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"},
|
||||
{"id":"mesh-wireguard.overlay-up","type":"service","unit":"wg-quick@mesh0","state":"running","boot":"enabled",
|
||||
"restart-on":["mesh-wireguard.overlay-config"],
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"`+config+`"}}`)
|
||||
}
|
||||
|
||||
// aHubInUse is a machine with the predecessor's tunnel up and the mesh's not yet.
|
||||
func aHubInUse(t *testing.T) (dir, config, mesh string, m *machine) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
config = filepath.Join(dir, "wg0.conf")
|
||||
mesh = filepath.Join(dir, "mesh0.conf")
|
||||
if err := os.WriteFile(config, []byte(foundConf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = &machine{containers: map[string]*fakeContainer{}, units: map[string]*fakeUnit{
|
||||
"wg-quick@wg0": {active: "active", enabled: "enabled"},
|
||||
"wg-quick@mesh0": {active: "inactive", enabled: "disabled", fragment: "/usr/lib/systemd/system/wg-quick@.service"},
|
||||
}}
|
||||
return dir, config, mesh, m
|
||||
}
|
||||
|
||||
func TestTheFoundTunnelIsStoppedNeverFlushedAndItsConfigurationKept(t *testing.T) {
|
||||
dir, config, mesh, m := aHubInUse(t)
|
||||
report, state := applyAdopted(t, aTakeover(t, config, mesh), store.State{}, m, dir)
|
||||
|
||||
// The found interface: its unit stopped and disabled, and nothing else done to it.
|
||||
if u := m.units["wg-quick@wg0"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Fatalf("the found unit was not stopped and disabled: %+v", u)
|
||||
}
|
||||
for _, asked := range m.asked {
|
||||
if strings.HasPrefix(asked, "wg ") && !strings.HasPrefix(asked, "wg show interfaces") {
|
||||
t.Errorf("the found interface was touched with %q; it is stopped, never flushed", asked)
|
||||
}
|
||||
if strings.HasPrefix(asked, "wg-quick") || strings.Contains(asked, "peer remove") {
|
||||
t.Errorf("the found interface was flushed: %q", asked)
|
||||
}
|
||||
}
|
||||
// Its configuration: on disk as it was, its original kept, held for the module.
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatalf("the found configuration was changed:\n%s", got)
|
||||
}
|
||||
held, ok := state.HeldAt("mesh-wireguard.overlay-up.takes-over")
|
||||
if !ok || held.Kind != "file" || held.Target != config || held.Kept == "" || held.Module != "mesh-wireguard" {
|
||||
t.Fatalf("the found configuration is not held: %+v", held)
|
||||
}
|
||||
if kept, _ := os.ReadFile(held.Kept); string(kept) != foundConf {
|
||||
t.Fatalf("the original was not kept as found: %q", kept)
|
||||
}
|
||||
// The mesh's interface: up, enabled, with the found peers in the file the mesh wrote.
|
||||
if u := m.units["wg-quick@mesh0"]; u.active != "active" || u.enabled != "enabled" {
|
||||
t.Fatalf("the mesh's interface was not raised: %+v", u)
|
||||
}
|
||||
if got, _ := os.ReadFile(mesh); !strings.Contains(string(got), "PEER-A=") || strings.Contains(string(got), "PrivateKey") {
|
||||
t.Fatalf("the mesh's configuration does not carry the found peer, or carries a key:\n%s", got)
|
||||
}
|
||||
// And the report says so, with what was found — port, range, peers — and never the key.
|
||||
if report.Tunnel == nil || !report.Tunnel.Taken || report.Tunnel.Port != 51900 ||
|
||||
report.Tunnel.Range != "192.0.2.0/24" || report.Tunnel.Peers != 2 || report.Tunnel.Kept != held.Kept {
|
||||
t.Fatalf("the report does not say what was carried: %+v", report.Tunnel)
|
||||
}
|
||||
private := strings.TrimSpace(strings.SplitN(strings.SplitN(foundConf, "PrivateKey = ", 2)[1], "\n", 2)[0])
|
||||
for _, o := range report.Outcomes {
|
||||
if strings.Contains(o.Detail, private) {
|
||||
t.Errorf("the found key was printed in an outcome: %+v", o)
|
||||
}
|
||||
}
|
||||
if o := outcomeOf(report, "mesh-wireguard.overlay-up.takes-over"); o.Action != "held" ||
|
||||
!strings.Contains(o.Detail, "stopped wg-quick@wg0") || !strings.Contains(o.Detail, "never flushed") {
|
||||
t.Errorf("the takeover was not reported as a hold that stopped the found unit: %+v", o)
|
||||
}
|
||||
if _, recorded := state.Find("mesh-wireguard.overlay-up.takes-over"); recorded {
|
||||
t.Error("the found configuration was recorded as applied, so it would be removed as an orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakeoverIsSteadyAndTheFoundUnitStaysDown(t *testing.T) {
|
||||
dir, config, mesh, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh)
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
m.asked = nil
|
||||
|
||||
report, again := applyAdopted(t, d, state, m, dir)
|
||||
if report.Changed() {
|
||||
t.Errorf("a second apply moved the machine: %+v", report.Outcomes)
|
||||
}
|
||||
if _, still := again.HeldAt("mesh-wireguard.overlay-up.takes-over"); !still {
|
||||
t.Error("the hold on the found configuration was forgotten while the service still declares it")
|
||||
}
|
||||
if m.did("systemctl stop wg-quick@wg0") {
|
||||
t.Error("a found unit already down was stopped again")
|
||||
}
|
||||
|
||||
// Somebody starts the found unit again: it would take the port back, so it is stopped again
|
||||
// — the one thing on an adopted node the mesh undoes, because the tunnel is the mesh's now.
|
||||
m.units["wg-quick@wg0"].active = "active"
|
||||
m.asked = nil
|
||||
_, _ = applyAdopted(t, d, again, m, dir)
|
||||
if m.units["wg-quick@wg0"].active != "inactive" || !m.did("systemctl stop wg-quick@wg0") {
|
||||
t.Error("a found unit started again was left holding the mesh's port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundInterfaceStillUpAfterItsUnitStoppedRefusesTheTakeover(t *testing.T) {
|
||||
dir, config, mesh, m := aHubInUse(t)
|
||||
m.wgUp = "wg0 mesh0\n"
|
||||
_, state, err := ApplyKeeping(t.Context(), archHost(t), aTakeover(t, config, mesh), store.State{},
|
||||
store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
|
||||
if err == nil || !strings.Contains(err.Error(), "still up") || !strings.Contains(err.Error(), "Nothing was flushed") {
|
||||
t.Fatalf("an interface something else raises was taken over anyway: %v", err)
|
||||
}
|
||||
if m.units["wg-quick@mesh0"].active == "active" {
|
||||
t.Error("the mesh's interface was started on a port the found one still holds")
|
||||
}
|
||||
if _, held := state.HeldAt("mesh-wireguard.overlay-up.takes-over"); !held {
|
||||
t.Error("the found configuration was not kept before the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakeoverIsRefusedOnAConvergedDeclaration(t *testing.T) {
|
||||
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "adopted") {
|
||||
t.Fatalf("a takeover on a converged node was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/firewall"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// Step names one stage. A failure says which one, because "the bootstrap failed" is a sentence
|
||||
@@ -201,6 +202,11 @@ type Options struct {
|
||||
// until each module is taken, its firewall stays in force, and the mesh guards its own ports
|
||||
// in a table that only refuses. Without it, a machine in use is refused.
|
||||
Adopted bool
|
||||
// Tunnel names the found tunnel's interface an adopted hub takes over (novox/hq ADR 0105), when
|
||||
// more than one is up and the machine cannot say which. Empty finds the one that is up. Once
|
||||
// found, the tunnel's port is the hub's and its range the private network's; --hub-port and
|
||||
// --overlay-range may agree with it or be left unsaid.
|
||||
Tunnel string
|
||||
}
|
||||
|
||||
// pivots reports whether this run goes past the foundation.
|
||||
@@ -311,6 +317,9 @@ type Result struct {
|
||||
// Filter is the packet filter chosen for when the node converges; an adopted genesis loads
|
||||
// none, and the flip assigns this one.
|
||||
Filter string `json:"filter-on-converge,omitempty"`
|
||||
// Tunnel is the found tunnel an adopted genesis takes over (novox/hq ADR 0105): what was read
|
||||
// from it, never its key.
|
||||
Tunnel *tunnel.Found `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Run performs the bootstrap, saying what it is doing as it goes.
|
||||
@@ -394,6 +403,23 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
}
|
||||
result.Firewall = string(kind)
|
||||
say(" adopted what is on this machine is kept; its firewall (" + string(kind) + ") stays in force")
|
||||
|
||||
// The tunnel the predecessor left, which the private network takes over (novox/hq ADR
|
||||
// 0105): its port is the hub's and its range is the mesh's from here on, so both are
|
||||
// settled before the ports are checked free and the bundle rewritten.
|
||||
found, err := TakeTheTunnel(&o, d.Run)
|
||||
if err != nil {
|
||||
return result, failed(StepPreflight, err)
|
||||
}
|
||||
if found != nil {
|
||||
result.Tunnel = found
|
||||
result.Ports = o.Ports
|
||||
say(fmt.Sprintf(" tunnel %s — the private network takes it over: its port %d is "+
|
||||
"the hub's, its range %s the mesh's, and its %d peer(s) are carried until they enrol",
|
||||
found.Interface, found.Port, found.Range, len(found.Peers)))
|
||||
} else {
|
||||
say(" tunnel none up on this machine; the private network is raised on its own port and range")
|
||||
}
|
||||
}
|
||||
|
||||
sys, err := WorkOutSystem(ctx, d.Run, o.System)
|
||||
|
||||
@@ -112,7 +112,14 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
|
||||
return out, err
|
||||
}
|
||||
joining, cancel := context.WithTimeout(ctx, o.Wait)
|
||||
joined, err := control.run(joining, o.Host, "enrol", "--token", token, "--state", o.State)
|
||||
args := []string{"enrol", "--token", token, "--state", o.State}
|
||||
if o.Tunnel != "" {
|
||||
// The found tunnel's key becomes this node's overlay key, and the tunnel travels with
|
||||
// the enrolment (novox/hq ADR 0105). Named, so the host takes the one genesis settled
|
||||
// its ports and range on and not another that came up since.
|
||||
args = append(args, "--tunnel", o.Tunnel)
|
||||
}
|
||||
joined, err := control.run(joining, o.Host, args...)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf(
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// FoundationPorts are the machine's ports the foundation binds (novox/hq ADR 0100).
|
||||
@@ -321,6 +323,36 @@ func PortsFree(ctx context.Context, run Runner, p FoundationPorts, ours func(rea
|
||||
return nil
|
||||
}
|
||||
|
||||
// TakeTheTunnel finds the tunnel an adopted machine's private network takes over (novox/hq ADR
|
||||
// 0105) and settles the options on it: the hub's port is the tunnel's, the mesh's range is the
|
||||
// tunnel's, and the interface is named for the enrolment that takes its key. Nil when no tunnel is
|
||||
// up, which is an ordinary machine. A --hub-port or --overlay-range that disagrees with the
|
||||
// tunnel is refused: the peers dial the tunnel's port and live in its range, and a mesh raised
|
||||
// beside them on other numbers is the two-tunnel shape the record rejects.
|
||||
func TakeTheTunnel(o *Options, run Runner) (*tunnel.Found, error) {
|
||||
found, err := tunnel.Find(context.Background(), tunnel.Runner(run), o.Tunnel)
|
||||
if errors.Is(err, tunnel.ErrNone) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w. An adopted hub takes over the tunnel it finds; nothing was changed", err)
|
||||
}
|
||||
if o.Ports.Hub != 0 && o.Ports.Hub != DefaultPorts().Hub && o.Ports.Hub != found.Port {
|
||||
return nil, fmt.Errorf("--hub-port %d disagrees with the tunnel %s, which listens on %d: the "+
|
||||
"private network takes over that tunnel on its own port, so leave --hub-port unsaid or "+
|
||||
"say %d", o.Ports.Hub, found.Interface, found.Port, found.Port)
|
||||
}
|
||||
if o.OverlayRange != "" && o.OverlayRange != DefaultOverlayRange && o.OverlayRange != found.Range {
|
||||
return nil, fmt.Errorf("--overlay-range %s disagrees with the tunnel %s, whose range is %s: the "+
|
||||
"private network takes over that tunnel with its range, so leave --overlay-range unsaid "+
|
||||
"or say %s", o.OverlayRange, found.Interface, found.Range, found.Range)
|
||||
}
|
||||
o.Tunnel = found.Interface
|
||||
o.Ports.Hub = found.Port
|
||||
o.OverlayRange = found.Range
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
// OverlayClear refuses a private-network range that overlaps an address or a route the machine
|
||||
// already has — a predecessor's tunnel still running — naming the interface. The mesh's own
|
||||
// interface is not counted.
|
||||
@@ -459,12 +491,26 @@ func CheckTheMachine(ctx context.Context, o Options, run Runner, bundle *declara
|
||||
}
|
||||
return false
|
||||
}
|
||||
if o.Tunnel != "" {
|
||||
// The hub's port is the found tunnel's, held by that tunnel until the mesh's interface
|
||||
// takes it over (novox/hq ADR 0105): held by design, not by something else.
|
||||
inner := ours
|
||||
ours = func(r reachable.Reach) bool {
|
||||
return inner(r) || (r.Protocol == "udp" && r.Port == p.Hub)
|
||||
}
|
||||
}
|
||||
if err := PortsFree(ctx, run, p, ours); err != nil {
|
||||
return err
|
||||
}
|
||||
say(fmt.Sprintf(" ports free store %d, bus %d, amqp %d, management %d, registry %d, packages %d, hub %d/udp",
|
||||
p.Store, p.Bus, p.AMQP, p.Management, p.Registry, p.Packages, p.Hub))
|
||||
if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
|
||||
if o.Tunnel != "" {
|
||||
// One tunnel and one range: the mesh's range IS the found tunnel's, so the rule that the
|
||||
// two must not overlap applies only where a found tunnel is left running beside the mesh's
|
||||
// (ADR 0100, narrowed by ADR 0105).
|
||||
say(fmt.Sprintf(" range %s is the tunnel %s's, taken over; not checked against it",
|
||||
o.OverlayRange, o.Tunnel))
|
||||
} else if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := NamesFree(ctx, run, names, known); err != nil {
|
||||
|
||||
@@ -2,6 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0100: the foundation's ports are the node's — inputs to genesis, checked free,
|
||||
@@ -130,9 +134,9 @@ func TestTwoThingsOnOnePortAreRefused(t *testing.T) {
|
||||
|
||||
// machineRunner answers ss, docker ps, docker inspect and ip from fixtures.
|
||||
type machineRunner struct {
|
||||
ss, ps, addrs, routes string
|
||||
unlabelled map[string]bool
|
||||
labelled map[string]bool
|
||||
ss, ps, addrs, routes, wg string
|
||||
unlabelled map[string]bool
|
||||
labelled map[string]bool
|
||||
}
|
||||
|
||||
func (m machineRunner) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -154,6 +158,8 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
|
||||
return m.addrs, nil
|
||||
case name == "ip" && args[1] == "route":
|
||||
return m.routes, nil
|
||||
case name == "wg":
|
||||
return m.wg, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -295,3 +301,76 @@ func TestARerunOfGenesisFindsItsOwnPackageRegistry(t *testing.T) {
|
||||
t.Error("a container under the package registry's name on a fresh machine was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: an adopted genesis takes over the tunnel it finds — its port is the hub's,
|
||||
// its range the mesh's, and neither is refused for being held by it.
|
||||
func TestAnAdoptedGenesisSettlesOnTheTunnelItFinds(t *testing.T) {
|
||||
private, err := aFoundKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := "[Interface]\nPrivateKey = " + private + "\nListenPort = 51900\nAddress = 192.0.2.1/24\n" +
|
||||
"[Peer]\nPublicKey = PEER=\nAllowedIPs = 192.0.2.2/32\n"
|
||||
tunnel.ReadFile = func(path string) ([]byte, error) {
|
||||
if path == tunnel.ConfigDir+"/wg0.conf" {
|
||||
return []byte(conf), nil
|
||||
}
|
||||
return nil, errors.New("no such file")
|
||||
}
|
||||
t.Cleanup(func() { tunnel.ReadFile = os.ReadFile })
|
||||
m := machineRunner{
|
||||
wg: "wg0\n",
|
||||
ss: "udp UNCONN 0 0 0.0.0.0:51900 0.0.0.0:*\n",
|
||||
addrs: "5: wg0 inet 192.0.2.1/24 scope global wg0\n",
|
||||
routes: "192.0.2.0/24 dev wg0 proto kernel scope link src 192.0.2.1\n",
|
||||
}
|
||||
|
||||
o := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange, State: filepath.Join(t.TempDir(), "state.json")}
|
||||
found, err := TakeTheTunnel(&o, m.run)
|
||||
if err != nil || found == nil {
|
||||
t.Fatalf("the tunnel was not found and taken: %+v %v", found, err)
|
||||
}
|
||||
if o.Tunnel != "wg0" || o.Ports.Hub != 51900 || o.OverlayRange != "192.0.2.0/24" {
|
||||
t.Fatalf("genesis did not settle on the tunnel's port and range: %+v", o)
|
||||
}
|
||||
// Its port is held by the tunnel and its range overlaps the tunnel's — by design, not refused.
|
||||
if err := CheckTheMachine(context.Background(), o, m.run, producedBundle(t).Declaration, func(string) {}); err != nil {
|
||||
t.Fatalf("the machine was refused for the tunnel it takes over: %v", err)
|
||||
}
|
||||
// Whereas the same machine not taking it over is refused on both counts (ADR 0100).
|
||||
plain := o
|
||||
plain.Tunnel = ""
|
||||
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
|
||||
!strings.Contains(err.Error(), "51900") {
|
||||
t.Fatalf("a tunnel not taken over stopped being refused for holding the hub's port: %v", err)
|
||||
}
|
||||
plain.Ports.Hub = 51821
|
||||
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
|
||||
!strings.Contains(err.Error(), "wg0") {
|
||||
t.Fatalf("a tunnel not taken over stopped being refused for overlapping the range: %v", err)
|
||||
}
|
||||
|
||||
// Numbers that disagree with the tunnel are refused, naming the tunnel's.
|
||||
for name, given := range map[string]Options{
|
||||
"--hub-port": {Adopted: true, Ports: FoundationPorts{Hub: 51821}, OverlayRange: DefaultOverlayRange},
|
||||
"--overlay-range": {Adopted: true, Ports: DefaultPorts(), OverlayRange: "10.77.0.0/16"},
|
||||
} {
|
||||
if _, err := TakeTheTunnel(&given, m.run); err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("a %s disagreeing with the tunnel was accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
// And no tunnel up is an ordinary machine.
|
||||
m.wg = "mesh0\n"
|
||||
none := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange}
|
||||
if found, err := TakeTheTunnel(&none, m.run); err != nil || found != nil || none.Ports.Hub != DefaultPorts().Hub {
|
||||
t.Errorf("a machine with no tunnel was not left as it was: %+v %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func aFoundKey() (string, error) {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.Bytes()), nil
|
||||
}
|
||||
|
||||
@@ -105,3 +105,28 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
|
||||
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
|
||||
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
|
||||
adoptedWith := func(service string) error {
|
||||
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
|
||||
return err
|
||||
}
|
||||
good := `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`
|
||||
if err := adoptedWith(good); err != nil {
|
||||
t.Fatalf("a whole takeover on an adopted node was refused: %v", err)
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"its own unit": `{"id":"up","type":"service","unit":"wg-quick@wg0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
|
||||
"no config": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0"}}`,
|
||||
"a stopped service": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"stopped",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
|
||||
} {
|
||||
if err := adoptedWith(bad); err == nil {
|
||||
t.Errorf("a takeover naming %s was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +617,21 @@ type Service struct {
|
||||
// container runtime, whose restart stops every container on the machine (novox/hq ADR 0102).
|
||||
// A change that is also in RestartOn restarts it, which covers a reload.
|
||||
ReloadOn []string `json:"reload-on,omitempty"`
|
||||
|
||||
// TakesOver names the found tunnel this service replaces (novox/hq ADR 0105): before this unit
|
||||
// is started, the named unit is stopped and disabled — never flushed — and its configuration
|
||||
// file is kept like any held file. Only on an adopted node, and only said by the controller,
|
||||
// which knows the found tunnel's key is this node's own: without that, starting this unit on
|
||||
// the found one's port would drop every peer's packets.
|
||||
TakesOver *TakeOver `json:"takes-over,omitempty"`
|
||||
}
|
||||
|
||||
// TakeOver is a found tunnel a service replaces: its interface, the unit that raised it, and its
|
||||
// configuration file.
|
||||
type TakeOver struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit"`
|
||||
Config string `json:"config"`
|
||||
}
|
||||
|
||||
func (s *Service) Identity() string { return s.ID }
|
||||
@@ -637,6 +652,19 @@ func (s *Service) validate(where string, _ bool) []string {
|
||||
"%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+
|
||||
"leave the machine's own setting alone", where, s.Boot))
|
||||
}
|
||||
if t := s.TakesOver; t != nil {
|
||||
switch {
|
||||
case t.Unit == "" || t.Config == "" || t.Interface == "":
|
||||
problems = append(problems, where+": takes-over names the found tunnel's interface, unit "+
|
||||
"and config, and this leaves one out")
|
||||
case t.Unit == s.Unit:
|
||||
problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit",
|
||||
where, t.Unit))
|
||||
case s.State != "running":
|
||||
problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+
|
||||
"the found one for a service that will not run would leave the peers with nothing")
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1167,6 +1195,14 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
"resource %q: an opening is for an adopted node, and this declaration does not "+
|
||||
"say the node is adopted", r.Identity()))
|
||||
}
|
||||
if svc, ok := r.(*Service); ok && svc.TakesOver != nil {
|
||||
// A tunnel is taken over on an adopted node, where what is found is kept: on a
|
||||
// converged one there is nothing found to take over, and stopping a unit the
|
||||
// mesh did not declare would be the host deciding (novox/hq ADR 0105).
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"resource %q: taking over a tunnel is for an adopted node, and this declaration "+
|
||||
"does not say the node is adopted", r.Identity()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,27 @@ func GenerateOverlayKey() (OverlayKey, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OverlayKeyFrom makes this node's overlay key from a private key it did not generate: the found
|
||||
// tunnel's, on an adopted node whose private network takes that tunnel over (novox/hq ADR 0105).
|
||||
// The one case where the mesh takes a credential it did not mint. From here on it is stored and
|
||||
// sealed exactly as a generated one — in the identity file and the key file, readable by root
|
||||
// alone — and the mesh receives only the public half, derived here from the private one so the
|
||||
// two cannot disagree.
|
||||
func OverlayKeyFrom(privateBase64 string) (OverlayKey, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(privateBase64)
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not base64: %w", err)
|
||||
}
|
||||
private, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not a Curve25519 key: %w", err)
|
||||
}
|
||||
return OverlayKey{
|
||||
Public: base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()),
|
||||
Private: base64.StdEncoding.EncodeToString(private.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OverlayKeyPath is where the private half lives: a file of its own, referenced by the interface
|
||||
// configuration rather than embedded in it.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: the found tunnel's private key becomes the node's overlay key, stored as a
|
||||
// generated one is, and the public half the mesh records is derived from it — so the peers that
|
||||
// know the tunnel by that key keep reaching it.
|
||||
func TestAnOverlayKeyTakenFromAFoundTunnelIsTheSameKey(t *testing.T) {
|
||||
generated, err := GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := OverlayKeyFrom(generated.Private)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken.Public != generated.Public || taken.Private != generated.Private {
|
||||
t.Fatalf("a key taken from a private half is not that key: %+v vs %+v", taken, generated)
|
||||
}
|
||||
for _, bad := range []string{"", "not base64!", "c2hvcnQ="} {
|
||||
if _, err := OverlayKeyFrom(bad); err == nil || !strings.Contains(err.Error(), "found tunnel") {
|
||||
t.Errorf("%q was taken as a key: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -52,6 +52,30 @@ type EnrolRequest struct {
|
||||
// holds the private half of PublicKey. The mesh asks for it before letting an enrolment finish
|
||||
// on a token this key already spent (novox/hq issue 083).
|
||||
Proof []byte `json:"proof,omitempty"`
|
||||
|
||||
// Tunnel is the tunnel this node found and whose key it took as its overlay key (novox/hq ADR
|
||||
// 0105): everything about it but that key. Sent with the keys because it is one of them —
|
||||
// OverlayKey above IS this tunnel's public key when this is set — and the mesh composes the
|
||||
// hub's address, the range and the carried peers from it before the first declaration.
|
||||
Tunnel *Tunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Tunnel is a found tunnel as it travels: no private key.
|
||||
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 key, and the address the tunnel routes to it.
|
||||
type TunnelPeer struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// EnrolReply is what the mesh says back.
|
||||
@@ -125,7 +149,7 @@ func answered(reply EnrolReply, asking time.Duration) (again bool, err error) {
|
||||
// the broker who connected.
|
||||
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
|
||||
overlayKey, sealingKey, servingKey string, profile map[string]any, proof []byte,
|
||||
timeout time.Duration) (EnrolReply, error) {
|
||||
tunnel *Tunnel, timeout time.Duration) (EnrolReply, error) {
|
||||
|
||||
config, err := PinnedConfig(pin)
|
||||
if err != nil {
|
||||
@@ -172,7 +196,7 @@ func Enrol(ctx context.Context, address, pin, node, secret string, public []byte
|
||||
|
||||
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public,
|
||||
OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile,
|
||||
Proof: proof}
|
||||
Proof: proof, Tunnel: tunnel}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
|
||||
@@ -100,6 +100,21 @@ type Report struct {
|
||||
// published container port. Only an adopted node reports it; it is what converging the node
|
||||
// previews, so nothing closes without being named first.
|
||||
Reachable []Reach `json:"reachable,omitempty"`
|
||||
|
||||
// Tunnel is what this adopted node says about the tunnel it found and carried (novox/hq ADR
|
||||
// 0105): which 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"`
|
||||
}
|
||||
|
||||
// CarriedTunnel is this node's account of the tunnel it took over.
|
||||
type CarriedTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Port int `json:"port"`
|
||||
Range string `json:"range"`
|
||||
Peers int `json:"peers"`
|
||||
Taken bool `json:"taken"`
|
||||
Kept string `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// Held is one file or container found on an adopted node and kept as it was.
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Package tunnel reads the tunnel a predecessor left on a machine, so the mesh's private network
|
||||
// can take it over in place (novox/hq ADR 0105).
|
||||
//
|
||||
// On an adopted node that is the hub, the mesh's interface is raised with the found interface's
|
||||
// private key, on its port, with its address and range, and every peer it had. The found interface
|
||||
// is stopped, never flushed; its configuration stays on disk. What this package does is the
|
||||
// reading: which interface is there, what its file says, and what of that travels to the mesh —
|
||||
// everything but the private key, which becomes the node's own overlay key and is stored the way
|
||||
// that key is stored.
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Runner executes a command. The same shape as everywhere else in this host.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// MeshInterface is the private network's own interface, which is never the found one.
|
||||
const MeshInterface = "mesh0"
|
||||
|
||||
// ConfigDir is where wg-quick keeps an interface's configuration.
|
||||
const ConfigDir = "/etc/wireguard"
|
||||
|
||||
// Found is a tunnel as found on the machine: everything the mesh is told about it, and the
|
||||
// private key, which it is not.
|
||||
type Found struct {
|
||||
// Interface, Unit and Config are what the mesh's interface takes over.
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit"`
|
||||
Config string `json:"config"`
|
||||
// Port is the port the interface listens on; Address its own address with prefix length;
|
||||
// Range the network that prefix names.
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
// PublicKey is what every peer knows this tunnel by — derived here from the private key, so
|
||||
// it is the key the file actually holds and not a comment beside it.
|
||||
PublicKey string `json:"public_key"`
|
||||
Peers []Peer `json:"peers,omitempty"`
|
||||
|
||||
// privateKey never travels and never prints: not in JSON, not in %v. It is read once, to
|
||||
// become the node's overlay key, and the file it came from is kept as found.
|
||||
privateKey string
|
||||
}
|
||||
|
||||
// Peer is one peer of the found tunnel.
|
||||
type Peer struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
// Address is the one address the tunnel routes to the peer, as the file's AllowedIPs said it
|
||||
// (with or without a /32).
|
||||
Address string `json:"address"`
|
||||
// Endpoint is where the found tunnel dialled the peer, if it did. Not carried to the mesh —
|
||||
// a carried peer dials in, as it always did — but kept so a person reading the report sees
|
||||
// what the file said.
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// PrivateKey is the found interface's private key, base64 as WireGuard writes it. The one
|
||||
// accessor; a caller that has it is taking it as the node's key.
|
||||
func (f Found) PrivateKey() string { return f.privateKey }
|
||||
|
||||
// String is what a found tunnel prints as: never the key.
|
||||
func (f Found) String() string {
|
||||
return fmt.Sprintf("%s on port %d, %s in %s, %d peer(s)", f.Interface, f.Port, f.Address,
|
||||
f.Range, len(f.Peers))
|
||||
}
|
||||
|
||||
// MarshalJSON writes everything but the private key, whatever a caller passes to an encoder.
|
||||
func (f Found) MarshalJSON() ([]byte, error) {
|
||||
type wire Found
|
||||
return json.Marshal(wire(f))
|
||||
}
|
||||
|
||||
// ErrNone is a machine with no tunnel to take over.
|
||||
var ErrNone = errors.New("no tunnel is up on this machine besides the mesh's own")
|
||||
|
||||
// ErrSeveral is a machine with more than one, when nobody said which.
|
||||
var ErrSeveral = errors.New("more than one tunnel is up on this machine")
|
||||
|
||||
// ReadFile is how a configuration is read; a variable so a test can hand in a file.
|
||||
var ReadFile = os.ReadFile
|
||||
|
||||
// Find reads the tunnel to take over: the one named, or the one interface up besides the mesh's
|
||||
// own. Nothing up is ErrNone — an ordinary answer, the machine has no tunnel to adopt — and two
|
||||
// or more with none named is ErrSeveral, naming them, because choosing would be deciding.
|
||||
//
|
||||
// Read from the interface's configuration file rather than from the running interface: the file
|
||||
// is what wg-quick raised and what carries the address, which the kernel does not report per
|
||||
// interface the way the key and peers are. The running interface is consulted only to know the
|
||||
// tunnel is up — a file for an interface nothing runs is not a tunnel the peers are reaching.
|
||||
func Find(ctx context.Context, run Runner, named string) (Found, error) {
|
||||
out, err := run(ctx, "wg", "show", "interfaces")
|
||||
if err != nil {
|
||||
return Found{}, fmt.Errorf("cannot ask which tunnels are up on this machine: %w", err)
|
||||
}
|
||||
var up []string
|
||||
for _, iface := range strings.Fields(out) {
|
||||
if iface != MeshInterface {
|
||||
up = append(up, iface)
|
||||
}
|
||||
}
|
||||
sort.Strings(up)
|
||||
iface := named
|
||||
switch {
|
||||
case named != "":
|
||||
found := false
|
||||
for _, u := range up {
|
||||
if u == named {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Found{}, fmt.Errorf("%s was named as the tunnel to take over and is not up; up: %s",
|
||||
named, orNone(up))
|
||||
}
|
||||
case len(up) == 0:
|
||||
return Found{}, ErrNone
|
||||
case len(up) > 1:
|
||||
return Found{}, fmt.Errorf("%w: %s. Name the one the predecessor's machines reach with --tunnel",
|
||||
ErrSeveral, strings.Join(up, ", "))
|
||||
default:
|
||||
iface = up[0]
|
||||
}
|
||||
|
||||
path := ConfigDir + "/" + iface + ".conf"
|
||||
raw, err := ReadFile(path)
|
||||
if err != nil {
|
||||
return Found{}, fmt.Errorf("%s is up and its configuration cannot be read: %w", iface, err)
|
||||
}
|
||||
found, err := Parse(raw)
|
||||
if err != nil {
|
||||
return Found{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
found.Interface, found.Unit, found.Config = iface, "wg-quick@"+iface, path
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func orNone(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// Parse reads a wg-quick configuration: the interface's key, port and address, and each peer's
|
||||
// key and allowed address. Refused when it lacks what the mesh needs — a key, an address with a
|
||||
// prefix — because a tunnel taken over without them is one the peers cannot reach.
|
||||
func Parse(raw []byte) (Found, error) {
|
||||
var f Found
|
||||
section := ""
|
||||
var peer *Peer
|
||||
closePeer := func() error {
|
||||
if peer == nil {
|
||||
return nil
|
||||
}
|
||||
if peer.PublicKey == "" {
|
||||
return errors.New("a [Peer] section has no PublicKey")
|
||||
}
|
||||
if peer.Address == "" {
|
||||
return fmt.Errorf("the peer %s has no AllowedIPs, so the tunnel routes nothing to it",
|
||||
short(peer.PublicKey))
|
||||
}
|
||||
f.Peers = append(f.Peers, *peer)
|
||||
peer = nil
|
||||
return nil
|
||||
}
|
||||
for n, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if i := strings.IndexAny(line, "#;"); i >= 0 {
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
if err := closePeer(); err != nil {
|
||||
return Found{}, err
|
||||
}
|
||||
section = strings.ToLower(strings.Trim(line, "[]"))
|
||||
if section == "peer" {
|
||||
peer = &Peer{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return Found{}, fmt.Errorf("line %d is not `key = value`", n+1)
|
||||
}
|
||||
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
|
||||
switch section {
|
||||
case "interface":
|
||||
switch key {
|
||||
case "privatekey":
|
||||
f.privateKey = value
|
||||
case "listenport":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return Found{}, fmt.Errorf("ListenPort %q is not a port", value)
|
||||
}
|
||||
f.Port = port
|
||||
case "address":
|
||||
// The first address is the interface's; a second family would be a second
|
||||
// tunnel's worth of addressing, which this does not carry.
|
||||
first := strings.TrimSpace(strings.Split(value, ",")[0])
|
||||
ip, network, err := net.ParseCIDR(first)
|
||||
if err != nil {
|
||||
return Found{}, fmt.Errorf("Address %q is not an address with a prefix length, "+
|
||||
"and the range the mesh takes over is read from the prefix", first)
|
||||
}
|
||||
f.Address = first
|
||||
f.Range = network.String()
|
||||
_ = ip
|
||||
}
|
||||
case "peer":
|
||||
switch key {
|
||||
case "publickey":
|
||||
peer.PublicKey = value
|
||||
case "allowedips":
|
||||
peer.Address = strings.TrimSpace(strings.Split(value, ",")[0])
|
||||
case "endpoint":
|
||||
peer.Endpoint = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := closePeer(); err != nil {
|
||||
return Found{}, err
|
||||
}
|
||||
if f.privateKey == "" {
|
||||
return Found{}, errors.New("no PrivateKey in [Interface]; the mesh takes a tunnel over with its key or not at all")
|
||||
}
|
||||
if f.Address == "" {
|
||||
return Found{}, errors.New("no Address in [Interface], so neither the hub's address nor the range can be read")
|
||||
}
|
||||
if f.Port == 0 {
|
||||
return Found{}, errors.New("no ListenPort in [Interface]: a tunnel with no port is one nothing dials, so there is nothing to take over")
|
||||
}
|
||||
public, err := PublicKeyOf(f.privateKey)
|
||||
if err != nil {
|
||||
return Found{}, err
|
||||
}
|
||||
f.PublicKey = public
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// PublicKeyOf derives the public half of a WireGuard private key, both base64.
|
||||
func PublicKeyOf(privateBase64 string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(privateBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the private key is not base64: %w", err)
|
||||
}
|
||||
private, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the private key is not a Curve25519 key: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()), nil
|
||||
}
|
||||
|
||||
func short(key string) string {
|
||||
if len(key) > 8 {
|
||||
return key[:8] + "…"
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: the host reads the predecessor's tunnel — key, port, address and range, every
|
||||
// peer — and the private key becomes the node's, never printed and never sent.
|
||||
|
||||
// aKey is a real WireGuard keypair, made here so a key that stopped being a key is caught.
|
||||
func aKey(t *testing.T) (private, public string) {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.Bytes()),
|
||||
base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
func aConfig(private string, peers ...string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# the predecessor's hub\n[Interface]\nPrivateKey = %s\nListenPort = 51900\n"+
|
||||
"Address = 192.0.2.1/24\n", private)
|
||||
for i, key := range peers {
|
||||
fmt.Fprintf(&b, "\n[Peer]\nPublicKey = %s\nAllowedIPs = 192.0.2.%d/32\n", key, i+2)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestTheConfigurationIsReadWhole(t *testing.T) {
|
||||
private, public := aKey(t)
|
||||
_, peerA := aKey(t)
|
||||
_, peerB := aKey(t)
|
||||
found, err := Parse([]byte(aConfig(private, peerA, peerB) + "PersistentKeepalive = 25 ; a comment\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.Port != 51900 || found.Address != "192.0.2.1/24" || found.Range != "192.0.2.0/24" {
|
||||
t.Errorf("port, address or range misread: %+v", found)
|
||||
}
|
||||
if found.PublicKey != public {
|
||||
t.Errorf("the public key is not the one derived from the file's private key")
|
||||
}
|
||||
if found.PrivateKey() != private {
|
||||
t.Error("the private key was not read")
|
||||
}
|
||||
if len(found.Peers) != 2 || found.Peers[0].PublicKey != peerA || found.Peers[0].Address != "192.0.2.2/32" ||
|
||||
found.Peers[1].PublicKey != peerB || found.Peers[1].Address != "192.0.2.3/32" {
|
||||
t.Errorf("the peers were misread: %+v", found.Peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePrivateKeyNeverPrintsAndNeverTravels(t *testing.T) {
|
||||
private, _ := aKey(t)
|
||||
found, err := Parse([]byte(aConfig(private)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for what, said := range map[string]string{
|
||||
"JSON": string(raw),
|
||||
"String": found.String(),
|
||||
"%v": fmt.Sprintf("%v", found),
|
||||
"%+v": fmt.Sprintf("%+v", found),
|
||||
"%#v via %v": fmt.Sprintf("%v", []Found{found}),
|
||||
} {
|
||||
if strings.Contains(said, private) {
|
||||
t.Errorf("the private key appears in %s: %s", what, said)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(raw), found.PublicKey) {
|
||||
t.Error("the public key does not travel, so the mesh could not know the tunnel's key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATunnelWithoutWhatTheMeshNeedsIsRefused(t *testing.T) {
|
||||
private, _ := aKey(t)
|
||||
_, peer := aKey(t)
|
||||
for name, conf := range map[string]string{
|
||||
"no key": "[Interface]\nListenPort = 51900\nAddress = 192.0.2.1/24\n",
|
||||
"no address": fmt.Sprintf("[Interface]\nPrivateKey = %s\nListenPort = 51900\n", private),
|
||||
"bare address": fmt.Sprintf("[Interface]\nPrivateKey = %s\nListenPort = 51900\nAddress = 192.0.2.1\n", private),
|
||||
"no port": fmt.Sprintf("[Interface]\nPrivateKey = %s\nAddress = 192.0.2.1/24\n", private),
|
||||
"peer no route": aConfig(private) + "\n[Peer]\nPublicKey = " + peer + "\n",
|
||||
"peer no key": aConfig(private) + "\n[Peer]\nAllowedIPs = 192.0.2.9/32\n",
|
||||
"not a key": "[Interface]\nPrivateKey = not-base64!\nListenPort = 1\nAddress = 192.0.2.1/24\n",
|
||||
} {
|
||||
if _, err := Parse([]byte(conf)); err == nil {
|
||||
t.Errorf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aMachine answers `wg show interfaces` and reads configurations from a map.
|
||||
type aMachine struct {
|
||||
up string
|
||||
files map[string]string
|
||||
asked []string
|
||||
}
|
||||
|
||||
func (m *aMachine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
|
||||
if name == "wg" && len(args) == 2 && args[0] == "show" && args[1] == "interfaces" {
|
||||
return m.up, nil
|
||||
}
|
||||
return "", errors.New("unexpected: " + name)
|
||||
}
|
||||
|
||||
func (m *aMachine) read(path string) ([]byte, error) {
|
||||
if raw, ok := m.files[path]; ok {
|
||||
return []byte(raw), nil
|
||||
}
|
||||
return nil, errors.New("no such file: " + path)
|
||||
}
|
||||
|
||||
func TestTheOneTunnelUpBesidesTheMeshsIsFound(t *testing.T) {
|
||||
private, public := aKey(t)
|
||||
m := &aMachine{up: "mesh0 wg0\n", files: map[string]string{ConfigDir + "/wg0.conf": aConfig(private)}}
|
||||
ReadFile = m.read
|
||||
t.Cleanup(func() { ReadFile = os.ReadFile })
|
||||
|
||||
found, err := Find(context.Background(), m.run, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.Interface != "wg0" || found.Unit != "wg-quick@wg0" || found.Config != ConfigDir+"/wg0.conf" ||
|
||||
found.PublicKey != public {
|
||||
t.Errorf("the wrong tunnel, or misnamed: %+v", found)
|
||||
}
|
||||
for _, asked := range m.asked {
|
||||
if strings.HasPrefix(asked, "wg set") || strings.Contains(asked, "private-key") {
|
||||
t.Errorf("finding a tunnel ran %q; reading is reading", asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoneUpIsAnOrdinaryAnswerAndSeveralIsAQuestion(t *testing.T) {
|
||||
private, _ := aKey(t)
|
||||
m := &aMachine{up: "mesh0\n", files: map[string]string{
|
||||
ConfigDir + "/wg0.conf": aConfig(private), ConfigDir + "/wg1.conf": aConfig(private)}}
|
||||
ReadFile = m.read
|
||||
t.Cleanup(func() { ReadFile = os.ReadFile })
|
||||
|
||||
if _, err := Find(context.Background(), m.run, ""); !errors.Is(err, ErrNone) {
|
||||
t.Errorf("a machine with only the mesh's interface up was not an ordinary none: %v", err)
|
||||
}
|
||||
m.up = "wg1 mesh0 wg0\n"
|
||||
_, err := Find(context.Background(), m.run, "")
|
||||
if !errors.Is(err, ErrSeveral) || !strings.Contains(err.Error(), "wg0, wg1") || strings.Contains(err.Error(), "mesh0") {
|
||||
t.Errorf("two tunnels up were not refused naming both and only them: %v", err)
|
||||
}
|
||||
found, err := Find(context.Background(), m.run, "wg1")
|
||||
if err != nil || found.Interface != "wg1" {
|
||||
t.Errorf("naming one of two did not find it: %+v %v", found, err)
|
||||
}
|
||||
if _, err := Find(context.Background(), m.run, "wg9"); err == nil || !strings.Contains(err.Error(), "wg9") {
|
||||
t.Errorf("naming a tunnel that is not up was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user