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 }