Create, rename and remove the devices that dial in
On a tunnel this machine configures — today that means OpenWrt's UCI, which is where the server here lives — the VPN tab can add a device, rename it and take it away. A wg-quick host is reported as unmanaged and shows no buttons, because half-supporting a second configuration format is worse than an honest refusal. Adding generates the keypair and a pre-shared key on the machine, picks the lowest free address in the tunnel's range, and hands back the complete client profile with a QR code for the WireGuard apps. The private key in it is generated for that one call and stored nowhere, so the profile is shown until dismissed and then gone — the same bargain as any password this console makes. Nothing restarts the network, which matters when the interface belongs to the router the house is behind. `uci commit` writes the file and applies nothing; `wg set` changes one peer on the live interface and touches nothing else. The order of the two is chosen so they cannot disagree quietly. Adding writes the configuration first and rolls it back if the live tunnel refuses, because a device that works only after the next reboot is the worst of the three outcomes. Removing does the live tunnel first, since that is the moment access actually ends, and says plainly if the configuration half then fails. Removing asks for the device's name to be typed back, at both ends. It is the one action here that cannot be undone and it sits one tap from a list of devices that all look alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32fdbf169a
commit
8fb21ee95e
@@ -0,0 +1,575 @@
|
||||
package vpn
|
||||
|
||||
// Adding, renaming and removing the devices that dial in.
|
||||
//
|
||||
// Only OpenWrt's UCI is written, because that is where the server here is configured and
|
||||
// because a half-supported second path is worse than an honest refusal. A wg-quick host is
|
||||
// reported as unmanaged and the buttons do not appear.
|
||||
//
|
||||
// Two things happen for every change, in the order that fails safest:
|
||||
//
|
||||
// - Adding: the configuration is written first and committed, then the peer is applied to
|
||||
// the running tunnel. If applying fails the configuration is rolled back, so the two never
|
||||
// disagree silently — a peer that works only after the next reboot is the worst outcome
|
||||
// of the three.
|
||||
// - Removing: the running tunnel first, because that is the moment access actually ends,
|
||||
// then the configuration. If the second half fails the caller is told plainly that the
|
||||
// device is off now but would return on a reboot.
|
||||
//
|
||||
// Nothing here restarts the network. `wg set` changes one peer on a live interface and
|
||||
// touches nothing else, which matters rather a lot when the interface belongs to the router
|
||||
// the house is behind.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NameRe is what a device may be called: a short slug, because it also names the config
|
||||
// section it is stored in.
|
||||
var NameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9 ._-]{0,30}$`)
|
||||
|
||||
// sectionRe guards the identifier that goes into a uci path.
|
||||
var sectionRe = regexp.MustCompile(`^[a-zA-Z0-9_]{1,48}$`)
|
||||
|
||||
const sectionPrefix = "wgpeer_"
|
||||
|
||||
var (
|
||||
ErrUnmanaged = errors.New("this tunnel is not configured through UCI, so Ham Lab will not edit it here")
|
||||
ErrNoRoom = errors.New("no free address is left in this tunnel's range")
|
||||
)
|
||||
|
||||
// NewPeer is what the caller asks for.
|
||||
type NewPeer struct {
|
||||
Iface string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
// Address is the tunnel address to give the device, with or without a prefix. Empty means
|
||||
// the next free one.
|
||||
Address string `json:"address"`
|
||||
// Endpoint is what the device will dial: the house's name or address, and the port.
|
||||
Endpoint string `json:"endpoint"`
|
||||
// DNS is what the device should use while connected. Empty leaves the line out.
|
||||
DNS string `json:"dns"`
|
||||
// Split routes only the home networks through the tunnel instead of everything.
|
||||
Split bool `json:"split"`
|
||||
}
|
||||
|
||||
// Created is a new device, and the one chance to read its configuration.
|
||||
type Created struct {
|
||||
Section string `json:"section"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Address string `json:"address"`
|
||||
// Config is the complete client profile. It holds the private key, which exists nowhere
|
||||
// else: it is generated here, handed over once and never stored.
|
||||
Config string `json:"config"`
|
||||
}
|
||||
|
||||
// uciPeer is one peer section as UCI holds it.
|
||||
type uciPeer struct {
|
||||
Section string
|
||||
Iface string // the wireguard_<iface> type it belongs to
|
||||
Desc string
|
||||
PublicKey string
|
||||
AllowedIPs []string
|
||||
}
|
||||
|
||||
// uciPeers reads every peer section. The type of a peer section names its interface
|
||||
// ("wireguard_wg0"), which is how a peer is tied to a tunnel.
|
||||
func uciPeers(ctx context.Context) ([]uciPeer, error) {
|
||||
if _, err := exec.LookPath("uci"); err != nil {
|
||||
return nil, ErrUnmanaged
|
||||
}
|
||||
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
by := map[string]*uciPeer{}
|
||||
var order []string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
lhs, rhs, ok := strings.Cut(line, "=")
|
||||
if !ok || !strings.HasPrefix(lhs, "network.") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".")
|
||||
v := strings.Trim(rhs, "'")
|
||||
section := parts[0]
|
||||
if len(parts) == 1 { // the section's own type
|
||||
if !strings.HasPrefix(v, "wireguard_") {
|
||||
continue
|
||||
}
|
||||
by[section] = &uciPeer{Section: section, Iface: strings.TrimPrefix(v, "wireguard_")}
|
||||
order = append(order, section)
|
||||
continue
|
||||
}
|
||||
p := by[section]
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
switch parts[1] {
|
||||
case "description":
|
||||
p.Desc = v
|
||||
case "public_key":
|
||||
p.PublicKey = v
|
||||
case "allowed_ips":
|
||||
p.AllowedIPs = strings.Fields(strings.ReplaceAll(v, ",", " "))
|
||||
}
|
||||
}
|
||||
out := make([]uciPeer, 0, len(order))
|
||||
for _, s := range order {
|
||||
out = append(out, *by[s])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Managed reports whether this tunnel's peers can be edited here, and labels each peer with
|
||||
// the section that holds it so the caller has a safe identifier to act on.
|
||||
func Managed(ctx context.Context, t *Tunnel) bool {
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
byKey := map[string]string{}
|
||||
for _, p := range peers {
|
||||
if p.Iface == t.Name {
|
||||
byKey[p.PublicKey] = p.Section
|
||||
found = true
|
||||
}
|
||||
}
|
||||
for i := range t.Peers {
|
||||
t.Peers[i].Section = byKey[t.Peers[i].PublicKey]
|
||||
}
|
||||
// A tunnel with no peers at all is still manageable if UCI is the thing configuring it.
|
||||
if !found {
|
||||
for _, line := range uciInterfaces(ctx) {
|
||||
if line == t.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// uciInterfaces is every interface UCI configures as wireguard.
|
||||
func uciInterfaces(ctx context.Context) []string {
|
||||
var out []string
|
||||
if _, err := exec.LookPath("uci"); err != nil {
|
||||
return out
|
||||
}
|
||||
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
lhs, rhs, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.Trim(rhs, "'") != "wireguard" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".")
|
||||
if len(parts) == 2 && parts[1] == "proto" {
|
||||
out = append(out, parts[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NextAddress is the lowest address in the tunnel's range that nothing is using. The
|
||||
// interface's own address is taken, as is every address any peer is allowed.
|
||||
func NextAddress(t Tunnel) (string, error) {
|
||||
var base netip.Prefix
|
||||
for _, a := range t.Addrs {
|
||||
p, err := netip.ParsePrefix(a)
|
||||
if err != nil || !p.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
base = p
|
||||
break
|
||||
}
|
||||
if !base.IsValid() {
|
||||
return "", errors.New("this tunnel has no IPv4 range to allocate from")
|
||||
}
|
||||
taken := map[netip.Addr]bool{base.Addr(): true}
|
||||
for _, p := range t.Peers {
|
||||
for _, a := range p.AllowedIPs {
|
||||
if ip, err := netip.ParsePrefix(strings.TrimSpace(a)); err == nil {
|
||||
taken[ip.Addr()] = true
|
||||
} else if ip, err := netip.ParseAddr(strings.TrimSpace(a)); err == nil {
|
||||
taken[ip] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ip := base.Masked().Addr().Next() // .0 is the network
|
||||
for base.Contains(ip) {
|
||||
next := ip.Next()
|
||||
if !base.Contains(next) { // the last address in the range is the broadcast
|
||||
break
|
||||
}
|
||||
if !taken[ip] {
|
||||
return ip.String(), nil
|
||||
}
|
||||
ip = next
|
||||
}
|
||||
return "", ErrNoRoom
|
||||
}
|
||||
|
||||
// slug turns a device name into a section name, which may hold rather less.
|
||||
func slug(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ', r == '-', r == '.', r == '_':
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
s := strings.Trim(b.String(), "_")
|
||||
if s == "" {
|
||||
s = "peer"
|
||||
}
|
||||
if len(s) > 24 {
|
||||
s = s[:24]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// genKeys makes a private key, its public half and a pre-shared key. The private key is
|
||||
// returned to be handed over once and is never written anywhere by this daemon.
|
||||
func genKeys(ctx context.Context) (priv, pub, psk string, err error) {
|
||||
run := func(name string, stdin string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
if priv, err = run("wg", "", "genkey"); err != nil {
|
||||
return "", "", "", fmt.Errorf("generating a key: %w", err)
|
||||
}
|
||||
if pub, err = run("wg", priv+"\n", "pubkey"); err != nil {
|
||||
return "", "", "", fmt.Errorf("deriving the public key: %w", err)
|
||||
}
|
||||
if psk, err = run("wg", "", "genpsk"); err != nil {
|
||||
return "", "", "", fmt.Errorf("generating a pre-shared key: %w", err)
|
||||
}
|
||||
return priv, pub, psk, nil
|
||||
}
|
||||
|
||||
// AddPeer creates a device: it writes the peer into the configuration, applies it to the
|
||||
// running tunnel, and returns the client profile once.
|
||||
func AddPeer(ctx context.Context, t Tunnel, n NewPeer) (*Created, error) {
|
||||
if !NameRe.MatchString(strings.TrimSpace(n.Name)) {
|
||||
return nil, errors.New("a device name is 1–31 letters, digits, spaces, dots, dashes or underscores")
|
||||
}
|
||||
name := strings.TrimSpace(n.Name)
|
||||
if strings.TrimSpace(n.Endpoint) == "" {
|
||||
return nil, errors.New("the device needs an endpoint to dial: the house's address or name, and the port")
|
||||
}
|
||||
endpoint, err := checkEndpoint(n.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr := strings.TrimSpace(n.Address)
|
||||
if addr == "" {
|
||||
if addr, err = NextAddress(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
host, err := hostOf(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range t.Peers {
|
||||
for _, a := range p.AllowedIPs {
|
||||
if strings.HasPrefix(strings.TrimSpace(a), host+"/") {
|
||||
return nil, fmt.Errorf("%s is already used by %s", host, firstNonEmpty(p.Name, "another device"))
|
||||
}
|
||||
}
|
||||
}
|
||||
section := sectionPrefix + slug(name)
|
||||
existing, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return nil, ErrUnmanaged
|
||||
}
|
||||
taken := map[string]bool{}
|
||||
for _, p := range existing {
|
||||
taken[p.Section] = true
|
||||
}
|
||||
for i := 2; taken[section]; i++ {
|
||||
section = fmt.Sprintf("%s%s%d", sectionPrefix, slug(name), i)
|
||||
}
|
||||
if !sectionRe.MatchString(section) {
|
||||
return nil, errors.New("that name cannot be turned into a configuration section")
|
||||
}
|
||||
|
||||
priv, pub, psk, err := genKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := host + "/32"
|
||||
|
||||
// the configuration first, so a peer can never be live but unrecorded
|
||||
set := [][]string{
|
||||
{"set", "network." + section + "=wireguard_" + t.Name},
|
||||
{"set", "network." + section + ".description=" + name},
|
||||
{"set", "network." + section + ".public_key=" + pub},
|
||||
{"set", "network." + section + ".preshared_key=" + psk},
|
||||
{"set", "network." + section + ".allowed_ips=" + allowed},
|
||||
}
|
||||
for _, args := range set {
|
||||
if err := uci(ctx, args...); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return nil, fmt.Errorf("writing the configuration: %w", err)
|
||||
}
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return nil, fmt.Errorf("saving the configuration: %w", err)
|
||||
}
|
||||
|
||||
// then the running tunnel; if this fails the configuration goes back as it was
|
||||
if err := wgSet(ctx, t.Name, pub, psk, allowed); err != nil {
|
||||
_ = uci(ctx, "delete", "network."+section)
|
||||
_ = uci(ctx, "commit", "network")
|
||||
return nil, fmt.Errorf("applying it to the running tunnel: %w", err)
|
||||
}
|
||||
|
||||
return &Created{
|
||||
Section: section, Name: name, PublicKey: pub, Address: host,
|
||||
Config: profile(priv, psk, t, host, endpoint, n.DNS, n.Split),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EditPeer changes a device's label or the address it is allowed. The section keeps the name
|
||||
// it was created with; the description is what is shown.
|
||||
func EditPeer(ctx context.Context, t Tunnel, section, name, address string) error {
|
||||
if !sectionRe.MatchString(section) {
|
||||
return errors.New("that is not a device this can edit")
|
||||
}
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return ErrUnmanaged
|
||||
}
|
||||
var me *uciPeer
|
||||
for i := range peers {
|
||||
if peers[i].Section == section && peers[i].Iface == t.Name {
|
||||
me = &peers[i]
|
||||
}
|
||||
}
|
||||
if me == nil {
|
||||
return errors.New("no such device on this tunnel")
|
||||
}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
if !NameRe.MatchString(name) {
|
||||
return errors.New("a device name is 1–31 letters, digits, spaces, dots, dashes or underscores")
|
||||
}
|
||||
if err := uci(ctx, "set", "network."+section+".description="+name); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
}
|
||||
allowed := ""
|
||||
if address = strings.TrimSpace(address); address != "" {
|
||||
host, err := hostOf(address)
|
||||
if err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
for _, p := range t.Peers {
|
||||
if p.Section == section {
|
||||
continue
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
if strings.HasPrefix(strings.TrimSpace(a), host+"/") {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return fmt.Errorf("%s is already used by %s", host, firstNonEmpty(p.Name, "another device"))
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed = host + "/32"
|
||||
if err := uci(ctx, "set", "network."+section+".allowed_ips="+allowed); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
if allowed != "" { // the running tunnel has to be told, or the device keeps the old address
|
||||
if err := wgSet(ctx, t.Name, me.PublicKey, "", allowed); err != nil {
|
||||
return fmt.Errorf("saved, but the running tunnel refused the new address: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePeer removes a device. The running tunnel goes first, because that is the moment the
|
||||
// device actually loses its way in.
|
||||
func DeletePeer(ctx context.Context, t Tunnel, section string) error {
|
||||
if !sectionRe.MatchString(section) {
|
||||
return errors.New("that is not a device this can remove")
|
||||
}
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return ErrUnmanaged
|
||||
}
|
||||
var me *uciPeer
|
||||
for i := range peers {
|
||||
if peers[i].Section == section && peers[i].Iface == t.Name {
|
||||
me = &peers[i]
|
||||
}
|
||||
}
|
||||
if me == nil {
|
||||
return errors.New("no such device on this tunnel")
|
||||
}
|
||||
if err := wgRemove(ctx, t.Name, me.PublicKey); err != nil {
|
||||
return fmt.Errorf("removing it from the running tunnel: %w", err)
|
||||
}
|
||||
if err := uci(ctx, "delete", "network."+section); err != nil {
|
||||
return fmt.Errorf("the device is disconnected, but removing it from the configuration failed, so it would come back on a reboot: %w", err)
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
return fmt.Errorf("the device is disconnected, but saving the configuration failed, so it would come back on a reboot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the small dangerous bits, each in one place ----
|
||||
|
||||
func uci(ctx context.Context, args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "uci", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("uci %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wgSet applies a peer to the running interface. The pre-shared key goes in through a file
|
||||
// because that is the only way wg takes one, so the file is made readable by nobody else and
|
||||
// removed immediately.
|
||||
func wgSet(ctx context.Context, iface, pub, psk, allowed string) error {
|
||||
args := []string{"set", iface, "peer", pub}
|
||||
if psk != "" {
|
||||
f, err := os.CreateTemp("", "hamlab-psk")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.WriteString(psk + "\n"); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
args = append(args, "preshared-key", f.Name())
|
||||
}
|
||||
args = append(args, "allowed-ips", allowed)
|
||||
out, err := exec.CommandContext(ctx, "wg", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wgRemove(ctx context.Context, iface, pub string) error {
|
||||
out, err := exec.CommandContext(ctx, "wg", "set", iface, "peer", pub, "remove").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// profile is the client's own configuration file, which is the only place its private key
|
||||
// ever appears.
|
||||
func profile(priv, psk string, t Tunnel, addr, endpoint, dns string, split bool) string {
|
||||
prefix := 24
|
||||
for _, a := range t.Addrs {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
prefix = p.Bits()
|
||||
break
|
||||
}
|
||||
}
|
||||
allowed := "0.0.0.0/0"
|
||||
if split {
|
||||
nets := []string{}
|
||||
for _, a := range t.Addrs {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
nets = append(nets, p.Masked().String())
|
||||
}
|
||||
}
|
||||
nets = append(nets, "10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12")
|
||||
allowed = strings.Join(dedupe(nets), ", ")
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s/%d\n", priv, addr, prefix)
|
||||
if dns = strings.TrimSpace(dns); dns != "" {
|
||||
fmt.Fprintf(&b, "DNS = %s\n", dns)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Peer]\nPublicKey = %s\n", t.PublicKey)
|
||||
if psk != "" {
|
||||
fmt.Fprintf(&b, "PresharedKey = %s\n", psk)
|
||||
}
|
||||
fmt.Fprintf(&b, "Endpoint = %s\nAllowedIPs = %s\n", endpoint, allowed)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := in[:0:0]
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hostOf takes an address with or without a prefix and returns the bare address.
|
||||
func hostOf(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if p, err := netip.ParsePrefix(s); err == nil {
|
||||
return p.Addr().String(), nil
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q is not an address", s)
|
||||
}
|
||||
return a.String(), nil
|
||||
}
|
||||
|
||||
// checkEndpoint makes sure what the device will dial looks like a host and a port.
|
||||
func checkEndpoint(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
host, port, err := net.SplitHostPort(s)
|
||||
if err != nil || host == "" {
|
||||
return "", fmt.Errorf("%q should be a name or address and a port, like home.example.com:51820", s)
|
||||
}
|
||||
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||
return "", fmt.Errorf("%q is not a port", port)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package vpn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tunnel(addrs []string, peers ...string) Tunnel {
|
||||
t := Tunnel{Name: "wg0", PublicKey: "SERVERKEY=", Addrs: addrs}
|
||||
for _, p := range peers {
|
||||
t.Peers = append(t.Peers, Peer{PublicKey: p + "KEY=", AllowedIPs: []string{p}})
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestNextAddress(t *testing.T) {
|
||||
// .1 is the interface itself and .2 and .3 are taken, so the next free one is .4
|
||||
got, err := NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.3/32", "172.21.46.2/32"))
|
||||
if err != nil || got != "172.21.46.4" {
|
||||
t.Errorf("NextAddress = %q, %v; want 172.21.46.4", got, err)
|
||||
}
|
||||
// a gap is filled before the end is reached
|
||||
got, _ = NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.2/32", "172.21.46.4/32"))
|
||||
if got != "172.21.46.3" {
|
||||
t.Errorf("NextAddress = %q, want the gap at .3", got)
|
||||
}
|
||||
// an empty tunnel starts after the server
|
||||
got, _ = NextAddress(tunnel([]string{"10.9.0.1/24"}))
|
||||
if got != "10.9.0.2" {
|
||||
t.Errorf("NextAddress = %q, want 10.9.0.2", got)
|
||||
}
|
||||
// a full one says so rather than handing out a duplicate
|
||||
full := tunnel([]string{"10.9.0.1/30"}, "10.9.0.2/32")
|
||||
if _, err := NextAddress(full); err != ErrNoRoom {
|
||||
t.Errorf("err = %v, want ErrNoRoom on a full range", err)
|
||||
}
|
||||
// IPv6-only, or nothing at all, is refused rather than guessed
|
||||
if _, err := NextAddress(tunnel([]string{"fc00::1/64"})); err == nil {
|
||||
t.Error("want an error when there is no IPv4 range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"ip12": "ip12",
|
||||
"Mitchell's iPad": "mitchells_ipad",
|
||||
"my-phone": "my_phone",
|
||||
" spaced out ": "spaced__out",
|
||||
"!!!": "peer",
|
||||
strings.Repeat("a", 40): strings.Repeat("a", 24),
|
||||
} {
|
||||
if got := slug(in); got != want {
|
||||
t.Errorf("slug(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile(t *testing.T) {
|
||||
tun := tunnel([]string{"172.21.46.1/24"})
|
||||
got := profile("PRIV=", "PSK=", tun, "172.21.46.4", "home.example.com:51820", "1.1.1.1, 1.0.0.1", false)
|
||||
for _, want := range []string{
|
||||
"PrivateKey = PRIV=",
|
||||
"Address = 172.21.46.4/24", // the interface's own prefix, not a bare /32
|
||||
"DNS = 1.1.1.1, 1.0.0.1",
|
||||
"PublicKey = SERVERKEY=",
|
||||
"PresharedKey = PSK=",
|
||||
"Endpoint = home.example.com:51820",
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("profile is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// no DNS line at all when none was asked for, rather than an empty one
|
||||
if strings.Contains(profile("P=", "", tun, "172.21.46.4", "h:1", "", false), "DNS") {
|
||||
t.Error("an empty DNS should leave the line out")
|
||||
}
|
||||
// split routes the private ranges instead of everything
|
||||
split := profile("P=", "", tun, "172.21.46.4", "h:1", "", true)
|
||||
if strings.Contains(split, "0.0.0.0/0") {
|
||||
t.Errorf("a split tunnel must not route everything:\n%s", split)
|
||||
}
|
||||
if !strings.Contains(split, "172.21.46.0/24") {
|
||||
t.Errorf("a split tunnel should include the tunnel's own range:\n%s", split)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEndpoint(t *testing.T) {
|
||||
for _, ok := range []string{"bigham.dev:51820", "1.2.3.4:51820", "[2001:db8::1]:51820"} {
|
||||
if _, err := checkEndpoint(ok); err != nil {
|
||||
t.Errorf("checkEndpoint(%q) = %v, want it accepted", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "bigham.dev", ":51820", "bigham.dev:0", "bigham.dev:notaport", "bigham.dev:99999"} {
|
||||
if _, err := checkEndpoint(bad); err == nil {
|
||||
t.Errorf("checkEndpoint(%q) was accepted, want refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostOf(t *testing.T) {
|
||||
for in, want := range map[string]string{"172.21.46.4": "172.21.46.4", "172.21.46.4/32": "172.21.46.4", " 10.0.0.9/24 ": "10.0.0.9"} {
|
||||
got, err := hostOf(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("hostOf(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := hostOf("not-an-address"); err == nil {
|
||||
t.Error("hostOf accepted nonsense")
|
||||
}
|
||||
}
|
||||
|
||||
// The name is what becomes a configuration section, so it is the input worth being strict
|
||||
// about; everything refused here is refused before anything is written.
|
||||
func TestNameRe(t *testing.T) {
|
||||
for _, ok := range []string{"ip12", "My Phone", "a", "work-laptop.2"} {
|
||||
if !NameRe.MatchString(ok) {
|
||||
t.Errorf("%q should be a valid name", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", " leading", "semi;colon", "quote'd", "back`tick", "new\nline", "$(whoami)", strings.Repeat("a", 32)} {
|
||||
if NameRe.MatchString(bad) {
|
||||
t.Errorf("%q should be refused as a name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -46,11 +46,17 @@ type Peer struct {
|
||||
Keepalive int `json:"keepalive,omitempty"`
|
||||
// PSK notes that a pre-shared key is in use, without the key itself going anywhere.
|
||||
PSK bool `json:"psk,omitempty"`
|
||||
// Section is the configuration section holding this peer, and the identifier anything that
|
||||
// edits it uses. Empty when the peer is not in a configuration this can edit.
|
||||
Section string `json:"section,omitempty"`
|
||||
}
|
||||
|
||||
// Tunnel is one WireGuard interface.
|
||||
type Tunnel struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
// Managed is whether devices on this tunnel can be added and removed from here, which
|
||||
// today means its peers are configured through OpenWrt's UCI.
|
||||
Managed bool `json:"managed"`
|
||||
PublicKey string `json:"public_key"`
|
||||
// Kind is "server" when peers dial in to this machine, "client" when this machine dials
|
||||
// out through a peer, and "" when it is too new or too odd to tell. It is worked out from
|
||||
@@ -113,6 +119,7 @@ func Look(ctx context.Context) Report {
|
||||
p.Online = p.Handshake != nil && time.Since(*p.Handshake) < onlineWithin
|
||||
}
|
||||
t.Kind, t.Exit = classify(*t)
|
||||
t.Managed = Managed(ctx, t)
|
||||
sort.SliceStable(t.Peers, func(a, b int) bool {
|
||||
pa, pb := t.Peers[a], t.Peers[b]
|
||||
if pa.Online != pb.Online {
|
||||
|
||||
Reference in New Issue
Block a user