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