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:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -22,6 +23,9 @@ type FirewallRule struct {
|
||||
Destination string // one address or CIDR, empty: the whole zone
|
||||
Ports string // destination ports
|
||||
Protocol string
|
||||
NATOnly bool // only connections that a port forward translated
|
||||
Log bool // log new connections
|
||||
RateLimit string // per source address, like 10/minute
|
||||
Remove bool
|
||||
}
|
||||
|
||||
@@ -36,7 +40,7 @@ type ZoneMember struct {
|
||||
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
|
||||
|
||||
// simpleRuleKeys are the settings keys the page edits.
|
||||
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources"}
|
||||
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
|
||||
|
||||
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
|
||||
// named after itself unless it says otherwise.
|
||||
@@ -85,9 +89,6 @@ func (s Settings) zones() []string {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -123,9 +124,6 @@ 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 != "" {
|
||||
@@ -140,9 +138,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
||||
if p := portsText(r["ports"]); p != "" {
|
||||
desc += " " + p
|
||||
}
|
||||
if dnat, _ := r["dnat"].(bool); dnat {
|
||||
desc += " (port forward)"
|
||||
}
|
||||
other = append(other, desc)
|
||||
continue
|
||||
}
|
||||
@@ -152,7 +147,8 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
||||
}
|
||||
simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto,
|
||||
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]),
|
||||
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination")})
|
||||
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
|
||||
NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
|
||||
}
|
||||
return simple, other
|
||||
}
|
||||
@@ -263,7 +259,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, fwds []WANRule) error {
|
||||
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
|
||||
errs := FormErrors{}
|
||||
s.applyZones(zf, errs)
|
||||
zones := s.zones()
|
||||
@@ -280,7 +276,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(zones, r.From) {
|
||||
if !slices.Contains(zones, r.From) && r.From != "wan" {
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
if !slices.Contains(zones, r.To) {
|
||||
@@ -314,11 +310,35 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
if r.Destination != "" && !validAddress(r.Destination) {
|
||||
errs[field("destination")] = "one address or CIDR, or empty for the whole zone"
|
||||
}
|
||||
if r.From == "wan" {
|
||||
// What the module requires, so that a typo cannot open a whole zone.
|
||||
if r.Destination == "" {
|
||||
errs[field("destination")] = "rules from the internet must name the host or network inside"
|
||||
}
|
||||
if r.Protocol == "any" {
|
||||
errs[field("protocol")] = "rules from the internet must name a protocol"
|
||||
} else if r.Protocol != "icmp" && len(ports) == 0 {
|
||||
errs[field("ports")] = "rules from the internet must list the ports they open"
|
||||
}
|
||||
}
|
||||
r.RateLimit = strings.TrimSpace(r.RateLimit)
|
||||
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
|
||||
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
|
||||
}
|
||||
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.NATOnly {
|
||||
rule["natOnly"] = true
|
||||
}
|
||||
if r.Log {
|
||||
rule["log"] = true
|
||||
}
|
||||
if r.RateLimit != "" {
|
||||
rule["rateLimit"] = r.RateLimit
|
||||
}
|
||||
if r.Protocol != "any" {
|
||||
rule["protocol"] = r.Protocol
|
||||
}
|
||||
@@ -336,16 +356,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
}
|
||||
next = append(next, rule)
|
||||
}
|
||||
next = append(next, s.forwardRules(fwds, zones, errs)...)
|
||||
natRows, allows := s.natSettings(nats, zones, errs)
|
||||
for _, a := range allows {
|
||||
if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
|
||||
next = append(next, a)
|
||||
}
|
||||
}
|
||||
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) || wanRuleSimple(r) {
|
||||
if r == nil || ruleSimple(r) {
|
||||
continue
|
||||
}
|
||||
// Port forwards and the like are not edited here, but must keep a zone to point at.
|
||||
// Rules with options not edited here must keep their zones.
|
||||
if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) {
|
||||
errs["zone."+f] = "a rule that is not editable here still uses this zone"
|
||||
}
|
||||
@@ -362,6 +387,11 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
} else {
|
||||
fw["rules"] = next
|
||||
}
|
||||
if len(natRows) == 0 {
|
||||
delete(fw, "nat")
|
||||
} else {
|
||||
fw["nat"] = natRows
|
||||
}
|
||||
if len(fw) == 0 {
|
||||
delete(router, "firewall")
|
||||
}
|
||||
@@ -369,19 +399,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
}
|
||||
|
||||
type firewallData struct {
|
||||
Rows []FirewallRule
|
||||
Forwards []WANRule
|
||||
Other []string
|
||||
Zones []string
|
||||
Members []ZoneMember
|
||||
Empty []string // zones without members, which can be removed
|
||||
New string
|
||||
Errors FormErrors
|
||||
Rows []FirewallRule
|
||||
NATs []NATRule
|
||||
FromZones []string // the zones and wan
|
||||
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, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData {
|
||||
func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
|
||||
_, other := s.firewallRules()
|
||||
d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs}
|
||||
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
|
||||
d.FromZones = append([]string{"wan"}, d.Zones...)
|
||||
if d.Members == nil {
|
||||
d.Members = s.zoneMembers()
|
||||
}
|
||||
@@ -403,7 +435,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), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil)
|
||||
p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
|
||||
a.render(w, "firewall", p)
|
||||
}
|
||||
|
||||
@@ -422,27 +454,28 @@ 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"})
|
||||
nn, _ := strconv.Atoi(r.PostFormValue("nats"))
|
||||
var nats []NATRule
|
||||
for i := range min(nn, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
|
||||
nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
|
||||
SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
|
||||
ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", 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"})
|
||||
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
|
||||
NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
|
||||
}
|
||||
if err := s.applyFirewall(zf, rows, fwds); err != nil {
|
||||
if err := s.applyFirewall(zf, rows, nats); 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, fwds, zf.Members, fe)
|
||||
d := s.firewallData(rows, nats, zf.Members, fe)
|
||||
d.New = zf.New
|
||||
p.Data = d
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NATRule is a row of the address translation table (router.firewall.nat).
|
||||
type NATRule struct {
|
||||
Type string // dnat, snat or masquerade
|
||||
From string // zone, "wan", or empty (any) for snat and masquerade
|
||||
To string // dnat: the host's zone; snat and masquerade: the zone or wan it leaves through
|
||||
Protocol string
|
||||
Ports string // destination ports to match
|
||||
SourcePorts string
|
||||
Sources string // IPv4 addresses or CIDRs to match
|
||||
Destination string // IPv4 address or CIDR the traffic is addressed to; empty: the router itself
|
||||
ToAddress string // dnat: the host; snat: the source address to use
|
||||
ToPort string // dnat: the host's port when it differs
|
||||
Hairpin bool // dnat from wan: LANs reach it through the public address too
|
||||
Allow bool // form only: also add the filter rule that lets a forward through
|
||||
Remove bool
|
||||
}
|
||||
|
||||
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
|
||||
|
||||
func boolOf(r map[string]any, k string) bool {
|
||||
b, _ := r[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (s Settings) natRules() []NATRule {
|
||||
router, _ := s["router"].(map[string]any)
|
||||
fw, _ := router["firewall"].(map[string]any)
|
||||
rows, _ := fw["nat"].([]any)
|
||||
var out []NATRule
|
||||
for _, v := range rows {
|
||||
r, _ := v.(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
proto := str(r, "protocol")
|
||||
if proto == "" {
|
||||
proto = "any"
|
||||
}
|
||||
out = append(out, NATRule{Type: str(r, "type"), From: str(r, "from"), To: str(r, "to"), Protocol: proto,
|
||||
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Sources: strings.Join(stringList(r, "sources"), ", "),
|
||||
Destination: str(r, "destination"), ToAddress: str(r, "toAddress"), ToPort: str(r, "toPort"), Hairpin: boolOf(r, "hairpin")})
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func validV4Net(v string) bool {
|
||||
if p, err := netip.ParsePrefix(v); err == nil {
|
||||
return p.Addr().Is4()
|
||||
}
|
||||
a, err := netip.ParseAddr(v)
|
||||
return err == nil && a.Is4()
|
||||
}
|
||||
|
||||
// natSettings checks the rows and returns them as settings, together with the
|
||||
// filter rules the rows ask for ("also allow").
|
||||
func (s Settings) natSettings(rows []NATRule, zones []string, errs FormErrors) (nat []any, allows []any) {
|
||||
seen := map[string]bool{}
|
||||
for i, r := range rows {
|
||||
field := func(n string) string { return fmt.Sprintf("nat[%d].%s", i, n) }
|
||||
r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
|
||||
r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
|
||||
r.Sources = strings.Join(splitList(r.Sources), ", ")
|
||||
r.Destination, r.ToAddress, r.ToPort = strings.TrimSpace(r.Destination), strings.TrimSpace(r.ToAddress), strings.TrimSpace(r.ToPort)
|
||||
if r.Protocol == "" {
|
||||
r.Protocol = "any"
|
||||
}
|
||||
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" &&
|
||||
r.Destination == "" && r.ToAddress == "" && r.ToPort == "") {
|
||||
continue
|
||||
}
|
||||
dnat, snat := r.Type == "dnat", r.Type == "snat"
|
||||
if !dnat && !snat && r.Type != "masquerade" {
|
||||
errs[field("type")] = "port forward, source NAT or masquerade"
|
||||
}
|
||||
inZones := func(z string) bool { return slices.Contains(zones, z) || z == "wan" }
|
||||
switch {
|
||||
case dnat && r.From == "":
|
||||
errs[field("from")] = "where the traffic comes from"
|
||||
case r.From != "" && !inZones(r.From):
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
switch {
|
||||
case dnat && !slices.Contains(zones, r.To):
|
||||
errs[field("to")] = "the zone of the host"
|
||||
case !dnat && !inZones(r.To):
|
||||
errs[field("to")] = "choose a zone"
|
||||
case !dnat && r.From == r.To:
|
||||
errs[field("to")] = "source and destination are the same zone"
|
||||
}
|
||||
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
|
||||
errs[field("protocol")] = "any, tcp, udp or icmp"
|
||||
}
|
||||
ports, msg := portList(r.Ports)
|
||||
if msg != "" {
|
||||
errs[field("ports")] = msg
|
||||
}
|
||||
sports, msg := portList(r.SourcePorts)
|
||||
if msg != "" {
|
||||
errs[field("sourceports")] = msg
|
||||
}
|
||||
if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
errs[field("ports")] = "ports need protocol tcp or udp"
|
||||
}
|
||||
var sources []any
|
||||
for _, a := range splitList(r.Sources) {
|
||||
if !validV4Net(a) {
|
||||
errs[field("sources")] = a + " is not an IPv4 address or CIDR"
|
||||
break
|
||||
}
|
||||
sources = append(sources, a)
|
||||
}
|
||||
if r.Destination != "" && !validV4Net(r.Destination) {
|
||||
errs[field("destination")] = "an IPv4 address or CIDR"
|
||||
}
|
||||
switch {
|
||||
case dnat || snat:
|
||||
if a, ok := ipv4(r.ToAddress); !ok {
|
||||
errs[field("toaddress")] = "an IPv4 address"
|
||||
} else if dnat {
|
||||
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { return n.Contains(a) }) {
|
||||
errs[field("toaddress")] = "not inside a network of zone " + r.To
|
||||
}
|
||||
}
|
||||
case r.ToAddress != "":
|
||||
errs[field("toaddress")] = "masquerade uses the outgoing interface's address"
|
||||
}
|
||||
toPort := 0
|
||||
if r.ToPort != "" {
|
||||
n, err := strconv.Atoi(r.ToPort)
|
||||
switch {
|
||||
case !dnat:
|
||||
errs[field("toport")] = "only for port forwards"
|
||||
case err != nil || n < 1 || n > 65535:
|
||||
errs[field("toport")] = "a port number"
|
||||
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
|
||||
errs[field("toport")] = "needs exactly one forwarded port"
|
||||
}
|
||||
toPort = n
|
||||
}
|
||||
if dnat {
|
||||
if r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
errs[field("protocol")] = "a port forward needs tcp or udp"
|
||||
} else if len(ports) == 0 {
|
||||
errs[field("ports")] = "name the ports to forward"
|
||||
}
|
||||
}
|
||||
if r.Hairpin && (!dnat || r.From != "wan") {
|
||||
errs[field("hairpin")] = "only for port forwards from wan"
|
||||
}
|
||||
key := fmt.Sprint(r.Type, r.From, r.To, r.Protocol, r.Ports, r.SourcePorts, r.Sources, r.Destination, r.ToAddress, r.ToPort)
|
||||
if seen[key] {
|
||||
errs[field("type")] = "the same rule twice"
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
row := map[string]any{"type": r.Type, "to": r.To}
|
||||
if r.From != "" {
|
||||
row["from"] = r.From
|
||||
}
|
||||
if r.Protocol != "any" {
|
||||
row["protocol"] = r.Protocol
|
||||
}
|
||||
if len(ports) > 0 {
|
||||
row["ports"] = ports
|
||||
}
|
||||
if len(sports) > 0 {
|
||||
row["sourcePorts"] = sports
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
row["sources"] = sources
|
||||
}
|
||||
if r.Destination != "" {
|
||||
row["destination"] = r.Destination
|
||||
}
|
||||
if r.ToAddress != "" && (dnat || snat) {
|
||||
row["toAddress"] = r.ToAddress
|
||||
}
|
||||
if toPort > 0 {
|
||||
row["toPort"] = toPort
|
||||
}
|
||||
if r.Hairpin {
|
||||
row["hairpin"] = true
|
||||
}
|
||||
nat = append(nat, row)
|
||||
if r.Allow && dnat {
|
||||
// What lets the forward through: translated connections to the host.
|
||||
allow := map[string]any{"from": r.From, "to": r.To, "destination": r.ToAddress, "protocol": r.Protocol, "natOnly": true}
|
||||
switch {
|
||||
case toPort > 0:
|
||||
allow["ports"] = []any{toPort}
|
||||
case len(ports) > 0:
|
||||
allow["ports"] = ports
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
allow["sources"] = sources
|
||||
}
|
||||
allows = append(allows, allow)
|
||||
}
|
||||
}
|
||||
return nat, allows
|
||||
}
|
||||
@@ -40,11 +40,11 @@
|
||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th>Remove</th></tr>
|
||||
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th title="only connections that a port forward translated">Forwarded only</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Rows}}
|
||||
<tr>
|
||||
<td><select name="rule.{{$i}}.from"><option value=""></option>
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
|
||||
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
|
||||
@@ -56,50 +56,62 @@
|
||||
<td><select name="rule.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input type="checkbox" name="rule.{{$i}}.natonly" {{if $r.NATOnly}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="rule.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
|
||||
<td><input name="rule.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
|
||||
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
|
||||
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
|
||||
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
|
||||
port forward translated. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>From the internet</legend>
|
||||
<input type="hidden" name="forwards" value="{{len .Forwards}}">
|
||||
<legend>Address translation (NAT)</legend>
|
||||
<input type="hidden" name="nats" value="{{len .NATs}}">
|
||||
<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><th>Type</th><th>From zone</th><th>From address</th><th>Sent to</th><th>Protocol</th><th>Port</th><th>To zone</th><th>Translate to</th><th>To port</th><th>Hairpin</th><th>Also allow</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .NATs}}
|
||||
<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}}
|
||||
<td><select name="nat.{{$i}}.type">
|
||||
{{range $p := list "dnat" "snat" "masquerade"}}<option value="{{$p}}" {{if eq $p $r.Type}}selected{{end}}>{{if eq $p "dnat"}}port forward{{else if eq $p "snat"}}source NAT{{else}}masquerade{{end}}</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}}
|
||||
<td><select name="nat.{{$i}}.from"><option value="">any</option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}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>
|
||||
<td><input name="nat.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="any"></td>
|
||||
<td><input name="nat.{{$i}}.destination" value="{{$r.Destination}}" size="14" spellcheck="false" placeholder="the router"></td>
|
||||
<td><select name="nat.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
|
||||
<td><select name="nat.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.toaddress" value="{{$r.ToAddress}}" size="14" spellcheck="false" placeholder="192.168.10.5"></td>
|
||||
<td><input name="nat.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
|
||||
<td><input type="checkbox" name="nat.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
|
||||
<td>{{if or $r.ToAddress $r.Ports}}{{if $r.Allow}}<input type="checkbox" name="nat.{{$i}}.allow" checked>{{else}}<input type="checkbox" name="nat.{{$i}}.allow">{{end}}{{end}}</td>
|
||||
<td>{{if or $r.ToAddress $r.Ports $r.To}}<input type="checkbox" name="nat.{{$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}}
|
||||
{{range $f := list "type" "from" "sources" "destination" "protocol" "ports" "to" "toaddress" "toport" "hairpin"}}{{with index $e (printf "nat[%d].%s" $i $f)}}<tr><td colspan="12" 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>
|
||||
<p class="hint"><strong>Port forward:</strong> traffic from the zone (<code>wan</code> is the internet) addressed to the router
|
||||
(or to the address in <em>Sent to</em>) on these ports goes to the host in <em>Translate to</em>, in zone <em>To zone</em>;
|
||||
the sender's address is kept. <em>Also allow</em> adds the filter rule that lets it through (without it the firewall still
|
||||
blocks it). <em>Hairpin</em> lets your LANs use a forward from the internet through the router's public address.<br>
|
||||
<strong>Masquerade</strong> and <strong>source NAT</strong> rewrite where traffic from the zone (or any zone) leaves through
|
||||
<em>To zone</em>: masquerade with the outgoing interface's address, source NAT with the address in <em>Translate to</em>.
|
||||
Internet-enabled LANs are already masqueraded out the uplink. Fill in the last row to add one.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user