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:
2026-10-30 10:20:50 +01:00
co-authored by Claude Sonnet 5.5
parent 9f9628eecc
commit ec2a10658f
5 changed files with 419 additions and 35 deletions
+226
View File
@@ -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
}