diff --git a/pkgs/router-webui/firewall.go b/pkgs/router-webui/firewall.go index c75ebb9..c64eeef 100644 --- a/pkgs/router-webui/firewall.go +++ b/pkgs/router-webui/firewall.go @@ -123,6 +123,9 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) { if r == nil { continue } + if wanRuleSimple(r) { + continue + } if !ruleSimple(r) { desc := str(r, "from") + " → " + str(r, "to") if d := str(r, "destination"); d != "" { @@ -260,7 +263,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) { // applyFirewall changes the zones, then 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(zf ZoneForm, rows []FirewallRule) error { +func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error { errs := FormErrors{} s.applyZones(zf, errs) zones := s.zones() @@ -333,12 +336,13 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error { } next = append(next, rule) } + next = append(next, s.forwardRules(fwds, zones, errs)...) router := obj(s, "router") fw := obj(router, "firewall") old, _ := fw["rules"].([]any) for _, v := range old { r, _ := v.(map[string]any) - if r == nil || ruleSimple(r) { + if r == nil || ruleSimple(r) || wanRuleSimple(r) { continue } // Port forwards and the like are not edited here, but must keep a zone to point at. @@ -365,18 +369,19 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error { } type firewallData struct { - Rows []FirewallRule - Other []string - Zones []string - Members []ZoneMember - Empty []string // zones without members, which can be removed - New string - Errors FormErrors + Rows []FirewallRule + Forwards []WANRule + Other []string + Zones []string + Members []ZoneMember + Empty []string // zones without members, which can be removed + New string + Errors FormErrors } -func (s Settings) firewallData(rows []FirewallRule, members []ZoneMember, errs FormErrors) firewallData { +func (s Settings) firewallData(rows []FirewallRule, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData { _, other := s.firewallRules() - d := firewallData{Rows: rows, Other: other, Zones: s.zones(), Members: members, Errors: errs} + d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs} if d.Members == nil { d.Members = s.zoneMembers() } @@ -398,7 +403,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) } rows, _ := s.firewallRules() add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"} - p.Data = s.firewallData(append(rows, add), nil, nil) + p.Data = s.firewallData(append(rows, add), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil) a.render(w, "firewall", p) } @@ -417,19 +422,27 @@ func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) { zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")}) } n, _ := strconv.Atoi(r.PostFormValue("rows")) + nf, _ := strconv.Atoi(r.PostFormValue("forwards")) + var fwds []WANRule + for i := range min(nf, 512) { + v := func(k string) string { return r.PostFormValue(fmt.Sprintf("fwd.%d.%s", i, k)) } + fwds = append(fwds, WANRule{Protocol: v("protocol"), Ports: v("ports"), To: v("to"), Destination: v("destination"), + ToPort: v("toport"), Sources: v("sources"), NAT: v("nat") == "on", Hairpin: v("hairpin") == "on", + Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"}) + } 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"), Sources: v("sources"), SourcePorts: v("sourceports"), To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"), Remove: v("remove") == "on"}) } - if err := s.applyFirewall(zf, rows); err != nil { + if err := s.applyFirewall(zf, rows, fwds); err != nil { var fe FormErrors errors.As(err, &fe) // Show what was entered; the zone lists are those before this attempt. s, _ = loadSettings(a.sys.settingsPath()) p.Error = "Please correct the marked fields." - d := s.firewallData(rows, zf.Members, fe) + d := s.firewallData(rows, fwds, zf.Members, fe) d.New = zf.New p.Data = d w.WriteHeader(http.StatusUnprocessableEntity) diff --git a/pkgs/router-webui/firewall_test.go b/pkgs/router-webui/firewall_test.go index 21950e3..bc9add8 100644 --- a/pkgs/router-webui/firewall_test.go +++ b/pkgs/router-webui/firewall_test.go @@ -14,15 +14,15 @@ func TestFirewallRulesRoundTrip(t *testing.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. + // A rule the page does not edit (it logs) 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)}}, + map[string]any{"from": "main", "to": "vpn", "log": true}, } rows, other := s.firewallRules() - if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") { + if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") { t.Fatalf("rows=%+v other=%v", rows, other) } - err := s.applyFirewall(ZoneForm{}, []FirewallRule{ + 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 @@ -38,23 +38,23 @@ func TestFirewallRulesRoundTrip(t *testing.T) { t.Errorf("rows = %+v", rows) } out := string(mustMarshal(t, s)) - for _, want := range []string{`"dnat": true`, `"5000-5100"`} { + 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.applyFirewall(ZoneForm{}, rows); err != nil { + 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.applyFirewall(ZoneForm{}, nil); err != nil { + 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 port forward was removed with the allow rules") + t.Error("the rule that is not editable was removed with the allow rules") } } @@ -69,7 +69,7 @@ func TestFirewallRulesAreChecked(t *testing.T) { } { r := good mod(&r) - err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{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) @@ -77,10 +77,10 @@ func TestFirewallRulesAreChecked(t *testing.T) { } r := good r.Protocol = "any" - if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}); err == nil { + if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}); err == nil { t.Error("ports without tcp/udp accepted") } - if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{good, good}); err == nil { + if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good, good}); err == nil { t.Error("the same rule twice accepted") } } @@ -110,7 +110,7 @@ func TestFirewallPage(t *testing.T) { func TestFirewallAddressesAndSourcePorts(t *testing.T) { s := mustParse(t) - err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp", + 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) @@ -128,7 +128,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) { } { r := want mod(&r) - if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" { + if fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" { t.Errorf("%s not refused: %v", name, fe) } } @@ -137,7 +137,7 @@ func TestFirewallAddressesAndSourcePorts(t *testing.T) { 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 { + 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" { @@ -150,7 +150,7 @@ func TestZonesAreAssignedAddedAndRemoved(t *testing.T) { t.Fatalf("members = %+v", members) } members[1].Zone = "guests" - if err := s.applyFirewall(ZoneForm{Members: members}, nil); err != nil { + 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") != "" { @@ -161,7 +161,7 @@ func TestZonesAreAssignedAddedAndRemoved(t *testing.T) { } // 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 { + 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 { @@ -182,23 +182,23 @@ func TestZoneChangesAreChecked(t *testing.T) { "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) + 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.applyFirewall(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil { + 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.applyFirewall(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors) + 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).applyFirewall(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors) + 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) } @@ -229,3 +229,104 @@ func TestDefaultRangeIs15Minutes(t *testing.T) { 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) + } +} diff --git a/pkgs/router-webui/forwards.go b/pkgs/router-webui/forwards.go new file mode 100644 index 0000000..8f40071 --- /dev/null +++ b/pkgs/router-webui/forwards.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "net/netip" + "regexp" + "slices" + "strconv" + "strings" +) + +// WANRule is a rule from the internet: a port forward (NAT, IPv4) or an +// opening to an address inside (IPv4 or IPv6, no translation). +type WANRule struct { + Protocol string // tcp, udp or icmp + Ports string // on the router's public address (NAT) or on the host + To string // zone + Destination string // the host; for a forward a single IPv4 address + ToPort string // the host's port when it differs (forwards, one port) + Sources string // only from these addresses, empty: anywhere + NAT bool + Hairpin bool + Log bool + RateLimit string // "10/minute" per source address + Remove bool +} + +var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`) + +// wanRuleKeys are the settings keys the port forward editor handles. +var wanRuleKeys = []string{"from", "to", "protocol", "ports", "destination", "toPort", "sources", "dnat", "hairpin", "rateLimit", "log"} + +func wanRuleSimple(r map[string]any) bool { + if str(r, "from") != "wan" { + return false + } + for k := range r { + if !slices.Contains(wanRuleKeys, k) { + return false + } + } + return true +} + +func boolOf(r map[string]any, k string) bool { + b, _ := r[k].(bool) + return b +} + +// portForwards are the rules from the internet, in order. +func (s Settings) portForwards() []WANRule { + router, _ := s["router"].(map[string]any) + fw, _ := router["firewall"].(map[string]any) + rules, _ := fw["rules"].([]any) + var out []WANRule + for _, v := range rules { + r, _ := v.(map[string]any) + if r == nil || !wanRuleSimple(r) { + continue + } + out = append(out, WANRule{Protocol: str(r, "protocol"), Ports: portsText(r["ports"]), To: str(r, "to"), + Destination: str(r, "destination"), ToPort: str(r, "toPort"), Sources: strings.Join(stringList(r, "sources"), ", "), + NAT: boolOf(r, "dnat"), Hairpin: boolOf(r, "hairpin"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")}) + } + return out +} + +// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel +// is in the zone too (its networks are the peers' routes, not checked here). +func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) { + lans := map[string]netip.Prefix{} + for _, l := range s.form().LANs { + a, aok := ipv4(l.Address) + if !aok { + continue + } + bits := 24 + if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok { + bits = n + } + lans[l.Name] = netip.PrefixFrom(a, bits).Masked() + } + for _, m := range s.zoneMembers() { + if m.Zone != zone { + continue + } + if m.Kind != "lan" { + return nil, false + } + if p, found := lans[m.Name]; found { + nets = append(nets, p) + } + } + return nets, len(nets) > 0 +} + +// forwardRules checks the rows and returns them as settings rules. +func (s Settings) forwardRules(rows []WANRule, zones []string, errs FormErrors) []any { + var out []any + seen := map[WANRule]bool{} + for i, r := range rows { + field := func(n string) string { return fmt.Sprintf("fwd[%d].%s", i, n) } + r.Ports, r.Sources = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.Sources), ", ") + r.To, r.Destination = strings.TrimSpace(r.To), strings.TrimSpace(r.Destination) + r.ToPort, r.RateLimit = strings.TrimSpace(r.ToPort), strings.TrimSpace(r.RateLimit) + if r.Remove || (r.Ports == "" && r.To == "" && r.Destination == "" && r.ToPort == "" && r.Sources == "" && r.RateLimit == "") { + continue + } + if !slices.Contains(zones, r.To) { + errs[field("to")] = "choose a zone" + } + if !slices.Contains([]string{"tcp", "udp", "icmp"}, r.Protocol) { + errs[field("protocol")] = "tcp, udp or icmp" + } + var ports []any + if r.Protocol == "icmp" { + if r.Ports != "" { + errs[field("ports")] = "icmp has no ports" + } + } else { + var msg string + if ports, msg = portList(r.Ports); msg != "" { + errs[field("ports")] = msg + } else if len(ports) == 0 { + errs[field("ports")] = "name the ports to open" + } + } + dest, destOK := netip.Prefix{}, false + switch { + case r.Destination == "": + errs[field("destination")] = "the host or network inside" + case !validAddress(r.Destination): + errs[field("destination")] = "an address (or CIDR)" + default: + if p, err := netip.ParsePrefix(r.Destination); err == nil { + dest = p + if p.Masked() != p { + errs[field("destination")] = "has host bits set" + } + } else { + a, _ := netip.ParseAddr(r.Destination) + dest = netip.PrefixFrom(a, a.BitLen()) + } + destOK = errs[field("destination")] == "" + } + if destOK && dest.Addr().Is4() { + if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { + return n.Bits() <= dest.Bits() && n.Contains(dest.Addr()) + }) { + errs[field("destination")] = "not inside a network of zone " + r.To + } + } + var sources []any + for _, a := range splitList(r.Sources) { + switch { + case !validAddress(a): + errs[field("sources")] = a + " is not an address or CIDR" + case r.NAT && strings.Contains(a, ":"): + errs[field("sources")] = "port forwards are IPv4 only" + } + sources = append(sources, a) + } + if r.NAT { + if r.Protocol == "icmp" { + errs[field("protocol")] = "a port forward needs tcp or udp" + } + if destOK && (!dest.Addr().Is4() || dest.Bits() != 32) { + errs[field("destination")] = "a port forward goes to one IPv4 address" + } + } + toPort := 0 + if r.ToPort != "" { + n, err := strconv.Atoi(r.ToPort) + switch { + case err != nil || n < 1 || n > 65535: + errs[field("toport")] = "a port number" + case !r.NAT: + errs[field("toport")] = "only for port forwards" + case len(ports) != 1 || strings.Contains(r.Ports, "-"): + errs[field("toport")] = "needs exactly one public port" + } + toPort = n + } + if r.Hairpin && !r.NAT { + errs[field("hairpin")] = "only for port forwards" + } + if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) { + errs[field("ratelimit")] = "like 10/minute (second, minute or hour)" + } + if seen[r] { + errs[field("to")] = "the same rule twice" + } + seen[r] = true + rule := map[string]any{"from": "wan", "to": r.To, "protocol": r.Protocol} + if len(ports) > 0 { + rule["ports"] = ports + } + if destOK { + if r.NAT || dest.Bits() == dest.Addr().BitLen() { + rule["destination"] = dest.Addr().String() + } else { + rule["destination"] = dest.String() + } + } + if len(sources) > 0 { + rule["sources"] = sources + } + if r.NAT { + rule["dnat"] = true + } + if toPort > 0 { + rule["toPort"] = toPort + } + if r.Hairpin { + rule["hairpin"] = true + } + if r.Log { + rule["log"] = true + } + if r.RateLimit != "" { + rule["rateLimit"] = r.RateLimit + } + out = append(out, rule) + } + return out +} diff --git a/pkgs/router-webui/templates/firewall.html b/pkgs/router-webui/templates/firewall.html index 0a5dc59..d5fd0f3 100644 --- a/pkgs/router-webui/templates/firewall.html +++ b/pkgs/router-webui/templates/firewall.html @@ -65,13 +65,49 @@

Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR; to address is one address or network. Ports (tcp and udp only): 22, 443, 5000-5100.

+ +
+ From the internet + +
+ + + {{range $i, $r := .Forwards}} + + + + + + + + + + + + + + {{range $f := list "protocol" "ports" "to" "destination" "toport" "sources" "hairpin" "ratelimit"}}{{with index $e (printf "fwd[%d].%s" $i $f)}}{{end}}{{end}} + {{end}} +
ProtocolPublic portTo zoneTo addressTo portOnly fromPort forwardHairpinLogRate limitRemove
{{if or $r.Ports $r.Destination}}{{end}}
{{$f}}: {{.}}
+
+

Everything from the internet is blocked unless listed here. A port forward sends tcp or udp + traffic for the router's public IPv4 address to one host inside (to address is that host; to port only if it differs, + with a single public port); the sender's address is kept. Without it the rule just lets traffic through to an address + or network inside, for IPv6 or for a public range. Hairpin lets your LANs use the forward through the + router's public address. Rate limit caps new connections per sender, like 10/minute. + Fill in the last row to add one.

+
{{if .Other}}

Other rules

-

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

+

Rules with options this page does not edit are kept as they are; change them in settings.json.

{{end}} diff --git a/tests/webui.nix b/tests/webui.nix index 1e2a436..d9148a8 100644 --- a/tests/webui.nix +++ b/tests/webui.nix @@ -136,6 +136,14 @@ pkgs.testers.runNixOSTest { 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("a port forward is saved and listed"): + fwd = {"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"} + assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303" + assert 'value="192.168.10.5"' in curl(f"{ui}/firewall") + saved = router.succeed(f"cat {cfg}") + assert '"dnat": true' in saved and '"from": "wan"' in saved, saved + 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")