mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-11 16:37:51 +08:00
691 lines
28 KiB
Ucode
691 lines
28 KiB
Ucode
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* generator/dns.uc: build the sing-box `dns` block.
|
|
*
|
|
* Two routing-mode families exist:
|
|
*
|
|
* 1. bypass_mainland_china / proxy_mainland_china / global /
|
|
* gfwlist - the proxy path. The orchestrator passes main_node
|
|
* (possibly 'urltest'), main_udp_node, china_dns_server,
|
|
* direct_domain_list and proxy_domain_list in ctx; the legacy
|
|
* NAPTR trick (qtype 35) and the CN-IP fallback (cn_ip_fallback)
|
|
* are part of this path.
|
|
*
|
|
* 2. custom - the user-defined rules path. The orchestrator passes
|
|
* default_outbound, default_outbound_dns, the dns settings
|
|
* (default_strategy, default_server, disable_cache, ...) and the
|
|
* dns_server / dns_rule section lists under dm.dns.
|
|
*
|
|
* The dns_server / dns_rule iteration is the part that is mode-agnostic
|
|
* and lives in this module rather than the orchestrator because every
|
|
* field comes straight from UCI and the legacy 1.14 evaluate/match_response
|
|
* rewrite is the per-field payload.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy-pro.uc';
|
|
|
|
|
|
import { get_outbound, get_resolver, get_ruleset } from './common.uc';
|
|
|
|
/* Build a DNS server spec from a UCI address string. sing-box wants the
|
|
* server name, the protocol kind, an optional path, and an integer port;
|
|
* the UCI representation is a single string, optionally without a
|
|
* scheme, which is why a default_protocol is supplied. */
|
|
function parse_dnsserver(server_addr, default_protocol) {
|
|
if (isEmpty(server_addr))
|
|
return null;
|
|
|
|
if (!match(server_addr, /:\/\//))
|
|
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
|
|
|
|
/* parseURL() returns null for an address it cannot make sense of (a bad
|
|
* host, an unknown scheme). The old code dereferenced the result straight
|
|
* away, so one malformed UCI value took the whole generator down with
|
|
* "left-hand side expression is null" instead of naming the dns_server. */
|
|
server_addr = parseURL(server_addr);
|
|
if (!server_addr)
|
|
return null;
|
|
|
|
return {
|
|
type: server_addr.protocol,
|
|
server: server_addr.hostname,
|
|
server_port: strToInt(server_addr.port),
|
|
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
|
|
}
|
|
}
|
|
|
|
/* Parse the DNS rule query_type list UCI emits. A single value comes as
|
|
* a string, a list as an array of strings; both forms are turned into an
|
|
* array of strings or integers (mixing the two is intentional - the type
|
|
* can be a name or a numeric code in UCI). */
|
|
function parse_dnsquery(strquery) {
|
|
if (type(strquery) !== 'array' || isEmpty(strquery))
|
|
return null;
|
|
|
|
let querys = [];
|
|
for (let i in strquery)
|
|
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
|
|
|
|
return querys;
|
|
}
|
|
|
|
/* Build the sing-box `headers` object from the UCI string list.
|
|
*
|
|
* sing-box types this field as `badoption.HTTPHeader`, i.e.
|
|
* map[string][]string; an array (or a bare string) fails to decode and the
|
|
* whole configuration is rejected. The LuCI form is a DynamicList, so every
|
|
* entry lands in UCI as a string and the two sides disagreed on the shape:
|
|
* `"headers": ["Name: value"]` is not a map.
|
|
*
|
|
* The form keeps its list widget and one entry is written `Name: Value`;
|
|
* repeating a name appends to that name's value list, which is how HTTP
|
|
* expresses a multi-valued header. An entry that does not fit the form is
|
|
* named and rejected here rather than left for sing-box to report as
|
|
* "cannot unmarshal array into Go value of type badoption.HTTPHeader". */
|
|
function parse_http_headers(list, where) {
|
|
if (isEmpty(list))
|
|
return null;
|
|
|
|
/* A single hand-written UCI option arrives as a string, not a list. */
|
|
if (type(list) !== 'array')
|
|
list = [ list ];
|
|
|
|
const headers = {};
|
|
for (let item in list) {
|
|
const line = trim(item);
|
|
if (line === '')
|
|
continue;
|
|
|
|
const sep = index(line, ':');
|
|
if (sep <= 0)
|
|
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
|
|
|
|
const name = trim(substr(line, 0, sep));
|
|
const value = trim(substr(line, sep + 1));
|
|
if (isEmpty(name) || isEmpty(value))
|
|
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
|
|
|
|
if (!headers[name])
|
|
headers[name] = [];
|
|
push(headers[name], value);
|
|
}
|
|
|
|
return isEmpty(keys(headers)) ? null : headers;
|
|
}
|
|
|
|
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
|
|
|
|
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
|
|
* (china-dns's default backend, 223.5.5.5) returns NAPTR with multi-
|
|
* second first responses for these suffixes, which sing-box reports as
|
|
* 'context deadline exceeded' (sipgz12.hbq.r.10086.cn being the
|
|
* reproducible offender); routing the qtype to default-dns (the ISP
|
|
* resolver or the wan_dns fallback) keeps IMS/VoLTE registration fast.
|
|
*
|
|
* The list is deliberately small: every entry is a suffix match, so
|
|
* adding a single host name can broaden the rule by accident. Add new
|
|
* suffixes here when a fresh NAPTR offender is observed, not when
|
|
* someone reads the comment and thinks "I know a domain that should
|
|
* go direct". */
|
|
const NAPTR_BYPASS_SUFFIXES = [
|
|
'10086.cn',
|
|
'pub.3gppnetwork.org'
|
|
];
|
|
|
|
/* The address of the bootstrap resolver, or null when there is none.
|
|
*
|
|
* Every DNS server whose address is a *hostname* needs a domain_resolver
|
|
* before sing-box will start it, and that lookup is the one step nothing
|
|
* else can bootstrap: it has to work before any of the configured servers
|
|
* can answer. `main-dns` is exactly that case - it is the user's DoH/DoT
|
|
* endpoint - and it used to borrow default-dns (the WAN/ISP resolver) for
|
|
* it, which is the single resolver a polluted or unreachable WAN takes down.
|
|
*
|
|
* It used to be a field of its own in the form, which made no sense to read:
|
|
* a second DNS address next to the China DNS server, both of them a bare
|
|
* public IP, for a lookup that happens once at startup. It is derived now,
|
|
* from the China DNS server, which is the one address this router already
|
|
* knows it can reach directly. That answer is only usable when it is a bare
|
|
* IP: the option also accepts the literal 'wan' and a DoH/DoT URL, and both
|
|
* of those are hostnames - a hostname cannot resolve the hostname it would be
|
|
* needed to resolve. For those, and only for those, this returns null and
|
|
* main-dns keeps borrowing the WAN resolver, exactly as it did before any of
|
|
* this existed.
|
|
*/
|
|
function bootstrap_addr(ctx) {
|
|
const addr = ctx.china_dns_server;
|
|
if (isEmpty(addr))
|
|
return null;
|
|
|
|
return (match(addr, /^[0-9]+(\.[0-9]+){3}$/) != null
|
|
|| match(addr, /^[0-9a-fA-F:]+$/) != null) ? addr : null;
|
|
}
|
|
|
|
/* Append the bootstrap resolver, when there is a usable address for it.
|
|
*
|
|
* The bootstrap server carries a plain IP, has no domain_resolver of its own
|
|
* and is never referenced by a DNS rule: it exists only as the target of the
|
|
* domain_resolver pointer, so there is no cycle for sing-box to reject.
|
|
* Nothing is emitted when there is no such address ("resolve through the WAN
|
|
* resolver"), which keeps a configuration whose China DNS server is a
|
|
* hostname byte-identical to one that never had this.
|
|
*
|
|
* It is emitted only alongside main-dns: without a node there is no DoH/DoT
|
|
* endpoint to bootstrap, and a server nothing points at is just noise. */
|
|
function append_bootstrap_dns(config, ctx) {
|
|
const addr = bootstrap_addr(ctx);
|
|
if (isEmpty(addr) || isEmpty(ctx.main_node))
|
|
return;
|
|
|
|
push(config.dns.servers, {
|
|
tag: 'bootstrap-dns',
|
|
type: 'udp',
|
|
server: addr,
|
|
detour: ctx.self_mark ? 'direct-out' : null
|
|
});
|
|
}
|
|
|
|
function append_proxy_dns(config, dm, ctx) {
|
|
if (isEmpty(ctx.main_node))
|
|
return;
|
|
|
|
/* The user's DoH/DoT endpoint is the one server in this block whose
|
|
* address is a hostname, so it is the one that needs the bootstrap
|
|
* resolver. Which resolver that is follows the China DNS server - the
|
|
* one direct-routable address already configured for this router - and
|
|
* only when it is a bare IP; see bootstrap_addr(). */
|
|
const main_resolver = isEmpty(bootstrap_addr(ctx)) ? 'default-dns' : 'bootstrap-dns';
|
|
|
|
/* Main DNS */
|
|
push(config.dns.servers, {
|
|
tag: 'main-dns',
|
|
domain_resolver: {
|
|
server: main_resolver,
|
|
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
|
|
},
|
|
detour: 'main-out',
|
|
...parse_dnsserver(ctx.dns_server, 'tcp')
|
|
});
|
|
/* The DNS half of the mode's default policy. It has to name the same
|
|
* side the route chain falls through to (ctx.proxy_fallback), or a
|
|
* domain that ends up proxied would be resolved by the resolver of the
|
|
* other path. The pair is asserted per mode in the generator suite. */
|
|
config.dns.final = ctx.proxy_fallback ? 'main-dns' : 'default-dns';
|
|
|
|
if (length(ctx.direct_domain_list))
|
|
push(config.dns.rules, {
|
|
rule_set: 'direct-domain',
|
|
action: 'route',
|
|
server: (ctx.routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
|
|
});
|
|
|
|
/* Everything on the proxy list resolves through the proxy path, in
|
|
* every proxy mode - not just bypass_mainland_china. The list is the
|
|
* user's "this domain must not be answered by the domestic resolver"
|
|
* escape hatch (Google Play's connect.googleapis.cn and friends), and
|
|
* the routing half of it (route.uc pushes the same tag to main-out in
|
|
* all of these modes) was already mode-agnostic. It has to sit after
|
|
* direct-domain so that a suffix present in both lists keeps the
|
|
* bypass-mode precedence and stays predictable. */
|
|
if (length(ctx.proxy_domain_list))
|
|
push(config.dns.rules, {
|
|
rule_set: 'proxy-domain',
|
|
action: 'route',
|
|
server: 'main-dns'
|
|
});
|
|
|
|
/* Reject SVCB/HTTPS queries: a client that learns the address from these
|
|
* record types dials the embedded IP without asking again, which is what
|
|
* lets a query slip past the DNS split (and, in the custom path, past
|
|
* Fake-IP).
|
|
*
|
|
* It sits after the two domain lists on purpose, so a domain the user put
|
|
* on a list keeps whatever resolver that list chose - the custom path
|
|
* below puts the same reject first instead, because there the user's own
|
|
* rules are the ones that must not be shadowed. The two orders are
|
|
* deliberate; do not "fix" one into the other. */
|
|
push(config.dns.rules, {
|
|
query_type: [64, 65],
|
|
action: 'reject'
|
|
});
|
|
|
|
if (ctx.routing_mode === 'bypass_mainland_china') {
|
|
push(config.dns.servers, {
|
|
tag: 'china-dns',
|
|
domain_resolver: {
|
|
server: 'default-dns',
|
|
/* Mirror default-dns's strategy: china-dns itself is
|
|
* overwhelmingly IPv4-only in practice (223.5.5.5 etc.),
|
|
* so the field mostly affects the upstream chain; the
|
|
* two should still track each other for the same reason
|
|
* default-dns follows ipv6_support. */
|
|
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : 'prefer_ipv6'
|
|
},
|
|
detour: ctx.self_mark ? 'direct-out' : null,
|
|
...parse_dnsserver(ctx.china_dns_server)
|
|
});
|
|
|
|
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
|
|
default-dns directly: see NAPTR_BYPASS_SUFFIXES for the
|
|
rationale and the explicit offenders. */
|
|
push(config.dns.rules, {
|
|
query_type: [35],
|
|
domain_suffix: NAPTR_BYPASS_SUFFIXES,
|
|
action: 'route',
|
|
server: 'default-dns'
|
|
});
|
|
|
|
push(config.dns.rules, {
|
|
rule_set: 'geosite-cn',
|
|
action: 'route',
|
|
server: 'china-dns'
|
|
});
|
|
|
|
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
|
if (ctx.cn_ip_fallback === '1') {
|
|
push(config.dns.rules, {
|
|
action: 'evaluate',
|
|
server: 'main-dns',
|
|
tag: 'cn-fallback'
|
|
});
|
|
push(config.dns.rules, {
|
|
match_response: 'cn-fallback',
|
|
rule_set: 'geoip-cn',
|
|
action: 'route',
|
|
server: 'china-dns'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Known DoH/DoT/DoQ hostnames and the IPs we should pin them to. The
|
|
* table is consulted by append_custom_dns() to detect when a user
|
|
* configured one of these encrypted upstreams; without the predefined
|
|
* entry, the resolver itself has to be resolved through the system DNS,
|
|
* and a DNS outage of any kind taken once out leaves the resolver
|
|
* unreachable - a chicken-and-egg loop. Mirrors the hosts server in
|
|
* linux.json line 10; see docs/linux.json 与 pro 的差距分析.md §2.4.
|
|
*
|
|
* Additions go here when the user's pool of encrypted resolvers grows;
|
|
* a new entry needs no code change beyond this table. */
|
|
const KNOWN_ENCRYPTED_DNS_HOSTS = {
|
|
'doh.pub': ['1.12.12.21', '120.53.53.53'],
|
|
'dns.pub': ['1.12.12.21', '120.53.53.53'],
|
|
'dns.alidns.com': ['223.5.5.5', '223.6.6.6'],
|
|
'dns.google': ['8.8.8.8', '8.8.4.4'],
|
|
'one.one.one.one': ['1.1.1.1', '1.0.0.1']
|
|
};
|
|
|
|
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
|
|
|
|
function append_custom_dns(config, dm, ctx) {
|
|
/* §2.4 DoH fallback. Pre-emit a hosts-type DNS server pinning any
|
|
* encrypted-resolver hostname the user configured to its real IPs. The
|
|
* loop also remembers which server sections hit the table, so the user
|
|
* server loop below can stamp `domain_resolver: 'hp-dns-hosts'` onto
|
|
* them - sing-box only consults the hosts table for resolvers that
|
|
* explicitly point at it, mirroring linux.json line 7
|
|
* (`{"domain_resolver": "hosts"}`). Without the stamp, the resolver
|
|
* still has to look up its own hostname through the system DNS and the
|
|
* chicken-and-egg loop we are trying to break stays open. */
|
|
let doh_predefined = null;
|
|
const doh_resolved_hosts = {};
|
|
for (let cfg in (dm.dns.servers || [])) {
|
|
if (!cfg.enabled)
|
|
continue;
|
|
if (cfg.type !== 'https' && cfg.type !== 'tls' && cfg.type !== 'quic')
|
|
continue;
|
|
const host = cfg.server;
|
|
if (!host || !KNOWN_ENCRYPTED_DNS_HOSTS[host])
|
|
continue;
|
|
if (!doh_predefined)
|
|
doh_predefined = {};
|
|
doh_predefined[host] = KNOWN_ENCRYPTED_DNS_HOSTS[host];
|
|
doh_resolved_hosts[cfg.name] = true;
|
|
}
|
|
if (doh_predefined) {
|
|
push(config.dns.servers, {
|
|
tag: 'hp-dns-hosts',
|
|
type: 'hosts',
|
|
predefined: doh_predefined
|
|
});
|
|
}
|
|
|
|
/* DNS servers */
|
|
for (let cfg in dm.dns.servers) {
|
|
if (!cfg.enabled)
|
|
continue;
|
|
|
|
let outbound = get_outbound(cfg.outbound, dm);
|
|
if (outbound === 'direct-out' && isEmpty(ctx.self_mark))
|
|
outbound = null;
|
|
|
|
push(config.dns.servers, {
|
|
tag: 'cfg-' + cfg.name + '-dns',
|
|
type: cfg.type,
|
|
server: cfg.server,
|
|
server_port: strToInt(cfg.server_port),
|
|
path: cfg.path,
|
|
headers: parse_http_headers(cfg.headers, sprintf("dns server '%s'", cfg.name)),
|
|
tls: cfg.tls_sni ? {
|
|
enabled: true,
|
|
server_name: cfg.tls_sni
|
|
} : null,
|
|
domain_resolver: doh_resolved_hosts[cfg.name]
|
|
? 'hp-dns-hosts'
|
|
: ((cfg.address_resolver || cfg.address_strategy) ? {
|
|
server: get_resolver(cfg.address_resolver || ctx.dns_default_server, dm),
|
|
strategy: cfg.address_strategy
|
|
} : null),
|
|
detour: outbound
|
|
});
|
|
}
|
|
|
|
/* DNS rules */
|
|
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
|
|
evaluate action; deprecated strategy/accept_empty fields are dropped. */
|
|
const builtin_dns_rules = [];
|
|
/* §2.2 First safe rule: reject SVCB (qtype 64) and HTTPS (qtype 65)
|
|
* queries. A client that learns the answer from these record types
|
|
* connects to the embedded IP without going through the resolver again,
|
|
* which makes Fake-IP and any route that hinges on A/AAAA resolution
|
|
* bypass. Mirrors append_proxy_dns() line 102; emit here as the FIRST
|
|
* entry so a user rule that targets the same query_type cannot shadow
|
|
* it (sing-box walks the array in order and stops at the first match).
|
|
* Without this prefix, a user who adds a higher-priority rule on the
|
|
* same query_type in custom mode loses Fake-IP integrity. */
|
|
push(builtin_dns_rules, {
|
|
query_type: [64, 65],
|
|
action: 'reject'
|
|
});
|
|
for (let cfg in dm.dns.rules) {
|
|
if (!cfg.enabled)
|
|
continue;
|
|
|
|
/* Match fields are valid for every action; action-specific fields
|
|
* are emitted below per sing-box 1.14's per-action schema. The
|
|
* previous code dumped server/method/no_drop/rcode/answer/ns/extra
|
|
* (and the cache / TTL / client-subnet family) onto every rule
|
|
* regardless of action, and sing-box then rejected the whole
|
|
* configuration with "json: unknown field" the moment a user
|
|
* changed a rule's action and left the old fields in UCI -
|
|
* LuCI's form value parsing does not delete the previous value
|
|
* when a depends() guard hides it, the same path route.uc
|
|
* already had to fix. */
|
|
const rule = {
|
|
ip_version: strToInt(cfg.ip_version),
|
|
query_type: parse_dnsquery(cfg.query_type),
|
|
network: cfg.network,
|
|
protocol: cfg.protocol,
|
|
domain: cfg.domain,
|
|
domain_suffix: cfg.domain_suffix,
|
|
domain_keyword: cfg.domain_keyword,
|
|
domain_regex: cfg.domain_regex,
|
|
port: parse_port(cfg.port),
|
|
port_range: cfg.port_range,
|
|
source_ip_cidr: cfg.source_ip_cidr,
|
|
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
|
source_port: parse_port(cfg.source_port),
|
|
source_port_range: cfg.source_port_range,
|
|
process_name: cfg.process_name,
|
|
process_path: cfg.process_path,
|
|
process_path_regex: cfg.process_path_regex,
|
|
user: cfg.user,
|
|
rule_set: get_ruleset(cfg.rule_set, dm),
|
|
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
|
invert: strToBool(cfg.invert),
|
|
action: cfg.action
|
|
};
|
|
|
|
/* race/speculative are action fields, not match fields, and their
|
|
* legality is decided at load time by sing-box's
|
|
* validateDNSRuleAction() (route/rule/rule_dns.go):
|
|
*
|
|
* - race and speculative are mutually exclusive ("`race` and
|
|
* `speculative` cannot be combined on the same rule");
|
|
* - race is only allowed on a final action - route, respond,
|
|
* reject, predefined - and "`race` requires a final action"
|
|
* otherwise;
|
|
* - speculative exists only in the route and evaluate option
|
|
* structs, so on any other action it is an unknown field and
|
|
* DisallowUnknownFields rejects the whole configuration.
|
|
*
|
|
* The old code emitted both unconditionally. The form hides them
|
|
* with depends(), but LuCI does not delete the value of a hidden
|
|
* option, so "tick race on a route rule, then switch the rule to
|
|
* evaluate" left an illegal value behind. Gate by action and die
|
|
* on the combination, naming the field rather than letting sing-box
|
|
* answer with "unknown field". */
|
|
const want_race = strToBool(cfg.race) === true,
|
|
want_speculative = strToBool(cfg.speculative) === true;
|
|
|
|
if (want_race && want_speculative)
|
|
die(sprintf("homeproxy-pro: DNS rule '%s' enables both race and speculative; sing-box refuses that combination.", cfg.name));
|
|
|
|
if (want_race) {
|
|
if (!(cfg.action in ['route', 'reject', 'predefined']))
|
|
die(sprintf("homeproxy-pro: DNS rule '%s' uses race with action '%s'; race is only allowed on a final action (route, reject, predefined).", cfg.name, cfg.action));
|
|
rule.race = true;
|
|
}
|
|
|
|
if (want_speculative) {
|
|
if (!(cfg.action in ['route', 'evaluate']))
|
|
die(sprintf("homeproxy-pro: DNS rule '%s' uses speculative with action '%s'; speculative is only valid for route and evaluate.", cfg.name, cfg.action));
|
|
rule.speculative = true;
|
|
}
|
|
|
|
/* `route` and `evaluate` (which needs a server for the eval step).
|
|
* DNS rules have no `resolve` action - that one belongs to the route
|
|
* rules - and the form does not offer it here. It used to be handled
|
|
* anyway, which would have emitted an action sing-box does not know
|
|
* the moment a stale UCI value carried it. */
|
|
if (cfg.action === 'route' || cfg.action === 'evaluate') {
|
|
rule.server = get_resolver(cfg.server, dm);
|
|
rule.disable_cache = strToBool(cfg.dns_disable_cache);
|
|
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
|
|
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
|
|
/* Load-time mutual exclusion in validateDNSRuleAction():
|
|
* "`client_subnet` and `remove_client_subnet` are mutually
|
|
* exclusive". The form draws them as two independent
|
|
* controls, so both can be left set. */
|
|
if (!isEmpty(cfg.client_subnet) && strToBool(cfg.remove_client_subnet) === true)
|
|
die(sprintf("homeproxy-pro: DNS rule '%s' sets both client_subnet and remove_client_subnet; sing-box refuses that combination.", cfg.name));
|
|
rule.client_subnet = cfg.client_subnet;
|
|
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
|
|
}
|
|
|
|
/* 1.14's evaluate action carries the same timeout override as route
|
|
* (DNSEvaluateActionOptions embeds AbstractDNSRouteActionOptions), and
|
|
* the form shows the field for both actions. Emitting it for route
|
|
* only made the value disappear silently on an evaluate rule. */
|
|
if (cfg.action === 'route' || cfg.action === 'evaluate')
|
|
rule.timeout = strToTime(cfg.dns_timeout);
|
|
|
|
/* `reject` only accepts method + no_drop. Anything else here is
|
|
* an unknown field for the action and the whole config fails. */
|
|
if (cfg.action === 'reject') {
|
|
rule.method = cfg.reject_method;
|
|
rule.no_drop = strToBool(cfg.reject_no_drop);
|
|
}
|
|
|
|
/* `predefined` only accepts rcode + answer + ns + extra. */
|
|
if (cfg.action === 'predefined') {
|
|
rule.rcode = cfg.predefined_rcode;
|
|
rule.answer = cfg.predefined_answer;
|
|
rule.ns = cfg.predefined_ns;
|
|
rule.extra = cfg.predefined_extra;
|
|
}
|
|
|
|
if (cfg.action === 'evaluate')
|
|
rule.tag = cfg.evaluate_tag || null;
|
|
|
|
if (cfg.match_response && cfg.match_response !== '0')
|
|
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
|
|
|
|
rule.query_client_subnet = cfg.query_client_subnet;
|
|
rule.query_dnssec = strToBool(cfg.query_dnssec);
|
|
rule.source_mac_address = cfg.source_mac_address;
|
|
rule.source_hostname = cfg.source_hostname;
|
|
|
|
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
|
|
/* Every action needs the evaluate prefix to see the response, not just
|
|
* `route`: with the old `&& cfg.action === 'route'` an address-filtered
|
|
* reject rule lost the filter entirely and became an unconditional
|
|
* reject - a DNS black hole for every query. */
|
|
if (legacy_filter && !rule.match_response) {
|
|
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
|
|
paradigm. Carry the original query-matching fields onto the evaluate
|
|
prefix rule so only queries that would have hit this rule get
|
|
pre-resolved; an unconditional evaluate would resolve every query. */
|
|
const eval_tag = '_hp_eval_' + cfg.name;
|
|
const eval_rule = {
|
|
action: 'evaluate',
|
|
server: get_resolver(cfg.server, dm),
|
|
tag: eval_tag
|
|
};
|
|
const eval_match_fields = [
|
|
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
|
|
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
|
|
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
|
|
'source_port', 'source_port_range', 'process_name', 'process_path',
|
|
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
|
|
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
|
|
'source_hostname'
|
|
];
|
|
for (let f in eval_match_fields)
|
|
eval_rule[f] = rule[f];
|
|
push(builtin_dns_rules, eval_rule);
|
|
rule.match_response = eval_tag;
|
|
}
|
|
|
|
/* Response-match fields are only valid together with match_response in
|
|
* 1.14: sing-box rejects the rule with "Response Match Fields (...)
|
|
* require match_response to be enabled". ip_cidr / ip_is_private were
|
|
* already gated here; the response_* quartet was emitted
|
|
* unconditionally next door. */
|
|
if (rule.match_response) {
|
|
rule.ip_cidr = cfg.ip_cidr;
|
|
rule.ip_is_private = strToBool(cfg.ip_is_private);
|
|
rule.response_rcode = cfg.response_rcode;
|
|
rule.response_answer = cfg.response_answer;
|
|
rule.response_ns = cfg.response_ns;
|
|
rule.response_extra = cfg.response_extra;
|
|
}
|
|
|
|
/* race also requires match_response in 1.14. This check has to sit
|
|
* after every match_response assignment: the legacy address-filter
|
|
* wrapping above gives one to those rules too, and that satisfies
|
|
* sing-box just as well. */
|
|
if (want_race && !rule.match_response)
|
|
die(sprintf("homeproxy-pro: DNS rule '%s' enables race without match_response; sing-box requires one.", cfg.name));
|
|
|
|
push(builtin_dns_rules, rule);
|
|
}
|
|
config.dns.rules = builtin_dns_rules;
|
|
|
|
config.dns.final = get_resolver(ctx.dns_default_server, dm);
|
|
}
|
|
|
|
/* Reject AAAA when this router does not proxy IPv6.
|
|
*
|
|
* Every IPv6 rule-set and rule in firewall_post.ut sits inside
|
|
* `ipv6_support === '1'`, so with the switch off the router does not
|
|
* intercept v6 at all - and nothing in this DNS block ever refused a AAAA
|
|
* query. dns.strategy=ipv4_only on the preset paths is a resolution
|
|
* preference, not a query block, and custom routing takes the UCI default
|
|
* (prefer_ipv4). A dual-stack LAN therefore still received AAAA records and
|
|
* connected straight out over the WAN, bypassing both the proxied resolver
|
|
* and the destination-based split.
|
|
*
|
|
* Appended last so a user rule keeps precedence over it. A client with its
|
|
* own DoH/DoT can still bypass this layer; a hard block would have to be an
|
|
* nft rule, which changes the user-visible "IPv6 still works" behaviour of
|
|
* the switch and is therefore not done here. */
|
|
function append_ipv6_guard(config, ctx) {
|
|
if (ctx.ipv6_support === '1')
|
|
return;
|
|
|
|
push(config.dns.rules, {
|
|
query_type: [28],
|
|
action: 'reject'
|
|
});
|
|
}
|
|
|
|
/* --- public entry ------------------------------------------------------ */
|
|
|
|
/* The always-on DNS block. Every routing mode (proxy and custom) gets
|
|
* default-dns (UDP via the WAN resolver) and system-dns (local, used as
|
|
* a fallback). The routing-mode-specific builders push additional
|
|
* servers/rules on top of this list. Keeping the common prefix here is
|
|
* why the proxy-mode and custom-mode paths stay almost mirror images:
|
|
* both branches see config.dns already populated. */
|
|
function initDns(config, ctx) {
|
|
/* sing-box refuses the combination outright:
|
|
* FATAL initialize DNS router: `optimistic` is conflict with `disable_cache`
|
|
* (reproduced against 1.14.1 on a real config). Both are opt-in switches
|
|
* in the UI, so turning both on used to produce a configuration that
|
|
* cannot start - the reload fails and rolls back, and the only symptom the
|
|
* user sees is "my setting did not take". optimistic wins; the disable_*
|
|
* switches are dropped rather than emitted. */
|
|
const optimistic = (ctx.dns_optimistic_cache === '1');
|
|
|
|
config.dns = {
|
|
servers: [
|
|
{
|
|
tag: 'default-dns',
|
|
type: 'udp',
|
|
server: ctx.wan_dns,
|
|
detour: ctx.self_mark ? 'direct-out' : null
|
|
},
|
|
{
|
|
tag: 'system-dns',
|
|
type: 'local',
|
|
detour: ctx.self_mark ? 'direct-out' : null
|
|
}
|
|
],
|
|
rules: [],
|
|
strategy: ctx.dns_default_strategy,
|
|
disable_cache: optimistic ? null : strToBool(ctx.dns_disable_cache),
|
|
disable_expire: optimistic ? null : strToBool(ctx.dns_disable_cache_expire),
|
|
client_subnet: ctx.dns_client_subnet,
|
|
optimistic: optimistic ? {
|
|
enabled: true,
|
|
/* strToTime(), not the raw UCI string: sing-box parses this
|
|
* field as a duration and rejects a bare number outright
|
|
* ("time: missing unit in duration \"3600\""), so the whole
|
|
* generation would fail - and a failed generation keeps the
|
|
* previous configuration running, which the user sees as
|
|
* "my setting did not take". The UI documents the field as
|
|
* "Examples: 3d, 1h" but does not enforce a unit, so both
|
|
* forms have to work: strToTime() turns "3600" into "3600s"
|
|
* and leaves "3d" alone. dns_query_timeout below already
|
|
* does this. */
|
|
timeout: !isEmpty(ctx.dns_optimistic_timeout) ? strToTime(ctx.dns_optimistic_timeout) : '3d'
|
|
} : null,
|
|
timeout: !isEmpty(ctx.dns_query_timeout) ? strToTime(ctx.dns_query_timeout) : null
|
|
};
|
|
}
|
|
|
|
/* Build the sing-box `dns` block. Mutates `config` and returns it for
|
|
* chaining. Routing mode is determined by ctx.routing_mode: any non-
|
|
* 'custom' mode follows the proxy path (the proxy-mode split inside the
|
|
* proxy path is just which rules to emit). */
|
|
export function build_dns(config, dm, ctx) {
|
|
initDns(config, ctx);
|
|
|
|
/* Custom mode has no main-dns and never resolves its own servers'
|
|
* hostnames, so it has no bootstrap problem to solve. */
|
|
if (ctx.routing_mode === 'custom')
|
|
append_custom_dns(config, dm, ctx);
|
|
else {
|
|
append_bootstrap_dns(config, ctx);
|
|
append_proxy_dns(config, dm, ctx);
|
|
}
|
|
|
|
append_ipv6_guard(config, ctx);
|
|
|
|
return config;
|
|
}; |