The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
232 lines
8.7 KiB
Go
232 lines
8.7 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 port forward the page does not edit 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)}},
|
|
}
|
|
rows, other := s.firewallRules()
|
|
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") {
|
|
t.Fatalf("rows=%+v other=%v", rows, other)
|
|
}
|
|
err := s.applyFirewall(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{`"dnat": 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 {
|
|
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 {
|
|
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")
|
|
}
|
|
}
|
|
|
|
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).applyFirewall(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).applyFirewall(ZoneForm{}, []FirewallRule{r}); err == nil {
|
|
t.Error("ports without tcp/udp accepted")
|
|
}
|
|
if err := mustParse(t).applyFirewall(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.applyFirewall(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).applyFirewall(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.applyFirewall(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.applyFirewall(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.applyFirewall(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.applyFirewall(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 {
|
|
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)
|
|
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)
|
|
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)
|
|
}
|
|
}
|