package firewall import ( "encoding/json" "fmt" "net" "reflect" "regexp" "strconv" "strings" "time" ) var safeIdentifier = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`) var safeChain = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,15}$`) var commonFields = strings.Fields(`ip_version id order chain action protocol src_address dst_address src_port dst_port any_port in_interface out_interface in_interface_list out_interface_list packet_mark connection_mark routing_mark connection_type connection_state connection_nat_state src_address_list dst_address_list src_geoip dst_geoip comment disabled packets bytes content src_mac_address in_bridge_port out_bridge_port ipsec_policy dscp tcp_mss tos packet_size random tcp_flags icmp_options ttl time days limit nth connection_limit src_address_type dst_address_type ip_fragment log log_prefix jump_target address_list address_list_timeout`) // Capabilities defines the options actually implemented by this Linux backend. func Capabilities() map[string]any { actions := map[string][]string{ "filter": strings.Fields("accept drop reject return jump log passthrough add-src-to-address-list add-dst-to-address-list"), "nat": strings.Fields("accept return jump log passthrough masquerade src-nat dst-nat redirect netmap add-src-to-address-list add-dst-to-address-list"), "mangle": strings.Fields("accept drop return jump log passthrough mark-connection mark-routing mark-packet change-mss change-dscp change-ttl set-priority add-src-to-address-list add-dst-to-address-list"), } fields := map[string][]string{} for _, kind := range []string{"filter", "nat", "mangle"} { var model any switch kind { case "filter": model = FilterRule{} case "nat": model = NATRule{} case "mangle": model = MangleRule{} } present := map[string]bool{} typ := reflect.TypeOf(model) for i := 0; i < typ.NumField(); i++ { present[strings.Split(typ.Field(i).Tag.Get("json"), ",")[0]] = true } for _, field := range commonFields { if present[field] { fields[kind] = append(fields[kind], field) } } } fields["filter"] = append(fields["filter"], "reject_with") fields["nat"] = append(fields["nat"], "to_addresses", "to_ports") fields["mangle"] = append(fields["mangle"], strings.Fields("new_connection_mark new_routing_mark new_packet_mark new_mss new_dscp new_ttl new_priority passthrough")...) return map[string]any{"actions": actions, "fields": fields, "time_zone": "UTC", "geoip_ipv6": false} } func values(rule any) map[string]any { b, _ := json.Marshal(rule) var v map[string]any _ = json.Unmarshal(b, &v) return v } func text(v map[string]any, k string) string { s, _ := v[k].(string); return s } func populated(v any) bool { switch x := v.(type) { case nil: return false case string: return x != "" case bool: return x case float64: return x != 0 case []any: return len(x) > 0 } return false } func plain(s string) (string, string) { if strings.HasPrefix(s, "!") { return "! ", strings.TrimSpace(s[1:]) } return "", strings.TrimSpace(s) } func quote(s string) string { return strconv.Quote(s) } func intRange(s string, min, max int) error { n, e := strconv.Atoi(s) if e != nil || n < min || n > max { return fmt.Errorf("expected a number from %d to %d", min, max) } return nil } func portValue(s string) string { return strings.ReplaceAll(s, "-", ":") } func validatePorts(s string) error { _, s = plain(s) for _, p := range strings.Split(portValue(s), ",") { bounds := strings.Split(p, ":") if len(bounds) > 2 { return fmt.Errorf("invalid port range") } for _, n := range bounds { if err := intRange(n, 0, 65535); err != nil { return err } } if len(bounds) == 2 { a, _ := strconv.Atoi(bounds[0]) b, _ := strconv.Atoi(bounds[1]) if a > b { return fmt.Errorf("reversed port range") } } } if len(strings.Split(s, ",")) > 15 { return fmt.Errorf("at most 15 ports per rule") } return nil } func timeoutSeconds(s string) (int, error) { if s == "" || s == "0" || s == "none-dynamic" || s == "none-static" { return 0, nil } if strings.HasSuffix(s, "d") { n, e := strconv.ParseFloat(strings.TrimSuffix(s, "d"), 64) if e != nil || n <= 0 || n > 24855 { return 0, fmt.Errorf("invalid address list timeout") } return int(n * 86400), nil } d, e := time.ParseDuration(s) if e != nil || d < time.Second || d/time.Second > 2147483 { return 0, fmt.Errorf("timeout must be 0 or a duration such as 30s, 5m, 1h, 1d") } return int(d / time.Second), nil } func validateRule(kind string, rule any) error { v := values(rule) if disabled, _ := v["disabled"].(bool); disabled { return nil } if family, _ := v["ip_version"].(float64); family != 0 && family != 4 && family != 6 { return fmt.Errorf("IP family must be 4 or 6") } cap := Capabilities() allowed := map[string]bool{} for _, k := range cap["fields"].(map[string][]string)[kind] { allowed[k] = true } for key, value := range v { if s, ok := value.(string); ok && strings.ContainsAny(s, "\n\r\x00") { return fmt.Errorf("%s: control characters are not allowed", key) } } for _, key := range []string{"id", "jump_target"} { s := text(v, key) if s != "" && !safeIdentifier.MatchString(s) { return fmt.Errorf("invalid %s", key) } } chain := text(v, "chain") if chain == "" || !safeChain.MatchString(chain) { return fmt.Errorf("a valid chain is required (1–16 characters)") } if disabled, _ := v["disabled"].(bool); disabled { return nil } for key, value := range v { if !allowed[key] && populated(value) { return fmt.Errorf("%s is not supported by the Linux firewall; clear this legacy setting", key) } } action := text(v, "action") valid := false for _, a := range cap["actions"].(map[string][]string)[kind] { if action == a { valid = true } } if !valid { return fmt.Errorf("action %q is not supported in %s", action, kind) } for _, key := range []string{"src_address", "dst_address"} { _, s := plain(text(v, key)) if s != "" { if net.ParseIP(s) == nil { if _, _, err := net.ParseCIDR(s); err != nil { return fmt.Errorf("invalid %s", key) } } } } for _, key := range []string{"src_port", "dst_port", "any_port"} { if s := text(v, key); s != "" { if err := validatePorts(s); err != nil { return fmt.Errorf("%s: %w", key, err) } } } for _, key := range []string{"in_interface", "out_interface", "in_interface_list", "out_interface_list", "in_bridge_port", "out_bridge_port"} { _, s := plain(text(v, key)) if s != "" && !safeIdentifier.MatchString(s) { return fmt.Errorf("invalid %s", key) } } for _, pair := range [][2]string{{"in_interface", "in_interface_list"}, {"out_interface", "out_interface_list"}} { if text(v, pair[0]) != "" && text(v, pair[1]) != "" { return fmt.Errorf("choose %s or %s, not both", pair[0], pair[1]) } } for _, key := range []string{"in_interface_list", "out_interface_list"} { _, s := plain(text(v, key)) if strings.HasPrefix(text(v, key), "!") { return fmt.Errorf("negated interface lists are not supported; use an explicit negated interface") } if s != "" && len(resolveInterfaceList(s)) == 0 { return fmt.Errorf("interface list %q has no members", s) } } for _, key := range []string{"src_address_list", "dst_address_list", "address_list"} { _, s := plain(text(v, key)) if s != "" { if err := validateListName(s); err != nil { return err } } } if text(v, "packet_mark") != "" && text(v, "routing_mark") != "" && text(v, "packet_mark") != text(v, "routing_mark") { return fmt.Errorf("packet mark and routing mark use the same Linux mark; choose one criterion") } if action == "jump" && !safeChain.MatchString(text(v, "jump_target")) { return fmt.Errorf("jump target must be a valid custom chain") } if strings.HasPrefix(action, "add-") && text(v, "address_list") == "" { return fmt.Errorf("address list is required") } if _, err := timeoutSeconds(text(v, "address_list_timeout")); err != nil { return err } if kind == "nat" { if text(v, "src_port") != "" || text(v, "dst_port") != "" || text(v, "any_port") != "" || text(v, "to_ports") != "" { _, protocol := plain(text(v, "protocol")) if protocol != "tcp" && protocol != "udp" { return fmt.Errorf("NAT ports require an explicit TCP or UDP protocol; create separate rules for both") } } if action == "netmap" && text(v, "to_ports") != "" { return fmt.Errorf("netmap translates addresses only; use src-nat/dst-nat to translate ports") } if (action == "src-nat" || action == "dst-nat" || action == "netmap") && text(v, "to_addresses") == "" { return fmt.Errorf("to-addresses is required for %s", action) } if s := text(v, "to_addresses"); s != "" { if action == "netmap" { if _, _, err := net.ParseCIDR(s); err != nil { return fmt.Errorf("netmap requires a destination CIDR") } } else { for _, ip := range strings.Split(s, "-") { if net.ParseIP(ip) == nil { return fmt.Errorf("invalid to-addresses") } } } } if s := text(v, "to_ports"); s != "" { if err := validatePorts(s); err != nil { return fmt.Errorf("to-ports: %w", err) } _, p := plain(text(v, "protocol")) if p != "tcp" && p != "udp" { return fmt.Errorf("to-ports requires TCP or UDP") } } } if kind == "mangle" { required := map[string]string{"mark-connection": "new_connection_mark", "mark-routing": "new_routing_mark", "mark-packet": "new_packet_mark", "change-mss": "new_mss", "change-ttl": "new_ttl", "set-priority": "new_priority"} if key := required[action]; key != "" && text(v, key) == "" { return fmt.Errorf("%s is required", key) } if action == "change-dscp" { n, _ := v["new_dscp"].(float64) if n < 0 || n > 63 { return fmt.Errorf("DSCP must be 0–63") } } if action == "change-mss" { _, p := plain(text(v, "protocol")) if p != "" && p != "all" && p != "tcp" { return fmt.Errorf("change-mss requires TCP") } s := text(v, "new_mss") if s != "clamp-to-pmtu" { if err := intRange(s, 1, 65535); err != nil { return fmt.Errorf("new-mss: %w", err) } } } if action == "set-priority" { if !strings.EqualFold(chain, "postrouting") { return fmt.Errorf("set-priority requires the postrouting chain") } if err := intRange(text(v, "new_priority"), 0, 65535); err != nil { return err } } } for _, key := range []string{"connection_state", "connection_nat_state"} { if items, ok := v[key].([]any); ok { for _, item := range items { state, _ := item.(string) allowed := map[string]bool{"new": true, "established": true, "related": true, "invalid": true, "untracked": true} if key == "connection_nat_state" { allowed = map[string]bool{"srcnat": true, "dstnat": true, "snat": true, "dnat": true} } if !allowed[strings.ToLower(state)] { return fmt.Errorf("invalid %s", key) } } } } if action == "netmap" && v["ip_version"] == float64(6) { return fmt.Errorf("netmap is IPv4-only") } for _, key := range []string{"src_geoip", "dst_geoip"} { if s := text(v, key); s != "" { _, s = plain(s) s = strings.TrimPrefix(s, "geo:") s = strings.TrimPrefix(s, "geo_") s = strings.ToUpper(s) if !regexp.MustCompile(`^[A-Z]{2}$`).MatchString(s) && s != "EU" && s != "BALKANS" && s != "BLOCKLIST" && s != "HIGHRISK" { return fmt.Errorf("invalid GeoIP country or preset") } if v["ip_version"] == float64(6) { return fmt.Errorf("GeoIP is IPv4-only") } } } if action == "reject" { if s := text(v, "reject_with"); s != "" { allowed := strings.Fields("icmp-net-unreachable icmp-network-unreachable icmp-host-unreachable icmp-proto-unreachable icmp-port-unreachable icmp-net-prohibited icmp-host-prohibited icmp-admin-prohibited tcp-reset") if v["ip_version"] == float64(6) { allowed = strings.Fields("icmp6-no-route icmp6-adm-prohibited icmp6-addr-unreachable icmp6-port-unreachable tcp-reset") } valid := false for _, option := range allowed { valid = valid || s == option } if !valid { return fmt.Errorf("reject type is not supported for this IP family") } } } return validateAdvanced(v) } func validateListName(s string) error { if strings.TrimSpace(s) != s || len(s) > 128 || s == "" || strings.HasPrefix(s, "geo:") || strings.HasPrefix(s, "geo_") || strings.ContainsAny(s, "\n\r\x00") { return fmt.Errorf("invalid or reserved address list name %q", s) } return nil } func ValidateRule(kind string, rule any) error { if kind != "filter" && kind != "nat" && kind != "mangle" { return fmt.Errorf("unknown firewall rule kind") } rule = normalizeNativeRule(kind, rule) if err := validateRule(kind, rule); err != nil { return err } if err := validateNewPortProtocol(rule); err != nil { return err } return validateChainPlacement(kind, rule) } func validateNewPortProtocol(rule any) error { v := values(rule) if text(v, "src_port") == "" && text(v, "dst_port") == "" && text(v, "any_port") == "" && text(v, "to_ports") == "" { return nil } inv, protocol := plain(strings.ToLower(text(v, "protocol"))) if inv != "" || (protocol != "tcp" && protocol != "udp") { return fmt.Errorf("port matches require explicit TCP or UDP without protocol inversion; use separate rules for TCP and UDP") } return nil } // Accept native target names at the API boundary while preserving the storage // format used by existing installations and older clients. func normalizeNativeRule(kind string, rule any) any { if r, ok := rule.(FilterRule); ok && kind == "filter" { r.Action = strings.ToLower(r.Action) return r } if r, ok := rule.(MangleRule); ok && kind == "mangle" { r.Action = strings.ToLower(r.Action) switch r.Action { case "mark": r.Action = "mark-packet" case "connmark": r.Action = "mark-connection" case "tcpmss": r.Action = "change-mss" case "dscp": r.Action = "change-dscp" case "ttl", "hl": r.Action = "change-ttl" case "classify": r.Action = "set-priority" } return r } if kind == "nat" { if r, ok := rule.(NATRule); ok { switch strings.ToLower(r.Action) { case "dnat": r.Action = "dst-nat" case "snat": r.Action = "src-nat" default: r.Action = strings.ToLower(r.Action) } return r } } return rule } // Validate new and edited rules without changing the interpretation of saved rules. func validateChainPlacement(kind string, rule any) error { v := values(rule) chain := strings.ToLower(strings.TrimSpace(text(v, "chain"))) if text(v, "action") == "jump" { target := strings.ToLower(strings.TrimSpace(text(v, "jump_target"))) if target == chain || target == "input" || target == "output" || target == "forward" || target == "prerouting" || target == "postrouting" || target == "srcnat" || target == "dstnat" { return fmt.Errorf("jump requires a different user-defined chain") } } if kind != "nat" && (chain == "srcnat" || chain == "dstnat") { return fmt.Errorf("chain %s is available in the nat table only", chain) } if kind == "nat" { switch chain { case "dstnat": chain = "prerouting" case "srcnat": chain = "postrouting" } } reserved := map[string]bool{"input": true, "output": true, "forward": true, "prerouting": true, "postrouting": true} if !reserved[chain] { return nil } // User chains are reached through an explicit jump. if kind == "filter" && (chain == "prerouting" || chain == "postrouting") || kind == "nat" && chain == "forward" { return fmt.Errorf("chain %s is not available in the %s table", chain, kind) } if (chain == "output" || chain == "postrouting") && (text(v, "in_interface") != "" || text(v, "in_interface_list") != "" || text(v, "in_bridge_port") != "" || text(v, "src_mac_address") != "") { return fmt.Errorf("chain %s cannot match an incoming interface or source MAC", chain) } if (chain == "input" || chain == "prerouting") && (text(v, "out_interface") != "" || text(v, "out_interface_list") != "" || text(v, "out_bridge_port") != "") { return fmt.Errorf("chain %s cannot match an outgoing interface", chain) } if kind == "nat" { switch text(v, "action") { case "dst-nat", "redirect": if chain != "prerouting" && chain != "output" { return fmt.Errorf("%s requires prerouting/dstnat or output", text(v, "action")) } case "masquerade": if chain != "postrouting" { return fmt.Errorf("masquerade requires postrouting/srcnat") } case "src-nat": if chain != "postrouting" && chain != "input" { return fmt.Errorf("src-nat requires postrouting/srcnat or input") } } } return nil }