package firewall import ( "fmt" "os" "os/exec" "strings" ) const tableName = "nano-router" // TproxyPort is the fixed Mihomo transparent-proxy port, always enabled. const TproxyPort = 7893 // TproxyMark is the fwmark set on packets that should be routed to tproxy. const TproxyMark = 1 // TproxyTable is the ip routing table used for fwmark-based local delivery. const TproxyTable = 100 // 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 } // ClientPolicies holds per-policy IP lists derived from known device policies. type ClientPolicies struct { DisabledIPs []string // drop all traffic for these IPs VPNIPs []string // redirect TCP/UDP to tproxy for these IPs } // IsInstalled reports whether the nft binary is available. func IsInstalled() bool { _, err := exec.LookPath("nft") return err == nil } // CleanupAll removes all kernel state managed by nano-router so that a fresh // applyConfig() starts from a guaranteed clean slate: // - All nftables tables created by nano-router (main + traffic) // - The ip rule and route used for tproxy fwmark delivery // // Errors are silently ignored — most arise because entries don't exist yet. func CleanupAll() { // Remove nftables tables. The traffic table is managed by the traffic tracker. exec.Command("nft", "delete", "table", "ip", tableName).Run() exec.Command("nft", "delete", "table", "ip", "nano-router-nat").Run() exec.Command("nft", "delete", "table", "ip", "nano-router-traffic").Run() // Remove all ip rules pointing to our tproxy routing table. // Loop to handle duplicate entries that can accumulate across restarts. mark := fmt.Sprintf("%d", TproxyMark) table := fmt.Sprintf("%d", TproxyTable) for { err := exec.Command("ip", "rule", "del", "fwmark", mark, "table", table).Run() if err != nil { break } } // Remove the local default route in our tproxy routing table. exec.Command("ip", "route", "del", "local", "0.0.0.0/0", "dev", "lo", "table", table, ).Run() } // SetupTproxyRouting installs the ip rule and route needed for TPROXY to work. // Packets marked with TproxyMark are routed to the loopback interface (local delivery), // which allows Mihomo's tproxy socket to receive them. // Call CleanupAll first to avoid duplicate entries. func SetupTproxyRouting() { mark := fmt.Sprintf("%d", TproxyMark) table := fmt.Sprintf("%d", TproxyTable) exec.Command("ip", "rule", "add", "fwmark", mark, "table", table).Run() exec.Command("ip", "route", "add", "local", "0.0.0.0/0", "dev", "lo", "table", table, ).Run() } // ApplyAll atomically regenerates the complete nftables ruleset: // - Tproxy prerouting for cp.VPNIPs (mangle priority, TPROXY → Mihomo :TproxyPort) // - NAT masquerade for natCfg.Interfaces // - Disabled client IP drops (cp.DisabledIPs) // - User rules from fwCfg (in order, enabled only) // - LAN isolation (if fwCfg.VLANIsolation): blocks traffic between any two LAN interfaces // - Default accept from LAN interfaces to WAN // // lanIfaces is the union of NAT interfaces and all VLAN interfaces. func ApplyAll(natCfg NATConfig, fwCfg Config, lanIfaces []string, cp ClientPolicies) 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", "nano-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 hasDisabled := len(cp.DisabledIPs) > 0 hasVPN := len(cp.VPNIPs) > 0 hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2 if !hasNAT && !hasDisabled && !hasVPN && !hasVLANIsolation && len(activeRules) == 0 { return nil } var sb strings.Builder fmt.Fprintf(&sb, "table ip %s {\n", tableName) // ── Tproxy prerouting chain (mangle priority) ──────────────────────────── // Intercepts TCP/UDP from VPN-policy clients and delivers them to Mihomo. // Runs before the forward chain, so forwarding rules don't affect these packets. if hasVPN { sb.WriteString(" chain tproxy_pre {\n") sb.WriteString(" type filter hook prerouting priority mangle; policy accept;\n") // Never redirect traffic destined for the router itself (admin panel, // SSH, DNS served locally, etc.). Without this, Mihomo intercepts // connections to the router's own IPs and the admin UI becomes unreachable. sb.WriteString(" fib daddr type local return\n") for _, ip := range cp.VPNIPs { fmt.Fprintf(&sb, " ip saddr %s meta l4proto { tcp, udp } tproxy to :%d meta mark set %d\n", ip, TproxyPort, TproxyMark, ) } sb.WriteString(" }\n") } // ── 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") // Drop traffic for disabled clients (both directions). for _, ip := range cp.DisabledIPs { fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) } // User-defined forward rules (ordered, enabled only). 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 outbound to WAN. 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) } // Set up ip rule/route for tproxy fwmark routing when VPN clients are active. if hasVPN { SetupTproxyRouting() } // Flush connection tracking so existing sessions are re-evaluated. flushConntrack() return nil } // flushConntrack clears the kernel connection tracking table so that all traffic // is re-evaluated against the current nftables ruleset. func flushConntrack() { if err := exec.Command("conntrack", "-F").Run(); err == nil { return } _ = 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, " ") }