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
357 lines
14 KiB
Go
357 lines
14 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 has a key the page lacks) must survive.
|
|
obj(obj(s, "router"), "firewall")["rules"] = []any{
|
|
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") {
|
|
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{`"extra": 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 TestNATRoundTrip(t *testing.T) {
|
|
s := mustParse(t)
|
|
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.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)
|
|
}
|
|
add := []NATRule{
|
|
got[0],
|
|
{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)
|
|
}
|
|
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)
|
|
}
|
|
// "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 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 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, []NATRule{r}).(FormErrors)
|
|
if !ok || fe["nat[0]."+name] == "" {
|
|
t.Errorf("%s accepted: %v", name, 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)
|
|
}
|
|
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 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="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}, "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("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.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"`) || !strings.Contains(body, `name="rule.0.natonly" checked`) {
|
|
t.Errorf("the forward or its allow rule is not listed:\n%s", body)
|
|
}
|
|
}
|