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) } }