diff --git a/pkgs/router-webui/firewall.go b/pkgs/router-webui/firewall.go new file mode 100644 index 0000000..4c37f34 --- /dev/null +++ b/pkgs/router-webui/firewall.go @@ -0,0 +1,270 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" +) + +// FirewallRule is an allow rule between two zones, as the page edits it: +// from, to, protocol, ports and destination. Rules with more (port forwards, +// sources, rate limits, logging) are kept as they are and only listed. +type FirewallRule struct { + From string + To string + Protocol string + Ports string + Destination string + Remove bool +} + +var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`) + +// simpleRuleKeys are the settings keys the page edits. +var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "destination"} + +// zones returns the firewall zones of the LANs and tunnels, sorted: a zone is +// named after its LAN or tunnel unless it says otherwise. +func (s Settings) zones() []string { + router, _ := s["router"].(map[string]any) + var out []string + for _, kind := range []string{"lans", "wireguard"} { + m, _ := router[kind].(map[string]any) + for name, v := range m { + member, _ := v.(map[string]any) + zone := str(member, "zone") + if zone == "" { + zone = name + } + if !slices.Contains(out, zone) { + out = append(out, zone) + } + } + } + slices.Sort(out) + return out +} + +func ruleSimple(r map[string]any) bool { + if str(r, "from") == "wan" { + return false + } + for k := range r { + if !slices.Contains(simpleRuleKeys, k) { + return false + } + } + return true +} + +func portsText(v any) string { + var out []string + items, _ := v.([]any) + for _, i := range items { + switch p := i.(type) { + case string: + out = append(out, p) + case float64: + out = append(out, strconv.FormatFloat(p, 'f', -1, 64)) + case int: + out = append(out, strconv.Itoa(p)) + } + } + return strings.Join(out, ", ") +} + +// firewallRules splits the rules into those the page edits and a description +// of the others, one line each. +func (s Settings) firewallRules() (simple []FirewallRule, other []string) { + router, _ := s["router"].(map[string]any) + fw, _ := router["firewall"].(map[string]any) + rules, _ := fw["rules"].([]any) + for _, v := range rules { + r, _ := v.(map[string]any) + if r == nil { + continue + } + if !ruleSimple(r) { + desc := str(r, "from") + " → " + str(r, "to") + if d := str(r, "destination"); d != "" { + desc += " " + d + } + if p := str(r, "protocol"); p != "" && p != "any" { + desc += " " + p + } + if p := portsText(r["ports"]); p != "" { + desc += " " + p + } + if dnat, _ := r["dnat"].(bool); dnat { + desc += " (port forward)" + } + other = append(other, desc) + continue + } + proto := str(r, "protocol") + if proto == "" { + proto = "any" + } + simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto, + Ports: portsText(r["ports"]), Destination: str(r, "destination")}) + } + return simple, other +} + +// applyFirewall replaces the rules the page edits with rows (an untouched +// "add" row is skipped); the other rules stay as they are. +func (s Settings) applyFirewall(rows []FirewallRule) error { + errs := FormErrors{} + zones := s.zones() + var next []any + seen := map[FirewallRule]bool{} + for i, r := range rows { + field := func(n string) string { return fmt.Sprintf("rule[%d].%s", i, n) } + r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To) + r.Ports, r.Destination = strings.Join(splitList(r.Ports), ", "), strings.TrimSpace(r.Destination) + if r.Protocol == "" { + r.Protocol = "any" + } + if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.Destination == "") { + continue + } + if !slices.Contains(zones, r.From) { + errs[field("from")] = "choose a zone" + } + if !slices.Contains(zones, r.To) { + errs[field("to")] = "choose a zone" + } + if r.From == r.To && r.From != "" { + errs[field("to")] = "traffic inside a zone is already allowed" + } + if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) { + errs[field("protocol")] = "any, tcp, udp or icmp" + } + var ports []any + for _, p := range splitList(r.Ports) { + if !portRangeRe.MatchString(p) { + errs[field("ports")] = "numbers or ranges like 5000-5100, separated by commas" + break + } + lo, hi, _ := strings.Cut(p, "-") + l, _ := strconv.Atoi(lo) + h := l + if hi != "" { + h, _ = strconv.Atoi(hi) + } + if l < 1 || h > 65535 || h < l { + errs[field("ports")] = p + " is not a valid port or range" + break + } + if hi == "" { + ports = append(ports, l) + } else { + ports = append(ports, p) + } + } + if len(ports) > 0 && r.Protocol != "tcp" && r.Protocol != "udp" { + errs[field("ports")] = "ports need protocol tcp or udp" + } + if r.Destination != "" { + if _, err := netip.ParsePrefix(r.Destination); err != nil { + if _, err := netip.ParseAddr(r.Destination); err != nil { + errs[field("destination")] = "an address or CIDR, or empty for the whole zone" + } + } + } + if seen[r] { + errs[field("from")] = "the same rule twice" + } + seen[r] = true + rule := map[string]any{"from": r.From, "to": r.To} + if r.Protocol != "any" { + rule["protocol"] = r.Protocol + } + if len(ports) > 0 { + rule["ports"] = ports + } + if r.Destination != "" { + rule["destination"] = r.Destination + } + next = append(next, rule) + } + if len(errs) > 0 { + return errs + } + router := obj(s, "router") + fw := obj(router, "firewall") + old, _ := fw["rules"].([]any) + for _, v := range old { + if r, _ := v.(map[string]any); r != nil && !ruleSimple(r) { + next = append(next, v) + } + } + if len(next) == 0 { + delete(fw, "rules") + if len(fw) == 0 { + delete(router, "firewall") + } + return nil + } + fw["rules"] = next + return nil +} + +type firewallData struct { + Rows []FirewallRule + Other []string + Zones []string + Errors FormErrors +} + +func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "firewall", p) + return + } + rows, other := s.firewallRules() + add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"} + p.Data = firewallData{Rows: append(rows, add), Other: other, Zones: s.zones()} + a.render(w, "firewall", p) +} + +func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) { + p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true} + s, err := loadSettings(a.sys.settingsPath()) + if err != nil { + p.Error = err.Error() + a.render(w, "firewall", p) + return + } + n, _ := strconv.Atoi(r.PostFormValue("rows")) + var rows []FirewallRule + for i := range min(n, 512) { + v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) } + rows = append(rows, FirewallRule{From: v("from"), To: v("to"), Protocol: v("protocol"), + Ports: v("ports"), Destination: v("destination"), Remove: v("remove") == "on"}) + } + if err := s.applyFirewall(rows); err != nil { + var fe FormErrors + errors.As(err, &fe) + _, other := s.firewallRules() + p.Error = "Please correct the marked fields." + p.Data = firewallData{Rows: rows, Other: other, Zones: s.zones(), Errors: fe} + w.WriteHeader(http.StatusUnprocessableEntity) + a.render(w, "firewall", p) + return + } + if err := s.save(a.sys.settingsPath()); err != nil { + p.Error = "Saving: " + err.Error() + a.render(w, "firewall", p) + return + } + back(w, r, "Saved. Apply to make the changes take effect.") +} diff --git a/pkgs/router-webui/firewall_test.go b/pkgs/router-webui/firewall_test.go new file mode 100644 index 0000000..22cb031 --- /dev/null +++ b/pkgs/router-webui/firewall_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/http" + "net/url" + "regexp" + "strings" + "testing" +) + +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([]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(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(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([]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([]FirewallRule{r}); err == nil { + t.Error("ports without tcp/udp accepted") + } + if err := mustParse(t).applyFirewall([]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) + } +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index 8b28623..1f03194 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -243,7 +243,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app { func (a *app) parseTemplates() (map[string]*template.Template, error) { files := map[string][]string{"overview": {"templates/dashboard.html"}} pages := map[string]*template.Template{} - for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "advanced", "update"} { + for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "advanced", "update"} { t, err := template.New("layout.html").Funcs(a.funcs). ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) if err != nil { @@ -292,6 +292,8 @@ func (a *app) routes() http.Handler { mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete)) mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient)) mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport)) + mux.HandleFunc("GET /firewall", a.auth(a.firewallPage)) + mux.HandleFunc("POST /firewall", a.auth(a.firewall)) mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage)) mux.HandleFunc("POST /dhcp", a.auth(a.dhcp)) mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment)) diff --git a/pkgs/router-webui/templates/firewall.html b/pkgs/router-webui/templates/firewall.html new file mode 100644 index 0000000..f2d0bde --- /dev/null +++ b/pkgs/router-webui/templates/firewall.html @@ -0,0 +1,45 @@ +{{define "content"}} +{{with .Data}} +{{$e := .Errors}} +

Traffic between zones is denied unless a rule allows it. A rule works in one direction only + (replies are always allowed), so letting two networks talk to each other takes two rules. + Traffic inside a zone is always allowed. A zone is a LAN or a WireGuard tunnel (named on the WireGuard page).

+
+ +
+ Allow rules + + + + {{range $i, $r := .Rows}} + + + + + + + + + {{range $f := list "from" "to" "protocol" "ports" "destination"}}{{with index $e (printf "rule[%d].%s" $i $f)}}{{end}}{{end}} + {{end}} +
FromToProtocolPortsOnly to addressRemove
{{if or $r.From $r.To}}{{end}}
{{$f}}: {{.}}
+

Fill in the last row to add a rule. Ports apply to tcp and udp: 22, 443, 5000-5100.

+
+ +
+ +{{if .Other}} +
+

Other rules

+

Port forwards and rules with more options are kept as they are; change them in settings.json.

+ +
+{{end}} +{{end}} +{{end}} diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html index 52dbc98..ac10621 100644 --- a/pkgs/router-webui/templates/layout.html +++ b/pkgs/router-webui/templates/layout.html @@ -17,6 +17,7 @@ DHCP DNS WireGuard + Firewall Update Advanced
diff --git a/tests/webui.nix b/tests/webui.nix index 9f4dfcb..1e2a436 100644 --- a/tests/webui.nix +++ b/tests/webui.nix @@ -131,6 +131,11 @@ pkgs.testers.runNixOSTest { assert post("/network", fields) == "303" assert "printer" in router.succeed(f"cat {cfg}") + with subtest("the firewall page lists the zones and refuses a rule to an unknown one"): + assert 'value="main"' in curl(f"{ui}/firewall") + rule = {"rows": "1", "rule.0.from": "main", "rule.0.to": "nowhere", "rule.0.protocol": "any"} + assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422" + with subtest("the web UI hands out a backup of the router's state"): curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup") files = client.succeed("tar -tzf /tmp/backup.tar.gz")