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
227 lines
6.5 KiB
Go
227 lines
6.5 KiB
Go
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
|
|
}
|