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)
+80 -56
View File
@@ -14,9 +14,9 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
t.Fatalf("zones = %v", got)
}
// A rule the page does not edit (it logs) must survive.
// A rule the page does not edit (it has a key the page lacks) must survive.
obj(obj(s, "router"), "firewall")["rules"] = []any{
map[string]any{"from": "main", "to": "vpn", "log": true},
map[string]any{"from": "main", "to": "vpn", "extra": true},
}
rows, other := s.firewallRules()
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
@@ -38,7 +38,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
t.Errorf("rows = %+v", rows)
}
out := string(mustMarshal(t, s))
for _, want := range []string{`"log": true`, `"5000-5100"`} {
for _, want := range []string{`"extra": true`, `"5000-5100"`} {
if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out)
}
@@ -235,98 +235,122 @@ func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
return s.applyFirewall(zf, rows, nil)
}
func TestPortForwardsRoundTrip(t *testing.T) {
func TestNATRoundTrip(t *testing.T) {
s := mustParse(t)
obj(obj(s, "router"), "firewall")["rules"] = []any{
map[string]any{"from": "main", "to": "vpn", "log": true, "rateLimit": "5/minute"}, // not editable here, kept
map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp",
obj(obj(s, "router"), "firewall")["nat"] = []any{
map[string]any{"type": "dnat", "from": "wan", "to": "main", "toAddress": "192.168.10.5", "protocol": "tcp",
"ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true},
}
got := s.portForwards()
if len(got) != 1 || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].NAT || !got[0].Hairpin {
t.Fatalf("forwards = %+v", got)
got := s.natRules()
if len(got) != 1 || got[0].Type != "dnat" || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].Hairpin {
t.Fatalf("nat = %+v", got)
}
if _, other := s.firewallRules(); len(other) != 1 || strings.Contains(other[0], "port forward") {
t.Errorf("other = %v (the forward is edited, the zone rule is not)", other)
}
add := []WANRule{
add := []NATRule{
got[0],
{Protocol: "udp", Ports: "51000-51010", To: "main", Destination: "192.168.10.5", NAT: true, Sources: "198.51.100.0/24"},
{Protocol: "tcp", Ports: "443", To: "main", Destination: "fd00::5"}, // an opening, no NAT
{Protocol: "tcp", NAT: true}, // the empty add row
{Type: "dnat", From: "wan", To: "main", Protocol: "udp", Ports: "51000-51010", ToAddress: "192.168.10.5", Sources: "198.51.100.0/24", Allow: true},
{Type: "masquerade", From: "main", To: "wan", Sources: "192.168.10.0/24"},
{Type: "snat", To: "wan", ToAddress: "203.0.113.7", Destination: "10.0.0.0/8"},
{Type: "dnat", Protocol: "tcp"}, // the empty add row
}
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
if got := s.portForwards(); len(got) != 3 || got[1].Ports != "51000-51010" || got[2].NAT || got[2].Destination != "fd00::5" {
t.Fatalf("forwards = %+v", got)
got = s.natRules()
if len(got) != 4 || got[1].Ports != "51000-51010" || got[2].Type != "masquerade" || got[3].ToAddress != "203.0.113.7" {
t.Fatalf("nat = %+v", got)
}
if !strings.Contains(string(mustMarshal(t, s)), `"rateLimit": "5/minute"`) {
t.Error("a rule the editor does not handle was lost")
}
add[0].Remove, add[1].Remove, add[2].Remove = true, true, true
// "Also allow" added a filter rule; saving again does not add it twice.
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
if got := s.portForwards(); len(got) != 0 {
if n := strings.Count(string(mustMarshal(t, s)), `"natOnly": true`); n != 1 {
t.Errorf("%d natOnly rules, want 1:\n%s", n, mustMarshal(t, s))
}
for i := range add {
add[i].Remove = true
}
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
if got := s.natRules(); len(got) != 0 {
t.Errorf("not removed: %+v", got)
}
}
func TestPortForwardsAreChecked(t *testing.T) {
good := WANRule{Protocol: "tcp", Ports: "8080", To: "main", Destination: "192.168.10.5", NAT: true}
for name, mod := range map[string]func(*WANRule){
"to": func(r *WANRule) { r.To = "wan" },
"protocol": func(r *WANRule) { r.Protocol = "any" },
"ports": func(r *WANRule) { r.Ports = "" },
"destination": func(r *WANRule) { r.Destination = "10.9.9.9" }, // outside the LAN
"sources": func(r *WANRule) { r.Sources = "2001:db8::/32" },
"toport": func(r *WANRule) { r.ToPort = "80"; r.Ports = "80, 81" },
"hairpin": func(r *WANRule) { r.NAT = false; r.Hairpin = true },
"ratelimit": func(r *WANRule) { r.RateLimit = "lots" },
func TestNATRowsAreChecked(t *testing.T) {
good := NATRule{Type: "dnat", From: "wan", To: "main", Protocol: "tcp", Ports: "8080", ToAddress: "192.168.10.5"}
for name, mod := range map[string]func(*NATRule){
"type": func(r *NATRule) { r.Type = "bogus" },
"from": func(r *NATRule) { r.From = "" },
"to": func(r *NATRule) { r.To = "nowhere" },
"protocol": func(r *NATRule) { r.Protocol = "any" },
"ports": func(r *NATRule) { r.Ports = "" },
"toaddress": func(r *NATRule) { r.ToAddress = "10.9.9.9" }, // outside the LAN
"sources": func(r *NATRule) { r.Sources = "2001:db8::/32" },
"toport": func(r *NATRule) { r.ToPort = "80"; r.Ports = "80, 81" },
"hairpin": func(r *NATRule) { r.From = "main"; r.To = "main"; r.ToAddress = "192.168.10.5"; r.Hairpin = true },
} {
r := good
mod(&r)
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors)
if !ok || fe["fwd[0]."+name] == "" {
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
if !ok || fe["nat[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, fe)
}
}
r := good
r.Destination = "192.168.10.0/24"
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors); !ok || fe["fwd[0].destination"] == "" {
t.Errorf("a forward to a whole network accepted: %v", fe)
m := NATRule{Type: "masquerade", From: "main", To: "main"}
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].to"] == "" {
t.Errorf("masquerade into the same zone accepted: %v", fe)
}
r.NAT = false // an opening to a network is fine
if err := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}); err != nil {
t.Errorf("opening a network: %v", err)
m = NATRule{Type: "masquerade", From: "main", To: "wan", ToAddress: "192.0.2.1"}
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].toaddress"] == "" {
t.Errorf("masquerade with an address accepted: %v", fe)
}
}
func TestPortForwardPage(t *testing.T) {
func TestWANFilterRule(t *testing.T) {
good := FirewallRule{From: "wan", To: "main", Destination: "192.168.10.5", Protocol: "tcp", Ports: "443", NATOnly: true, Log: true, RateLimit: "10/minute"}
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good}); err != nil {
t.Fatal(err)
}
for name, mod := range map[string]func(*FirewallRule){
"destination": func(r *FirewallRule) { r.Destination = "" },
"protocol": func(r *FirewallRule) { r.Protocol = "any" },
"ports": func(r *FirewallRule) { r.Ports = "" },
"ratelimit": func(r *FirewallRule) { r.RateLimit = "lots" },
} {
r := good
mod(&r)
fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors)
if !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, fe)
}
}
}
func TestNATPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/firewall", nil, cookie).Body.String()
if !strings.Contains(body, `name="forwards" value="1"`) || !strings.Contains(body, "fwd.0.nat") {
t.Fatalf("no row to add a forward:\n%s", body)
if !strings.Contains(body, `name="nats" value="1"`) || !strings.Contains(body, "nat.0.type") {
t.Fatalf("no row to add a NAT rule:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "forwards": {"1"}, "fwd.0.protocol": {"tcp"}, "fwd.0.ports": {"8080"},
"fwd.0.to": {"main"}, "fwd.0.destination": {"192.168.10.5"}, "fwd.0.nat": {"on"}, "fwd.0.toport": {"80"}, "fwd.0.hairpin": {"on"}}
form.Set("fwd.0.destination", "192.168.10.0/24")
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "one IPv4 address") {
t.Fatalf("a forward to a network: %d\n%s", rec.Code, rec.Body)
form := url.Values{"csrf": {token}, "nats": {"1"}, "nat.0.type": {"dnat"}, "nat.0.from": {"wan"}, "nat.0.protocol": {"tcp"},
"nat.0.ports": {"8080"}, "nat.0.to": {"main"}, "nat.0.toaddress": {"10.9.9.9"}, "nat.0.toport": {"80"},
"nat.0.hairpin": {"on"}, "nat.0.allow": {"on"}}
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not inside a network") {
t.Fatalf("a forward outside the LAN: %d\n%s", rec.Code, rec.Body)
}
form.Set("fwd.0.destination", "192.168.10.5")
form.Set("nat.0.toaddress", "192.168.10.5")
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if f := s.portForwards(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
if f := s.natRules(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
t.Fatalf("not saved: %+v", f)
}
if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) {
t.Errorf("the forward is not listed:\n%s", body)
if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) || !strings.Contains(body, `name="rule.0.natonly" checked`) {
t.Errorf("the forward or its allow rule is not listed:\n%s", body)
}
}
-226
View File
@@ -1,226 +0,0 @@
package main
import (
"fmt"
"net/netip"
"regexp"
"slices"
"strconv"
"strings"
)
// WANRule is a rule from the internet: a port forward (NAT, IPv4) or an
// opening to an address inside (IPv4 or IPv6, no translation).
type WANRule struct {
Protocol string // tcp, udp or icmp
Ports string // on the router's public address (NAT) or on the host
To string // zone
Destination string // the host; for a forward a single IPv4 address
ToPort string // the host's port when it differs (forwards, one port)
Sources string // only from these addresses, empty: anywhere
NAT bool
Hairpin bool
Log bool
RateLimit string // "10/minute" per source address
Remove bool
}
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
// wanRuleKeys are the settings keys the port forward editor handles.
var wanRuleKeys = []string{"from", "to", "protocol", "ports", "destination", "toPort", "sources", "dnat", "hairpin", "rateLimit", "log"}
func wanRuleSimple(r map[string]any) bool {
if str(r, "from") != "wan" {
return false
}
for k := range r {
if !slices.Contains(wanRuleKeys, k) {
return false
}
}
return true
}
func boolOf(r map[string]any, k string) bool {
b, _ := r[k].(bool)
return b
}
// portForwards are the rules from the internet, in order.
func (s Settings) portForwards() []WANRule {
router, _ := s["router"].(map[string]any)
fw, _ := router["firewall"].(map[string]any)
rules, _ := fw["rules"].([]any)
var out []WANRule
for _, v := range rules {
r, _ := v.(map[string]any)
if r == nil || !wanRuleSimple(r) {
continue
}
out = append(out, WANRule{Protocol: str(r, "protocol"), Ports: portsText(r["ports"]), To: str(r, "to"),
Destination: str(r, "destination"), ToPort: str(r, "toPort"), Sources: strings.Join(stringList(r, "sources"), ", "),
NAT: boolOf(r, "dnat"), Hairpin: boolOf(r, "hairpin"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
}
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
}
// forwardRules checks the rows and returns them as settings rules.
func (s Settings) forwardRules(rows []WANRule, zones []string, errs FormErrors) []any {
var out []any
seen := map[WANRule]bool{}
for i, r := range rows {
field := func(n string) string { return fmt.Sprintf("fwd[%d].%s", i, n) }
r.Ports, r.Sources = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.Sources), ", ")
r.To, r.Destination = strings.TrimSpace(r.To), strings.TrimSpace(r.Destination)
r.ToPort, r.RateLimit = strings.TrimSpace(r.ToPort), strings.TrimSpace(r.RateLimit)
if r.Remove || (r.Ports == "" && r.To == "" && r.Destination == "" && r.ToPort == "" && r.Sources == "" && r.RateLimit == "") {
continue
}
if !slices.Contains(zones, r.To) {
errs[field("to")] = "choose a zone"
}
if !slices.Contains([]string{"tcp", "udp", "icmp"}, r.Protocol) {
errs[field("protocol")] = "tcp, udp or icmp"
}
var ports []any
if r.Protocol == "icmp" {
if r.Ports != "" {
errs[field("ports")] = "icmp has no ports"
}
} else {
var msg string
if ports, msg = portList(r.Ports); msg != "" {
errs[field("ports")] = msg
} else if len(ports) == 0 {
errs[field("ports")] = "name the ports to open"
}
}
dest, destOK := netip.Prefix{}, false
switch {
case r.Destination == "":
errs[field("destination")] = "the host or network inside"
case !validAddress(r.Destination):
errs[field("destination")] = "an address (or CIDR)"
default:
if p, err := netip.ParsePrefix(r.Destination); err == nil {
dest = p
if p.Masked() != p {
errs[field("destination")] = "has host bits set"
}
} else {
a, _ := netip.ParseAddr(r.Destination)
dest = netip.PrefixFrom(a, a.BitLen())
}
destOK = errs[field("destination")] == ""
}
if destOK && dest.Addr().Is4() {
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool {
return n.Bits() <= dest.Bits() && n.Contains(dest.Addr())
}) {
errs[field("destination")] = "not inside a network of zone " + r.To
}
}
var sources []any
for _, a := range splitList(r.Sources) {
switch {
case !validAddress(a):
errs[field("sources")] = a + " is not an address or CIDR"
case r.NAT && strings.Contains(a, ":"):
errs[field("sources")] = "port forwards are IPv4 only"
}
sources = append(sources, a)
}
if r.NAT {
if r.Protocol == "icmp" {
errs[field("protocol")] = "a port forward needs tcp or udp"
}
if destOK && (!dest.Addr().Is4() || dest.Bits() != 32) {
errs[field("destination")] = "a port forward goes to one IPv4 address"
}
}
toPort := 0
if r.ToPort != "" {
n, err := strconv.Atoi(r.ToPort)
switch {
case err != nil || n < 1 || n > 65535:
errs[field("toport")] = "a port number"
case !r.NAT:
errs[field("toport")] = "only for port forwards"
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
errs[field("toport")] = "needs exactly one public port"
}
toPort = n
}
if r.Hairpin && !r.NAT {
errs[field("hairpin")] = "only for port forwards"
}
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
}
if seen[r] {
errs[field("to")] = "the same rule twice"
}
seen[r] = true
rule := map[string]any{"from": "wan", "to": r.To, "protocol": r.Protocol}
if len(ports) > 0 {
rule["ports"] = ports
}
if destOK {
if r.NAT || dest.Bits() == dest.Addr().BitLen() {
rule["destination"] = dest.Addr().String()
} else {
rule["destination"] = dest.String()
}
}
if len(sources) > 0 {
rule["sources"] = sources
}
if r.NAT {
rule["dnat"] = true
}
if toPort > 0 {
rule["toPort"] = toPort
}
if r.Hairpin {
rule["hairpin"] = true
}
if r.Log {
rule["log"] = true
}
if r.RateLimit != "" {
rule["rateLimit"] = r.RateLimit
}
out = append(out, rule)
}
return out
}
+241
View File
@@ -0,0 +1,241 @@
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
}
+40 -28
View File
@@ -40,11 +40,11 @@
<input type="hidden" name="rows" value="{{len .Rows}}">
<div class="scroll">
<table>
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th>Remove</th></tr>
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th title="only connections that a port forward translated">Forwarded only</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
{{range $i, $r := .Rows}}
<tr>
<td><select name="rule.{{$i}}.from"><option value=""></option>
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
@@ -56,50 +56,62 @@
<td><select name="rule.{{$i}}.protocol">
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
</select></td>
<td><input type="checkbox" name="rule.{{$i}}.natonly" {{if $r.NATOnly}}checked{{end}}></td>
<td><input type="checkbox" name="rule.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
<td><input name="rule.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR;
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.</p>
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
port forward translated. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
</fieldset>
<fieldset>
<legend>From the internet</legend>
<input type="hidden" name="forwards" value="{{len .Forwards}}">
<legend>Address translation (NAT)</legend>
<input type="hidden" name="nats" value="{{len .NATs}}">
<div class="scroll">
<table>
<tr><th>Protocol</th><th>Public port</th><th>To zone</th><th>To address</th><th>To port</th><th>Only from</th><th>Port forward</th><th>Hairpin</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
{{range $i, $r := .Forwards}}
<tr><th>Type</th><th>From zone</th><th>From address</th><th>Sent to</th><th>Protocol</th><th>Port</th><th>To zone</th><th>Translate to</th><th>To port</th><th>Hairpin</th><th>Also allow</th><th>Remove</th></tr>
{{range $i, $r := .NATs}}
<tr>
<td><select name="fwd.{{$i}}.protocol">
{{range $p := list "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
<td><select name="nat.{{$i}}.type">
{{range $p := list "dnat" "snat" "masquerade"}}<option value="{{$p}}" {{if eq $p $r.Type}}selected{{end}}>{{if eq $p "dnat"}}port forward{{else if eq $p "snat"}}source NAT{{else}}masquerade{{end}}</option>{{end}}
</select></td>
<td><input name="fwd.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
<td><select name="fwd.{{$i}}.to"><option value=""></option>
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
<td><select name="nat.{{$i}}.from"><option value="">any</option>
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="fwd.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="192.168.10.5"></td>
<td><input name="fwd.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
<td><input name="fwd.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="anywhere"></td>
<td><input type="checkbox" name="fwd.{{$i}}.nat" {{if $r.NAT}}checked{{end}}></td>
<td><input type="checkbox" name="fwd.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
<td><input type="checkbox" name="fwd.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
<td><input name="fwd.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
<td>{{if or $r.Ports $r.Destination}}<input type="checkbox" name="fwd.{{$i}}.remove">{{end}}</td>
<td><input name="nat.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="any"></td>
<td><input name="nat.{{$i}}.destination" value="{{$r.Destination}}" size="14" spellcheck="false" placeholder="the router"></td>
<td><select name="nat.{{$i}}.protocol">
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
</select></td>
<td><input name="nat.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
<td><select name="nat.{{$i}}.to"><option value=""></option>
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="nat.{{$i}}.toaddress" value="{{$r.ToAddress}}" size="14" spellcheck="false" placeholder="192.168.10.5"></td>
<td><input name="nat.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
<td><input type="checkbox" name="nat.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
<td>{{if or $r.ToAddress $r.Ports}}{{if $r.Allow}}<input type="checkbox" name="nat.{{$i}}.allow" checked>{{else}}<input type="checkbox" name="nat.{{$i}}.allow">{{end}}{{end}}</td>
<td>{{if or $r.ToAddress $r.Ports $r.To}}<input type="checkbox" name="nat.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "protocol" "ports" "to" "destination" "toport" "sources" "hairpin" "ratelimit"}}{{with index $e (printf "fwd[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{range $f := list "type" "from" "sources" "destination" "protocol" "ports" "to" "toaddress" "toport" "hairpin"}}{{with index $e (printf "nat[%d].%s" $i $f)}}<tr><td colspan="12" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Everything from the internet is blocked unless listed here. A <strong>port forward</strong> sends tcp or udp
traffic for the router's public IPv4 address to one host inside (to address is that host; to port only if it differs,
with a single public port); the sender's address is kept. Without it the rule just lets traffic through to an address
or network inside, for IPv6 or for a public range. <strong>Hairpin</strong> lets your LANs use the forward through the
router's public address. Rate limit caps new connections per sender, like <code>10/minute</code>.
Fill in the last row to add one.</p>
<p class="hint"><strong>Port forward:</strong> traffic from the zone (<code>wan</code> is the internet) addressed to the router
(or to the address in <em>Sent to</em>) on these ports goes to the host in <em>Translate to</em>, in zone <em>To zone</em>;
the sender's address is kept. <em>Also allow</em> adds the filter rule that lets it through (without it the firewall still
blocks it). <em>Hairpin</em> lets your LANs use a forward from the internet through the router's public address.<br>
<strong>Masquerade</strong> and <strong>source NAT</strong> rewrite where traffic from the zone (or any zone) leaves through
<em>To zone</em>: masquerade with the outgoing interface's address, source NAT with the address in <em>Translate to</em>.
Internet-enabled LANs are already masqueraded out the uplink. Fill in the last row to add one.</p>
</fieldset>
<button>Save</button>
</form>