#!/usr/bin/utpl -S {%- /* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2025 ImmortalWrt.org * * Declares every homeproxy_* fw4 chain/set. The inventory of those objects is * tracked in scripts/fw4_names.sh, which init.d/homeproxy-pro uses to clean them up * on stop; add/remove entries there together with any change below. * tests/ucode/test_fw4_names.sh compares both lists, and * tests/ucode/test_firewall_template.sh renders this template and checks the * objects actually survive into the generated nftables script. * * Every UCI-derived value (IPv4, MAC, interface name, port) goes through * one of the *_to_nftarr() helpers in firewall_utils.uc - not because the * UCI writers are untrusted (UI validation is there), but because UCI is * writable from anywhere on the LAN and the package principle is "backend * must enforce". Why those helpers exist, and why dropping silently * matches the IPv6 path, is pinned by tests/ucode/test_firewall_validators.uc * and guard 11 in tests/arch-guard.sh. * * Keep this comment inside the ucode block: text placed before `{%-` has its * trailing newline trimmed, which would glue it onto the first nft statement. */ import { readfile } from 'fs'; import { cursor } from 'uci'; import { isEmpty, isValidCIDR, udp_transport_verdict } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc'; import { ipv4_to_nftarr, mac_to_nftarr, iface_to_nftarr, ports_to_nftarr, isValidPort, isValidIface } from '/etc/homeproxy-pro/scripts/firewall_utils.uc'; const fw4 = require('fw4'); function array_to_nftarr(array) { if (type(array) !== 'array') return null; return `{ ${join(', ', uniq(array))} }`; } function resolve_ipv6(str) { if (isEmpty(str)) return null; let ipv6 = fw4.parse_subnet(str)?.[0]; if (!ipv6 || ipv6.family !== 6) return null; if (ipv6.bits > -1) return `${ipv6.addr}/${ipv6.bits}`; else return ipv6.addr; } /* The node server addresses the generator exported (generate_client.uc's * export_node_addresses()), as an nftables anonymous set - or null when there * is nothing to protect. * * Only the literal-address form is rendered here. A node configured with a * host name reaches the same set at runtime: the dnsmasq snippet runtime/dns.sh * writes resolves the name and back-fills the set through `nftset=`, so one * mechanism covers both without this template needing a resolver of its own. * A literal address has no name to resolve, so it can only come from here. * * readfile() rather than a UCI read on purpose: the generator is the only * place that knows which nodes are actually in use (main_node, main_udp_node, * urltest membership, custom-mode routing_nodes, wireguard endpoints), and a * copy of that selection in this template is a copy that can drift. A missing * file renders null, which is the behaviour before this existed. */ function node_addr_to_nftarr(run_dir, family) { let raw = readfile(run_dir + '/node-addr-ips.txt'); if (isEmpty(raw)) return null; let out = []; for (let entry in split(trim(raw), /[\r\n]/)) { let parsed = fw4.parse_subnet(entry)?.[0]; if (!parsed || parsed.family !== family) continue; push(out, (parsed.bits > -1) ? `${parsed.addr}/${parsed.bits}` : parsed.addr); } return isEmpty(out) ? null : array_to_nftarr(out); } /* Whether the node bypass has anything to protect at all. Either export file counts. The domains file is the one that matters most: a host-name node - the common case, and the one the device this was written for uses - appears only there, and its set has to exist before dnsmasq can put anything in it. Gating on the literal-address file instead is what left the whole mechanism doing nothing. */ function node_addr_protected(run_dir) { return !isEmpty(readfile(run_dir + '/node-addr-ips.txt')) || !isEmpty(readfile(run_dir + '/node-addr-domains.txt')); } /* Normalize a list of IPv6 entries into an nftables anonymous set (or null when empty), so IPv6 ACLs match in a single set lookup instead of one rule per address. */ function ipv6_to_nftarr(list) { if (isEmpty(list)) return null; let out = []; for (let ip in list) { const r = resolve_ipv6(ip); if (!isEmpty(r)) push(out, r); } return isEmpty(out) ? null : `{ ${join(', ', out)} }`; } function resolve_mark(str) { if (isEmpty(str)) return null; let mark = fw4.parse_mark(str); if (isEmpty(mark)) return null; if (mark.mask === 0xffffffff) return fw4.hex(mark.mark); else if (mark.mark === 0) return `mark and ${fw4.hex(~mark.mask & 0xffffffff)}`; else if (mark.mark === mark.mask) return `mark or ${fw4.hex(mark.mark)}`; else if (mark.mask === 0) return `mark xor ${fw4.hex(mark.mark)}`; else return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`; } /* Single-value guards for the five fields that were still read straight from * UCI and interpolated into the nft text: self_mark, redirect_port, * tproxy_port, dns_port and tun_name. H1 fixed the *_to_nftarr() families but * missed these; a ';' in one of them is a statement boundary in nft, and a * non-numeric value makes fw4 reject the whole ruleset, which takes the * router's firewall down with it. A rejected value falls back to the built-in * default rather than being emitted, so the side keeps working. */ function port_or(str, fallback) { return isValidPort(str) ? str : fallback; } function iface_or(str, fallback) { return isValidIface(str) ? str : fallback; } /* self_mark is used as a *match* value (`meta mark `), so it has to be a * plain number. resolve_mark() also returns set-expressions such as * `mark and 0xff xor 0x100` for masked input, and those are only valid after * `mark set`; anything that is not a bare hex value falls back. */ function mark_value_or(str, fallback) { const m = resolve_mark(str); return match(m || '', /^0x[0-9a-fA-F]+$/) ? m : fallback; } /* gfwlist mode routes by the address set that dnsmasq fills through * `nftset=`, and the gates below compare the destination against it. A dnsmasq * built without HAVE_NFTSET never fills the set, and `ip daddr != @` * is true for every packet - the whole mode then silently routes everything * direct while the DNS side keeps answering through sing-box. init.d records * the capability in resources/.dnsmasq-nftset before this template is rendered, * so the gates can be omitted there and sing-box decides by geoip instead. * A missing file means "capability unknown" and keeps the historical output. */ function dnsmasq_nftset() { let marker = null; try { marker = trim(readfile(resources_dir + '/.dnsmasq-nftset')); } catch (e) { return true; } return marker === '' || marker === '1'; } /* Misc config */ const resources_dir = '/etc/homeproxy-pro/resources'; const run_dir = '/var/run/homeproxy-pro'; /* Resolved here, in the ucode block, rather than with `{% set %}` in the * template body below: utpl's `set` tag takes a plain expression and rejects a * call outright - `{% set x = f(); %}` is a parse error ("Unexpected token, * Expecting ';'"). A parse error kills the whole render, fw4_post.nft comes * out empty, and the health gate silently keeps the previous ruleset, so a * template that fails this way does not look broken at all. r41 shipped * `{% set node_addr_v4 = node_addr_to_nftarr(4); %}` and never rendered once. * * Both helpers take run_dir as a parameter rather than closing over it. In * this ucode a closure captures the block variables that already exist where * the function is DEFINED, not where it is called: a function defined above * `const run_dir` cannot see run_dir even when it is called far below, and * the error is "Reference error: access to undeclared variable run_dir" - * which reads as though run_dir were missing, when it is in fact declared 80 * lines above the call. Passing it in is the only spelling that works. */ const node_protect = node_addr_protected(run_dir); const node_addr_v4 = node_addr_to_nftarr(run_dir, 4); const node_addr_v6 = node_addr_to_nftarr(run_dir, 6); /* UCI config start */ const cfgname = 'homeproxy-pro'; const uci = cursor(); uci.load(cfgname); const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china'; let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic; if (routing_mode !== 'custom') { outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil'; outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil'; china_dns_server = uci.get(cfgname, 'config', 'china_dns_server'); } else { outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil'; bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0'; } let routing_port = uci.get(cfgname, 'config', 'routing_port'); if (routing_port === 'common') routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418'; const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy', ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0'; /* --- can IPv6 be classified as mainland at all? ------------------------ * * Every IPv6 decision in this template used to hang off ipv6_support * alone. That flag only says whether the user *wants* IPv6 handled; it * says nothing about whether we can tell a mainland IPv6 destination from * a foreign one. That test is the homeproxy_mainland_addr_v6 set, and the * set is filled straight from resources/china_ip6.txt - so an install whose * list is missing, empty or entirely malformed produced a *syntactically * valid but permanently empty* set. `ip6 daddr @homeproxy_mainland_addr_v6 * counter return` then matched nothing, and every mainland IPv6 connection * fell through to the proxy hop at the bottom of the chain: a silent, * unlogged inversion of the mode's promise, and the reason a user who * enables IPv6 finds Chinese sites taking the long way round. * * nft happily accepts `elements = { }`, so nothing downstream could notice. * The count is taken here, once, with the same isValidCIDR() the set * rendering uses - a list of entries this function rejects would render an * empty set too, so counting something else would not be the same test. * * v6_handled is what every v6 branch below now keys off. When the list is * unusable it is false, and the ruleset degrades to exactly the IPv6-off * shape: IPv6 is not intercepted, not marked, not redirected. That is a * deliberate choice of failure direction. The alternative - keep * intercepting and proxy everything - trades a wrong-but-working path for * a leaked one, and the user cannot see either from the client. Passing * IPv6 through unproxied is the state the router was in before this package * touched it, it is recoverable (fix the list, reload), and it is announced: * see the comment on the dstnat jump below and the warning * hp_prepare_runtime_files() logs on every start. */ let cn_ipv6_count = 0; for (let entry in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)) if (isValidCIDR(entry, 6)) cn_ipv6_count++; const cn_ipv6_ready = (cn_ipv6_count > 0); const v6_handled = (ipv6_support === '1') && cn_ipv6_ready; /* The degraded-state warning, as a ready-made nft comment suffix. * * A real `comment` rather than a `#` line on purpose: fw4 keeps `#` lines * only in the generated file, while a rule comment is part of the rule and * survives into `nft list ruleset` - which is where someone debugging "why * is my IPv6 not going through the proxy" actually looks. * * Computed once and interpolated into all three jump rules rather than * wrapped around each of them: the rule text is identical either way, only * the comment differs, and one string is one place to change if the wording * ever has to. */ const v6_warn_comment = ((ipv6_support === '1') && !cn_ipv6_ready) ? ' comment "!homeproxy-pro: WARNING china_ip6.txt has 0 usable prefixes - IPv6 is NOT proxied, run update_resources.sh china_ip6"' : ''; let self_mark, redirect_port, tproxy_port, tproxy_mark, tun_name, tun_mark, tun_self_mark; if (match(proxy_mode, /redirect/)) { self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100')); redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331'); } /* Whether QUIC has to be rejected, and why. * * This used to ask "is there a UDP node?" and that question has been wrong in * both directions. * * Wrong by too much: it keyed off `routing_mode !== 'custom'` alone, so a * router with `main_udp_node` set still rejected QUIC on UDP 443 (measured on * the device: 1722 QUIC packets rejected in one sample) and browsers that had * already learned HTTP/3 fell back to TCP needlessly. * * Wrong by too little, which is what the current form is: "a UDP node exists" * says nothing about what that node's transport is. `main_udp_node = same` * with a TCP-transport main node is the shape that slips through - the UI * label reads "Same as main node" and nothing tells the user the consequence. * The UDP is proxied, yes, but it is tunnelled over TCP, and QUIC over a TCP * tunnel stalls: measured with both nodes on the SAME server and only the * transport differing (anytls TCP:443 vs hysteria2 QUIC/UDP:50466), UDP came * back at 174 ms +/- 1 ms through hysteria2 and produced a 568 ms spike with * 37 ms jitter and outright loss through anytls. * * So the gate is now the transport, resolved by udp_transport_verdict() in * homeproxy-pro.uc - one definition, shared with the diagnostic report so the log * and the rule cannot disagree. * * NOTE the deliberate asymmetry with the tproxy chain below and in the UDP * tproxy block: those still key off the plain "is there a UDP node" test, * because they decide whether UDP is PROXIED at all. This gate decides * whether QUIC is WORTH proxying. A TCP-transport UDP node still proxies * games, voice and everything else; only QUIC is turned away, so the browser * falls straight back to a TCP path that the same tunnel carries far better. */ const udp_transport = udp_transport_verdict(cfgname, uci); const udp_quic_rejected = !udp_transport.native; if (match(proxy_mode, /tproxy/)) if (outbound_udp_node !== 'nil' || routing_mode === 'custom') { tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332'); tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101') || resolve_mark('101'); } if (match(proxy_mode, /tun/)) { tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0'); tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_mark('102'); /* The mark sing-box stamps on its own outbound sockets in tun mode * (generator: route.default_mark, same UCI option as self_mark elsewhere). * The TUN output chain has to return on it, otherwise the tunnel captures * sing-box's own dial - including the one to the node - and the only guard * it had was `iifname `, which cannot match on the output hook. */ tun_self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100')); } const control_options = [ "listen_interfaces", "lan_proxy_mode", "lan_direct_mac_addrs", "lan_direct_ipv4_ips", "lan_direct_ipv6_ips", "lan_proxy_mac_addrs", "lan_proxy_ipv4_ips", "lan_proxy_ipv6_ips", "lan_gaming_mode_mac_addrs", "lan_gaming_mode_ipv4_ips", "lan_gaming_mode_ipv6_ips", "lan_global_proxy_mac_addrs", "lan_global_proxy_ipv4_ips", "lan_global_proxy_ipv6_ips", "wan_proxy_ipv4_ips", "wan_proxy_ipv6_ips", "wan_direct_ipv4_ips", "wan_direct_ipv6_ips" ]; const control_info = {}; for (let i in control_options) control_info[i] = uci.get(cfgname, 'control', i); /* Devices of the wan zone, resolved by fw4 itself. * * The intercept layer must not cover the wan side. When the user names * interfaces the guards below use exactly those; when the option is empty the * UI says "all", and "all" used to include wan - so wan-inbound traffic to the * router's own address on a routing_port was captured by the redirect / tproxy * jump and by the DNS hijack as well. It happened to be masked on the device * (dnsmasq's own dns_redirect suppressed the DNS half, fw4's input policy * dropped the rest), but the hole was structural: any wan-side service on one * of the routing ports would have been hijacked. Empty now means "everything * except the wan zone". * * fw4's own ruleset template renders zone devices from `zone.match_devices`, * so this asks the same source. A render outside fw4 (the off-target test * harness has no zone state) leaves the list empty and the guards below fall * back to the previous behaviour. */ let wan_devices = []; try { /* fw4.load() is what resolves a zone's *logical* networks to the devices * that actually appear in `iifname` - the wan zone is `network wan` but * the device is `pppoe-wan`, and this template runs OUTSIDE fw4's own * process, where the module has no state at all. Two wrong answers were * measured on the device before this: * * - no load at all: `zones()` is null, the loop threw, the catch left * the list empty and the guard silently disappeared from the rendered * ruleset (that is the state this package shipped in r46); * - fw4.read_state(): parses /var/run/fw4.state, which carries the * LOGICAL names ("wan", "wan_6"), so the guard rendered as * `meta iifname { wan }` and could never match - a guard that looks * present and does nothing. * * load() builds the model through ubus the same way fw4 itself does, and * yields match_devices = [ "pppoe-wan" ]. It is read-only, ~tens of ms, * and its warnings go to stderr, which the caller discards. */ fw4.load(); for (let zone in (fw4.zones() || [])) if (zone.name === 'wan') for (let device in zone.match_devices) push(wan_devices, device); } catch (e) { } wan_devices = sort(uniq(wan_devices)); /* Validated, nft-ready form of every UCI list, computed once. * * The template used to gate on the *raw* UCI value and interpolate the * validator-backed helper. A list that is non-empty but has no usable entry - * hand-edited UCI, an address in the wrong family - therefore rendered * `ip daddr counter return`, an nft syntax error that fails the whole fw4 * transaction, or `elements = { }`. Gating on the helper's result instead * means an unusable list simply emits no rule. The rendered output for a * usable configuration is unchanged (the helpers already dropped invalid * entries one by one). */ const NFTARR_FIELDS = { listen_interfaces: iface_to_nftarr, lan_proxy_ipv4_ips: ipv4_to_nftarr, lan_proxy_ipv6_ips: ipv6_to_nftarr, lan_proxy_mac_addrs: mac_to_nftarr, lan_direct_ipv4_ips: ipv4_to_nftarr, lan_direct_ipv6_ips: ipv6_to_nftarr, lan_direct_mac_addrs: mac_to_nftarr, lan_gaming_mode_ipv4_ips: ipv4_to_nftarr, lan_gaming_mode_ipv6_ips: ipv6_to_nftarr, lan_gaming_mode_mac_addrs: mac_to_nftarr, lan_global_proxy_ipv4_ips: ipv4_to_nftarr, lan_global_proxy_ipv6_ips: ipv6_to_nftarr, lan_global_proxy_mac_addrs: mac_to_nftarr, wan_proxy_ipv4_ips: ipv4_to_nftarr, wan_proxy_ipv6_ips: ipv6_to_nftarr, wan_direct_ipv4_ips: ipv4_to_nftarr, wan_direct_ipv6_ips: ipv6_to_nftarr }; const nftarr = {}; for (let field in NFTARR_FIELDS) nftarr[field] = NFTARR_FIELDS[field](control_info[field] || []); /* The local-output chain also exempts `lo`, which the tproxy guard adds by * hand. Same precomputation, so the guard cannot disagree with the list. */ nftarr.listen_interfaces_lo = iface_to_nftarr([ ...(control_info.listen_interfaces || []), 'lo' ]); /* The routing-port filter. Gating on the raw option while interpolating * ports_to_nftarr() had the same defect, and there the fallback direction * matters: an empty rendering would make `tcp dport != @homeproxy_routing_port` * match everything and send all TCP traffic direct. An unusable list * therefore disables the filter (ports are proxied) instead of widening it. */ const routing_port_set = routing_port ? ports_to_nftarr(routing_port) : null; const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1'; const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0', /* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port, * 5333). Both were called `dns_port` here and in the generator, which * made the hijack rule look like it pointed at the sing-box listener. */ dnsmasq_port = port_or(uci.get('dhcp', '@dnsmasq[0]', 'port'), '53'); /* UCI config end */ -%} {# Reserved addresses -#} set homeproxy_local_addr_v4 { type ipv4_addr flags interval auto-merge elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.31.196.0/24, 192.52.193.0/24, 192.88.99.0/24, 192.168.0.0/16, 192.175.48.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 } } {% if (v6_handled): %} set homeproxy_local_addr_v6 { type ipv6_addr flags interval auto-merge elements = { ::/128, ::1/128, ::ffff:0:0/96, 100::/64, 64:ff9b::/96, 2001::/32, 2001:10::/28, 2001:20::/28, 2001:db8::/28, 2002::/16, fc00::/7, fe80::/10, ff00::/8 } } {% endif %} {% if (routing_mode === 'gfwlist'): %} set homeproxy_gfw_list_v4 { type ipv4_addr flags interval auto-merge } {% if (v6_handled): %} set homeproxy_gfw_list_v6 { type ipv6_addr flags interval auto-merge } {% endif /* v6_handled */ %} {% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %} set homeproxy_mainland_addr_v4 { type ipv4_addr flags interval auto-merge elements = { {% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %} {% if (isValidCIDR(cnip4, 4)): %} {{ cnip4 }}, {% endif %} {% endfor %} } } {% if (v6_handled): %} set homeproxy_mainland_addr_v6 { type ipv6_addr flags interval auto-merge elements = { {% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %} {% if (isValidCIDR(cnip6, 6)): %} {{ cnip6 }}, {% endif %} {% endfor %} } } {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {# WAN ACL addresses #} set homeproxy_wan_proxy_addr_v4 { type ipv4_addr flags interval auto-merge {% if (nftarr.wan_proxy_ipv4_ips): %} elements = { {{ nftarr.wan_proxy_ipv4_ips }} } {% endif %} } {% if (v6_handled): %} set homeproxy_wan_proxy_addr_v6 { type ipv6_addr flags interval auto-merge {% if (nftarr.wan_proxy_ipv6_ips): %} elements = { {{ nftarr.wan_proxy_ipv6_ips }} } {% endif /* wan_proxy_ipv6_ips*/ %} } {% endif /* v6_handled */ %} set homeproxy_wan_direct_addr_v4 { type ipv4_addr flags interval auto-merge {% if (nftarr.wan_direct_ipv4_ips): %} elements = { {{ nftarr.wan_direct_ipv4_ips }} } {% endif %} } {% if (v6_handled): %} set homeproxy_wan_direct_addr_v6 { type ipv6_addr flags interval auto-merge {% if (nftarr.wan_direct_ipv6_ips): %} elements = { {{ nftarr.wan_direct_ipv6_ips }} } {% endif /* wan_direct_ipv6_ips */ %} } {% endif /* v6_handled */ %} {% if (routing_port_set): %} set homeproxy_routing_port { type inet_service flags interval auto-merge elements = { {{ routing_port_set }} } } {% endif %} {# Node server addresses. Declared when the generator exported any node address at all - and NOT only when there is a literal address to put in it. That distinction is the whole point of the set existing. A node configured with a host name contributes no element here; dnsmasq resolves the name and back-fills this set through the nftset= snippet runtime/dns.sh writes. A set that is declared only when it already has elements is therefore never declared for a host-name node, the back-fill has nothing to land in, and the return rules below - which are what actually exclude the address - are not emitted either. r41 gated both on the literal form and was inert for the configuration it was written for. An empty set is harmless: nft accepts one with no elements, and the cost is one lookup per packet until the first answer back-fills it. #} {% if (node_protect): %} set homeproxy_node_addr_v4 { type ipv4_addr flags interval auto-merge {% if (node_addr_v4): %} elements = { {{ node_addr_v4 }} } {% endif %} } {% endif %} {% if (node_protect && v6_handled): %} set homeproxy_node_addr_v6 { type ipv6_addr flags interval auto-merge {% if (node_addr_v6): %} elements = { {{ node_addr_v6 }} } {% endif %} } {% endif /* node_addr_v6 */ %} {# DNS hijack & TCP redirect #} chain dstnat { {% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %} {% if (nftarr.listen_interfaces): %} meta iifname {{ nftarr.listen_interfaces }} {%- elif (!isEmpty(wan_devices)): %} meta iifname != {{ array_to_nftarr(wan_devices) }} {%- endif /* listen_interfaces */ %} meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack" {% endif /* dns_hijacked */ %} {% if (match(proxy_mode, /redirect/)): %} meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac{{ v6_warn_comment }} {% endif /* proxy_mode */ %} } {# TCP redirect #} {% if (match(proxy_mode, /redirect/)): %} chain homeproxy_redirect_proxy { meta l4proto tcp counter redirect to :{{ redirect_port }} } chain homeproxy_redirect_proxy_port { {% if (routing_port_set): %} tcp dport != @homeproxy_routing_port counter return {% endif %} goto homeproxy_redirect_proxy } chain homeproxy_redirect_lanac { {% if (nftarr.listen_interfaces): %} meta iifname != {{ nftarr.listen_interfaces }} counter return {% elif (!isEmpty(wan_devices)): %} meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} meta mark {{ self_mark }} counter return {% if (control_info.lan_proxy_mode === 'listed_only'): %} {% if (nftarr.lan_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_redirect {% endif /* lan_proxy_ipv4_ips */ %} {% if (nftarr.lan_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_redirect {% endif /* lan_proxy_ipv6_ips */ %} {% if (nftarr.lan_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_redirect {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} {% if (nftarr.lan_direct_ipv4_ips): %} ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} {% if (nftarr.lan_direct_ipv6_ips): %} ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} {% if (nftarr.lan_direct_mac_addrs): %} ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} {% if (control_info.lan_proxy_mode !== 'listed_only'): %} counter goto homeproxy_redirect {% endif %} } chain homeproxy_redirect { meta mark {{ self_mark }} counter return {% if (node_protect): %} ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" {% endif %} {% if (node_protect && v6_handled): %} ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" {% endif %} ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port {% endif %} ip daddr @homeproxy_local_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} {% if (routing_mode !== 'custom'): %} {% if (nftarr.lan_global_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_ipv4_ips */ %} {% if (nftarr.lan_global_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_ipv6_ips */ %} {% if (nftarr.lan_global_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} ip daddr @homeproxy_wan_direct_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return {% endif /* v6_handled */ %} {# No QUIC reject here: this whole block only renders when UDP is proxyable (see the gate above), so rejecting QUIC on UDP 443 would break HTTP/3 for traffic that can be proxied. The TUN block below, which renders either way, carries the reject when UDP cannot be proxied. #} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (nftarr.lan_gaming_mode_ipv4_ips): %} ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_ipv4_ips */ %} {% if (nftarr.lan_gaming_mode_ipv6_ips): %} ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_ipv6_ips */ %} {% if (nftarr.lan_gaming_mode_mac_addrs): %} ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_redirect_proxy_port } chain homeproxy_output_redir { type nat hook output priority filter -105; policy accept meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect } {% endif %} {# UDP tproxy #} {% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %} chain homeproxy_mangle_tproxy { meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept {% if (v6_handled): %} meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept {% endif %} } chain homeproxy_mangle_tproxy_port { {% if (routing_port_set): %} udp dport != @homeproxy_routing_port counter return {% endif %} goto homeproxy_mangle_tproxy } chain homeproxy_mangle_mark { {% if (routing_port_set): %} udp dport != @homeproxy_routing_port counter return {% endif %} meta l4proto udp meta mark set {{ tproxy_mark }} counter accept } chain homeproxy_mangle_lanac { {% if (nftarr.listen_interfaces): %} meta iifname != {{ nftarr.listen_interfaces_lo }} counter return {% elif (!isEmpty(wan_devices)): %} meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} meta iifname != lo udp dport 53 counter return meta mark {{ self_mark }} counter return {% if (control_info.lan_proxy_mode === 'listed_only'): %} {% if (nftarr.lan_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_ipv4_ips */ %} {% if (nftarr.lan_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_ipv6_ips */ %} {% if (nftarr.lan_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} {% if (nftarr.lan_direct_ipv4_ips): %} ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} {% if (nftarr.lan_direct_ipv6_ips): %} ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} {% if (nftarr.lan_direct_mac_addrs): %} ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} {% if (control_info.lan_proxy_mode !== 'listed_only'): %} counter goto homeproxy_mangle_prerouting {% endif %} } chain homeproxy_mangle_prerouting { {% if (node_protect): %} ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" {% endif %} {% if (node_protect && v6_handled): %} ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" {% endif %} ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port {% endif %} ip daddr @homeproxy_local_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} {% if (routing_mode !== 'custom'): %} {% if (nftarr.lan_global_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_ipv4_ips */ %} {% if (nftarr.lan_global_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_ipv6_ips */ %} {% if (nftarr.lan_global_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} ip daddr @homeproxy_wan_direct_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return {% endif /* v6_handled */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (nftarr.lan_gaming_mode_ipv4_ips): %} ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_ipv4_ips */ %} {% if (nftarr.lan_gaming_mode_ipv6_ips): %} ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_ipv6_ips */ %} {% if (nftarr.lan_gaming_mode_mac_addrs): %} ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_mangle_tproxy_port } chain homeproxy_mangle_output { meta mark {{ self_mark }} counter return {% if (node_protect): %} ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" {% endif %} {% if (node_protect && v6_handled): %} ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" {% endif %} ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark {% endif %} ip daddr @homeproxy_local_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} ip daddr @homeproxy_wan_direct_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_wan_direct_addr_v6 counter return {% endif /* v6_handled */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} counter goto homeproxy_mangle_mark } chain mangle_prerouting { meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac{{ v6_warn_comment }} } chain mangle_output { meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output } {% endif %} {# TUN #} {% if (match(proxy_mode, /tun/)): %} chain homeproxy_mangle_lanac { iifname {{ tun_name }} counter return udp dport 53 counter return {% if (nftarr.listen_interfaces): %} meta iifname != {{ nftarr.listen_interfaces }} counter return {% elif (!isEmpty(wan_devices)): %} meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} {% if (control_info.lan_proxy_mode === 'listed_only'): %} {% if (nftarr.lan_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_ipv4_ips */ %} {% if (nftarr.lan_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_ipv6_ips */ %} {% if (nftarr.lan_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} {% if (nftarr.lan_direct_ipv4_ips): %} ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} {% if (nftarr.lan_direct_ipv6_ips): %} ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} {% if (nftarr.lan_direct_mac_addrs): %} ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} {% if (control_info.lan_proxy_mode !== 'listed_only'): %} counter goto homeproxy_mangle_tun {% endif %} } chain homeproxy_mangle_tun_mark { {% if (routing_port_set): %} {% if (proxy_mode === 'tun'): %} tcp dport != @homeproxy_routing_port counter return {% endif /* proxy_mode */ %} udp dport != @homeproxy_routing_port counter return {% endif /* routing_port */ %} counter meta mark set {{ tun_mark }} } chain homeproxy_mangle_tun { iifname {{ tun_name }} counter return {% if (tun_self_mark): %} meta mark {{ tun_self_mark }} counter return comment "!{{ cfgname }}: sing-box's own traffic" {% endif %} {% if (node_protect): %} ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" {% endif %} {% if (node_protect && v6_handled): %} ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" {% endif %} ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark {% endif %} ip daddr @homeproxy_local_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_local_addr_v6 counter return {% endif %} {% if (routing_mode !== 'custom'): %} {% if (nftarr.lan_global_proxy_ipv4_ips): %} ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_ipv4_ips */ %} {% if (nftarr.lan_global_proxy_ipv6_ips): %} ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_ipv6_ips */ %} {% if (nftarr.lan_global_proxy_mac_addrs): %} ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} {% if (nftarr.wan_direct_ipv4_ips): %} ip daddr {{ nftarr.wan_direct_ipv4_ips }} counter return {% endif /* wan_direct_ipv4_ips */ %} {% if (nftarr.wan_direct_ipv6_ips): %} ip6 daddr {{ nftarr.wan_direct_ipv6_ips }} counter return {% endif /* wan_direct_ipv6_ips */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} {% if (udp_quic_rejected): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (UDP path is not UDP-native)" {% endif /* udp_quic_rejected */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% if (udp_quic_rejected): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (UDP path is not UDP-native)" {% endif /* udp_quic_rejected */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr != @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} {% if (nftarr.lan_gaming_mode_ipv4_ips): %} ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_ipv4_ips */ %} {% if (nftarr.lan_gaming_mode_ipv6_ips): %} ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_ipv6_ips */ %} {% if (nftarr.lan_gaming_mode_mac_addrs): %} ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_mangle_tun_mark } chain mangle_prerouting { meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac{{ v6_warn_comment }} } chain mangle_output { meta nfproto { {{ (v6_handled) ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun } {% endif %}