Add a generic NAT table: port forward, source NAT, masquerade
router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter rules. Filter rules gain natOnly, and may use wan with log and rate limit in the web UI. The Firewall page edits the NAT table and can add the matching allow rule. Settings schema 1 (migrations/1.jq) converts old forwards. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -14,9 +14,9 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
|
||||
t.Fatalf("zones = %v", got)
|
||||
}
|
||||
// A rule the page does not edit (it logs) must survive.
|
||||
// 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", "log": true},
|
||||
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") {
|
||||
@@ -38,7 +38,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
for _, want := range []string{`"log": true`, `"5000-5100"`} {
|
||||
for _, want := range []string{`"extra": true`, `"5000-5100"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s missing:\n%s", want, out)
|
||||
}
|
||||
@@ -235,98 +235,122 @@ func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
|
||||
return s.applyFirewall(zf, rows, nil)
|
||||
}
|
||||
|
||||
func TestPortForwardsRoundTrip(t *testing.T) {
|
||||
func TestNATRoundTrip(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",
|
||||
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.portForwards()
|
||||
if len(got) != 1 || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].NAT || !got[0].Hairpin {
|
||||
t.Fatalf("forwards = %+v", got)
|
||||
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)
|
||||
}
|
||||
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{
|
||||
add := []NATRule{
|
||||
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
|
||||
{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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
// "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 got := s.portForwards(); len(got) != 0 {
|
||||
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 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" },
|
||||
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, []WANRule{r}).(FormErrors)
|
||||
if !ok || fe["fwd[0]."+name] == "" {
|
||||
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
|
||||
if !ok || fe["nat[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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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 TestPortForwardPage(t *testing.T) {
|
||||
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="forwards" value="1"`) || !strings.Contains(body, "fwd.0.nat") {
|
||||
t.Fatalf("no row to add a forward:\n%s", body)
|
||||
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}, "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 := 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("fwd.0.destination", "192.168.10.5")
|
||||
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.portForwards(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
|
||||
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"`) {
|
||||
t.Errorf("the forward is not listed:\n%s", body)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user