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.
286 lines
9.8 KiB
Go
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
|
|
}
|