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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user