Files
mesh-host/internal/tunnel/tunnel.go
T
jschoubben 8e2a1e762d A found tunnel carries its MTU to the mesh
The host parses MTU from the found [Interface] and reports it, so the
mesh's interface can come up with the same MTU when it takes the tunnel
over. A path tuned to 1380 regresses to the 1420 default otherwise —
invisible to ping, fatal to TLS handshakes and transfers over that path
(novox/hq: the mesh had no MTU concept). Zero when the config named
none, and the mesh writes no MTU line then.
2026-09-26 22:39:54 +02:00

286 lines
9.8 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"`
// MTU is the interface's, when the found config set one. Kept because a tuned tunnel (a path
// that needs 1380, say) breaks silently if the mesh's interface comes up at the 1420 default:
// no ping fails, but TLS handshakes stall and transfers hang (novox/hq: a taken tunnel carries
// its MTU). Zero when the config named none, and the mesh sets no MTU line then.
MTU int `json:"mtu,omitempty"`
// 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 "mtu":
mtu, err := strconv.Atoi(value)
if err != nil || mtu < 576 || mtu > 65535 {
return Found{}, fmt.Errorf("MTU %q is not a plausible MTU", value)
}
f.MTU = mtu
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
}