Edit port forwards and openings from the internet on the firewall page

Rules from wan are edited in their own table: protocol, ports, zone, host,
host port, allowed sources, port forward (NAT), hairpin, log and rate
limit, with the module's checks done up front.

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 10:20:50 +01:00
co-authored by Claude Sonnet 5.5
parent 9f9628eecc
commit ec2a10658f
5 changed files with 419 additions and 35 deletions
+20 -7
View File
@@ -123,6 +123,9 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if r == nil { if r == nil {
continue continue
} }
if wanRuleSimple(r) {
continue
}
if !ruleSimple(r) { if !ruleSimple(r) {
desc := str(r, "from") + " → " + str(r, "to") desc := str(r, "from") + " → " + str(r, "to")
if d := str(r, "destination"); d != "" { if d := str(r, "destination"); d != "" {
@@ -260,7 +263,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
// applyFirewall changes the zones, then replaces the rules the page edits with // 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. // rows (an untouched "add" row is skipped); the other rules stay as they are.
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error { func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error {
errs := FormErrors{} errs := FormErrors{}
s.applyZones(zf, errs) s.applyZones(zf, errs)
zones := s.zones() zones := s.zones()
@@ -333,12 +336,13 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error {
} }
next = append(next, rule) next = append(next, rule)
} }
next = append(next, s.forwardRules(fwds, zones, errs)...)
router := obj(s, "router") router := obj(s, "router")
fw := obj(router, "firewall") fw := obj(router, "firewall")
old, _ := fw["rules"].([]any) old, _ := fw["rules"].([]any)
for _, v := range old { for _, v := range old {
r, _ := v.(map[string]any) r, _ := v.(map[string]any)
if r == nil || ruleSimple(r) { if r == nil || ruleSimple(r) || wanRuleSimple(r) {
continue continue
} }
// Port forwards and the like are not edited here, but must keep a zone to point at. // Port forwards and the like are not edited here, but must keep a zone to point at.
@@ -366,6 +370,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error {
type firewallData struct { type firewallData struct {
Rows []FirewallRule Rows []FirewallRule
Forwards []WANRule
Other []string Other []string
Zones []string Zones []string
Members []ZoneMember Members []ZoneMember
@@ -374,9 +379,9 @@ type firewallData struct {
Errors FormErrors Errors FormErrors
} }
func (s Settings) firewallData(rows []FirewallRule, members []ZoneMember, errs FormErrors) firewallData { func (s Settings) firewallData(rows []FirewallRule, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData {
_, other := s.firewallRules() _, other := s.firewallRules()
d := firewallData{Rows: rows, Other: other, Zones: s.zones(), Members: members, Errors: errs} d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs}
if d.Members == nil { if d.Members == nil {
d.Members = s.zoneMembers() d.Members = s.zoneMembers()
} }
@@ -398,7 +403,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session)
} }
rows, _ := s.firewallRules() rows, _ := s.firewallRules()
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"} add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
p.Data = s.firewallData(append(rows, add), nil, nil) p.Data = s.firewallData(append(rows, add), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil)
a.render(w, "firewall", p) a.render(w, "firewall", p)
} }
@@ -417,19 +422,27 @@ 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")}) zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")})
} }
n, _ := strconv.Atoi(r.PostFormValue("rows")) 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"})
}
var rows []FirewallRule var rows []FirewallRule
for i := range min(n, 512) { for i := range min(n, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) } 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"), 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"), Remove: v("remove") == "on"})
} }
if err := s.applyFirewall(zf, rows); err != nil { if err := s.applyFirewall(zf, rows, fwds); err != nil {
var fe FormErrors var fe FormErrors
errors.As(err, &fe) errors.As(err, &fe)
// Show what was entered; the zone lists are those before this attempt. // Show what was entered; the zone lists are those before this attempt.
s, _ = loadSettings(a.sys.settingsPath()) s, _ = loadSettings(a.sys.settingsPath())
p.Error = "Please correct the marked fields." p.Error = "Please correct the marked fields."
d := s.firewallData(rows, zf.Members, fe) d := s.firewallData(rows, fwds, zf.Members, fe)
d.New = zf.New d.New = zf.New
p.Data = d p.Data = d
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
+121 -20
View File
@@ -14,15 +14,15 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
if got := s.zones(); strings.Join(got, ",") != "main,vpn" { if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
t.Fatalf("zones = %v", got) t.Fatalf("zones = %v", got)
} }
// A port forward the page does not edit must survive. // A rule the page does not edit (it logs) must survive.
obj(obj(s, "router"), "firewall")["rules"] = []any{ obj(obj(s, "router"), "firewall")["rules"] = []any{
map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp", "ports": []any{float64(443)}}, map[string]any{"from": "main", "to": "vpn", "log": true},
} }
rows, other := s.firewallRules() rows, other := s.firewallRules()
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") { if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
t.Fatalf("rows=%+v other=%v", rows, other) t.Fatalf("rows=%+v other=%v", rows, other)
} }
err := s.applyFirewall(ZoneForm{}, []FirewallRule{ err := s.applyRules(ZoneForm{}, []FirewallRule{
{From: "main", To: "vpn", Protocol: "any"}, {From: "main", To: "vpn", Protocol: "any"},
{From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"}, {From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"},
{Protocol: "any"}, // the empty add row {Protocol: "any"}, // the empty add row
@@ -38,23 +38,23 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
t.Errorf("rows = %+v", rows) t.Errorf("rows = %+v", rows)
} }
out := string(mustMarshal(t, s)) out := string(mustMarshal(t, s))
for _, want := range []string{`"dnat": true`, `"5000-5100"`} { for _, want := range []string{`"log": true`, `"5000-5100"`} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out) t.Errorf("%s missing:\n%s", want, out)
} }
} }
rows[0].Remove, rows[1].Remove = true, true rows[0].Remove, rows[1].Remove = true, true
if err := s.applyFirewall(ZoneForm{}, rows); err != nil { if err := s.applyRules(ZoneForm{}, rows); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 { if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 {
t.Errorf("rows=%+v other=%v", rows, other) t.Errorf("rows=%+v other=%v", rows, other)
} }
if err := s.applyFirewall(ZoneForm{}, nil); err != nil { if err := s.applyRules(ZoneForm{}, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok { if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
t.Error("the port forward was removed with the allow rules") t.Error("the rule that is not editable was removed with the allow rules")
} }
} }
@@ -69,7 +69,7 @@ func TestFirewallRulesAreChecked(t *testing.T) {
} { } {
r := good r := good
mod(&r) mod(&r)
err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}) err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r})
fe, ok := err.(FormErrors) fe, ok := err.(FormErrors)
if !ok || fe["rule[0]."+name] == "" { if !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s: err = %v", name, err) t.Errorf("%s: err = %v", name, err)
@@ -77,10 +77,10 @@ func TestFirewallRulesAreChecked(t *testing.T) {
} }
r := good r := good
r.Protocol = "any" r.Protocol = "any"
if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}); err == nil { if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}); err == nil {
t.Error("ports without tcp/udp accepted") t.Error("ports without tcp/udp accepted")
} }
if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{good, good}); err == nil { if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good, good}); err == nil {
t.Error("the same rule twice accepted") t.Error("the same rule twice accepted")
} }
} }
@@ -110,7 +110,7 @@ func TestFirewallPage(t *testing.T) {
func TestFirewallAddressesAndSourcePorts(t *testing.T) { func TestFirewallAddressesAndSourcePorts(t *testing.T) {
s := mustParse(t) s := mustParse(t)
err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp", err := s.applyRules(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp",
Sources: "198.51.100.0/24, 2001:db8::1", SourcePorts: "53 1024-2048", Destination: "192.168.10.5", Ports: "5353"}}) Sources: "198.51.100.0/24, 2001:db8::1", SourcePorts: "53 1024-2048", Destination: "192.168.10.5", Ports: "5353"}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -128,7 +128,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) {
} { } {
r := want r := want
mod(&r) mod(&r)
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" { if fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s not refused: %v", name, fe) t.Errorf("%s not refused: %v", name, fe)
} }
} }
@@ -137,7 +137,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) {
func TestZonesAreAssignedAddedAndRemoved(t *testing.T) { func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
s := mustParse(t) s := mustParse(t)
// A new zone, empty at first. // A new zone, empty at first.
if err := s.applyFirewall(ZoneForm{New: "guests, iot"}, nil); err != nil { if err := s.applyRules(ZoneForm{New: "guests, iot"}, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := strings.Join(s.zones(), ","); got != "guests,iot,main,vpn" { if got := strings.Join(s.zones(), ","); got != "guests,iot,main,vpn" {
@@ -150,7 +150,7 @@ func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
t.Fatalf("members = %+v", members) t.Fatalf("members = %+v", members)
} }
members[1].Zone = "guests" members[1].Zone = "guests"
if err := s.applyFirewall(ZoneForm{Members: members}, nil); err != nil { if err := s.applyRules(ZoneForm{Members: members}, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if str(s, "router", "wireguard", "vpn", "zone") != "guests" || str(s, "router", "lans", "main", "zone") != "" { if str(s, "router", "wireguard", "vpn", "zone") != "guests" || str(s, "router", "lans", "main", "zone") != "" {
@@ -161,7 +161,7 @@ func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
} }
// Back to the default name drops the key; an empty zone can be removed. // Back to the default name drops the key; an empty zone can be removed.
members[1].Zone = "vpn" members[1].Zone = "vpn"
if err := s.applyFirewall(ZoneForm{Members: members, Remove: []string{"iot"}}, nil); err != nil { if err := s.applyRules(ZoneForm{Members: members, Remove: []string{"iot"}}, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := s["router"].(map[string]any)["wireguard"].(map[string]any)["vpn"].(map[string]any)["zone"]; ok { if _, ok := s["router"].(map[string]any)["wireguard"].(map[string]any)["vpn"].(map[string]any)["zone"]; ok {
@@ -182,23 +182,23 @@ func TestZoneChangesAreChecked(t *testing.T) {
"member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} }, "member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} },
} { } {
s := mustParse(t) s := mustParse(t)
fe, ok := s.applyFirewall(zf(&s), nil).(FormErrors) fe, ok := s.applyRules(zf(&s), nil).(FormErrors)
if !ok || fe[strings.TrimSpace(name)] == "" { if !ok || fe[strings.TrimSpace(name)] == "" {
t.Errorf("%s accepted: %v", name, fe) t.Errorf("%s accepted: %v", name, fe)
} }
} }
// A zone with members stays; a rule left without its zone is refused. // A zone with members stays; a rule left without its zone is refused.
s := mustParse(t) s := mustParse(t)
if err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil { if err := s.applyRules(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
m := s.zoneMembers() m := s.zoneMembers()
m[1].Zone = "other" m[1].Zone = "other"
fe, ok := s.applyFirewall(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors) fe, ok := s.applyRules(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors)
if !ok || fe["rule[0].to"] == "" { if !ok || fe["rule[0].to"] == "" {
t.Errorf("rule to a vanished zone accepted: %v", fe) t.Errorf("rule to a vanished zone accepted: %v", fe)
} }
fe, ok = mustParse(t).applyFirewall(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors) fe, ok = mustParse(t).applyRules(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors)
if ok && len(fe) > 0 { if ok && len(fe) > 0 {
t.Errorf("removing a zone that is not declared is not an error: %v", fe) t.Errorf("removing a zone that is not declared is not an error: %v", fe)
} }
@@ -229,3 +229,104 @@ func TestDefaultRangeIs15Minutes(t *testing.T) {
t.Errorf("1h axis spans %v", got) t.Errorf("1h axis spans %v", got)
} }
} }
// applyRules is applyFirewall without port forwards.
func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
return s.applyFirewall(zf, rows, nil)
}
func TestPortForwardsRoundTrip(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",
"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)
}
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{
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
}
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)
}
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
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
if got := s.portForwards(); 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" },
} {
r := good
mod(&r)
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors)
if !ok || fe["fwd[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)
}
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)
}
}
func TestPortForwardPage(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)
}
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.Set("fwd.0.destination", "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 {
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)
}
}
+226
View File
@@ -0,0 +1,226 @@
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
}
+37 -1
View File
@@ -65,13 +65,49 @@
<p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR; <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>.</p>
</fieldset> </fieldset>
<fieldset>
<legend>From the internet</legend>
<input type="hidden" name="forwards" value="{{len .Forwards}}">
<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>
<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}}
</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}}
</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>
</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}}
{{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>
</fieldset>
<button>Save</button> <button>Save</button>
</form> </form>
{{if .Other}} {{if .Other}}
<section> <section>
<h2>Other rules</h2> <h2>Other rules</h2>
<p class="muted">Port forwards and rules with more options are kept as they are; change them in <code>settings.json</code>.</p> <p class="muted">Rules with options this page does not edit are kept as they are; change them in <code>settings.json</code>.</p>
<ul>{{range .Other}}<li>{{.}}</li>{{end}}</ul> <ul>{{range .Other}}<li>{{.}}</li>{{end}}</ul>
</section> </section>
{{end}} {{end}}
+8
View File
@@ -136,6 +136,14 @@ pkgs.testers.runNixOSTest {
rule = {"rows": "1", "rule.0.from": "main", "rule.0.to": "nowhere", "rule.0.protocol": "any"} rule = {"rows": "1", "rule.0.from": "main", "rule.0.to": "nowhere", "rule.0.protocol": "any"}
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422" assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
with subtest("a port forward is saved and listed"):
fwd = {"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"}
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
saved = router.succeed(f"cat {cfg}")
assert '"dnat": true' in saved and '"from": "wan"' in saved, saved
with subtest("the web UI hands out a backup of the router's state"): with subtest("the web UI hands out a backup of the router's state"):
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup") curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
files = client.succeed("tar -tzf /tmp/backup.tar.gz") files = client.succeed("tar -tzf /tmp/backup.tar.gz")