Files
nixos-router/pkgs/router-webui/nat.go
T
nilsandClaude Sonnet 5.5 a58600823f Add a generic NAT table: port forward, source NAT, masquerade
router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter
rules. Filter rules gain natOnly, and may use wan with log and rate limit
in the web UI. The Firewall page edits the NAT table and can add the
matching allow rule. Settings schema 1 (migrations/1.jq) converts old
forwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 15:54:29 +01:00

242 lines
7.5 KiB
Go

package main
import (
"fmt"
"net/netip"
"regexp"
"slices"
"strconv"
"strings"
)
// NATRule is a row of the address translation table (router.firewall.nat).
type NATRule struct {
Type string // dnat, snat or masquerade
From string // zone, "wan", or empty (any) for snat and masquerade
To string // dnat: the host's zone; snat and masquerade: the zone or wan it leaves through
Protocol string
Ports string // destination ports to match
SourcePorts string
Sources string // IPv4 addresses or CIDRs to match
Destination string // IPv4 address or CIDR the traffic is addressed to; empty: the router itself
ToAddress string // dnat: the host; snat: the source address to use
ToPort string // dnat: the host's port when it differs
Hairpin bool // dnat from wan: LANs reach it through the public address too
Allow bool // form only: also add the filter rule that lets a forward through
Remove bool
}
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
func boolOf(r map[string]any, k string) bool {
b, _ := r[k].(bool)
return b
}
func (s Settings) natRules() []NATRule {
router, _ := s["router"].(map[string]any)
fw, _ := router["firewall"].(map[string]any)
rows, _ := fw["nat"].([]any)
var out []NATRule
for _, v := range rows {
r, _ := v.(map[string]any)
if r == nil {
continue
}
proto := str(r, "protocol")
if proto == "" {
proto = "any"
}
out = append(out, NATRule{Type: str(r, "type"), From: str(r, "from"), To: str(r, "to"), Protocol: proto,
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Sources: strings.Join(stringList(r, "sources"), ", "),
Destination: str(r, "destination"), ToAddress: str(r, "toAddress"), ToPort: str(r, "toPort"), Hairpin: boolOf(r, "hairpin")})
}
return out
}
// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
// is in the zone too (its networks are the peers' routes, not checked here).
func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
lans := map[string]netip.Prefix{}
for _, l := range s.form().LANs {
a, aok := ipv4(l.Address)
if !aok {
continue
}
bits := 24
if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
bits = n
}
lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
}
for _, m := range s.zoneMembers() {
if m.Zone != zone {
continue
}
if m.Kind != "lan" {
return nil, false
}
if p, found := lans[m.Name]; found {
nets = append(nets, p)
}
}
return nets, len(nets) > 0
}
func validV4Net(v string) bool {
if p, err := netip.ParsePrefix(v); err == nil {
return p.Addr().Is4()
}
a, err := netip.ParseAddr(v)
return err == nil && a.Is4()
}
// natSettings checks the rows and returns them as settings, together with the
// filter rules the rows ask for ("also allow").
func (s Settings) natSettings(rows []NATRule, zones []string, errs FormErrors) (nat []any, allows []any) {
seen := map[string]bool{}
for i, r := range rows {
field := func(n string) string { return fmt.Sprintf("nat[%d].%s", i, n) }
r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
r.Sources = strings.Join(splitList(r.Sources), ", ")
r.Destination, r.ToAddress, r.ToPort = strings.TrimSpace(r.Destination), strings.TrimSpace(r.ToAddress), strings.TrimSpace(r.ToPort)
if r.Protocol == "" {
r.Protocol = "any"
}
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" &&
r.Destination == "" && r.ToAddress == "" && r.ToPort == "") {
continue
}
dnat, snat := r.Type == "dnat", r.Type == "snat"
if !dnat && !snat && r.Type != "masquerade" {
errs[field("type")] = "port forward, source NAT or masquerade"
}
inZones := func(z string) bool { return slices.Contains(zones, z) || z == "wan" }
switch {
case dnat && r.From == "":
errs[field("from")] = "where the traffic comes from"
case r.From != "" && !inZones(r.From):
errs[field("from")] = "choose a zone"
}
switch {
case dnat && !slices.Contains(zones, r.To):
errs[field("to")] = "the zone of the host"
case !dnat && !inZones(r.To):
errs[field("to")] = "choose a zone"
case !dnat && r.From == r.To:
errs[field("to")] = "source and destination are the same zone"
}
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
errs[field("protocol")] = "any, tcp, udp or icmp"
}
ports, msg := portList(r.Ports)
if msg != "" {
errs[field("ports")] = msg
}
sports, msg := portList(r.SourcePorts)
if msg != "" {
errs[field("sourceports")] = msg
}
if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
errs[field("ports")] = "ports need protocol tcp or udp"
}
var sources []any
for _, a := range splitList(r.Sources) {
if !validV4Net(a) {
errs[field("sources")] = a + " is not an IPv4 address or CIDR"
break
}
sources = append(sources, a)
}
if r.Destination != "" && !validV4Net(r.Destination) {
errs[field("destination")] = "an IPv4 address or CIDR"
}
switch {
case dnat || snat:
if a, ok := ipv4(r.ToAddress); !ok {
errs[field("toaddress")] = "an IPv4 address"
} else if dnat {
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { return n.Contains(a) }) {
errs[field("toaddress")] = "not inside a network of zone " + r.To
}
}
case r.ToAddress != "":
errs[field("toaddress")] = "masquerade uses the outgoing interface's address"
}
toPort := 0
if r.ToPort != "" {
n, err := strconv.Atoi(r.ToPort)
switch {
case !dnat:
errs[field("toport")] = "only for port forwards"
case err != nil || n < 1 || n > 65535:
errs[field("toport")] = "a port number"
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
errs[field("toport")] = "needs exactly one forwarded port"
}
toPort = n
}
if dnat {
if r.Protocol != "tcp" && r.Protocol != "udp" {
errs[field("protocol")] = "a port forward needs tcp or udp"
} else if len(ports) == 0 {
errs[field("ports")] = "name the ports to forward"
}
}
if r.Hairpin && (!dnat || r.From != "wan") {
errs[field("hairpin")] = "only for port forwards from wan"
}
key := fmt.Sprint(r.Type, r.From, r.To, r.Protocol, r.Ports, r.SourcePorts, r.Sources, r.Destination, r.ToAddress, r.ToPort)
if seen[key] {
errs[field("type")] = "the same rule twice"
}
seen[key] = true
row := map[string]any{"type": r.Type, "to": r.To}
if r.From != "" {
row["from"] = r.From
}
if r.Protocol != "any" {
row["protocol"] = r.Protocol
}
if len(ports) > 0 {
row["ports"] = ports
}
if len(sports) > 0 {
row["sourcePorts"] = sports
}
if len(sources) > 0 {
row["sources"] = sources
}
if r.Destination != "" {
row["destination"] = r.Destination
}
if r.ToAddress != "" && (dnat || snat) {
row["toAddress"] = r.ToAddress
}
if toPort > 0 {
row["toPort"] = toPort
}
if r.Hairpin {
row["hairpin"] = true
}
nat = append(nat, row)
if r.Allow && dnat {
// What lets the forward through: translated connections to the host.
allow := map[string]any{"from": r.From, "to": r.To, "destination": r.ToAddress, "protocol": r.Protocol, "natOnly": true}
switch {
case toPort > 0:
allow["ports"] = []any{toPort}
case len(ports) > 0:
allow["ports"] = ports
}
if len(sources) > 0 {
allow["sources"] = sources
}
allows = append(allows, allow)
}
}
return nat, allows
}