Core packages: config validation, network renderers, root helper, auth, SQLite store, capture engine

Capture engine streams dumpcap pcapng into verbatim, hashed segments with
per-segment tshark summaries; display filters, PDML dissection with verified
byte ranges, export and import. Tests run in a Debian trixie container.
This commit is contained in:
Andy Dixon
2026-10-09 17:34:32 +01:00
commit 52da3c13f6
26 changed files with 4335 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
// Package netgen renders NetworkManager, dnsmasq and nftables configuration from
// a validated config.Network. Pure functions: no I/O, fully unit-tested.
package netgen
import (
"fmt"
"net"
"strings"
"pilens.dev/pilens/internal/config"
)
const (
ConnName = "pilens-ap"
ConnUUID = "6f1c3a2e-9d4b-4e57-8a51-0c7e1b2d9f30" // stable so reloads replace, not duplicate
TableName = "pilens"
)
// keyfileEscape escapes a GKeyFile string value (NM keyfile format).
func keyfileEscape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
if strings.HasPrefix(s, " ") {
s = `\s` + s[1:]
}
return s
}
// NMKeyfile renders /etc/NetworkManager/system-connections/pilens-ap.nmconnection.
func NMKeyfile(n config.Network) string {
a := n.AP
var b strings.Builder
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
w("# Managed by PiLens. Changes are overwritten; use the PiLens UI.")
w("[connection]")
w("id=%s", ConnName)
w("uuid=%s", ConnUUID)
w("type=wifi")
w("interface-name=%s", a.Interface)
w("autoconnect=%t", a.Enabled)
w("autoconnect-priority=100")
w("")
w("[wifi]")
w("mode=ap")
w("ssid=%s", keyfileEscape(a.SSID))
w("band=%s", a.Band)
if a.Channel != 0 {
w("channel=%d", a.Channel)
}
w("hidden=%t", a.Hidden)
if a.ClientIsolation {
w("ap-isolation=1")
} else {
w("ap-isolation=0")
}
w("")
w("[wifi-security]")
if a.Security == "wpa3" {
w("key-mgmt=sae")
w("pmf=3") // required for SAE
} else {
w("key-mgmt=wpa-psk")
w("pmf=1") // disabled: broadest client compatibility for WPA2-only
}
w("proto=rsn")
w("pairwise=ccmp")
w("group=ccmp")
w("psk=%s", keyfileEscape(a.Passphrase))
w("")
w("[ipv4]")
w("method=manual")
w("address1=%s", n.LAN.Address)
w("never-default=true")
w("")
w("[ipv6]")
w("method=disabled")
return b.String()
}
// Paths used by the rendered dnsmasq config.
const (
LeaseFile = "/var/lib/pilens-helper/dnsmasq.leases"
)
// DnsmasqConf renders the dedicated dnsmasq instance for the AP interface.
func DnsmasqConf(n config.Network) string {
l := n.LAN
sub := l.Subnet()
mask := net.CIDRMask(sub.Bits(), 32)
gw := l.Gateway().String()
var b strings.Builder
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
w("# Managed by PiLens. Changes are overwritten; use the PiLens UI.")
w("interface=%s", n.AP.Interface)
w("bind-dynamic") // only the AP interface; survives the address appearing late
w("no-hosts")
w("domain-needed")
w("bogus-priv")
w("cache-size=1000")
if len(l.UpstreamDNS) == 0 {
w("resolv-file=/etc/resolv.conf")
} else {
w("no-resolv")
for _, s := range l.UpstreamDNS {
w("server=%s", s)
}
}
w("dhcp-authoritative")
w("dhcp-range=%s,%s,%s,%dm", l.DHCPStart, l.DHCPEnd, net.IP(mask).String(), l.LeaseMinutes)
w("dhcp-option=option:router,%s", gw)
w("dhcp-option=option:dns-server,%s", gw)
w("dhcp-leasefile=%s", LeaseFile)
for _, r := range l.Reservations {
hw, _ := net.ParseMAC(r.MAC)
if r.Hostname != "" {
w("dhcp-host=%s,%s,%s", hw, r.IP, r.Hostname)
} else {
w("dhcp-host=%s,%s", hw, r.IP)
}
}
for _, o := range l.DNSOverrides {
w("host-record=%s,%s", strings.ToLower(o.Name), o.IP) // exact name only, unlike address=/x/
}
w("log-queries=extra")
w("log-dhcp")
w("log-facility=-")
return b.String()
}
func q(s string) string { return `"` + s + `"` } // inputs are validated interface names
// Nftables renders the complete inet pilens table. The output begins by
// (re)creating and deleting the table so `nft -f` replaces it atomically while
// leaving every other table untouched.
func Nftables(n config.Network) string {
ap, up := q(n.AP.Interface), q(n.Uplink)
sub := n.LAN.Subnet().String()
var mgmtIf []string
for _, i := range n.Management.Interfaces {
mgmtIf = append(mgmtIf, q(i))
}
var v4, v6 []string
for _, s := range n.Management.AllowedSubnets {
if strings.Contains(s, ":") {
v6 = append(v6, s)
} else {
v4 = append(v4, s)
}
}
blocked := append(append([]string{}, config.PrivateRanges...), v4...)
blocked = append(blocked, "255.255.255.255/32")
var b strings.Builder
w := func(f string, v ...any) { fmt.Fprintf(&b, f+"\n", v...) }
w("# Managed by PiLens. Only this table is ever created or deleted by PiLens.")
w("table inet %s", TableName)
w("delete table inet %s", TableName)
w("table inet %s {", TableName)
set := func(name, typ string, elems []string) {
w("\tset %s {", name)
w("\t\ttype %s; flags interval; auto-merge", typ)
if len(elems) > 0 {
w("\t\telements = { %s }", strings.Join(elems, ", "))
}
w("\t}")
}
set("mgmt_v4", "ipv4_addr", v4)
set("mgmt_v6", "ipv6_addr", v6)
set("blocked_v4", "ipv4_addr", blocked)
set("allow_v4", "ipv4_addr", n.Forwarding.AllowCIDRs)
// Input: decide everything from the AP side; restrict only the management port elsewhere.
w("\tchain input {")
w("\t\ttype filter hook input priority filter - 10; policy accept;")
w("\t\tiifname \"lo\" accept")
w("\t\tiifname %s jump ap_input", ap)
w("\t\ttcp dport %d iifname { %s } ip saddr @mgmt_v4 accept", n.Management.Port, strings.Join(mgmtIf, ", "))
w("\t\ttcp dport %d iifname { %s } ip6 saddr @mgmt_v6 accept", n.Management.Port, strings.Join(mgmtIf, ", "))
w("\t\ttcp dport %d counter drop comment \"management not allowed from here\"", n.Management.Port)
w("\t}")
w("\tchain ap_input {")
w("\t\tmeta nfproto ipv6 counter drop")
w("\t\tct state established,related accept")
w("\t\tct state invalid drop")
w("\t\tudp dport 67 accept")
w("\t\tip daddr %s udp dport 53 accept", n.LAN.Gateway())
w("\t\tip daddr %s tcp dport 53 accept", n.LAN.Gateway())
w("\t\ticmp type { echo-request, destination-unreachable, time-exceeded } accept")
w("\t\tcounter drop comment \"AP clients cannot reach the appliance\"")
w("\t}")
w("\tchain forward {")
w("\t\ttype filter hook forward priority filter - 10; policy accept;")
w("\t\tiifname %s jump ap_forward", ap)
w("\t\toifname %s jump ap_return", ap)
w("\t}")
w("\tchain ap_forward {")
w("\t\tmeta nfproto ipv6 counter drop comment \"IPv6 forwarding not implemented: blocked\"")
w("\t\tct state invalid counter drop")
w("\t\tip saddr != %s counter drop comment \"spoofed source\"", sub)
w("\t\toifname != %s counter drop comment \"only the uplink\"", up)
if n.Forwarding.Mode != "internet" {
w("\t\tcounter drop comment \"forwarding disabled\"")
} else {
w("\t\tip daddr @allow_v4 ct state { new, established, related } accept")
w("\t\tip daddr @blocked_v4 counter drop comment \"private/management destinations\"")
w("\t\tct state { new, established, related } accept")
}
w("\t\tcounter drop")
w("\t}")
w("\tchain ap_return {")
w("\t\tiifname %s ct state established,related accept", up)
w("\t\tcounter drop")
w("\t}")
w("\tchain postrouting {")
w("\t\ttype nat hook postrouting priority srcnat; policy accept;")
w("\t\tip saddr %s oifname %s masquerade", sub, up)
w("\t}")
w("}")
return b.String()
}
// DenyAll renders the fail-closed table used before any configuration exists:
// AP-side traffic is dropped entirely, nothing else is touched.
func DenyAll(apIface string) string {
ap := q(apIface)
return fmt.Sprintf(`# Managed by PiLens: fail-closed placeholder.
table inet %[1]s
delete table inet %[1]s
table inet %[1]s {
chain input {
type filter hook input priority filter - 10; policy accept;
iifname %[2]s counter drop
}
chain forward {
type filter hook forward priority filter - 10; policy accept;
iifname %[2]s counter drop
oifname %[2]s counter drop
}
}
`, TableName, ap)
}
+90
View File
@@ -0,0 +1,90 @@
package netgen
import (
"strings"
"testing"
"pilens.dev/pilens/internal/config"
)
func testNet() config.Network {
n := config.DefaultNetwork()
n.Management.Interfaces = []string{"eth0"}
n.Management.AllowedSubnets = []string{"10.1.0.0/24"}
n.Uplink = "eth0"
n.AP.Interface = "wlan0"
n.AP.Passphrase = `pass\word 123`
n.LAN.Reservations = []config.Reservation{{MAC: "AA:BB:CC:00:11:22", IP: "192.168.50.20", Hostname: "phone"}}
n.LAN.DNSOverrides = []config.DNSOverride{{Name: "Api.Test.Local", IP: "192.168.50.1"}}
return n
}
func TestValidate(t *testing.T) {
n := testNet()
if err := n.Validate(true); err != nil {
t.Fatal(err)
}
bad := []func(*config.Network){
func(n *config.Network) { n.AP.Interface = "eth0" },
func(n *config.Network) { n.AP.SSID = "a;b" },
func(n *config.Network) { n.AP.SSID = "bad\nname" },
func(n *config.Network) { n.AP.Passphrase = "short" },
func(n *config.Network) { n.AP.Channel = 36 },
func(n *config.Network) { n.LAN.DHCPStart = "192.168.50.1" },
func(n *config.Network) { n.LAN.DHCPEnd = "192.168.51.10" },
func(n *config.Network) { n.Management.AllowedSubnets = []string{"192.168.50.0/25"} },
func(n *config.Network) { n.LAN.DNSOverrides[0].Name = "x\nserver=1.2.3.4" },
func(n *config.Network) { n.Uplink = "eth0; rm -rf" },
func(n *config.Network) { n.Forwarding.Mode = "open" },
func(n *config.Network) { n.LAN.Address = "192.168.50.0/24" },
}
for i, f := range bad {
c := testNet()
f(&c)
if c.Validate(true) == nil {
t.Errorf("case %d: expected validation error", i)
}
}
}
func TestKeyfile(t *testing.T) {
k := NMKeyfile(testNet())
for _, want := range []string{"mode=ap", "interface-name=wlan0", `psk=pass\\word 123`, "key-mgmt=wpa-psk", "method=disabled", "address1=192.168.50.1/24", "ssid=PiLens-Lab"} {
if !strings.Contains(k, want) {
t.Errorf("keyfile missing %q", want)
}
}
}
func TestDnsmasq(t *testing.T) {
d := DnsmasqConf(testNet())
for _, want := range []string{"interface=wlan0", "dhcp-range=192.168.50.100,192.168.50.199,255.255.255.0,720m", "dhcp-host=aa:bb:cc:00:11:22,192.168.50.20,phone", "host-record=api.test.local,192.168.50.1", "option:router,192.168.50.1"} {
if !strings.Contains(d, want) {
t.Errorf("dnsmasq missing %q\n%s", want, d)
}
}
}
func TestNftables(t *testing.T) {
s := Nftables(testNet())
for _, want := range []string{
"delete table inet pilens",
`iifname "wlan0" jump ap_forward`,
"meta nfproto ipv6 counter drop",
`ip saddr 192.168.50.0/24 oifname "eth0" masquerade`,
"10.1.0.0/24",
`tcp dport 8443 counter drop`,
} {
if !strings.Contains(s, want) {
t.Errorf("ruleset missing %q", want)
}
}
if strings.Contains(s, "flush ruleset") {
t.Error("must never flush the whole ruleset")
}
n := testNet()
n.Forwarding.Mode = "deny"
if !strings.Contains(Nftables(n), `"forwarding disabled"`) {
t.Error("deny mode must drop forwarded traffic")
}
}