Files
nixos-router/pkgs/router-webui/firewall_test.go
T
nilsandClaude Sonnet 5.5 ec2a10658f 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
2026-10-30 10:20:50 +01:00

333 lines
13 KiB
Go

package main
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"time"
)
func TestFirewallRulesRoundTrip(t *testing.T) {
s := mustParse(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.
obj(obj(s, "router"), "firewall")["rules"] = []any{
map[string]any{"from": "main", "to": "vpn", "log": true},
}
rows, other := s.firewallRules()
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
t.Fatalf("rows=%+v other=%v", rows, other)
}
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
})
if err != nil {
t.Fatal(err)
}
rows, other = s.firewallRules()
if len(rows) != 2 || len(other) != 1 {
t.Fatalf("rows=%+v other=%v", rows, other)
}
if rows[1].Ports != "22, 443, 5000-5100" || rows[1].Protocol != "tcp" || rows[0].Protocol != "any" {
t.Errorf("rows = %+v", rows)
}
out := string(mustMarshal(t, s))
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.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.applyRules(ZoneForm{}, nil); err != nil {
t.Fatal(err)
}
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
t.Error("the rule that is not editable was removed with the allow rules")
}
}
func TestFirewallRulesAreChecked(t *testing.T) {
good := FirewallRule{From: "main", To: "vpn", Protocol: "tcp", Ports: "80"}
for name, mod := range map[string]func(*FirewallRule){
"from": func(r *FirewallRule) { r.From = "nowhere" },
"to": func(r *FirewallRule) { r.To = "main" },
"protocol": func(r *FirewallRule) { r.Protocol = "gre" },
"ports": func(r *FirewallRule) { r.Ports = "80-70" },
"destination": func(r *FirewallRule) { r.Destination = "the nas" },
} {
r := good
mod(&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)
}
}
r := good
r.Protocol = "any"
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}); err == nil {
t.Error("ports without tcp/udp accepted")
}
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good, good}); err == nil {
t.Error("the same rule twice accepted")
}
}
func TestFirewallPage(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?from=main&to=vpn", nil, cookie).Body.String()
if !strings.Contains(body, `value="vpn"`) {
t.Errorf("zones missing:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "rows": {"1"}, "rule.0.from": {"main"}, "rule.0.to": {"nowhere"}, "rule.0.protocol": {"any"}}
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("unknown zone: %d", rec.Code)
}
form.Set("rule.0.to", "vpn")
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 rows, _ := s.firewallRules(); len(rows) != 1 || rows[0].From != "main" || rows[0].To != "vpn" {
t.Fatalf("not saved: %v", s)
}
}
func TestFirewallAddressesAndSourcePorts(t *testing.T) {
s := mustParse(t)
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)
}
rows, _ := s.firewallRules()
want := 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 len(rows) != 1 || rows[0] != want {
t.Fatalf("rows = %+v", rows)
}
for name, mod := range map[string]func(*FirewallRule){
"sources": func(r *FirewallRule) { r.Sources = "the internet" },
"sourcePorts": func(r *FirewallRule) { r.SourcePorts = "70000" },
"ports": func(r *FirewallRule) { r.Protocol = "icmp" },
} {
r := want
mod(&r)
if fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s not refused: %v", name, fe)
}
}
}
func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
s := mustParse(t)
// A new zone, empty at first.
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" {
t.Fatalf("zones = %s", got)
}
// Two interfaces into one zone; the tunnel's old zone disappears, the
// LAN's keeps its name by default.
members := s.zoneMembers()
if len(members) != 2 || members[0].Kind != "lan" || members[1].Device != "wg-vpn" {
t.Fatalf("members = %+v", members)
}
members[1].Zone = "guests"
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") != "" {
t.Errorf("zones not assigned: %s", mustMarshal(t, s))
}
if got := strings.Join(s.declaredZones(), ","); got != "iot" {
t.Errorf("declared = %s (guests has a member now)", got)
}
// Back to the default name drops the key; an empty zone can be removed.
members[1].Zone = "vpn"
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 {
t.Error("the default zone is written out")
}
if len(s.declaredZones()) != 0 {
t.Errorf("declared = %v", s.declaredZones())
}
if _, ok := s["router"].(map[string]any)["firewall"]; ok {
t.Error("an empty firewall section is left behind")
}
}
func TestZoneChangesAreChecked(t *testing.T) {
for name, zf := range map[string]func(*Settings) ZoneForm{
"newzone": func(*Settings) ZoneForm { return ZoneForm{New: "Not Ok"} },
"newzone ": func(*Settings) ZoneForm { return ZoneForm{New: "wan"} },
"member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} },
} {
s := mustParse(t)
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.applyRules(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil {
t.Fatal(err)
}
m := s.zoneMembers()
m[1].Zone = "other"
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).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)
}
}
func TestDefaultRangeIs15Minutes(t *testing.T) {
if findRange("").Name != "15m" || findRange("1h").Name != "1h" || findRange("bogus").Name != "15m" {
t.Fatal("ranges", ranges)
}
a, _ := newTestApp(t)
now := time.Now()
a.metrics.store.Add("sys:cpu", now, 50)
c15 := a.chart("t", "%", findRange("15m"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
c1h := a.chart("t", "%", findRange("1h"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
if c15.Empty || c1h.Empty || len(c15.Cols) != chartCols {
t.Fatalf("charts: %+v", c15)
}
// The ends of the axis are 15 and 60 minutes apart.
span := func(c Chart) time.Duration {
a, _ := time.ParseInLocation("15:04", c.XTicks[0].Label, time.Local)
b, _ := time.ParseInLocation("15:04", c.XTicks[4].Label, time.Local)
return (b.Sub(a) + 24*time.Hour) % (24 * time.Hour)
}
if got := span(c15); got < 14*time.Minute || got > 16*time.Minute {
t.Errorf("15m axis spans %v", got)
}
if got := span(c1h); got < 59*time.Minute || got > 61*time.Minute {
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)
}
}