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 }