Files
pilens/internal/netgen/netgen.go
T

244 lines
7.4 KiB
Go

// 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("except-interface=lo") // dnsmasq otherwise adds loopback implicitly
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)
}