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
This commit is contained in:
2026-10-30 15:54:29 +01:00
co-authored by Claude Sonnet 5.5
parent 3e9c2e4a7a
commit a58600823f
19 changed files with 1018 additions and 505 deletions
+70 -37
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/netip"
"reflect"
"regexp"
"slices"
"strconv"
@@ -22,6 +23,9 @@ type FirewallRule struct {
Destination string // one address or CIDR, empty: the whole zone
Ports string // destination ports
Protocol string
NATOnly bool // only connections that a port forward translated
Log bool // log new connections
RateLimit string // per source address, like 10/minute
Remove bool
}
@@ -36,7 +40,7 @@ type ZoneMember struct {
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
// simpleRuleKeys are the settings keys the page edits.
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources"}
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
// named after itself unless it says otherwise.
@@ -85,9 +89,6 @@ func (s Settings) zones() []string {
}
func ruleSimple(r map[string]any) bool {
if str(r, "from") == "wan" {
return false
}
for k := range r {
if !slices.Contains(simpleRuleKeys, k) {
return false
@@ -123,9 +124,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if r == nil {
continue
}
if wanRuleSimple(r) {
continue
}
if !ruleSimple(r) {
desc := str(r, "from") + " → " + str(r, "to")
if d := str(r, "destination"); d != "" {
@@ -140,9 +138,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if p := portsText(r["ports"]); p != "" {
desc += " " + p
}
if dnat, _ := r["dnat"].(bool); dnat {
desc += " (port forward)"
}
other = append(other, desc)
continue
}
@@ -152,7 +147,8 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
}
simple = append(simple, FirewallRule{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")})
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
}
return simple, other
}
@@ -263,7 +259,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
// applyFirewall changes the zones, then replaces the rules the page edits with
// rows (an untouched "add" row is skipped); the other rules stay as they are.
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error {
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
errs := FormErrors{}
s.applyZones(zf, errs)
zones := s.zones()
@@ -280,7 +276,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
continue
}
if !slices.Contains(zones, r.From) {
if !slices.Contains(zones, r.From) && r.From != "wan" {
errs[field("from")] = "choose a zone"
}
if !slices.Contains(zones, r.To) {
@@ -314,11 +310,35 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Destination != "" && !validAddress(r.Destination) {
errs[field("destination")] = "one address or CIDR, or empty for the whole zone"
}
if r.From == "wan" {
// What the module requires, so that a typo cannot open a whole zone.
if r.Destination == "" {
errs[field("destination")] = "rules from the internet must name the host or network inside"
}
if r.Protocol == "any" {
errs[field("protocol")] = "rules from the internet must name a protocol"
} else if r.Protocol != "icmp" && len(ports) == 0 {
errs[field("ports")] = "rules from the internet must list the ports they open"
}
}
r.RateLimit = strings.TrimSpace(r.RateLimit)
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
}
if seen[r] {
errs[field("from")] = "the same rule twice"
}
seen[r] = true
rule := map[string]any{"from": r.From, "to": r.To}
if r.NATOnly {
rule["natOnly"] = true
}
if r.Log {
rule["log"] = true
}
if r.RateLimit != "" {
rule["rateLimit"] = r.RateLimit
}
if r.Protocol != "any" {
rule["protocol"] = r.Protocol
}
@@ -336,16 +356,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
}
next = append(next, rule)
}
next = append(next, s.forwardRules(fwds, zones, errs)...)
natRows, allows := s.natSettings(nats, zones, errs)
for _, a := range allows {
if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
next = append(next, a)
}
}
router := obj(s, "router")
fw := obj(router, "firewall")
old, _ := fw["rules"].([]any)
for _, v := range old {
r, _ := v.(map[string]any)
if r == nil || ruleSimple(r) || wanRuleSimple(r) {
if r == nil || ruleSimple(r) {
continue
}
// Port forwards and the like are not edited here, but must keep a zone to point at.
// Rules with options not edited here must keep their zones.
if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) {
errs["zone."+f] = "a rule that is not editable here still uses this zone"
}
@@ -362,6 +387,11 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
} else {
fw["rules"] = next
}
if len(natRows) == 0 {
delete(fw, "nat")
} else {
fw["nat"] = natRows
}
if len(fw) == 0 {
delete(router, "firewall")
}
@@ -369,19 +399,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
}
type firewallData struct {
Rows []FirewallRule
Forwards []WANRule
Other []string
Zones []string
Members []ZoneMember
Empty []string // zones without members, which can be removed
New string
Errors FormErrors
Rows []FirewallRule
NATs []NATRule
FromZones []string // the zones and wan
Other []string
Zones []string
Members []ZoneMember
Empty []string // zones without members, which can be removed
New string
Errors FormErrors
}
func (s Settings) firewallData(rows []FirewallRule, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData {
func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
_, other := s.firewallRules()
d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs}
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
d.FromZones = append([]string{"wan"}, d.Zones...)
if d.Members == nil {
d.Members = s.zoneMembers()
}
@@ -403,7 +435,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session)
}
rows, _ := s.firewallRules()
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
p.Data = s.firewallData(append(rows, add), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil)
p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
a.render(w, "firewall", p)
}
@@ -422,27 +454,28 @@ func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) {
zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")})
}
n, _ := strconv.Atoi(r.PostFormValue("rows"))
nf, _ := strconv.Atoi(r.PostFormValue("forwards"))
var fwds []WANRule
for i := range min(nf, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("fwd.%d.%s", i, k)) }
fwds = append(fwds, WANRule{Protocol: v("protocol"), Ports: v("ports"), To: v("to"), Destination: v("destination"),
ToPort: v("toport"), Sources: v("sources"), NAT: v("nat") == "on", Hairpin: v("hairpin") == "on",
Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
nn, _ := strconv.Atoi(r.PostFormValue("nats"))
var nats []NATRule
for i := range min(nn, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
}
var rows []FirewallRule
for i := range min(n, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) }
rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"),
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"), Remove: v("remove") == "on"})
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
}
if err := s.applyFirewall(zf, rows, fwds); err != nil {
if err := s.applyFirewall(zf, rows, nats); err != nil {
var fe FormErrors
errors.As(err, &fe)
// Show what was entered; the zone lists are those before this attempt.
s, _ = loadSettings(a.sys.settingsPath())
p.Error = "Please correct the marked fields."
d := s.firewallData(rows, fwds, zf.Members, fe)
d := s.firewallData(rows, nats, zf.Members, fe)
d.New = zf.New
p.Data = d
w.WriteHeader(http.StatusUnprocessableEntity)