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:
Mitchell-Johnson
2026-09-27 23:20:41 -07:00
co-authored by Claude Opus 5
parent 32fdbf169a
commit 8fb21ee95e
10 changed files with 952 additions and 12 deletions
+3
View File
@@ -282,6 +282,9 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
m.HandleFunc("POST /api/vpn/{iface}/peers", s.auth(s.addVPNPeer))
m.HandleFunc("PATCH /api/vpn/{iface}/peers/{section}", s.auth(s.editVPNPeer))
m.HandleFunc("DELETE /api/vpn/{iface}/peers/{section}", s.auth(s.removeVPNPeer))
m.HandleFunc("GET /api/network/speedtest", s.auth(s.speedtestState))
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
+103 -4
View File
@@ -1,12 +1,20 @@
package api
// The VPN tab: this machine's WireGuard tunnels and who is on them. Read-only, and per
// machine like every other tab — the tunnels shown are the ones on the machine in the
// switcher, which is the only honest way to show them: the inbound server and an outbound
// tunnel are different machines here.
// The VPN tab: this machine's WireGuard tunnels, who is on them, and — where the tunnel is
// one this machine configures — adding, renaming and removing the devices that dial in.
//
// Per machine like every other tab: the tunnels shown and edited are the ones on the machine
// in the switcher, which is the only honest way to show them, since the inbound server and an
// outbound tunnel are different machines here.
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strings"
"hamlab/internal/vpn"
)
@@ -14,3 +22,94 @@ import (
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, vpn.Look(r.Context()))
}
// tunnelOf finds the tunnel a request names, so every editing handler starts from the live
// state rather than from whatever the page last saw.
func (s *Server) tunnelOf(ctx context.Context, w http.ResponseWriter, name string) (vpn.Tunnel, bool) {
for _, t := range vpn.Look(ctx).Tunnels {
if t.Name == name {
if !t.Managed {
jsonErr(w, 400, vpn.ErrUnmanaged.Error())
return t, false
}
return t, true
}
}
jsonErr(w, 404, "no such tunnel on this machine")
return vpn.Tunnel{}, false
}
// addVPNPeer makes a device and hands back its profile. The profile holds a private key that
// is generated for this call and stored nowhere, so it is the only time it can be read — the
// same bargain as any password this console generates.
func (s *Server) addVPNPeer(w http.ResponseWriter, r *http.Request) {
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
if !ok {
return
}
var body vpn.NewPeer
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
made, err := vpn.AddPeer(r.Context(), t, body)
if err != nil {
code := 400
if errors.Is(err, vpn.ErrUnmanaged) {
code = 409
}
jsonErr(w, code, err.Error())
return
}
// The name and address are worth a log line; the profile is emphatically not.
log.Printf("vpn: device %q added to %s as %s (by %s)", made.Name, t.Name, made.Address, clientIP(r))
jsonOK(w, 200, made)
}
func (s *Server) editVPNPeer(w http.ResponseWriter, r *http.Request) {
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
if !ok {
return
}
var body struct{ Name, Address string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
section := r.PathValue("section")
if err := vpn.EditPeer(r.Context(), t, section, body.Name, body.Address); err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("vpn: device %s on %s changed (by %s)", section, t.Name, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) removeVPNPeer(w http.ResponseWriter, r *http.Request) {
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
if !ok {
return
}
section := r.PathValue("section")
var body struct{ Confirm string }
_ = json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body)
// The name has to be typed back. Removing a device is the one action here that cannot be
// undone — the key is gone and the profile on the device stops working — and it is one tap
// away from a list of devices that all look alike.
var want string
for _, p := range t.Peers {
if p.Section == section {
want = p.Name
}
}
if want != "" && !strings.EqualFold(strings.TrimSpace(body.Confirm), want) {
jsonErr(w, 400, "type the device's name to remove it")
return
}
if err := vpn.DeletePeer(r.Context(), t, section); err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("vpn: device %s (%s) removed from %s (by %s)", section, want, t.Name, clientIP(r))
w.WriteHeader(204)
}
+575
View File
@@ -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
}
+126
View File
@@ -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
View File
@@ -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 {