On an adopted machine the private network takes the predecessor's tunnel over in place (hq ADR 0105). Genesis finds the one interface up besides the mesh's own, settles the hub's port and the mesh's range on it, and skips ADR 0100's non-overlap check for a range that is now the tunnel's; a --hub-port or --overlay-range that disagrees is refused naming the tunnel's. At enrolment the found interface's private key becomes this node's overlay key — the one credential the mesh takes rather than mints — stored where a generated one is stored, never printed and never sent; the tunnel (port, address, range, peers) travels with the keys so the mesh composes from it before the first declaration. The interface's service may say what it takes over. Before the mesh's unit starts, the found configuration is kept like any held file and the found unit is stopped and disabled; nothing is flushed, and an interface still up after its unit stopped refuses the takeover rather than half-working. The report says what was carried: interface, port, range, peer count, taken or not, and where the original was kept.
275 lines
9.2 KiB
Go
275 lines
9.2 KiB
Go
// 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
|
|
}
|