// 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 until the take is proven — a peer // has handshaken with the mesh's interface — and is then retired (novox/hq ADR 0119). 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 } // Handshaken is how many peers of an interface have completed a handshake with it: the proof that // the interface carries the tunnel, rather than merely being up (novox/hq ADR 0119). // // Asked of the running interface, since a handshake is a fact about the kernel's tunnel that no // file records. A question that cannot be asked — no `wg` on the machine, no such interface, a // permission refused — is an error and never a zero: "no peer has handshaken" retires nothing // either, but it is a different thing to tell a person. func Handshaken(ctx context.Context, run Runner, iface string) (int, error) { out, err := run(ctx, "wg", "show", iface, "latest-handshakes") if err != nil { return 0, fmt.Errorf("cannot ask %s which peers have handshaken: %w", iface, err) } return ParseHandshakes(out) } // ParseHandshakes reads `wg show latest-handshakes`: one line per peer, its public key // and the Unix time of its latest handshake, tab-separated — zero for a peer that never has. What // is counted is the peers with a time. A line that is not a key and a time is refused rather than // skipped: output this does not understand is not evidence of anything. func ParseHandshakes(out string) (int, error) { n := 0 for i, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Fields(line) if len(fields) != 2 { return 0, fmt.Errorf("line %d of the handshakes is not a peer and a time: %q", i+1, line) } at, err := strconv.ParseInt(fields[1], 10, 64) if err != nil || at < 0 { return 0, fmt.Errorf("line %d of the handshakes does not end in a time: %q", i+1, line) } if at > 0 { n++ } } return n, 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 }