Compare commits
2
Commits
32fdbf169a
...
0e82719070
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e82719070 | ||
|
|
8fb21ee95e |
@@ -648,10 +648,24 @@ what it has moved.
|
|||||||
- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is
|
- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is
|
||||||
silent when idle; there is no connection to be in, and a tab claiming otherwise would be
|
silent when idle; there is no connection to be in, and a tab claiming otherwise would be
|
||||||
lying twice a day.
|
lying twice a day.
|
||||||
- **Read-only, and no secrets.** Private keys are in the second column of the dump and are
|
- **No secrets are read.** Private keys are in the second column of the dump and are read
|
||||||
read past; pre-shared keys are noted as present and never read. Adding or editing peers is
|
past; pre-shared keys are noted as present and never read.
|
||||||
not here: it means generating keys and writing the machine's network configuration, which is
|
- **Devices can be created, renamed and removed** (added 2026-09-27, asked for the same day),
|
||||||
a bigger thing than this tab, and a bad thing to get half right.
|
on a tunnel the machine configures — today OpenWrt's UCI, which is where the server here
|
||||||
|
lives. A wg-quick host is reported unmanaged and shows no buttons: half-supporting a second
|
||||||
|
configuration format is worse than an honest refusal.
|
||||||
|
- **Nothing restarts the network.** `uci commit` writes the file and applies nothing; `wg
|
||||||
|
set` changes one peer on the live interface. Proven on `gate` by the existing peers'
|
||||||
|
transfer counters surviving a create and a delete — a reload would have zeroed them.
|
||||||
|
- **The two halves are ordered 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 ends, and says so plainly if the
|
||||||
|
configuration half then fails.
|
||||||
|
- **The keypair is generated on the machine** and the profile, with its QR, is shown once.
|
||||||
|
The private key is stored nowhere — the same bargain as a guest login's password.
|
||||||
|
- **Removing asks for the name to be typed back**, at both ends. It cannot be undone and it
|
||||||
|
sits one tap from a list of devices that all look alike.
|
||||||
|
|
||||||
## To do
|
## To do
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,9 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||||
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||||
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
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("GET /api/network/speedtest", s.auth(s.speedtestState))
|
||||||
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
|
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
|
||||||
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
|
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
|
||||||
|
|||||||
+103
-4
@@ -1,12 +1,20 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// The VPN tab: this machine's WireGuard tunnels and who is on them. Read-only, and per
|
// The VPN tab: this machine's WireGuard tunnels, who is on them, and — where the tunnel is
|
||||||
// machine like every other tab — the tunnels shown are the ones on the machine in the
|
// one this machine configures — adding, renaming and removing the devices that dial in.
|
||||||
// switcher, which is the only honest way to show them: the inbound server and an outbound
|
//
|
||||||
// tunnel are different machines here.
|
// 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 (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hamlab/internal/vpn"
|
"hamlab/internal/vpn"
|
||||||
)
|
)
|
||||||
@@ -14,3 +22,94 @@ import (
|
|||||||
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonOK(w, 200, vpn.Look(r.Context()))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
Keepalive int `json:"keepalive,omitempty"`
|
||||||
// PSK notes that a pre-shared key is in use, without the key itself going anywhere.
|
// PSK notes that a pre-shared key is in use, without the key itself going anywhere.
|
||||||
PSK bool `json:"psk,omitempty"`
|
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.
|
// Tunnel is one WireGuard interface.
|
||||||
type Tunnel struct {
|
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"`
|
PublicKey string `json:"public_key"`
|
||||||
// Kind is "server" when peers dial in to this machine, "client" when this machine dials
|
// 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
|
// 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
|
p.Online = p.Handshake != nil && time.Since(*p.Handshake) < onlineWithin
|
||||||
}
|
}
|
||||||
t.Kind, t.Exit = classify(*t)
|
t.Kind, t.Exit = classify(*t)
|
||||||
|
t.Managed = Managed(ctx, t)
|
||||||
sort.SliceStable(t.Peers, func(a, b int) bool {
|
sort.SliceStable(t.Peers, func(a, b int) bool {
|
||||||
pa, pb := t.Peers[a], t.Peers[b]
|
pa, pb := t.Peers[a], t.Peers[b]
|
||||||
if pa.Online != pb.Online {
|
if pa.Online != pb.Online {
|
||||||
|
|||||||
Generated
+7
@@ -11,6 +11,7 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -1551,6 +1552,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+107
-7
@@ -3,10 +3,11 @@
|
|||||||
// Per machine, like every other tab, which here is the whole point: the tunnel people dial
|
// 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
|
// 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".
|
// together would invite the question "which box is this actually on".
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { api, VPNReport, VPNPeer, VPNTunnel } from './api'
|
import qrcode from 'qrcode-generator'
|
||||||
|
import { api, CreatedVPNPeer, VPNReport, VPNPeer, VPNTunnel } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { Loading } from './ui'
|
import { Btn, Loading, askText, toast, useEscape } from './ui'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
|
|
||||||
// WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last
|
// 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.
|
// 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)
|
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 (
|
return (
|
||||||
<div className="card iface vpn-peer">
|
<div className="card iface vpn-peer">
|
||||||
<div className="sub-head">
|
<div className="sub-head">
|
||||||
@@ -33,6 +46,10 @@ function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
|
|||||||
{!p.handshake && <span className="badge">never connected</span>}
|
{!p.handshake && <span className="badge">never connected</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted small mono">{(p.allowed_ips ?? []).join(', ')}</span>
|
<span className="muted small mono">{(p.allowed_ips ?? []).join(', ')}</span>
|
||||||
|
{editable && p.section && <>
|
||||||
|
<Btn className="btn" onClick={rename}>Rename</Btn>
|
||||||
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="props">
|
<div className="props">
|
||||||
<div><span className="k">Last handshake</span><span>{ago(p.handshake)}</span></div>
|
<div><span className="k">Last handshake</span><span>{ago(p.handshake)}</span></div>
|
||||||
@@ -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 server = t.kind === 'server'
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [made, setMade] = useState<CreatedVPNPeer | null>(null)
|
||||||
const peers = t.peers ?? []
|
const peers = t.peers ?? []
|
||||||
const online = peers.filter((p) => p.online).length
|
const online = peers.filter((p) => p.online).length
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +76,7 @@ function Tunnel({ t }: { t: VPNTunnel }) {
|
|||||||
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
|
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
|
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
|
||||||
|
{t.managed && server && <Btn className="btn on" onClick={() => { setMade(null); setAdding(true) }}>+ Add device</Btn>}
|
||||||
</div>
|
</div>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="props">
|
<div className="props">
|
||||||
@@ -67,12 +87,92 @@ function Tunnel({ t }: { t: VPNTunnel }) {
|
|||||||
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
|
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
|
{adding && <AddDevice t={t} onClose={() => setAdding(false)} onMade={(c) => { setAdding(false); setMade(c); onChanged() }} />}
|
||||||
|
{made && <Profile made={made} onClose={() => setMade(null)} />}
|
||||||
|
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} iface={t.name} editable={!!t.managed} onChanged={onChanged} />)}
|
||||||
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
|
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<form className="card addform" onSubmit={submit} style={{ marginTop: 8 }}>
|
||||||
|
<label>Device name <input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="phone" spellCheck={false} required /></label>
|
||||||
|
<label>What it dials <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="home.example.com:51820" spellCheck={false} autoCapitalize="off" required /></label>
|
||||||
|
<label>Address in the tunnel <input value={address} onChange={(e) => setAddress(e.target.value)} placeholder="the next free one" spellCheck={false} /></label>
|
||||||
|
<label>DNS while connected <input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="none" spellCheck={false} /></label>
|
||||||
|
<label className="row-check"><input type="checkbox" checked={split} onChange={(e) => setSplit(e.target.checked)} /> Only route the home networks, not everything</label>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button className="btn on" disabled={busy || !name.trim() || !endpoint.trim()}>Create device</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section className="card fresh vpn-profile" style={{ marginTop: 8 }}>
|
||||||
|
<div className="sub-head"><span className="dname">{made.name}</span><span className="badge ok">created</span><span className="spacer" /><span className="muted small mono">{made.address}</span></div>
|
||||||
|
<div className="vpn-qr">
|
||||||
|
<img src={qr.createDataURL(5, 8)} alt={`WireGuard profile for ${made.name}`} width={220} height={220} />
|
||||||
|
<div>
|
||||||
|
<div className="muted small">Scan it in the WireGuard app, or copy the profile into one.</div>
|
||||||
|
<pre className="mono vpn-conf">{made.config}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||||
|
<Btn className="btn on" onClick={copy}>Copy profile</Btn>
|
||||||
|
<Btn className="btn" onClick={download}>Download .conf</Btn>
|
||||||
|
<span className="muted small">Shown once. The private key in it is not stored here — if it is lost, make the device again.</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function VPN() {
|
export default function VPN() {
|
||||||
const [d, setD] = useState<VPNReport | null>(null)
|
const [d, setD] = useState<VPNReport | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
@@ -105,7 +205,7 @@ export default function VPN() {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
|
{tunnels.map((t) => <Tunnel key={t.name} t={t} onChanged={load} />)}
|
||||||
{tunnels.length > 0 && (
|
{tunnels.length > 0 && (
|
||||||
<div className="muted small method" style={{ marginTop: 12 }}>
|
<div className="muted small method" style={{ marginTop: 12 }}>
|
||||||
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
|
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
|
||||||
|
|||||||
@@ -195,11 +195,20 @@ export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedR
|
|||||||
export interface VPNPeer {
|
export interface VPNPeer {
|
||||||
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
|
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
|
||||||
handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean
|
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 {
|
export interface VPNTunnel {
|
||||||
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
||||||
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string
|
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 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 }
|
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<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||||||
network: () => req<NetworkInfo>('GET', '/api/network'),
|
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||||||
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
||||||
|
addVPNPeer: (iface: string, p: NewVPNPeer) => req<CreatedVPNPeer>('POST', `/api/vpn/${encodeURIComponent(iface)}/peers`, p),
|
||||||
|
editVPNPeer: (iface: string, section: string, p: { name?: string; address?: string }) =>
|
||||||
|
req<void>('PATCH', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, p),
|
||||||
|
removeVPNPeer: (iface: string, section: string, confirm: string) =>
|
||||||
|
req<void>('DELETE', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, { confirm }),
|
||||||
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
||||||
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
||||||
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
||||||
|
|||||||
@@ -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 { 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:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
.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
|
/* 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. */
|
bar that fills while one is running. */
|
||||||
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user