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
+121 -20
View File
@@ -14,15 +14,15 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
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{
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()
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)
}
err := s.applyFirewall(ZoneForm{}, []FirewallRule{
err := s.applyRules(ZoneForm{}, []FirewallRule{
{From: "main", To: "vpn", Protocol: "any"},
{From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"},
{Protocol: "any"}, // the empty add row
@@ -38,23 +38,23 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
t.Errorf("rows = %+v", rows)
}
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) {
t.Errorf("%s missing:\n%s", want, out)
}
}
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)
}
if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 {
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)
}
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
mod(&r)
err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r})
err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r})
fe, ok := err.(FormErrors)
if !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s: err = %v", name, err)
@@ -77,10 +77,10 @@ func TestFirewallRulesAreChecked(t *testing.T) {
}
r := good
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")
}
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")
}
}
@@ -110,7 +110,7 @@ func TestFirewallPage(t *testing.T) {
func TestFirewallAddressesAndSourcePorts(t *testing.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"}})
if err != nil {
t.Fatal(err)
@@ -128,7 +128,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) {
} {
r := want
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)
}
}
@@ -137,7 +137,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) {
func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
s := mustParse(t)
// 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)
}
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)
}
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)
}
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.
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)
}
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} },
} {
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)] == "" {
t.Errorf("%s accepted: %v", name, fe)
}
}
// A zone with members stays; a rule left without its zone is refused.
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)
}
m := s.zoneMembers()
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"] == "" {
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 {
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)
}
}
// 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)
}
}