Files
Andy Dixon 31e39bdbef Management API, service binary and host probes
TLS-only HTTP API with Argon2id auth, setup token, CSRF, TOTP, login throttling,
management source allow-list; network apply via helper; capture endpoints;
SSE events; crash recovery of interrupted segments; systemd notify/watchdog.
2026-10-09 17:39:24 +01:00

294 lines
9.7 KiB
Go

// Package config holds the appliance configuration and its validation.
// The same validation runs in the unprivileged core and again in the root helper.
package config
import (
"errors"
"fmt"
"net"
"net/netip"
"regexp"
"strings"
)
// Network is everything the root helper needs to render AP, DHCP/DNS and firewall state.
type Network struct {
Management Management `json:"management" yaml:"management"`
Uplink string `json:"uplink" yaml:"uplink"` // interface name, e.g. eth0
AP AP `json:"ap" yaml:"ap"`
LAN LAN `json:"lan" yaml:"lan"`
Forwarding Forwarding `json:"forwarding" yaml:"forwarding"`
}
type Management struct {
Interfaces []string `json:"interfaces" yaml:"interfaces"`
AllowedSubnets []string `json:"allowed_subnets" yaml:"allowed_subnets"`
Port int `json:"port" yaml:"port"`
}
type AP struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Interface string `json:"interface" yaml:"interface"`
SSID string `json:"ssid" yaml:"ssid"`
Passphrase string `json:"passphrase,omitempty" yaml:"passphrase"`
Security string `json:"security" yaml:"security"` // wpa2 | wpa3
Band string `json:"band" yaml:"band"` // bg (2.4 GHz) | a (5 GHz)
Channel int `json:"channel" yaml:"channel"` // 0 = automatic
Hidden bool `json:"hidden" yaml:"hidden"`
Country string `json:"country" yaml:"country"`
ClientIsolation bool `json:"client_isolation" yaml:"client_isolation"`
}
type LAN struct {
Address string `json:"address" yaml:"address"` // gateway CIDR, e.g. 192.168.50.1/24
DHCPStart string `json:"dhcp_start" yaml:"dhcp_start"`
DHCPEnd string `json:"dhcp_end" yaml:"dhcp_end"`
LeaseMinutes int `json:"lease_minutes" yaml:"lease_minutes"`
UpstreamDNS []string `json:"upstream_dns" yaml:"upstream_dns"` // empty = system resolvers
Reservations []Reservation `json:"reservations" yaml:"reservations"`
DNSOverrides []DNSOverride `json:"dns_overrides" yaml:"dns_overrides"`
}
type Reservation struct {
MAC string `json:"mac" yaml:"mac"`
IP string `json:"ip" yaml:"ip"`
Hostname string `json:"hostname,omitempty" yaml:"hostname"`
}
type DNSOverride struct {
Name string `json:"name" yaml:"name"`
IP string `json:"ip" yaml:"ip"`
}
type Forwarding struct {
// Mode: "deny" forwards nothing; "internet" forwards to non-private destinations via the uplink.
Mode string `json:"mode" yaml:"mode"`
// AllowCIDRs are private destinations explicitly opened to AP clients.
AllowCIDRs []string `json:"allow_cidrs" yaml:"allow_cidrs"`
}
func DefaultNetwork() Network {
return Network{
Management: Management{Port: 8443},
AP: AP{Enabled: true, SSID: "PiLens-Lab", Security: "wpa2", Band: "bg", Channel: 0, Country: "GB"},
LAN: LAN{Address: "192.168.50.1/24", DHCPStart: "192.168.50.100", DHCPEnd: "192.168.50.199", LeaseMinutes: 720},
Forwarding: Forwarding{Mode: "internet"},
}
}
var (
ifaceRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,15}$`)
countryRe = regexp.MustCompile(`^[A-Z]{2}$`)
hostnameRe = regexp.MustCompile(`^(?i)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
)
// PrivateRanges are never reachable from AP clients unless explicitly allowed.
var PrivateRanges = []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "100.64.0.0/10", "0.0.0.0/8", "224.0.0.0/4", "240.0.0.0/4"}
func ValidInterface(s string) bool { return ifaceRe.MatchString(s) && s != "." && s != ".." }
// ValidSSID: 1-32 printable ASCII bytes, no backslash/semicolon, no edge spaces (keeps NM keyfile unambiguous).
func ValidSSID(s string) error {
if len(s) < 1 || len(s) > 32 {
return errors.New("SSID must be 1-32 characters")
}
if strings.TrimSpace(s) != s {
return errors.New("SSID must not start or end with a space")
}
for _, r := range s {
if r < 0x20 || r > 0x7e || r == '\\' || r == ';' {
return errors.New("SSID may contain printable ASCII except \\ and ;")
}
}
return nil
}
func ValidPassphrase(s string) error {
if len(s) < 8 || len(s) > 63 {
return errors.New("passphrase must be 8-63 characters")
}
for _, r := range s {
if r < 0x20 || r > 0x7e {
return errors.New("passphrase must be printable ASCII")
}
}
return nil
}
func ValidChannel(band string, ch int) error {
if ch == 0 {
return nil
}
switch band {
case "bg":
if ch >= 1 && ch <= 13 {
return nil
}
case "a":
for _, c := range []int{36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165} {
if c == ch {
return nil
}
}
}
return fmt.Errorf("channel %d is not valid for band %q", ch, band)
}
// Validate checks n for internal consistency. requirePassphrase is false when the
// caller keeps the stored passphrase.
func (n *Network) Validate(requirePassphrase bool) error {
var errs []error
add := func(f string, a ...any) { errs = append(errs, fmt.Errorf(f, a...)) }
if n.Management.Port < 1 || n.Management.Port > 65535 {
add("management.port out of range")
}
if len(n.Management.Interfaces) == 0 {
add("at least one management interface is required")
}
for _, i := range n.Management.Interfaces {
if !ValidInterface(i) {
add("invalid management interface %q", i)
}
}
if len(n.Management.AllowedSubnets) == 0 {
add("at least one management source subnet is required")
}
var mgmtNets []netip.Prefix
for _, s := range n.Management.AllowedSubnets {
p, err := netip.ParsePrefix(s)
if err != nil {
add("invalid management subnet %q", s)
continue
}
mgmtNets = append(mgmtNets, p.Masked())
}
if !ValidInterface(n.Uplink) {
add("invalid uplink interface %q", n.Uplink)
}
a := n.AP
if !ValidInterface(a.Interface) {
add("invalid AP interface %q", a.Interface)
}
if a.Interface == n.Uplink {
add("AP and uplink must be different interfaces")
}
for _, m := range n.Management.Interfaces {
if m == a.Interface {
add("the AP interface cannot be a management interface")
}
}
if err := ValidSSID(a.SSID); err != nil {
add("%v", err)
}
if requirePassphrase || a.Passphrase != "" {
if err := ValidPassphrase(a.Passphrase); err != nil {
add("%v", err)
}
}
if a.Security != "wpa2" && a.Security != "wpa3" {
add("security must be wpa2 or wpa3")
}
if a.Band != "bg" && a.Band != "a" {
add("band must be bg (2.4 GHz) or a (5 GHz)")
}
if err := ValidChannel(a.Band, a.Channel); err != nil {
add("%v", err)
}
if !countryRe.MatchString(a.Country) {
add("country must be a two-letter ISO 3166 code")
}
l := n.LAN
gw, err := netip.ParsePrefix(l.Address)
if err != nil || !gw.Addr().Is4() || gw.Bits() < 16 || gw.Bits() > 30 {
add("lan.address must be an IPv4 CIDR between /16 and /30, e.g. 192.168.50.1/24")
} else {
subnet := gw.Masked()
if gw.Addr() == subnet.Addr() || gw.Addr() == broadcast(subnet) {
add("gateway address cannot be the network or broadcast address")
}
for _, m := range mgmtNets {
if m.Overlaps(subnet) {
add("AP subnet %s overlaps management subnet %s", subnet, m)
}
}
start, e1 := netip.ParseAddr(l.DHCPStart)
end, e2 := netip.ParseAddr(l.DHCPEnd)
switch {
case e1 != nil || e2 != nil:
add("invalid DHCP range")
case !subnet.Contains(start) || !subnet.Contains(end) || end.Less(start):
add("DHCP range must be inside %s and start <= end", subnet)
case !gw.Addr().Less(start) && !end.Less(gw.Addr()):
add("DHCP range must not include the gateway address")
case start == subnet.Addr() || end == broadcast(subnet):
add("DHCP range must not include network or broadcast addresses")
}
seen := map[string]bool{}
for _, r := range l.Reservations {
hw, err := net.ParseMAC(r.MAC)
ip, err2 := netip.ParseAddr(r.IP)
if err != nil || len(hw) != 6 {
add("invalid reservation MAC %q", r.MAC)
} else if seen[hw.String()] {
add("duplicate reservation for %s", hw)
} else {
seen[hw.String()] = true
}
if err2 != nil || !subnet.Contains(ip) || ip == gw.Addr() {
add("reservation IP %q must be inside %s and not the gateway", r.IP, subnet)
}
if r.Hostname != "" && !hostnameRe.MatchString(r.Hostname) {
add("invalid reservation hostname %q", r.Hostname)
}
}
}
if l.LeaseMinutes < 2 || l.LeaseMinutes > 60*24*30 {
add("lease duration must be 2 minutes to 30 days")
}
for _, s := range l.UpstreamDNS {
if _, err := netip.ParseAddr(s); err != nil {
add("invalid upstream DNS server %q", s)
}
}
for _, o := range l.DNSOverrides {
if !hostnameRe.MatchString(o.Name) || len(o.Name) > 253 {
add("invalid DNS override name %q", o.Name)
}
if _, err := netip.ParseAddr(o.IP); err != nil {
add("invalid DNS override address %q", o.IP)
}
}
if n.Forwarding.Mode != "deny" && n.Forwarding.Mode != "internet" {
add("forwarding.mode must be deny or internet")
}
for _, c := range n.Forwarding.AllowCIDRs {
if p, err := netip.ParsePrefix(c); err != nil || !p.Addr().Is4() {
add("invalid IPv4 allow CIDR %q", c)
}
}
return errors.Join(errs...)
}
func broadcast(p netip.Prefix) netip.Addr {
a := p.Masked().Addr().As4()
host := uint32(1)<<(32-p.Bits()) - 1
v := uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3]) | host
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
}
// Subnet returns the AP subnet (masked) for a validated config.
func (l LAN) Subnet() netip.Prefix { return netip.MustParsePrefix(l.Address).Masked() }
// Gateway returns the AP gateway address for a validated config.
func (l LAN) Gateway() netip.Addr { return netip.MustParsePrefix(l.Address).Addr() }
// Redacted returns a copy safe to send to the browser or logs.
func (n Network) Redacted() Network {
n.AP.Passphrase = ""
return n
}