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
242 lines
7.5 KiB
Go
242 lines
7.5 KiB
Go
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
|
|
}
|