package firewall import ( "fmt" "os" "os/exec" "strings" ) const tableName = "alpine-router" // Rule is a single stateless forward-filter rule. type Rule struct { ID string `yaml:"id" json:"id"` Enabled bool `yaml:"enabled" json:"enabled"` Action string `yaml:"action" json:"action"` // accept | drop | reject Protocol string `yaml:"protocol" json:"protocol"` // tcp | udp | icmp | all SrcAddr string `yaml:"src_addr" json:"src_addr"` // CIDR or IP, empty = any SrcPort string `yaml:"src_port" json:"src_port"` // "80" | "80-443", empty = any DstAddr string `yaml:"dst_addr" json:"dst_addr"` DstPort string `yaml:"dst_port" json:"dst_port"` InIface string `yaml:"in_iface" json:"in_iface"` // input interface, empty = any OutIface string `yaml:"out_iface" json:"out_iface"` // output interface, empty = any Comment string `yaml:"comment" json:"comment"` } // Config is the top-level firewall config stored in config.yaml. type Config struct { Rules []Rule `yaml:"rules" json:"rules"` VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"` } // NATConfig holds NAT masquerade settings (passed to avoid a direct nat import). type NATConfig struct { Interfaces []string } // IsInstalled reports whether the nft binary is available. func IsInstalled() bool { _, err := exec.LookPath("nft") return err == nil } // ApplyAll atomically regenerates the complete nftables ruleset: // - NAT masquerade for natCfg.Interfaces // - Blocked client IP drops // - User rules from fwCfg (in order, enabled only) // - LAN isolation (if fwCfg.VLANIsolation): blocks traffic between any two LAN interfaces // (native + tagged VLANs). User rules placed above have priority. // - Default accept from LAN interfaces to WAN // // lanIfaces is the union of NAT interfaces and all VLAN interfaces — every interface // that serves a local subnet. Isolation prevents any two of them from talking directly. func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) error { if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { return fmt.Errorf("enable ip_forward: %w", err) } // Remove both old and new table names to ensure clean state. exec.Command("nft", "delete", "table", "ip", "alpine-router-nat").Run() exec.Command("nft", "delete", "table", "ip", tableName).Run() var activeRules []Rule for _, r := range fwCfg.Rules { if r.Enabled { activeRules = append(activeRules, r) } } hasNAT := len(natCfg.Interfaces) > 0 hasBlocked := len(blockedIPs) > 0 hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2 if !hasNAT && !hasBlocked && !hasVLANIsolation && len(activeRules) == 0 { return nil } var sb strings.Builder fmt.Fprintf(&sb, "table ip %s {\n", tableName) // ── Forward chain ──────────────────────────────────────────────────────── sb.WriteString(" chain forward {\n") sb.WriteString(" type filter hook forward priority filter; policy drop;\n") sb.WriteString(" ct state established,related accept\n") for _, ip := range blockedIPs { fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) } for _, rule := range activeRules { line := buildRuleLine(rule) if line == "" { continue } if rule.Comment != "" { fmt.Fprintf(&sb, " # %s\n", rule.Comment) } fmt.Fprintf(&sb, " %s\n", line) } // LAN isolation — drop traffic between any two local (LAN) interfaces. // Placed AFTER user rules so explicit allow rules above still take effect. if hasVLANIsolation { quoted := make([]string, len(lanIfaces)) for i, v := range lanIfaces { quoted[i] = fmt.Sprintf("%q", v) } set := "{ " + strings.Join(quoted, ", ") + " }" fmt.Fprintf(&sb, " iifname %s oifname %s drop\n", set, set) } // Allow from LAN/VLAN interfaces to WAN (non-VLAN, non-blocked traffic falls through above). for _, iface := range natCfg.Interfaces { fmt.Fprintf(&sb, " iifname %q accept\n", iface) } sb.WriteString(" }\n") // ── Postrouting (masquerade) ───────────────────────────────────────────── if hasNAT { sb.WriteString(" chain postrouting {\n") sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") for _, iface := range natCfg.Interfaces { fmt.Fprintf(&sb, " iifname %q masquerade\n", iface) } sb.WriteString(" }\n") } sb.WriteString("}\n") cmd := exec.Command("nft", "-f", "-") cmd.Stdin = strings.NewReader(sb.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err) } // Flush connection tracking table so existing sessions are re-evaluated // against the new ruleset. Without this, traffic already tracked as // "established/related" bypasses new drop rules until the session ends. flushConntrack() return nil } // flushConntrack clears the kernel connection tracking table so that all traffic // is re-evaluated against the current nftables ruleset. This is necessary when // adding new drop/reject rules to prevent previously-established sessions from // continuing to bypass the new rules via ct state established,related accept. func flushConntrack() { // Preferred: conntrack utility (part of conntrack-tools package). if err := exec.Command("conntrack", "-F").Run(); err == nil { return } // Fallback: write to /proc (available when nf_conntrack module is loaded). _ = os.WriteFile("/proc/sys/net/netfilter/nf_conntrack_flush", []byte("1"), 0644) } // buildRuleLine converts a Rule to a single nftables match+action string. // Returns "" if the rule has no valid action. func buildRuleLine(r Rule) string { if r.Action == "" { return "" } var parts []string if r.InIface != "" { parts = append(parts, fmt.Sprintf("iifname %q", r.InIface)) } if r.OutIface != "" { parts = append(parts, fmt.Sprintf("oifname %q", r.OutIface)) } if r.SrcAddr != "" { parts = append(parts, "ip saddr "+r.SrcAddr) } if r.DstAddr != "" { parts = append(parts, "ip daddr "+r.DstAddr) } proto := strings.ToLower(r.Protocol) switch proto { case "tcp", "udp": if r.SrcPort != "" || r.DstPort != "" { if r.SrcPort != "" { parts = append(parts, proto+" sport "+r.SrcPort) } if r.DstPort != "" { parts = append(parts, proto+" dport "+r.DstPort) } } else { parts = append(parts, "ip protocol "+proto) } case "icmp": parts = append(parts, "ip protocol icmp") } parts = append(parts, r.Action) return strings.Join(parts, " ") }