Edit port forwards and openings from the internet on the firewall page
Rules from wan are edited in their own table: protocol, ports, zone, host, host port, allowed sources, port forward (NAT), hairpin, log and rate limit, with the module's checks done up front. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -65,13 +65,49 @@
|
||||
<p class="hint">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): <code>22, 443, 5000-5100</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>From the internet</legend>
|
||||
<input type="hidden" name="forwards" value="{{len .Forwards}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Protocol</th><th>Public port</th><th>To zone</th><th>To address</th><th>To port</th><th>Only from</th><th>Port forward</th><th>Hairpin</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Forwards}}
|
||||
<tr>
|
||||
<td><select name="fwd.{{$i}}.protocol">
|
||||
{{range $p := list "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="fwd.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
|
||||
<td><select name="fwd.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="fwd.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="192.168.10.5"></td>
|
||||
<td><input name="fwd.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
|
||||
<td><input name="fwd.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="anywhere"></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.nat" {{if $r.NAT}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
|
||||
<td><input name="fwd.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
|
||||
<td>{{if or $r.Ports $r.Destination}}<input type="checkbox" name="fwd.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "protocol" "ports" "to" "destination" "toport" "sources" "hairpin" "ratelimit"}}{{with index $e (printf "fwd[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Everything from the internet is blocked unless listed here. A <strong>port forward</strong> 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. <strong>Hairpin</strong> lets your LANs use the forward through the
|
||||
router's public address. Rate limit caps new connections per sender, like <code>10/minute</code>.
|
||||
Fill in the last row to add one.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Other}}
|
||||
<section>
|
||||
<h2>Other rules</h2>
|
||||
<p class="muted">Port forwards and rules with more options are kept as they are; change them in <code>settings.json</code>.</p>
|
||||
<p class="muted">Rules with options this page does not edit are kept as they are; change them in <code>settings.json</code>.</p>
|
||||
<ul>{{range .Other}}<li>{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user