diff --git a/internal/api/server.go b/internal/api/server.go index 7e71ef5..8cf067d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/api/vpn.go b/internal/api/vpn.go index 0ab2472..6cbad86 100644 --- a/internal/api/vpn.go +++ b/internal/api/vpn.go @@ -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) +} diff --git a/internal/vpn/edit.go b/internal/vpn/edit.go new file mode 100644 index 0000000..c1af6c9 --- /dev/null +++ b/internal/vpn/edit.go @@ -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_ 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 +} diff --git a/internal/vpn/edit_test.go b/internal/vpn/edit_test.go new file mode 100644 index 0000000..2ab1926 --- /dev/null +++ b/internal/vpn/edit_test.go @@ -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) + } + } +} diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go index 41c62d8..7af2953 100644 --- a/internal/vpn/vpn.go +++ b/internal/vpn/vpn.go @@ -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 { diff --git a/web/package-lock.json b/web/package-lock.json index a16a578..9b757df 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "qrcode-generator": "^2.0.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1551,6 +1552,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/web/package.json b/web/package.json index 6fa8ed1..fc86e78 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "qrcode-generator": "^2.0.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/web/src/VPN.tsx b/web/src/VPN.tsx index ac7282e..3f58bdd 100644 --- a/web/src/VPN.tsx +++ b/web/src/VPN.tsx @@ -3,10 +3,11 @@ // Per machine, like every other tab, which here is the whole point: the tunnel people dial // into and a tunnel this machine dials out through are different machines, and showing them // together would invite the question "which box is this actually on". -import { useCallback, useEffect, useState } from 'react' -import { api, VPNReport, VPNPeer, VPNTunnel } from './api' +import { FormEvent, useCallback, useEffect, useState } from 'react' +import qrcode from 'qrcode-generator' +import { api, CreatedVPNPeer, VPNReport, VPNPeer, VPNTunnel } from './api' import { fmtBytes } from './fmt' -import { Loading } from './ui' +import { Btn, Loading, askText, toast, useEscape } from './ui' import { useRefreshMs } from './prefs' // WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last @@ -23,7 +24,19 @@ const ago = (iso?: string) => { // A public key is long and nobody reads the middle of it; the ends are what you compare. const shortKey = (k: string) => (k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k) -function Peer({ p, server }: { p: VPNPeer; server: boolean }) { +function Peer({ p, server, iface, editable, onChanged }: { p: VPNPeer; server: boolean; iface: string; editable: boolean; onChanged: () => void }) { + const rename = async () => { + const name = await askText(`Rename ${p.name || 'this device'}`, p.name ?? '') + if (!name || name.trim() === p.name) return + try { await api.editVPNPeer(iface, p.section!, { name: name.trim() }); toast('renamed'); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const remove = async () => { + const typed = await askText(`Remove ${p.name}? Its profile stops working at once and cannot be brought back — the device would need a new one.\n\nType its name to confirm.`, '', 'Remove') + if (typed === null) return + try { await api.removeVPNPeer(iface, p.section!, typed.trim()); toast(`${p.name} removed`); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } return (
@@ -33,6 +46,10 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) { {!p.handshake && never connected} {(p.allowed_ips ?? []).join(', ')} + {editable && p.section && <> + Rename + Remove + }
Last handshake{ago(p.handshake)}
@@ -46,8 +63,10 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) { ) } -function Tunnel({ t }: { t: VPNTunnel }) { +function Tunnel({ t, onChanged }: { t: VPNTunnel; onChanged: () => void }) { const server = t.kind === 'server' + const [adding, setAdding] = useState(false) + const [made, setMade] = useState(null) const peers = t.peers ?? [] const online = peers.filter((p) => p.online).length return ( @@ -57,6 +76,7 @@ function Tunnel({ t }: { t: VPNTunnel }) { {server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'} {server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''} + {t.managed && server && { setMade(null); setAdding(true) }}>+ Add device}
@@ -67,12 +87,92 @@ function Tunnel({ t }: { t: VPNTunnel }) { {t.fwmark &&
Firewall mark{t.fwmark}
}
- {peers.map((p) => )} + {adding && setAdding(false)} onMade={(c) => { setAdding(false); setMade(c); onChanged() }} />} + {made && setMade(null)} />} + {peers.map((p) => )} {peers.length === 0 &&
No peers are configured on this tunnel.
} ) } +// The form for a new device. The endpoint is what the device will dial from outside, so it is +// the one thing Ham Lab cannot work out for itself — remembered per machine once given. +function AddDevice({ t, onClose, onMade }: { t: VPNTunnel; onClose: () => void; onMade: (c: CreatedVPNPeer) => void }) { + const key = `hamlab.wg.endpoint.${t.name}` + const [name, setName] = useState('') + const [endpoint, setEndpoint] = useState(localStorage.getItem(key) ?? '') + const [address, setAddress] = useState('') + const [dns, setDns] = useState('1.1.1.1, 1.0.0.1') + const [split, setSplit] = useState(false) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + useEscape(onClose) + const submit = async (e: FormEvent) => { + e.preventDefault() + setBusy(true); setErr('') + try { + const made = await api.addVPNPeer(t.name, { name: name.trim(), endpoint: endpoint.trim(), address: address.trim(), dns: dns.trim(), split }) + localStorage.setItem(key, endpoint.trim()) + onMade(made) + } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + finally { setBusy(false) } + } + return ( +
+ + + + + + {err &&
{err}
} +
+ + + +
+
+ ) +} + +// The profile, once. Everything a device needs is in it, including a private key that exists +// nowhere else — so it is shown until dismissed, with the QR the WireGuard apps expect. +function Profile({ made, onClose }: { made: CreatedVPNPeer; onClose: () => void }) { + const qr = qrcode(0, 'M') + qr.addData(made.config) + qr.make() + const copy = async () => { + try { await navigator.clipboard.writeText(made.config); toast('profile copied') } + catch { toast('select and copy it by hand', 'err') } + } + const download = () => { + const url = URL.createObjectURL(new Blob([made.config], { type: 'text/plain' })) + const a = document.createElement('a') + a.href = url + a.download = `${made.name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.conf` + a.click() + URL.revokeObjectURL(url) + } + return ( +
+
{made.name}created{made.address}
+
+ {`WireGuard +
+
Scan it in the WireGuard app, or copy the profile into one.
+
{made.config}
+
+
+
+ Copy profile + Download .conf + Shown once. The private key in it is not stored here — if it is lost, make the device again. + + +
+
+ ) +} + export default function VPN() { const [d, setD] = useState(null) const [err, setErr] = useState('') @@ -105,7 +205,7 @@ export default function VPN() { )} - {tunnels.map((t) => )} + {tunnels.map((t) => )} {tunnels.length > 0 && (
Read from the kernel with wg show; names come from the configuration beside it. diff --git a/web/src/api.ts b/web/src/api.ts index f578c78..d15c4ca 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,11 +195,20 @@ export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedR export interface VPNPeer { name?: string; public_key: string; endpoint?: string; allowed_ips: string[] handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean + // The configuration section holding it, and the id anything that edits it uses. Absent when + // the peer is not in a configuration Ham Lab can edit. + section?: string } export interface VPNTunnel { name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string + // Whether devices on this tunnel can be added and removed from here. + managed?: boolean } +export interface NewVPNPeer { name: string; address?: string; endpoint: string; dns?: string; split?: boolean } +// A device just made, and the one chance to read its profile: the private key in it is +// generated for that call and kept nowhere. +export interface CreatedVPNPeer { section: string; name: string; public_key: string; address: string; config: string } export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string } export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean } @@ -317,6 +326,11 @@ export const api = { hardware: (refresh = false) => req('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`), network: () => req('GET', '/api/network'), vpn: () => req('GET', '/api/vpn'), + addVPNPeer: (iface: string, p: NewVPNPeer) => req('POST', `/api/vpn/${encodeURIComponent(iface)}/peers`, p), + editVPNPeer: (iface: string, section: string, p: { name?: string; address?: string }) => + req('PATCH', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, p), + removeVPNPeer: (iface: string, section: string, confirm: string) => + req('DELETE', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, { confirm }), speedtest: () => req('GET', '/api/network/speedtest'), startSpeedtest: () => req('POST', '/api/network/speedtest'), cancelSpeedtest: () => req('POST', '/api/network/speedtest/cancel'), diff --git a/web/src/styles.css b/web/src/styles.css index d0f0005..95ced6b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -422,6 +422,14 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; } .mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); } .mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); } +/* VPN tab: the profile handed over once, with its QR beside the file itself. */ +.vpn-profile .vpn-qr { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; } +.vpn-profile .vpn-qr img { background: #fff; border-radius: 6px; padding: 6px; flex: none; } +.vpn-profile .vpn-conf { margin: 6px 0 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; user-select: all; } +.vpn-peer .sub-head .btn { padding: 2px 8px; } +.addform label.row-check { flex-direction: row; align-items: center; gap: 8px; } +.addform label.row-check input { width: auto; } + /* Network tab: the speed test's four figures, large enough to read at arm's length, and the bar that fills while one is running. */ .net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }