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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user