🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
1 parent f4559bbed1
commit 065763f7fd
673 files changed
+261285 -34160

No files matched your search

@@ -0,0 +1,22 @@
{
"bounding": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE"
],
"effective": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE"
],
"ambient": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE"
],
"permitted": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE"
],
"inheritable": [
"CAP_NET_ADMIN",
"CAP_NET_BIND_SERVICE"
]
}
@@ -0,0 +1,93 @@
config homeproxy 'infra'
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
option mixed_port '5330'
option redirect_port '5331'
option tproxy_port '5332'
option dns_port '5333'
option dns_redirect '1'
option ntp_server 'nil'
option udp_timeout ''
option tun_name 'singtun0'
option tun_addr4 '172.19.0.1/30'
option tun_addr6 'fdfe:dcba:9876::1/126'
option tun_mtu '9000'
option table_mark '100'
option self_mark '100'
option tproxy_mark '101'
option tun_mark '102'
config homeproxy 'migration'
option crontab '1'
config homeproxy 'config'
option main_node 'nil'
option main_udp_node 'same'
option dns_server '8.8.8.8'
option china_dns_server '223.5.5.5'
option routing_mode 'bypass_mainland_china'
option routing_port 'common'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option github_token ''
option log_level 'warn'
# Functional health probes (runtime/health.sh). They run after the
# process/listener gate and only log by default: a probe can fail because
# the uplink is down or a captive portal answers, and rolling a good
# configuration back for that would be worse than the condition probed
# for. Set health_probe_strict to 1 only if a failing probe must fail the
# start (and therefore trigger the rollback).
option health_probe_strict '0'
option health_probe_name 'example.com'
option cn_ip_fallback '0'
option tun_dns_mode ''
option udp_mapping ''
option udp_filtering ''
option udp_nat_max ''
config homeproxy 'control'
option lan_proxy_mode 'disabled'
list wan_proxy_ipv4_ips '91.105.192.0/23'
list wan_proxy_ipv4_ips '91.108.4.0/22'
list wan_proxy_ipv4_ips '91.108.8.0/22'
list wan_proxy_ipv4_ips '91.108.16.0/22'
list wan_proxy_ipv4_ips '91.108.12.0/22'
list wan_proxy_ipv4_ips '91.108.20.0/22'
list wan_proxy_ipv4_ips '91.108.56.0/22'
list wan_proxy_ipv4_ips '149.154.160.0/20'
list wan_proxy_ipv4_ips '185.76.151.0/24'
list wan_proxy_ipv4_ips '203.208.50.66/32'
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
list wan_proxy_ipv6_ips '2a0a:f280::/32'
config homeproxy 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
option find_neighbor '0'
config homeproxy 'dns'
option default_strategy 'prefer_ipv4'
option default_server 'default-dns'
option disable_cache '0'
option disable_cache_expire '0'
option optimistic_cache '0'
option optimistic_timeout ''
option dns_timeout ''
option cache_file_store_dns '0'
config homeproxy 'subscription'
option auto_update '0'
option allow_insecure '0'
option packet_encoding 'xudp'
option update_via_proxy '0'
option filter_nodes 'blacklist'
list filter_keywords '重置|到期|过期|剩余|套餐'
list filter_keywords 'Expiration|Remaining'
config homeproxy 'server'
option enabled '0'
option log_level 'warn'
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
@@ -0,0 +1,19 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
NAME="homeproxy"
log_max_size="50" #KB
main_log_file="/var/run/$NAME/$NAME.log"
singc_log_file="/var/run/$NAME/sing-box-c.log"
sings_log_file="/var/run/$NAME/sing-box-s.log"
while true; do
sleep 180
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
[ -s "$i" ] || continue
[ "$(( $(wc -c < "$i") / 1024 >= log_max_size))" -eq "0" ] || : > "$i"
done
done
@@ -0,0 +1,765 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Stage A1.1 of the architecture refactor:
*
* Node -> Protocol Adapter -> SingBox Outbound
*
* This replaces the single 110-line `generate_outbound()` whose protocol
* branches were expressed as ~60 ternaries inside one object literal. Three
* properties are deliberate:
*
* kept the shared TLS/transport/multiplex code is still the existing
* production builders - the adapter only supplies them a
* Node-shaped view instead of a flat UCI dict.
* pinned the emitted JSON is frozen by tests/snapshots/generator/
* outbounds.json, so a field change for any protocol is a
* reviewable diff (test_golden_outbounds.sh).
* dropped a per-protocol Adapter class hierarchy. The protocols differ by
* which *fields* they carry, not by behaviour, so the honest
* extension point is the field tables below, not eleven classes.
*
* Adding a protocol means adding three entries: CREDENTIALS (model.uc),
* PROTOCOL_OPTIONS (loader.uc) and OPTION_FIELDS here - and a node in
* tests/fixtures/generators/outbounds.uci, which
* tests/ucode/test_protocol_inventory.sh enforces.
*/
'use strict';
import {
isEmpty, strToBool, strToInt, strToTime, removeBlankAttrs,
buildTLSObject, buildTransportObject, parse_port
} from '../homeproxy.uc';
import { Node, Inbound, INBOUND_TLS_SERVER } from './model.uc';
/* Per-protocol required credential fields. Node.validate() covers the
* cross-protocol rules (type/address/port/TLS); this table is the
* per-protocol half - "vless needs uuid, vmess needs uuid, trojan needs
* password, snell needs psk, ..." - and it is the Adapter's job because
* the requirement is shaped by what the sing-box outbound for each
* protocol will actually accept, not by anything the model knows about
* UCI. The keys are the canonical credential names the Loader uses
* (see CREDENTIALS in model.uc), so the check is data-driven and adding
* a protocol is a one-line change. */
export const REQUIRED_CREDENTIALS = {
vless: ['uuid'],
vmess: ['uuid'],
trojan: ['password'],
hysteria2: ['password'],
tuic: ['uuid', 'password'],
shadowsocks: ['password'],
snell: ['psk'],
anytls: ['password'],
shadowtls: ['password'],
ssh: ['user'],
/* These three have no hard required credential - the protocol can
* run anonymously (http / socks) or the auth is delivered out of
* band (hysteria, direct). Node.validate()'s address/port check
* is enough. */
hysteria: [],
http: [],
socks: [],
direct: []
};
/* Run a Node through the per-protocol required-field check. Returns
* the (possibly empty) list of human-readable problems.
*
* The same ucode quirk that bit Node.validate() bites here too: push()
* returns the value pushed, not the new array, so we build the result
* via spread instead of the obvious `problems = push(problems, X)`. */
function protocol_problems(node) {
const required = REQUIRED_CREDENTIALS[node.type] || [];
const creds = node.credentials || {};
let problems = [];
for (let field in required) {
if (!creds[field])
problems = [...problems, `${node.type} requires ${field}`];
}
/* WireGuard is the one protocol whose material does not live in
* `credentials`: the endpoint builder reads node.protocol_options.
* Checking it here means a broken WireGuard node is caught by the
* same "is this node buildable?" path as every other protocol, which
* is what lets a urltest list prune it instead of failing the whole
* configuration. */
if (node.type === 'wireguard') {
const opts = node.protocol_options || {};
for (let field in ['local_address', 'private_key', 'peer_public_key'])
if (!opts[field])
problems = [...problems, `wireguard requires ${field}`];
}
return problems;
}
/* --- field transforms --------------------------------------------------- */
/* A table value is either a function of the domain object (a Node for the
* outbound tables, an Inbound for the server tables), a literal, or a
* string naming a transform (in which case the value is null - the spec
* is present so the key still gets emitted, but the value will be
* stripped by removeBlankAttrs()). */
const TRANSFORMS = {
raw: (value) => value,
bool: (value) => strToBool(value),
int: (value) => strToInt(value),
time: (value) => strToTime(value)
};
/* Shared by the outbound and the inbound field tables: the spec is a
* plain function in every table except the outbound one, which carries a
* few TRANSFORMS markers for keys that exist but are runtime-owned. */
function resolve(spec, subject) {
if (type(spec) === 'function')
return spec(subject);
if (type(spec) === 'string' && spec in TRANSFORMS)
return null;
return spec;
}
/* --- field tables ------------------------------------------------------- */
/* Fields every protocol emits. Each is null when absent, and nulls are
* stripped once, at the end, by removeBlankAttrs(). Cross-protocol common
* fields live in node.common (Loader's load_common); protocol-specific
* canonical names live in node.protocol_options. The legacy
* `node.raw` opaque bag was dropped - the Adapter reads only the explicit
* sub-objects, so no escape hatch exists.
*
* This table is applied AFTER the literal in build_outbound(), so a key
* listed here wins over whatever that literal set. A field the runtime owns
* (routing_mark, from the `mark` argument) therefore must NOT be listed
* here, not even as `null`: the loop would overwrite the value with null and
* removeBlankAttrs() would drop the field entirely. routing_mark is what
* exempts sing-box's own proxy connections from the nft redirect chain, so
* losing it loops the traffic back into the redirect inbound. Guard 32 in
* tests/arch-guard.sh keeps the literal's keys and this table disjoint. */
const COMMON_FIELDS = {
server: (node) => node.address,
server_port: (node) => strToInt(node.port),
proxy_protocol: (node) => strToInt(node.common.proxy_protocol),
tcp_fast_open: (node) => strToBool(node.common.tcp_fast_open),
tcp_multi_path: (node) => strToBool(node.common.tcp_multi_path),
udp_fragment: (node) => strToBool(node.common.udp_fragment),
packet_encoding: (node) => node.protocol_options.packet_encoding || null
};
/* The node form stores an SSH private key in a DynamicList, i.e. one UCI
* list entry per line of the OpenSSH/PEM blob (a UCI option cannot hold
* newlines). sing-box wants the whole key as one string, so re-join the
* lines. A single-entry value is passed through unchanged.
*
* Declared before CLAIM_FIELDS on purpose: ucode resolves module-level names
* lexically, so a callee has to appear above its caller in the file. */
function ssh_private_key(value) {
if (type(value) === 'array')
return length(value) ? join('\n', value) : null;
return value || null;
}
/* Credential fields: one canonical set, emitted under the name each protocol
* wants. This is where the old builder repeated itself. */
function CLAIM_FIELDS(node) {
switch (node.type) {
case 'snell':
return {
psk: node.credentials.psk,
userkey: node.credentials.userkey,
reuse: strToBool(node.protocol_options.reuse)
};
case 'ssh':
return {
user: node.credentials.user,
password: node.credentials.password,
private_key: ssh_private_key(node.credentials.private_key),
private_key_passphrase: node.credentials.private_key_passphrase
};
case 'hysteria':
return {
auth: (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
auth_str: (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null
};
default:
return {
username: node.credentials.username,
uuid: node.credentials.uuid,
password: node.credentials.password,
method: node.credentials.method
};
}
}
/* Per-protocol option fields, keyed by the same canonical names the model
* exposes. Anything not modelled here is still in node.protocol_options
* (which is itself populated from the protocol's PROTOCOL_OPTIONS row in
* the Loader). */
export const OPTION_FIELDS = {
vless: {
flow: (node) => node.protocol_options.flow
/* No udp_over_tcp here: sing-box 1.14 rejects that field on a vless
* outbound ("json: unknown field"). It used to be emitted, so a
* node whose type was switched from shadowsocks to vless kept the
* stale UCI option and produced a config sing-box refused outright.
* The node form never offered the option for vless anyway. */
},
/* `mode` (snell v6 traffic shaping) is deliberately absent: sing-box
* 1.14 rejects it as an outbound field, and the node form only offered
* it for snell_version 6, which this sing-box does not support either. */
snell: {
version: (node) => strToInt(node.protocol_options.version) || 4,
obfs_mode: (node) => node.protocol_options.obfs_mode || null,
obfs_host: (node) => node.protocol_options.obfs_host || null
},
/* A4.1: shadowsocks. SIP002 plugin support is emitted only when both
* plugin and plugin_opts are present, matching the generator's ternary
* that emits `plugin: ...` unconditionally and lets removeBlankAttrs
* drop empties. `udp_over_tcp` mirrors the vless shape. */
shadowsocks: {
plugin: (node) => node.protocol_options.plugin || null,
plugin_opts: (node) => node.protocol_options.plugin_opts || null,
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
enabled: true,
version: strToInt(node.protocol_options.udp_over_tcp_version)
} : null
},
/* A4.2: anytls idle session tuning (sing-box 1.14 lets the client probe
* upstream sessions and proactively close stuck ones). */
anytls: {
idle_session_check_interval: (node) => strToTime(node.protocol_options.idle_session_check_interval),
idle_session_timeout: (node) => strToTime(node.protocol_options.idle_session_timeout),
min_idle_session: (node) => strToInt(node.protocol_options.min_idle_session)
},
/* A4.3: http - no protocol-specific options; transport / tls / multiplex
* are shared. */
http: {},
/* A4.4: socks. The node form offers UDP-over-TCP for socks and sing-box
* accepts it on the socks outbound, but the adapter never emitted it, so
* the setting was silently ignored. */
socks: {
version: (node) => node.protocol_options.version,
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
enabled: true,
version: strToInt(node.protocol_options.udp_over_tcp_version)
} : null
},
/* A4.5: tuic */
tuic: {
congestion_control: (node) => node.protocol_options.congestion_control,
udp_relay_mode: (node) => node.protocol_options.udp_relay_mode,
udp_over_stream: (node) => strToBool(node.protocol_options.udp_over_stream),
zero_rtt_handshake: (node) => strToBool(node.protocol_options.zero_rtt_handshake),
heartbeat: (node) => strToTime(node.protocol_options.heartbeat)
},
/* A4.6: trojan - no protocol-specific options. */
trojan: {},
/* A4.7: shadowtls */
shadowtls: {
version: (node) => strToInt(node.protocol_options.version)
},
/* A4.8: hysteria (v1). The generator emits two fields: `auth` when the
* payload is base64, `auth_str` when it is a string. Node.validate()
* only catches the obvious case; the conditional keeps the table-driven
* shape.
*
* obfs is the plain obfuscation password string here. The node form
* only offers the password for v1 (the type selector is hysteria2-only)
* and sing-box rejects the {type, password} object on a hysteria
* outbound - that shape belongs to hysteria2. Emitting the object made
* any obfuscated hysteria node produce a config sing-box refused. */
hysteria: {
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
obfs: (node) => node.protocol_options.obfs_password || null
},
/* A4.9: hysteria2 - extends hysteria with hop_interval_max /
* server_ports / bbr_profile / disable_chrome_parrot. The obfs
* shape is the same. Note sing-box 1.14 uses `server_ports`
* (port hopping list), not `hopping_port` - the latter is the
* UCI option name, so the rename happens here. */
hysteria2: {
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
hop_interval_max: (node) => strToTime(node.protocol_options.hop_interval_max),
server_ports: (node) => node.protocol_options.hopping_port,
obfs: (node) => node.protocol_options.obfs_type ? {
type: node.protocol_options.obfs_type,
password: node.protocol_options.obfs_password
} : null,
bbr_profile: (node) => node.protocol_options.bbr_profile || null,
disable_chrome_parrot: (node) => (node.protocol_options.disable_chrome_parrot === '1') ? true : null
},
/* A4.10: vmess */
vmess: {
alter_id: (node) => strToInt(node.protocol_options.alter_id),
security: (node) => node.protocol_options.security,
global_padding: (node) => strToBool(node.protocol_options.global_padding)
},
/* SSH: host-key pinning and the client banner, matching sing-box's ssh
* outbound field names. */
ssh: {
client_version: (node) => node.protocol_options.client_version || null,
host_key: (node) => node.protocol_options.host_key || null,
host_key_algorithms: (node) => node.protocol_options.host_key_algorithms || null
}
};
/* --- shared builders ---------------------------------------------------- */
function build_multiplex(mux) {
if (!mux || mux.enabled !== '1')
return null;
return {
enabled: true,
protocol: mux.protocol,
max_connections: strToInt(mux.max_connections),
min_streams: strToInt(mux.min_streams),
max_streams: strToInt(mux.max_streams),
padding: strToBool(mux.padding),
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
enabled: true,
up_mbps: strToInt(mux.brutal.up_mbps),
down_mbps: strToInt(mux.brutal.down_mbps)
} : null
};
}
/* --- adapter ------------------------------------------------------------ */
/* Both validation layers as one list. Standalone (not a method) because the
* OutboundFactory literal below cannot reference itself during its own
* initialisation: ucode resolves the binding lexically and throws
* "Can't access lexical declaration before initialization". */
function outbound_problems(node) {
return [
...Node.validate(node),
...protocol_problems(node)
];
}
/* Node -> sing-box outbound object. Pure: no UCI, no module state, no file
* access; `mark` is passed in instead of read from a global.
*
* Standalone for the same reason outbound_problems() is: the literal below
* cannot name OutboundFactory in any nested scope, not even inside an arrow
* body, without tripping ucode's "Can't access lexical declaration before
* initialization" check. Validation is done by the callers. */
function build_outbound(node, mark) {
const outbound = {
type: node.type,
tag: Node.tag(node),
routing_mark: strToInt(mark)
};
for (let field, spec in COMMON_FIELDS)
outbound[field] = resolve(spec, node);
const claims = CLAIM_FIELDS(node);
for (let field, value in claims)
outbound[field] = value;
const options = OPTION_FIELDS[node.type] || {};
for (let field, spec in options)
outbound[field] = resolve(spec, node);
outbound.multiplex = build_multiplex(node.multiplex);
outbound.tls = buildTLSObject(node.tls, false);
outbound.transport = buildTransportObject(node.transport, false);
if (node.type === 'direct')
outbound.proxy_protocol = strToInt(node.common.proxy_protocol);
/* removeBlankAttrs() is what the generator applies before writing, and
it drops every null the field tables produced. Cleaning here means
this function returns the final artifact, so a golden-snapshot test
can compare it against what sing-box would actually be handed. */
return removeBlankAttrs(outbound);
}
export const OutboundFactory = {
/* Validation split out of create() so a caller can decide whether a
* broken node is fatal. Two-layer: Node.validate() covers
* cross-protocol rules, protocol_problems() the per-protocol ones. */
problems: outbound_problems,
/* True when create() would succeed. Used to prune optional node lists
* (urltest candidates) so one misconfigured node cannot abort the
* entire configuration. */
buildable: (node) => length(outbound_problems(node)) === 0,
/* Node -> { outbound, problems }. Same result as create() but never
* dies, for nodes the user only *may* route through. */
tryCreate: (node, mark) => {
const problems = outbound_problems(node);
if (length(problems))
return { outbound: null, problems: problems };
return { outbound: build_outbound(node, mark), problems: [] };
},
/* Node -> sing-box outbound object, or die() when the node is not
* buildable. Use problems()/buildable()/tryCreate() for nodes whose
* breakage must not be fatal (urltest candidates). */
create: (node, mark) => {
const problems = outbound_problems(node);
if (length(problems))
die(`node '${node.id}': ${join(', ', problems)}\n`);
return build_outbound(node, mark);
}
};
/* --- WireGuard endpoint ------------------------------------------------- */
/* WireGuard is emitted as a
* sing-box *endpoint*, not an outbound, so it needs its own builder.
* That builder used to live in generator/outbound.uc, which meant the
* generator still held protocol business logic for one protocol. It
* now lives here, next to the outbound factory, and the generator only
* decides *when* an endpoint is needed (routing-mode split, urltest
* resolution).
*
* `ctx` carries the process-wide UDP tuning the runtime owns
* (udp_mapping / udp_filtering / udp_nat_max) - same shape
* build_outbounds() already passes around, so callers do not change. */
function build_endpoint(node, ctx) {
const opts = node.protocol_options || {};
const common = node.common || {};
return {
type: node.type,
tag: 'cfg-' + node.id + '-out',
address: opts.local_address,
mtu: strToInt(opts.mtu),
private_key: opts.private_key,
peers: (node.type === 'wireguard') ? [
{
address: node.address,
port: strToInt(node.port),
allowed_ips: [
'0.0.0.0/0',
'::/0'
],
persistent_keepalive_interval: strToInt(opts.persistent_keepalive_interval),
public_key: opts.peer_public_key,
pre_shared_key: opts.pre_shared_key,
reserved: parse_port(opts.reserved),
}
] : null,
system: (node.type === 'wireguard') ? false : null,
tcp_fast_open: strToBool(common.tcp_fast_open),
tcp_multi_path: strToBool(common.tcp_multi_path),
udp_fragment: strToBool(common.udp_fragment),
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
udp_nat_max: ctx.udp_nat_max
};
}
export const EndpointFactory = {
/* Node -> sing-box endpoint object, or null when there is nothing to
* build. Unlike OutboundFactory.create() this never dies: an endpoint
* is only ever reached for a node the routing layer already selected,
* and the caller treats null as "skip this endpoint". */
create: (node, ctx) => {
if (type(node) !== 'object' || isEmpty(node))
return null;
return build_endpoint(node, ctx);
}
};
/* --- server inbound ----------------------------------------------------- */
/* The server half of the Adapter. Same data-table approach as
* the outbound side: the protocol differences are *fields*, not
* behaviour, so they are expressed as tables rather than a branch per
* protocol. generator/server.uc used to spell every one of these out
* inline while reading flat UCI keys off the section dict.
*
* Adding a protocol means adding two rows: INBOUND_CREDENTIALS and
* INBOUND_OPTIONS in model.uc, and an INBOUND_OPTION_FIELDS row here. */
/* Listener-level fields every inbound emits. Each value is a function of
* the Inbound, or null (which removeBlankAttrs() strips). */
const INBOUND_COMMON_FIELDS = {
listen: (inbound) => inbound.address || '::',
listen_port: (inbound) => strToInt(inbound.port),
bind_interface: (inbound) => inbound.common.bind_interface,
reuse_addr: (inbound) => strToBool(inbound.common.reuse_addr),
tcp_fast_open: (inbound) => strToBool(inbound.common.tcp_fast_open),
tcp_multi_path: (inbound) => strToBool(inbound.common.tcp_multi_path),
udp_fragment: (inbound) => strToBool(inbound.common.udp_fragment),
udp_timeout: (inbound) => strToTime(inbound.common.udp_timeout),
network: (inbound) => inbound.common.network
};
/* Listener fields a protocol must NOT be given even when the UCI section
* happens to carry the option. sing-box 1.14's snell inbound takes no
* udp_fragment / udp_timeout / network; the previous generator had a
* dedicated build_snell_inbound() that omitted them, and this table
* keeps that behaviour instead of relying on the user not setting them. */
const INBOUND_COMMON_OMIT = {
snell: ['udp_fragment', 'udp_timeout', 'network']
};
/* Protocols whose sing-box inbound has no users[] block. snell carries a
* single top-level psk, shadowsocks a top-level password + method; giving
* either of them a users[] entry makes sing-box reject the section
* ("snell: bad user key" - the users entry is interpreted as an
* additional user key). */
const INBOUND_NO_USERS = ['shadowsocks', 'snell'];
/* Credential fields the inbound emits, keyed by the sing-box field
* name. Most protocols put them in the users[] block (below); snell
* wants a single top-level `psk`, and shadowsocks / shadowtls a
* top-level password (plus method for shadowsocks), so those are
* handled here. */
function INBOUND_CLAIM_FIELDS(inbound) {
switch (inbound.type) {
case 'snell':
return { psk: inbound.credentials.psk };
case 'shadowsocks':
return {
method: inbound.credentials.method,
password: inbound.credentials.password
};
case 'shadowtls':
return { password: inbound.credentials.password };
default:
return {};
}
}
/* The users[] entry. sing-box wants one user per inbound; every
* protocol not listed in INBOUND_NO_USERS accepts the block. */
function build_inbound_user(inbound) {
const creds = inbound.credentials || {};
const opts = inbound.protocol_options || {};
return {
name: !(inbound.type in ['http', 'mixed', 'naive', 'socks']) ?
'cfg-' + inbound.id + '-server' : null,
username: creds.username,
password: creds.password,
/* Hysteria (1) authentication: exactly one of the two
* spellings, selected by the auth type. */
auth: (opts.auth_type === 'base64') ? creds.auth_base64 : null,
auth_str: (opts.auth_type === 'string') ? creds.auth_str : null,
uuid: creds.uuid,
/* VLESS / VMess. Both are per-user fields: sing-box has no
* top-level `flow` on a vless inbound nor `alterId` on a vmess
* one, which is why INBOUND_OPTION_FIELDS has no row for these
* two protocols. */
flow: opts.flow,
alterId: strToInt(opts.alter_id)
};
}
/* Per-protocol inbound fields, keyed by the sing-box field name. Only
* the protocols whose sing-box inbound carries something beyond the
* shared listener/credential/TLS/transport set need a row. */
const INBOUND_OPTION_FIELDS = {
anytls: {
padding_scheme: (inbound) => inbound.protocol_options.padding_scheme
},
/* Hysteria (1) and Hysteria2 share three fields and differ on
* obfs: v1 carries the obfuscation as a plain password string,
* v2 as a {type, password, min_packet_size, max_packet_size}
* object. Emitting v1's obfs as a string is what the generator
* always did - the {type, ...} shape on a v1 inbound is a
* sing-box rejection. */
hysteria: {
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
obfs: (inbound) => inbound.protocol_options.obfs_password,
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
masquerade: (inbound) => inbound.protocol_options.masquerade
},
hysteria2: {
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
obfs: (inbound) => inbound.protocol_options.obfs_type ? {
type: inbound.protocol_options.obfs_type,
password: inbound.protocol_options.obfs_password,
min_packet_size: strToInt(inbound.protocol_options.obfs_min_packet_size),
max_packet_size: strToInt(inbound.protocol_options.obfs_max_packet_size)
/* hysteria2 takes an obfs *object* here. Falling back to the bare
* password string (which is what hysteria v1 wants) makes sing-box
* reject the inbound with "cannot unmarshal string into Go struct
* field Hysteria2InboundOptions.obfs" - so no type means no obfs. */
} : null,
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
masquerade: (inbound) => inbound.protocol_options.masquerade
},
snell: {
version: (inbound) => strToInt(inbound.protocol_options.version) || 5,
obfs_mode: (inbound) => inbound.protocol_options.obfs_mode
/* no `mode`: sing-box 1.14 rejects it on a snell inbound; it
* was a v6-only option and v6 is not supported. */
},
tuic: {
congestion_control: (inbound) => inbound.protocol_options.congestion_control,
auth_timeout: (inbound) => strToTime(inbound.protocol_options.auth_timeout),
zero_rtt_handshake: (inbound) => strToBool(inbound.protocol_options.zero_rtt_handshake),
heartbeat: (inbound) => strToTime(inbound.protocol_options.heartbeat)
}
/* No row for vless / vmess: both protocols' only per-protocol knobs
* (flow, alterId) are per-user fields, emitted by
* build_inbound_user() above. sing-box has no top-level `flow` on a
* vless inbound nor `alterId` on a vmess one, so adding a row here
* would emit a field sing-box rejects. The Loader still loads them
* into inbound.protocol_options (INBOUND_OPTIONS in model.uc), which
* is where build_inbound_user() reads them from.
*
* No row for trojan / shadowtls either: everything they carry is
* shared (credentials / TLS / transport). */
};
/* The server-only TLS tail, re-keyed from the Inbound's canonical
* names into the UCI option names buildTLSObject() reads. That shared
* builder is exercised directly by tests/ucode/test_tls_transport.uc
* and is used by both the client and the server path, so its signature
* stays as it is; this is the single place the flat names survive. */
function build_tls_server_extras(tls_server) {
const extras = {};
for (let canonical, uci in INBOUND_TLS_SERVER)
extras[uci] = tls_server[canonical];
return extras;
}
/* Build the inbound `multiplex` block. The server side has no
* max_connections / min_streams / max_streams (those are client
* dialling knobs), so this is deliberately a smaller shape than the
* outbound factory's build_multiplex(). */
function build_inbound_multiplex(mux) {
if (!mux || mux.enabled !== '1')
return null;
return {
enabled: true,
padding: strToBool(mux.padding),
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
enabled: true,
up_mbps: strToInt(mux.brutal.up_mbps),
down_mbps: strToInt(mux.brutal.down_mbps)
} : null
};
}
/* Inbound -> sing-box inbound object. Pure: no UCI, no file access. */
function build_inbound(inbound) {
const out = {
type: inbound.type,
tag: Inbound.tag(inbound)
};
const omit = INBOUND_COMMON_OMIT[inbound.type] || [];
for (let field, spec in INBOUND_COMMON_FIELDS) {
if (field in omit)
continue;
out[field] = resolve(spec, inbound);
}
const claims = INBOUND_CLAIM_FIELDS(inbound);
for (let field, value in claims)
out[field] = value;
const options = INBOUND_OPTION_FIELDS[inbound.type] || {};
for (let field, spec in options)
out[field] = resolve(spec, inbound);
out.users = (inbound.type in INBOUND_NO_USERS) ? null : [ build_inbound_user(inbound) ];
out.multiplex = build_inbound_multiplex(inbound.multiplex);
out.tls = buildTLSObject(inbound.tls, true, build_tls_server_extras(inbound.tls_server || {}));
out.transport = buildTransportObject(inbound.transport, true);
/* Same contract as build_outbound(): return the final artifact so a
* golden snapshot can compare it against what sing-box is handed. */
return removeBlankAttrs(out);
}
/* Per-protocol requirements, mirroring OutboundFactory's
* REQUIRED_CREDENTIALS: what the sing-box inbound for this protocol
* needs in order to be usable. */
const REQUIRED_INBOUND_CREDENTIALS = {
vless: ['uuid'],
vmess: ['uuid'],
trojan: ['password'],
hysteria2: ['password'],
tuic: ['uuid', 'password'],
shadowsocks: ['password'],
snell: ['psk'],
anytls: ['password'],
shadowtls: ['password'],
/* These run without a credential: http / mixed / socks / naive can
* be anonymous, and hysteria (1) may carry its auth out of band. */
hysteria: [],
http: [],
mixed: [],
naive: [],
socks: []
};
function inbound_problems(inbound) {
let problems = [...Inbound.validate(inbound)];
const required = REQUIRED_INBOUND_CREDENTIALS[inbound.type] || [];
const creds = inbound.credentials || {};
for (let field in required)
if (!creds[field])
problems = [...problems, `${inbound.type} requires ${field}`];
return problems;
}
export const InboundFactory = {
problems: inbound_problems,
buildable: (inbound) => length(inbound_problems(inbound)) === 0,
/* Inbound -> { inbound, problems }. Never dies, so a single broken
* server section can be reported by the caller instead of taking
* the whole server instance down. */
tryCreate: (inbound) => {
const problems = inbound_problems(inbound);
if (length(problems))
return { inbound: null, problems: problems };
return { inbound: build_inbound(inbound), problems: [] };
},
/* Inbound -> sing-box inbound object, or die(). */
create: (inbound) => {
const problems = inbound_problems(inbound);
if (length(problems))
die(`inbound '${inbound.id}': ${join(', ', problems)}\n`);
return build_inbound(inbound);
}
};
@@ -0,0 +1,440 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Stage A1.1 of the architecture refactor:
*
* UCI -> Config Loader -> HomeProxyConfig
*
* This file originated as the demo loader and was promoted into the
* production tree; the demo copy is gone, so this is the only one.
*
* What this layer does:
* - owns the only `uci.cursor()` in the client configuration path
* - reads general + infra + nodes + dns + routing + access_control + server
* - never emits sing-box JSON
* - never mutates/commits UCI
*/
'use strict';
import { cursor } from 'uci';
import {
Config, Node, CREDENTIALS, Inbound,
INBOUND_CREDENTIALS, INBOUND_OPTIONS, INBOUND_COMMON, INBOUND_TLS_SERVER
} from './model.uc';
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from '../parser/mapping.uc';
const UCICONFIG = 'homeproxy';
const SECTION = {
main: 'config',
infra: 'infra',
node: 'node',
dns: 'dns',
dns_server: 'dns_server',
dns_rule: 'dns_rule',
routing: 'routing',
routing_node: 'routing_node',
routing_rule: 'routing_rule',
ruleset: 'ruleset',
control: 'control',
subscription: 'subscription',
server: 'server'
};
/* Booleans are UCI '0'/'1'; keep the conversion in one place. */
function bool(value) {
return value === '1';
}
function opt(uci, section, name) {
/* UCI returns null for an absent option; the old code papered over that
* with `|| ''` at every call site, losing the distinction between "not
* set" and "set to empty". The model keeps null instead. */
return uci.get(UCICONFIG, SECTION[section], name);
}
function node_opt(uci, section_id, name) {
return uci.get(UCICONFIG, section_id, name);
}
/* --- sub-object extraction --------------------------------------------- */
/* These three functions are the whole reason a Node is hierarchical: the
* shared TLS/transport/multiplex builders (homeproxy.uc) take a flat,
* prefixed dict today, so every caller has to hand them the right subset. A
* sub-object makes the boundary explicit and testable.
*
* load_tls and load_transport are exported so the server generator can
* shape its UCI inbound sections the same way; only the multiplex shape is
* Loader-internal because no caller outside the client node path needs it. */
export function load_tls(get) {
return {
enabled: get('tls'),
server_name: get('tls_sni'),
insecure: get('tls_insecure'),
alpn: get('tls_alpn'),
min_version: get('tls_min_version'),
max_version: get('tls_max_version'),
handshake_timeout: get('tls_handshake_timeout'),
cipher_suites: get('tls_cipher_suites'),
cert_path: get('tls_cert_path'),
ech: {
enabled: get('tls_ech'),
config: get('tls_ech_config'),
config_path: get('tls_ech_config_path')
},
utls: {
fingerprint: get('tls_utls')
},
reality: {
enabled: get('tls_reality'),
public_key: get('tls_reality_public_key'),
short_id: get('tls_reality_short_id')
}
};
};
export function load_transport(get) {
const transport = get('transport');
if (transport == null || transport === '')
return { type: null };
return {
type: transport,
host: get('http_host') || get('httpupgrade_host'),
path: get('http_path') || get('ws_path'),
headers: get('ws_host') ? { Host: get('ws_host') } : null,
method: get('http_method'),
max_early_data: get('websocket_early_data'),
early_data_header_name: get('websocket_early_data_header'),
service_name: get('grpc_servicename'),
idle_timeout: get('http_idle_timeout'),
ping_timeout: get('http_ping_timeout'),
permit_without_stream: get('grpc_permit_without_stream')
};
};
function load_multiplex(get) {
return {
enabled: get('multiplex'),
protocol: get('multiplex_protocol'),
max_connections: get('multiplex_max_connections'),
min_streams: get('multiplex_min_streams'),
max_streams: get('multiplex_max_streams'),
padding: get('multiplex_padding'),
brutal: {
enabled: get('multiplex_brutal'),
up_mbps: get('multiplex_brutal_up'),
down_mbps: get('multiplex_brutal_down')
}
};
}
/* Cross-protocol common fields (every outbound protocol carries them, not
* just one). Lives as its own sub-object instead of being repeated in
* every PROTOCOL_OPTIONS row, and the Adapter reads from `node.common`. */
function load_common(get) {
return {
proxy_protocol: get('proxy_protocol'),
tcp_fast_open: get('tcp_fast_open'),
tcp_multi_path: get('tcp_multi_path'),
udp_fragment: get('udp_fragment')
};
}
/* --- protocol options --------------------------------------------------- */
/* The canonical <-> UCI mapping table lives in scripts/parser/mapping.uc and
* is imported as PROTOCOL_OPTIONS above. The Loader no longer maintains its
* own copy, so the parser, the Loader, the parser's normalize(), and the
* Repository all read from the same source. Anything not listed in
* parser/mapping.uc for a given protocol is not part of the domain
* contract; a stale UCI option is now dropped at the parser side too.
*/
function load_protocol_options(get, type) {
const mapping = PROTOCOL_OPTIONS[type] || {};
const options = {};
for (let canonical, uci_name in mapping)
options[canonical] = get(uci_name);
return options;
}
/* Pull the protocol's credential fields out under canonical names, using the
* single CREDENTIALS table from the model. This is the loader half of the
* mapping that the old code spread across ~20 ternaries in generate_outbound().
* Declared before Loader: ucode resolves module-level names at call time but
* not before they are declared. */
function load_credentials(get, type) {
const mapping = CREDENTIALS[type] || {};
const credentials = {};
for (let canonical, uci_name in mapping)
credentials[canonical] = get(uci_name);
return credentials;
}
/* --- domain sub-objects ------------------------------------------------ */
/* Settings: only the keys that exist in UCI are kept (null is dropped), so
* the consumer can tell "not set" from "set to empty". A3 (Generator split)
* will own the actual key list - this loader just preserves what UCI has. */
function load_settings(uci, section, keys) {
const settings = {};
for (let key in keys) {
const v = uci.get(UCICONFIG, section, key);
if (v != null)
settings[key] = v;
}
return settings;
}
/* The raw UCI section dict that
* uci.foreach() yields carries internal fields prefixed with a dot
* (`.name`, `.index`, `.type`); downstream generators had to read those
* directly, and every consumer had to remember `cfg.enabled !== '1'`
* to gate a section. Normalise once here so consumers see a flat
* domain object: the dot-prefixed pseudo-fields are dropped, the UCI
* section name is exposed as `name`, and `enabled` (when present) is
* coerced to a real boolean.
*
* Fields with non-prefixed names that happen to collide with the UCI
* metadata (e.g. an option literally called `name` or `index`) would
* be shadowed; in this codebase that does not happen, and the Adapter keeps
* it that way. */
function normalize_section(cfg) {
const item = {};
for (let k, v in cfg) {
/* substr(), not k[0]: the target ucode (2026.01.16) rejects
* string indexing with "left-hand side expression is not an
* array or object". subscription/repository.uc already uses
* the substr() form for the same check. */
if (substr(k, 0, 1) === '.')
continue;
item[k] = v;
}
if ('enabled' in item)
item.enabled = (item.enabled === '1');
item.name = cfg['.name'];
return item;
}
/* Single-section + list sections grouped under a domain sub-object.
* Each list entry is the normalised section dict produced by
* normalize_section(): the UCI `.name`/`.index`/`.type` pseudo-fields
* are gone, `name` is set to the section name, and `enabled` is a
* boolean. Sing-box field names are still produced by the generator
* modules; this loader just preserves what UCI has. */
function load_sections(uci, type) {
const items = [];
uci.foreach(UCICONFIG, type, (cfg) => push(items, normalize_section(cfg)));
return items;
}
function load_dns(uci) {
return {
settings: load_settings(uci, SECTION.dns, [
'default_strategy', 'default_server',
'disable_cache', 'disable_cache_expire',
'client_subnet',
'optimistic_cache', 'optimistic_timeout',
'dns_timeout', 'cache_file_store_dns'
]),
servers: load_sections(uci, SECTION.dns_server),
rules: load_sections(uci, SECTION.dns_rule)
};
}
function load_routing(uci) {
return {
settings: load_settings(uci, SECTION.routing, [
'default_outbound', 'default_outbound_dns',
'domain_strategy', 'find_neighbor',
'udp_timeout', 'tcpip_stack', 'endpoint_independent_nat'
]),
nodes: load_sections(uci, SECTION.routing_node),
rules: load_sections(uci, SECTION.routing_rule),
rulesets: load_sections(uci, SECTION.ruleset)
};
}
/* access_control is two single sections: `control` (lan_proxy_mode,
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
function load_access_control(uci) {
return {
control: load_settings(uci, SECTION.control, [
'bind_interface', 'lan_proxy_mode'
]),
/* wan_proxy_*_ips are list options on the control section; collect
them in their canonical form. */
wan_proxy_ipv4_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv4_ips') || [],
wan_proxy_ipv6_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv6_ips') || [],
subscription: load_settings(uci, SECTION.subscription, [
'auto_update', 'allow_insecure',
'packet_encoding', 'update_via_proxy',
'filter_nodes', 'user_agent'
]),
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
};
}
/* The two "read a table of canonical -> UCI names off a section"
* helpers shared by the node and inbound paths. Same shape as
* load_protocol_options() above; split out so the server side reads
* the same way the client side does. */
function load_table(get, mapping) {
const out = {};
for (let canonical, uci_name in mapping)
out[canonical] = get(uci_name);
return out;
}
/* A server inbound is now a
* domain object like a Node, built here and shaped into sing-box JSON
* by config/adapter.uc's InboundFactory. Before this the generator read
* the flat UCI section directly (`cfg.snell_version`,
* `cfg.shadowsocks_encrypt_method`, ...), so the server path had no
* domain model at all.
*
* Same sub-object split as Node: common (listener-level), credentials,
* tls (the shared shape the client uses too), tls_server (the
* server-only tail), transport, multiplex, protocol_options. */
function load_inbound(get, section) {
const type = get('type');
return Inbound.create({
id: section['.name'],
name: get('label') || section['.name'],
type: type,
enabled: section.enabled === '1',
address: get('address'),
port: get('port'),
firewall: get('firewall'),
common: load_table(get, INBOUND_COMMON),
credentials: load_table(get, INBOUND_CREDENTIALS[type] || {}),
tls: load_tls(get),
tls_server: load_table(get, INBOUND_TLS_SERVER),
transport: load_transport(get),
multiplex: load_multiplex(get),
protocol_options: load_table(get, INBOUND_OPTIONS[type] || {})
});
}
/* server has one enabled/log_level single section + N inbound sections
* (each with a `type` of vless / trojan / shadowsocks / ...). */
function load_server(uci) {
const inbounds = [];
uci.foreach(UCICONFIG, SECTION.server, (section) => {
push(inbounds, load_inbound((name) => node_opt(uci, section['.name'], name), section));
});
return {
settings: load_settings(uci, SECTION.server, [
'enabled', 'log_level'
]),
inbounds
};
}
/* --- loader ------------------------------------------------------------- */
export const Loader = {
/* Load the client side of the configuration. `dir` defaults to the
* production UCI directory but can be overridden, which removes the need
* for the test suite's `sed` of `const uci = cursor();`. */
load: (dir) => {
const uci = dir ? cursor(dir) : cursor();
uci.load(UCICONFIG);
const config = Config.create('homeproxy');
config.general = {
routing_mode: opt(uci, 'main', 'routing_mode') || 'bypass_mainland_china',
proxy_mode: opt(uci, 'main', 'proxy_mode') || 'redirect_tproxy',
main_node: opt(uci, 'main', 'main_node') || 'nil',
main_udp_node: opt(uci, 'main', 'main_udp_node') || 'nil',
/* Booleans stay as raw UCI strings here, same as Node. The
Adapter (A3) is the one that calls strToBool(); coercing in
the Loader and again in the Adapter produced two different
defaults for absent / garbage values during A1.1. */
ipv6_support: opt(uci, 'main', 'ipv6_support'),
/* A2.1: scalar + list fields on the `config` section. */
dns_server: opt(uci, 'main', 'dns_server'),
china_dns_server: opt(uci, 'main', 'china_dns_server'),
log_level: opt(uci, 'main', 'log_level') || 'warn',
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
udp_mapping: opt(uci, 'main', 'udp_mapping'),
udp_filtering: opt(uci, 'main', 'udp_filtering'),
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
main_udp_urltest_nodes: opt(uci, 'main', 'main_udp_urltest_nodes') || [],
main_udp_urltest_interval: opt(uci, 'main', 'main_udp_urltest_interval'),
main_udp_urltest_tolerance: opt(uci, 'main', 'main_udp_urltest_tolerance')
};
/* udp_timeout lives on two UCI sections: routing.udp_timeout
* (custom mode) and infra.udp_timeout (everything else). Mirror
* that split here so A2 can read either path without falling back
* to uci.get(). */
config.infra = load_settings(uci, SECTION.infra, [
'common_port', 'mixed_port', 'redirect_port', 'tproxy_port',
'dns_port', 'dns_redirect',
'tun_name', 'tun_addr4', 'tun_addr6', 'tun_mtu',
'table_mark', 'self_mark', 'tproxy_mark', 'tun_mark',
'ntp_server', 'udp_timeout'
]);
uci.foreach(UCICONFIG, SECTION.node, (section) => {
const get = (name) => node_opt(uci, section['.name'], name);
push(config.nodes, Node.create({
id: section['.name'],
name: get('label') || section['.name'],
type: get('type'),
address: get('address'),
port: get('port'),
common: load_common(get),
credentials: load_credentials(get, get('type')),
tls: load_tls(get),
transport: load_transport(get),
multiplex: load_multiplex(get),
protocol_options: load_protocol_options(get, get('type'))
}));
});
config.dns = load_dns(uci);
config.routing = load_routing(uci);
config.access_control = load_access_control(uci);
config.server = load_server(uci);
return config;
}
};
@@ -0,0 +1,378 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Stage A1.1 of the architecture refactor:
*
* UCI -> Config Loader -> HomeProxyConfig / Node -> Adapter -> sing-box
*
* The point of this layer is that nothing below it may touch UCI, and nothing
* in it may know about sing-box JSON. A Node therefore describes *what the
* user configured*, not what sing-box wants: no `outbound`, no `type` field
* named after a sing-box outbound kind, no `tag` arithmetic.
*
* Every field the Adapter used to read off
* `node.raw.*` has been promoted to an explicit Node sub-object
* (`credentials`, `tls`, `transport`, `multiplex`, `common`,
* `protocol_options`), so the legacy `raw` opaque bag was dropped - no code
* reads it and leaving it in only made the next reader assume it was
* authoritative.
*/
'use strict';
import { isEmpty } from '../homeproxy.uc';
/* Canonical credential multiplexing for a Node. The current generator picks
* these apart with ternaries per field (`username` vs `user` vs `password` vs
* `psk`, keyed on every protocol); expressing them as data is what makes a new
* protocol a table entry instead of another branch.
*
* The value is the UCI option name (as the loader sees it on the flat UCI
* section), the key is the canonical credential name. The adapter later
* emits the field under whatever name the sing-box outbound wants
* (e.g. snell: credentials.psk is emitted as the outbound `psk` field,
* shadowsocks: credentials.method becomes outbound `method`).
*
* Fixed during Stage A1.1: snell `userkey` actually comes from the
* `snell_userkey` UCI option (not `username`), and shadowsocks `method`
* actually comes from `shadowsocks_encrypt_method` (not `method`).
* The earlier table assumed both mapped to bare option names and failed
* the skeleton test against the real fixture. */
export const CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
socks: { username: 'username', password: 'password' },
http: { username: 'username', password: 'password' },
snell: { psk: 'password', userkey: 'snell_userkey' },
/* The node form writes ssh_priv_key / ssh_priv_key_pp, not the bare
* private_key this table used to claim, so the key the user pasted was
* never read. (There was also no PROTOCOL_OPTIONS row for ssh, so the
* rest of the SSH options were dropped as well.) */
ssh: { user: 'username', password: 'password',
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
anytls: { password: 'password' },
shadowtls: { password: 'password' },
direct: {}
};
/* Canonical credentials for a server Inbound. A different table from
* the client's CREDENTIALS because the server form exposes a smaller
* set (no ssh material, no snell userkey) and because snell's server
* side reads the psk off the same `password` UCI option the client
* node uses for its own psk. Keeping the two tables separate means a
* change to one side cannot silently alter the other.
*/
export const INBOUND_CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'hysteria_auth_payload', auth_base64: 'hysteria_auth_payload' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
shadowtls: { password: 'password' },
snell: { psk: 'password' },
anytls: { password: 'password' },
http: { username: 'username', password: 'password' },
mixed: { username: 'username', password: 'password' },
naive: { username: 'username', password: 'password' },
socks: { username: 'username', password: 'password' }
};
/* Per-protocol options on a server Inbound, canonical -> UCI. This is
* the server-side counterpart of parser/mapping.uc's PROTOCOL_TO_UCI;
* the two are NOT identical - the server side exposes inbound-only
* knobs (anytls padding_scheme, hysteria masquerade / packet sizes /
* ignore_client_bandwidth, tuic auth_timeout) and does not expose the
* client's port-hopping options - so they stay separate tables. A
* protocol whose server-side row is missing here produces no
* protocol_options rather than reading a flat UCI key. */
export const INBOUND_OPTIONS = {
anytls: {
padding_scheme: 'anytls_padding_scheme'
},
hysteria: {
auth_type: 'hysteria_auth_type',
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
obfs_password: 'hysteria_obfs_password',
masquerade: 'hysteria_masquerade',
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
},
hysteria2: {
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
obfs_type: 'hysteria_obfs_type',
obfs_password: 'hysteria_obfs_password',
obfs_min_packet_size: 'hysteria_obfs_min_packet_size',
obfs_max_packet_size: 'hysteria_obfs_max_packet_size',
masquerade: 'hysteria_masquerade',
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
},
shadowsocks: {
method: 'shadowsocks_encrypt_method'
},
snell: {
version: 'snell_version',
obfs_mode: 'snell_obfs_mode'
},
tuic: {
congestion_control: 'tuic_congestion_control',
auth_timeout: 'tuic_auth_timeout',
zero_rtt_handshake: 'tuic_enable_zero_rtt',
heartbeat: 'tuic_heartbeat'
},
vless: {
flow: 'vless_flow'
},
vmess: {
alter_id: 'vmess_alterid'
}
};
/* Listener-level fields every server inbound carries. Inbound-only
* (no client outbound emits them), so they are not part of the
* client's load_common() table. */
export const INBOUND_COMMON = {
bind_interface: 'bind_interface',
reuse_addr: 'reuse_addr',
tcp_fast_open: 'tcp_fast_open',
tcp_multi_path: 'tcp_multi_path',
udp_fragment: 'udp_fragment',
udp_timeout: 'udp_timeout',
network: 'network'
};
/* The server-only TLS tail. These options exist because a client
* outbound never needs them (key material, ACME, the server half of
* the REALITY handshake), so they live on the Inbound rather than
* being folded into the shared `tls` sub-object the Loader builds for
* both sides. Canonical -> UCI; the Adapter re-keys them back to the
* UCI names buildTLSObject() reads, which is the one place the flat
* names still exist. */
export const INBOUND_TLS_SERVER = {
key_path: 'tls_key_path',
ech_key: 'tls_ech_key',
reality_private_key: 'tls_reality_private_key',
reality_max_time_difference: 'tls_reality_max_time_difference',
reality_server_addr: 'tls_reality_server_addr',
reality_server_port: 'tls_reality_server_port',
acme: 'tls_acme',
acme_domain: 'tls_acme_domain',
acme_dsn: 'tls_acme_dsn',
acme_email: 'tls_acme_email',
acme_provider: 'tls_acme_provider',
acme_account_key: 'tls_acme_account_key',
acme_key_type: 'tls_acme_key_type',
acme_profile: 'tls_acme_profile',
acme_disable_http_challenge: 'tls_acme_dhc',
acme_disable_tls_alpn_challenge: 'tls_acme_dtac',
acme_alternative_http_port: 'tls_acme_ahp',
acme_alternative_tls_port: 'tls_acme_atp',
acme_external_account: 'tls_acme_external_account',
acme_external_account_key_id: 'tls_acme_ea_keyid',
acme_external_account_mac_key: 'tls_acme_ea_mackey',
dns01_challenge: 'tls_dns01_challenge',
dns01_provider: 'tls_dns01_provider',
dns01_access_key_id: 'tls_dns01_ali_akid',
dns01_access_key_secret: 'tls_dns01_ali_aksec',
dns01_region_id: 'tls_dns01_ali_rid',
dns01_api_token: 'tls_dns01_cf_api_token'
};
/* --- Domain types ------------------------------------------------------- */
export const Config = {
create: (name) => ({
name: name,
general: {},
nodes: [],
/* The five domain sub-objects the Loader fills in from the
* corresponding UCI sections (single + list). The Loader now
* normalises the list sections; downstream generators read only
* these sub-objects. */
dns: {},
routing: {},
access_control: {},
server: {}
})
};
export const Node = {
create: (opts) => ({
/* identity */
id: opts.id,
name: opts.name,
type: opts.type,
/* endpoint */
address: opts.address,
port: opts.port,
/* cross-protocol common fields (proxy_protocol, tcp_fast_open,
* tcp_multi_path, udp_fragment). The Adapter reads from here so
* it never has to reach back into UCI for these. */
common: opts.common || {},
/* credentials, already canonical */
credentials: opts.credentials || {},
/* transport/TLS are sub-objects, not flat prefixed keys: that is the
boundary the adapter needs in order to reuse the shared builders. */
tls: opts.tls || {},
transport: opts.transport || {},
multiplex: opts.multiplex || {},
/* per-protocol extras, canonical names only */
protocol_options: opts.protocol_options || {}
}),
/* A node is usable only if the adapter can build a valid outbound from it.
* The old code discovers this by crashing inside the JSON builder.
*
* Scope: generic, cross-protocol sanity only. The Adapter layer owns
* per-protocol required-field rules (a vless needs uuid, vmess needs
* both uuid and a security cipher, snell needs a psk, ...) because
* those rules are sing-box-outbound-shaped. Anything the model can
* express without naming a protocol or a JSON field goes here.
*
* Booleans are still raw UCI strings at this layer; this validator
* compares against the literal '1' the way UCI emits them.
*
* Implementation note: ucode's push() returns the value pushed, not
* the new array, so the natural-looking `problems = push(problems, X)`
* pattern reassigns problems to a string and silently corrupts the
* accumulator. Build the array via spread instead - cheap because the
* validator is at most four checks. */
validate: (node) => {
let problems = [];
if (!node.type)
problems = [...problems, 'missing type'];
/* A direct outbound can run without a server/server_port - sing-box
* uses the inbound's destination as the upstream, which is the
* transparent-proxy case. For every other protocol the server
* and port are mandatory. */
if (node.type !== 'direct') {
if (!node.address)
problems = [...problems, 'missing address'];
if (node.port == null || int(node.port) < 1 || int(node.port) > 65535)
problems = [...problems, `invalid port '${node.port}'`];
}
/* TLS without server_name is a sing-box-side rejection, but the
* rule itself is protocol-agnostic - the reality branch covers the
* only legitimate escape - so it stays here. */
if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1')
problems = [...problems, 'TLS enabled without server_name'];
return problems;
},
/* sing-box tag convention lives here, not scattered through the generator */
tag: (node) => 'cfg-' + node.id + '-out'
};
/* The server-side counterpart of Node. Same layering discipline: it
* describes what the user configured (listen address/port, protocol,
* credentials, TLS, transport, per-protocol knobs) and knows nothing
* about sing-box JSON. The Inbound adapter (config/adapter.uc,
* InboundFactory) is what turns it into a sing-box inbound.
*
* Field grouping mirrors Node deliberately so the Adapter's two halves
* read the same way:
* common listener-level shared fields (bind_interface,
* reuse_addr, tcp_fast_open, ...)
* credentials canonical credential names
* tls shared TLS shape (same load_tls() the client uses)
* tls_server server-only TLS tail (key material, ACME, the
* server half of the REALITY handshake)
* transport shared transport shape (same load_transport())
* multiplex shared multiplex shape
* protocol_options per-protocol knobs
*/
export const Inbound = {
create: (opts) => ({
/* identity */
id: opts.id,
name: opts.name,
type: opts.type,
enabled: opts.enabled,
/* listener */
address: opts.address,
port: opts.port,
/* firewall is consumed by firewall_pre.uc (which decides the
* nft accept rules), not by the inbound adapter. It stays on the
* model so the server section is described in one place. */
firewall: opts.firewall,
common: opts.common || {},
credentials: opts.credentials || {},
tls: opts.tls || {},
tls_server: opts.tls_server || {},
transport: opts.transport || {},
multiplex: opts.multiplex || {},
protocol_options: opts.protocol_options || {}
}),
/* Same scope as Node.validate(): cross-protocol sanity the model
* can express without naming a sing-box field. The per-protocol
* rules live in the Adapter (InboundFactory.problems). */
validate: (inbound) => {
let problems = [];
if (!inbound.type)
problems = [...problems, 'missing type'];
/* An inbound with no listen address is legitimate (sing-box
* defaults to all interfaces), but a port is required. */
if (inbound.port == null || int(inbound.port) < 1 || int(inbound.port) > 65535)
problems = [...problems, `invalid port '${inbound.port}'`];
return problems;
},
/* sing-box tag convention for a server inbound. Uses `id` (the UCI
* section name) rather than `name` (the user's label) so renaming a
* label does not change the tag. */
tag: (inbound) => 'cfg-' + inbound.id + '-in'
};
/* --- Config helpers ----------------------------------------------------- */
export const ConfigQuery = {
node_by_id: (config, id) => {
for (let node in config.nodes)
if (node.id === id)
return node;
return null;
},
/* Linear search by section name. The Loader normalises list sections so
* `name` is the UCI section name; the dotted `.name` UCI uses
* internally is gone from the loaded shape. */
find_by_name: (items, name) => {
for (let it in items)
if (it.name === name)
return it;
return null;
},
/* Which UDP node the routing modes route through. The default (`nil`)
* means "no proxy node", which is a legitimate configuration. The
* equivalent main_node read is one-liner enough that no helper
* exists. */
main_udp_node_id: (config) => config.general.main_udp_node || 'nil'
};
@@ -0,0 +1,762 @@
#!/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 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 } from '/etc/homeproxy/scripts/homeproxy.uc';
import {
ipv4_to_nftarr,
mac_to_nftarr,
iface_to_nftarr,
ports_to_nftarr,
isValidPort,
isValidIface
} from '/etc/homeproxy/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;
}
/* 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 <x>`), 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 != @<empty set>`
* 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/resources';
/* UCI config start */
const cfgname = 'homeproxy';
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';
let self_mark, redirect_port,
tproxy_port, tproxy_mark,
tun_name, tun_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');
}
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');
}
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);
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 (ipv6_support === '1'): %}
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 (ipv6_support === '1'): %}
set homeproxy_gfw_list_v6 {
type ipv6_addr
flags interval
auto-merge
}
{% endif /* ipv6_support */ %}
{% 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 ((ipv6_support === '1') || china_dns_server): %}
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 /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{# WAN ACL addresses #}
set homeproxy_wan_proxy_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv4_ips): %}
elements = { {{ ipv4_to_nftarr(control_info.wan_proxy_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_proxy_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv6_ips): %}
elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} }
{% endif /* wan_proxy_ipv6_ips*/ %}
}
{% endif /* ipv6_support */ %}
set homeproxy_wan_direct_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv4_ips): %}
elements = { {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_direct_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv6_ips): %}
elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} }
{% endif /* wan_direct_ipv6_ips */ %}
}
{% endif /* ipv6_support */ %}
{% if (routing_port): %}
set homeproxy_routing_port {
type inet_service
flags interval
auto-merge
elements = { {{ ports_to_nftarr(routing_port) }} }
}
{% endif %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
{% if (control_info.listen_interfaces): %}
meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }}
{%- 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 { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
{% 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): %}
tcp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_redirect_proxy
}
chain homeproxy_redirect_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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 (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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 { {{ (ipv6_support === '1') ? '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 (ipv6_support === '1'): %}
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): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_mangle_tproxy
}
chain homeproxy_mangle_mark {
{% if (routing_port): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
}
chain homeproxy_mangle_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ iface_to_nftarr([...control_info.listen_interfaces, 'lo']) }} counter return
{% endif %}
meta iifname != lo udp dport 53 counter return
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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 {
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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 (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
ip daddr != @homeproxy_gfw_list_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
counter goto homeproxy_mangle_mark
}
chain mangle_prerouting {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? '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 (control_info.listen_interfaces): %}
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
{% endif /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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): %}
{% 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
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
{% if (control_info.wan_direct_ipv4_ips): %}
ip daddr {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
{% endif /* wan_direct_ipv4_ips */ %}
{% if (control_info.wan_direct_ipv6_ips): %}
ip6 daddr {{ ipv6_to_nftarr(control_info.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 (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% endif /* routing_mode */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ mac_to_nftarr(control_info.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 { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun
}
{% endif %}
@@ -0,0 +1,99 @@
#!/usr/bin/ucode
/* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*/
'use strict';
import { writefile } from 'fs';
import { cursor } from 'uci';
import { isEmpty, RUN_DIR, validation } from './homeproxy.uc';
const cfgname = 'homeproxy';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china',
proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy';
let outbound_node, tun_name;
if (match(proxy_mode, /tun/)) {
if (routing_mode === 'custom')
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
else
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
/* Validate the interface name before it is interpolated into an nft
* ruleset that fw4 loads as root. It is a UCI value, and this file's own
* threat model treats UCI as untrusted - the port and network fields a few
* lines below are validated for exactly that reason, while tun_name was
* not. `;` and `}` need no newline, so a value like
* "singtun0 } ; chain evil { ..." would balance the template's trailing
* brace and add a chain of the attacker's choosing.
*
* Linux interface names are at most 15 characters and cannot contain
* whitespace or shell/nft metacharacters; anything outside that is not a
* name we could have created, so the tun rules are simply not emitted. */
if (outbound_node !== 'nil') {
const candidate = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
if (match(candidate, /^[A-Za-z0-9_.-]{1,15}$/) && candidate !== '.' && candidate !== '..')
tun_name = candidate;
else
print(sprintf('WARN: ignoring invalid tun_name "%s" (not a valid interface name).', candidate));
}
}
const server_enabled = uci.get(cfgname, 'server', 'enabled');
let forward = [],
input = [];
if (tun_name) {
push(forward, `oifname ${tun_name} counter accept comment "!${cfgname}: accept tun forward"`);
push(input ,`iifname ${tun_name} counter accept comment "!${cfgname}: accept tun input"`);
}
if (server_enabled === '1') {
uci.foreach(cfgname, 'server', (s) => {
if (s.enabled !== '1' || s.firewall !== '1')
return;
/* The section name is interpolated into the nft comment below, in a
* file fw4 loads as root. libuci only accepts [A-Za-z0-9_] in a
* section name (a `"`, `}`, `;`, space, `.` or `-` is "invalid
* character in name field" / "Invalid argument" on both the parser
* and the `uci set` path), so today no name that could close the
* comment can reach this loop through UCI at all - this mirrors the
* tun_name guard above rather than trusting a writer's in-memory
* cursor or a future libuci. The alphabet is exactly libuci's and
* there is deliberately no length cap (libuci has none, and a cap
* would skip a legal name and silently drop that server's rule).
* The check runs first so the two warnings below only ever echo a
* name that already passed it. */
if (!match(s['.name'], /^[A-Za-z0-9_]+$/)) {
print(`WARN: skipping server ${s['.name']}: invalid section name.`);
return;
}
if (!validation('port', s.port)) {
print(`WARN: skipping server ${s['.name']}: invalid port "${s.port}".`);
return;
}
let proto = s.network || '{ tcp, udp }';
if (!(proto in ['tcp', 'udp', '{ tcp, udp }'])) {
print(`WARN: skipping server ${s['.name']}: invalid network "${proto}".`);
return;
}
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
});
}
if (!isEmpty(forward))
writefile(RUN_DIR + '/fw4_forward.nft', join('\n', forward) + '\n');
if (!isEmpty(input))
writefile(RUN_DIR + '/fw4_input.nft', join('\n', input) + '\n');
@@ -0,0 +1,146 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Field validators for the nftables statements firewall_post.ut renders.
*
* Why this module exists: every UCI value the template concatenates into an
* nft expression is an injection surface. A malformed entry closes a set
* expression with `}`, and the next thing nft sees is whatever the attacker
* put after it - up to and including `accept` for a chain that should be a
* drop, or a `reject` outside any chain that the fw4 reload fails to
* compile, taking the whole router firewall with it. The homeproxy section
* in /etc/config/homeproxy is UCI-writable from the subscription updater,
* from any shell on the LAN and from any cron job the user has not locked
* down; `tests/ucode/test_firewall_validators.uc` covers that the helpers
* drop these inputs rather than letting them through, and `tests/arch-guard.sh`
* guard 11 covers that firewall_post.ut routes every field of these families
* through the helpers.
*
* Each helper returns a defensive shape:
* - null when the input was empty or every entry was rejected
* - otherwise `{ v1, v2, ... }` (the nftables anonymous-set spelling)
* so the template can plug the result into a statement unchanged. Invalid
* entries are dropped, matching the IPv6 helper's behaviour so the IPv4
* path is finally as defensive as the IPv6 path was.
*
* No logging on the drop path: utpl templates render to stdout, and any
* print() here would inject text into the nft ruleset. If a debug trail
* is ever wanted, do it from the template itself, not from these helpers.
*/
'use strict';
/* isEmpty() lives in homeproxy.uc, not in the ucode globals. Without this
* import every helper below threw "access to undeclared variable isEmpty" the
* moment it was called, which no host-side test caught because the test
* harness stages its own stand-in module. */
import { isEmpty } from './homeproxy.uc';
/* IPv4 (with optional /CIDR) - the regex is the standard four-octet body
* followed by an optional /0..32. Anything outside that - "1.2.3.4.5",
* "1.2.3", " 1.2.3.4 ", "1.2.3.4 " - is rejected.
*
* Spelled out as one literal rather than built with `new RegExp(...)`: ucode
* has no `new` keyword, so the constructor form was a compile error
* ("Unexpected token") and the module never loaded. The capture groups are
* plain `(...)` because ucode regexes go through POSIX regcomp(), which has
* no `(?:...)` non-capturing group; nothing here reads the captures, so the
* extra ones are harmless. */
const IPV4_CIDR_RE = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/(3[0-2]|[12]?[0-9]))?$/;
export function isValidIPv4(s) {
if (!s || type(s) !== 'string')
return false;
return !!match(s, IPV4_CIDR_RE);
};
export function ipv4_to_nftarr(list) {
if (isEmpty(list))
return null;
let out = [];
for (let ip in list) {
if (isValidIPv4(ip))
push(out, ip);
}
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
};
/* MAC address: six colon-separated hex octets, case-insensitive. */
const MAC_RE = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/;
export function isValidMAC(s) {
if (!s || type(s) !== 'string')
return false;
return !!match(s, MAC_RE);
};
export function mac_to_nftarr(list) {
if (isEmpty(list))
return null;
let out = [];
for (let m in list) {
if (isValidMAC(m))
push(out, m);
}
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
};
/* Linux interface names: alnum, dot, underscore, hyphen. Anything that
* would survive `ip link show` parsing is accepted here. */
const IFACE_RE = /^[A-Za-z0-9._-]+$/;
export function isValidIface(s) {
if (!s || type(s) !== 'string')
return false;
return !!match(s, IFACE_RE);
};
export function iface_to_nftarr(list) {
if (isEmpty(list))
return null;
let out = [];
for (let i in list) {
if (isValidIface(i))
push(out, i);
}
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
};
/* Routing-port validator. UCI delivers routing_port as a comma-separated
* string ('22,53,80,443,...'), so we split first and then validate each
* piece as a single port. The old template did `split(routing_port, ',')`
* and `join(', ', ...)` directly: an entry like '7890; reject' or '22,foo'
* would land verbatim in an nft inet_service set. */
const PORT_RE = /^[0-9]+$/;
export function isValidPort(s) {
if (!s || type(s) !== 'string')
return false;
if (!match(s, PORT_RE))
return false;
const n = +s;
/* Port 0 is not a port: nft rejects `th dport 0` and `redirect to :0`, and a
* ruleset that carries one is rolled back as a whole by fw4. The callers
* use this as "the value is usable", so 0 has to be a rejection. */
return n >= 1 && n <= 65535;
};
export function ports_to_nftarr(s) {
if (isEmpty(s) || type(s) !== 'string')
return null;
let out = [];
for (let p in split(s, ',')) {
const t = trim(p);
if (isValidPort(t))
push(out, t);
}
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
};
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Single source of truth for every fw4 chain and set owned by homeproxy.
#
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
# decides which of them actually exist for the current proxy mode.
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
#
# When you add or remove a chain/set in firewall_post.ut, update this list in
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
# fails when they drift.
#
# Each object is cleaned up independently: the cleanup loop deletes one at a
# time so that a chain/set which does not exist in the current mode cannot
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
# every preceding command in that case.
HP_FW4_CHAINS="
homeproxy_output_redir
homeproxy_redirect
homeproxy_redirect_proxy
homeproxy_redirect_proxy_port
homeproxy_redirect_lanac
homeproxy_mangle_prerouting
homeproxy_mangle_output
homeproxy_mangle_tproxy
homeproxy_mangle_tproxy_port
homeproxy_mangle_lanac
homeproxy_mangle_mark
homeproxy_mangle_tun
homeproxy_mangle_tun_mark
"
HP_FW4_SETS="
homeproxy_local_addr_v4
homeproxy_local_addr_v6
homeproxy_gfw_list_v4
homeproxy_gfw_list_v6
homeproxy_mainland_addr_v4
homeproxy_mainland_addr_v6
homeproxy_wan_proxy_addr_v4
homeproxy_wan_proxy_addr_v6
homeproxy_wan_direct_addr_v4
homeproxy_wan_direct_addr_v6
homeproxy_routing_port
"
@@ -0,0 +1,113 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* scripts/generate_client.uc: process-level entry point.
*
* The actual client generator is generator/client.uc; this script is the
* thin entry point that init.d/homeproxy execve's. Responsibilities:
*
* 1. Load UCI into the HomeProxyConfig domain model.
* 2. Resolve the GenerationContext env: the WAN resolver (ubus) and the two
* domain-resource lists (fs). This is the process-level boundary, which
* is why it lives here and not under generator/.
* 3. Call generate(dm, env) to get the sing-box JSON object.
* 4. Write atomically: candidate tmp -> `sing-box check` -> mv to live.
*
* The check is here (and not in the runtime, and not in the generator)
* because the architecture guide mandates the generator produce
* "atomic write candidate + serialization" and the runtime is already
* structured around "a failing generation leaves the previous file in
* place" (see runtime/config.sh's hp_ensure_live). Having the shell do
* the check keeps that contract single-sourced.
*/
'use strict';
import { connect } from 'ubus';
import { mkdtemp, readfile, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate } from './generator/client.uc';
import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
/* Resolve the GenerationContext inputs. This is the only impure step on the
* client generation path, and it is deliberately here rather than under
* generator/:
*
* - wan_dns is the upstream the default-dns server detours to. ubus may be
* unreachable (no ubusd, a dev host, no WAN lease); build_context() then
* applies the same mode-dependent public fallback the pre-split generator
* used, so an unresolved value stays safe rather than fatal.
* - direct_domain_list / proxy_domain_list are the two files the resource
* updater maintains. Custom mode ignores both, so they are not read
* there - matching the pre-split read exactly.
*
* The extra parentheses around the ubus call keep the ?. chain guarded when
* connect() returns null. */
function resolve_env(dm) {
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
const ubus = connect();
const env = {
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
direct_domain_list: [],
proxy_domain_list: []
};
if (routing_mode !== 'custom') {
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
const proxy_list_raw = readfile(HP_DIR + '/resources/proxy_list.txt');
env.proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
}
return env;
}
const dm = Loader.load(UCICONFIG_DIR);
const config = removeBlankAttrs(generate(dm, resolve_env(dm)));
system('mkdir -p ' + shellQuote(RUN_DIR));
/* A private scratch directory rather than a fixed `<out>.tmp`.
*
* Two generation runs can still overlap (a LuCI apply while the cron entry
* reloads, or a manual and a triggered reload). With a fixed name both wrote
* the same file, and `sing-box check` could be validating a file the other run
* was still writing - the winner then installed a half-written config.
*
* mkdtemp() is this package's existing primitive for that (executeCommand()
* uses it) and gives a 0700 directory under /tmp. The path here comes from
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
* the "all shell args quoted" rule is the machine-checkable
* invariant, not a comment about today's safety. */
const work_dir = mkdtemp();
const tmp = work_dir + '/sing-box-c.json';
/* writefile() returns null on failure, and ignoring that turned a full disk or
* a permission error into a later "sing-box check failed", which points at the
* wrong thing entirely. */
if (writefile(tmp, sprintf('%.J\n', config)) == null) {
system('rm -rf ' + shellQuote(work_dir));
die('failed to write the generated client configuration to ' + tmp);
}
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
system('rm -rf ' + shellQuote(work_dir));
exit(1);
}
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
system('rm -rf ' + shellQuote(work_dir));
exit(1);
}
/* The generated config carries every node credential - passwords, UUIDs,
* private keys - and writefile() has no mode argument, so it lands with the
* process umask (world-readable at the usual 022). sing-box runs as its own
* user and reads the file directly, so 0600 is enough. */
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-c.json');
system('rm -rf ' + shellQuote(work_dir));
@@ -0,0 +1,66 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* scripts/generate_server.uc: process-level entry point.
*
* Mirror of scripts/generate_client.uc for the server instance. The
* server generator (generator/server.uc) returns null when no inbound
* is enabled; the shell then exits 1 so init.d can detect
* "server disabled" without a separate UCI check.
*/
'use strict';
import { mkdtemp, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate_server } from './generator/server.uc';
import { removeBlankAttrs, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
const dm = Loader.load(UCICONFIG_DIR);
const config = generate_server(dm);
if (!config)
exit(1);
const cleaned = removeBlankAttrs(config);
system('mkdir -p ' + shellQuote(RUN_DIR));
/* A private scratch directory rather than a fixed `<out>.tmp`.
*
* Two generation runs can still overlap (a LuCI apply while the cron entry
* reloads, or a manual and a triggered reload). With a fixed name both wrote
* the same file, and `sing-box check` could be validating a file the other run
* was still writing - the winner then installed a half-written config.
*
* mkdtemp() is this package's existing primitive for that (executeCommand()
* uses it) and gives a 0700 directory under /tmp. The path here comes from
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
* the "all shell args quoted" rule is the machine-checkable
* invariant, not a comment about today's safety. */
const work_dir = mkdtemp();
const tmp = work_dir + '/sing-box-s.json';
/* Same reason as the client path: a failed write used to be reported as a
* failed `sing-box check`. */
if (writefile(tmp, sprintf('%.J\n', cleaned)) == null) {
system('rm -rf ' + shellQuote(work_dir));
die('failed to write the generated server configuration to ' + tmp);
}
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
system('rm -rf ' + shellQuote(work_dir));
exit(1);
}
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-s.json') !== 0) {
system('rm -rf ' + shellQuote(work_dir));
exit(1);
}
/* The server config carries inbound credentials and the REALITY/ECH key
* material, and writefile() has no mode argument, so it lands with the
* process umask. 0600 matches what sing-box needs to read it as its own
* user. */
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-s.json');
system('rm -rf ' + shellQuote(work_dir));
@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/client.uc: client-side orchestrator.
*
* This file is the only place the client generator still knows the
* overall shape. Each section builder lives in its own module under
* generator/; this file glues them together.
*
* generate(dm, env)
* -> build_context(dm, env)
* -> build_dns
* -> build_inbounds
* -> build_outbounds (mutates direct_overrides)
* -> build_route
* -> build_user_rulesets (custom mode only)
* -> build_http_clients
* -> attachSchema, attachExperimental
* -> final config object (caller writes + sing-box check)
*
* Every section builder takes (config, ctx, ...) where `ctx` carries
* the routing-mode- and proxy-mode-derived scalars (main_node,
* dns_server, proxy_mode, ...) plus the mutable accumulators (outbounds,
* endpoints, ...) the builders append to. The orchestrator owns them
* and threads them through, which is what kept the original
* generate_client.uc at 1240 lines: a 1100-line config blob with a
* single closure scope.
*
* The output is the final config object: no `$schema`, no atomic write,
* no `sing-box check`. The CLI shell (`scripts/generate_client.uc`)
* is responsible for those - per the architecture guide, the generator
* must not double as the runner.
*
* It is also responsible for resolving `env` (the WAN DNS via ubus and the
* two resource lists) and passing it in. This module imports no ubus and no
* fs: `generate(dm, env)` is a pure function, so the same arguments always
* produce the same bytes. tests/arch-guard.sh guard 27 keeps it that way.
*/
'use strict';
import { isEmpty, strToInt, RUN_DIR } from '../homeproxy.uc';
import { build_context } from './context.uc';
import { build_dns } from './dns.uc';
import { build_inbounds } from './inbound.uc';
import { build_outbounds } from './outbound.uc';
import { build_route } from './route.uc';
import { build_user_rulesets, build_http_clients } from './ruleset.uc';
import { attachSchema, attachExperimental } from './common.uc';
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box client config object. Pure function: takes the
* HomeProxyConfig from the Loader plus the GenerationContext inputs the CLI
* resolved (`env`), and returns the JSON object the caller writes and
* `sing-box check`s. No UCI, no file I/O, no ubus, no procd. */
export function generate(dm, env) {
const ctx = build_context(dm, env);
const config = {
log: {
disabled: false,
level: ctx.log_level,
output: RUN_DIR + '/sing-box-c.log',
timestamp: true
}
};
if (!isEmpty(ctx.ntp_server))
config.ntp = {
enabled: true,
server: ctx.ntp_server,
detour: 'direct-out',
domain_resolver: 'default-dns',
};
/* `direct_overrides` is the single side effect that survives the
* split: a direct node with override_address / override_port
* records the override here so build_route can emit the
* route-options action later. The orchestrator owns the map and
* passes it to both builders. */
const direct_overrides = {};
build_dns(config, dm, ctx);
build_inbounds(config, ctx);
/* direct_outbounds live on config.outbounds regardless of mode:
* the DNS path always references them, and a custom-mode config
* without any direct-out/block-out would not have a fallback for
* a routing rule that emits a missing tag. Initialised AFTER
* build_dns / build_inbounds so the JSON key order is
* log, ntp, dns, inbounds, outbounds, ... matching the
* pre-refactor generator. The builder appends to the same array
* (no reassignment), which preserves the key order that
* removeBlankAttrs() emits and keeps golden snapshots stable. */
config.outbounds = [
{
type: 'direct',
tag: 'direct-out',
routing_mark: strToInt(ctx.self_mark)
},
{
type: 'block',
tag: 'block-out'
}
];
config.endpoints = [];
build_outbounds(config, dm, ctx, direct_overrides);
build_route(config, dm, ctx, direct_overrides);
/* User-defined rulesets are custom-mode only; passing an empty
* dm.routing.rulesets through build_user_rulesets is a no-op
* because every cfg in it is filtered by !cfg.enabled. */
if (ctx.routing_mode === 'custom')
build_user_rulesets(config.route.rule_set, dm, ctx);
const http_clients = build_http_clients(config.route.rule_set, dm, ctx);
if (length(http_clients))
config.http_clients = http_clients;
/* A direct node used as the main node yields a main-out with no fields of
* its own: generate_outbound() turns its override_address / override_port
* into a route action rather than outbound fields, leaving a bare
* {"type":"direct","tag":"main-out"}. sing-box refuses to detour into
* such an outbound and the service never starts:
*
* FATAL start service: start dns/tcp[main-dns]: detour to an empty
* direct outbound makes no sense
* FATAL start service: initialize rule-set[0]: ... detour to an empty
* direct outbound makes no sense
*
* Dropping the detour is the equivalent behaviour - the query or the
* rule-set download leaves through the host's own network, which is what a
* direct outbound does - and mirrors the `ctx.self_mark ? 'direct-out' :
* null` that build_dns already applies to the other DNS servers. Only
* main-out is touched: direct-out is deliberate and carries a routing
* mark.
*
* This has to run after build_http_clients(): the rule-set downloads
* reach main-out through an http_client, not through a detour of their
* own. */
let bare_main_out = false;
for (let i = 0; i < length(config.outbounds); i++) {
const ob = config.outbounds[i];
/* build_outbounds leaves a null in the array for a node it could not
* emit (a pruned urltest candidate); skip those. */
if (ob && ob.tag === 'main-out' && ob.type === 'direct' && length(keys(ob)) === 2)
bare_main_out = true;
}
if (bare_main_out) {
for (let i = 0; i < length(config.dns.servers); i++) {
const s = config.dns.servers[i];
if (s && s.detour === 'main-out')
delete s.detour;
}
for (let i = 0; i < length(config.http_clients || []); i++) {
const c = config.http_clients[i];
if (c && c.detour === 'main-out')
delete c.detour;
}
}
if (isEmpty(config.route.rule_set))
config.route.rule_set = null;
if (isEmpty(config.endpoints))
config.endpoints = null;
attachExperimental(config, ctx.routing_mode, ctx.dns_store_dns);
attachSchema(config);
return config;
};
@@ -0,0 +1,193 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/common.uc: shared helpers used by every generator module.
*
* Every "select a tag from a UCI-style reference" function lives in this
* file because the same selector logic (a routing_node name, an
* array-of-names, a direct/block/default marker, a ruleset id list) is
* reached by the DNS section, the route section, the outbound/endpoint
* builder and the ruleset section. Duplicating them per-module would
* drift; consolidating them here keeps the convention "one place to
* change a tag shape".
*
* The Loader owns UCI; nothing in here reads UCI directly. The orchestrator
* passes `dm` for the routing_node lookups that need to resolve a name into
* the underlying node id.
*/
'use strict';
import { isEmpty } from '../homeproxy.uc';
import { ConfigQuery } from '../config/model.uc';
/* parse_port() lives in homeproxy.uc. The Adapter layer needs it
* too (EndpointFactory builds the WireGuard endpoint) and an adapter must
* not import from generator/, so the util moved to the module both layers
* already share. Callers import it from '../homeproxy.uc' now. */
/* Resolve a UCI outbound-style reference (string or array of strings)
* into the sing-box tag the generator should emit. The arrays carry
* either 'block-out' / 'direct-out' sentinels or routing_node section
* names; a single string is the same thing with one entry.
*
* `dm` is needed for the routing_node -> node id lookup; we deliberately
* do not read `dm.routing.nodes` until we know we need it (the common
* case is the sentinel). */
export function get_outbound(cfg, dm) {
if (isEmpty(cfg))
return null;
if (type(cfg) === 'array') {
let outbounds = [];
for (let i in cfg)
push(outbounds, get_outbound(i, dm));
return outbounds;
}
switch (cfg) {
case 'block-out':
case 'direct-out':
return cfg;
default:
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
if (!rn || isEmpty(rn.node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else if (rn.node === 'urltest')
return 'cfg-' + cfg + '-out';
else
return 'cfg-' + rn.node + '-out';
}
};
/* Find a loaded section by name, treating `enabled` as a gate. The Loader
* normalises `enabled` on every list section; a section with the field unset
* is enabled (boolean sections default to 0 in UCI, but a dns_server/ruleset
* without the option is a valid, enabled row).
*
* Declared before its callers on purpose: ucode resolves function references
* at call time from what has already been evaluated, so a `function` statement
* placed after get_resolver() fails with "access to undeclared variable". */
function find_enabled(items, name) {
const item = ConfigQuery.find_by_name(items || [], name);
if (!item)
return null;
if (item.enabled === false || item.enabled === '0')
return null;
return item;
};
/* Resolve a UCI resolver reference (a dns_server section name or one of
* the sentinels) into a sing-box resolver tag.
*
* `dm` is required for the same reason get_outbound() needs it: a reference to
* a section that was deleted or disabled produces `cfg-<name>-dns`, which
* nothing defines - sing-box then rejects the whole configuration with
* "dns server not found", the reload silently keeps the previous one, and the
* user only sees that their change did not take. Fail with the name instead. */
export function get_resolver(cfg, dm) {
if (isEmpty(cfg))
return null;
switch (cfg) {
case 'default-dns':
case 'system-dns':
return cfg;
default:
if (dm && !find_enabled((dm.dns || {}).servers, cfg))
die(sprintf("the DNS server '%s' does not exist or is disabled; check the rule that refers to it.", cfg));
return 'cfg-' + cfg + '-dns';
}
};
/* Resolve a UCI ruleset reference list (array of ruleset section names)
* into the corresponding sing-box rule_set tag list. Same existence gate as
* get_resolver(): ruleset.uc skips disabled entries, so a reference to one
* would leave a dangling `cfg-<name>-rule` tag behind. */
export function get_ruleset(cfg, dm) {
if (isEmpty(cfg))
return null;
let rules = [];
for (let i in cfg) {
if (isEmpty(i))
continue;
if (dm && !find_enabled((dm.routing || {}).rulesets, i))
die(sprintf("the rule-set '%s' does not exist or is disabled; check the rule that refers to it.", i));
push(rules, 'cfg-' + i + '-rule');
}
return rules;
};
/* Resolve the direct-node destination override that the route builder
* needs for a `direct` routing_node target. The override is recorded
* earlier by generate_outbound() (see outbound.uc) when a direct node
* carries override_address / override_port; this helper just looks it
* up. Returns null when the target is not a direct node or no override
* is configured. */
export function get_direct_override(outbound_selector, dm, direct_overrides) {
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
return null;
switch (outbound_selector) {
case 'direct-out':
case 'block-out':
return null;
default:
const rn = ConfigQuery.find_by_name(dm.routing.nodes, outbound_selector);
const node = rn && rn.node;
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
}
};
/* True when `tag` (the sing-box outbound tag) belongs to a direct outbound.
* Used by the http_clients builder to drop the detour field on a pure-TUN
* setup: sing-box 1.14 rejects detouring to a direct outbound that has
* nothing to detour through. */
export function isDirectOutboundTag(tag, dm) {
if (isEmpty(tag) || tag === 'block-out')
return false;
if (tag === 'direct-out')
return true;
/* The caller passes the tag the generator emits - get_outbound() builds
* `cfg-<node>-out` - not the routing_node section name, so that wrapper has
* to be undone before the lookup. Without this the two queries below were
* given a name that matches neither table, and the function returned false
* for every routing_node: build_http_clients() then kept a `detour` on a
* rule-set download in pure TUN mode, where sing-box refuses it ("detour to
* an empty direct outbound makes no sense") and rejects the whole
* configuration. */
const unwrapped = match(tag, /^cfg-(.+)-out$/);
const name = unwrapped ? unwrapped[1] : tag;
const rn = ConfigQuery.find_by_name(dm.routing.nodes, name);
const node_name = (rn && rn.node) || name;
const node = ConfigQuery.node_by_id(dm, node_name);
return !!(node && node.type === 'direct');
};
/* Append the sing-box JSON $schema field. Kept here because the value is
* fixed and the two generators used to spell it the same way; if it ever
* changes, both should pick up the new URL automatically. */
export function attachSchema(config) {
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
return config;
};
/* Attach the experimental cache_file block when one of the routing modes
* that needs it is active. Routing-mode gating stays here because the
* block is the same regardless of mode; only the condition differs. */
export function attachExperimental(config, routing_mode, dns_store_dns) {
if (routing_mode in ['bypass_mainland_china', 'custom']) {
config.experimental = {
cache_file: {
enabled: true,
path: '/etc/homeproxy/cache.db',
store_dns: (dns_store_dns === '1') ? true : null
}
};
}
return config;
};
@@ -0,0 +1,218 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* GenerationContext: the routing-mode/proxy-mode scalars every section
* builder consumes, built as a *pure* function of the domain model plus a
* small, explicit environment record.
*
* build_context(dm, env) -> ctx
*
* env = {
* wan_dns, the WAN resolver the CLI resolved via ubus
* direct_domain_list, /etc/homeproxy/resources/direct_list.txt
* proxy_domain_list, /etc/homeproxy/resources/proxy_list.txt
* }
*
* Why the split exists: this module used to sit inside generator/client.uc
* and resolve `env` itself - it called `ubus network.interface status` for
* the WAN DNS and `readfile()` for the two resource lists. The generator was
* documented as a pure function while depending on live router state, so
* "generate the same config twice" was not guaranteed. That is what made
* reload's preflight generation and start_service's generation two different
* artifacts in principle: the WAN lease can change between them.
*
* Now the impure part (ubus call, resource read, mode-dependent fallback for
* a router with no lease) belongs to the CLI shells - scripts/
* generate_client.uc and scripts/generate_server.uc - which are the
* process-level boundary and already own the atomic write and
* `sing-box check`. Everything under generator/ is a pure function of its
* arguments, which tests/arch-guard.sh enforces (guard 27: no ubus/fs import
* in generator/).
*
* `env` is allowed to be partial. A missing wan_dns still yields the same
* public fallback the pre-split code used, as a pure function of the routing
* mode - not a probe - so a caller that has no ubus keeps producing a valid
* config instead of failing.
*/
'use strict';
import { isEmpty, strToInt } from '../homeproxy.uc';
/* Build the routing-mode-independent ctx the section builders consume.
* Mode-dependent defaults are filled in here so each builder can stay a
* straight consumer of its fields without recomputing them. */
export function build_context(dm, env) {
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
const proxy_mode = dm.general.proxy_mode || 'redirect_tproxy';
/* Mode-shared scalars */
const ipv6_support = dm.general.ipv6_support || '0';
const log_level = dm.general.log_level || 'warn';
const ntp_server = dm.infra.ntp_server || 'time.apple.com';
const dns_port = dm.infra.dns_port || '5333';
const mixed_port = dm.infra.mixed_port || '5330';
const udp_timeout = (routing_mode === 'custom')
? (dm.routing.settings.udp_timeout)
: dm.infra.udp_timeout;
/* self_mark is intentionally null for proxy_mode 'tun' only:
* the DNS server detour (default-dns / system-dns / china-dns) is
* gated on it, and the old generator kept the same code shape -
* any non-empty string is truthy, so the test fixture's
* self_mark='100' would leak a 'detour: direct-out' onto every
* DNS server when proxy_mode is tun. removeBlankAttrs() then
* strips the null detour, so the generated JSON stays identical
* to the pre-refactor output. */
const self_mark = match(proxy_mode, /redirect/)
? (dm.infra.self_mark || '100') : null;
/* proxy-mode-derived ports / TUN params */
let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6,
tun_mtu, tcpip_stack, endpoint_independent_nat;
if (match(proxy_mode, /redirect/))
redirect_port = dm.infra.redirect_port || '5331';
if (match(proxy_mode, /tproxy/))
if ((dm.general.main_udp_node || 'nil') !== 'nil' || routing_mode === 'custom')
tproxy_port = dm.infra.tproxy_port || '5332';
if (match(proxy_mode, /tun/)) {
tun_name = dm.infra.tun_name || 'singtun0';
tun_addr4 = dm.infra.tun_addr4 || '172.19.0.1/30';
tun_addr6 = dm.infra.tun_addr6 || 'fdfe:dcba:9876::1/126';
tun_mtu = dm.infra.tun_mtu || '9000';
tcpip_stack = 'system';
if (routing_mode === 'custom') {
tcpip_stack = dm.routing.settings.tcpip_stack || 'system';
endpoint_independent_nat = dm.routing.settings.endpoint_independent_nat;
}
}
/* The WAN resolver is an input, not a lookup. See the module header for
* the fallback's provenance - it is the same mode-dependent public
* resolver the pre-split generator chose when ubus had nothing to say. */
const wan_dns = !isEmpty(env.wan_dns)
? env.wan_dns
: ((routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5');
/* dns/routing fields used by both sections */
const dns_optimistic_cache = (dm.dns.settings || {}).optimistic_cache || '0';
const dns_optimistic_timeout = (dm.dns.settings || {}).optimistic_timeout;
const dns_query_timeout = (dm.dns.settings || {}).dns_timeout;
const dns_store_dns = (dm.dns.settings || {}).cache_file_store_dns || '0';
const tun_dns_mode_raw = dm.general.tun_dns_mode,
tun_dns_address = dm.general.tun_dns_address,
udp_mapping_raw = dm.general.udp_mapping,
udp_filtering_raw = dm.general.udp_filtering,
udp_nat_max = strToInt(dm.general.udp_nat_max);
const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
/* Routing-mode-dependent defaults */
const ctx = {
routing_mode, proxy_mode, ipv6_support, log_level,
self_mark, ntp_server, dns_port, mixed_port,
redirect_port, tproxy_port,
tun_name, tun_addr4, tun_addr6, tun_mtu,
tcpip_stack, endpoint_independent_nat,
udp_timeout, udp_mapping, udp_filtering, udp_nat_max,
tun_dns_mode, tun_dns_address,
wan_dns, dns_optimistic_cache, dns_optimistic_timeout,
dns_query_timeout, dns_store_dns,
default_interface: (dm.access_control.control || {}).bind_interface,
/* udp/tcp shaping knobs the endpoint builder reads */
endpoint_options: {
udp_mapping, udp_filtering, udp_nat_max
},
/* Routing-mode-specific scalars; the dns/route modules consult
* only the fields their branch needs.
*
* The main-node reference is mode-dependent, and custom routing has
* its own outbound/rule tables (routing.default_outbound plus the
* routing_node / routing_rule / ruleset sections). A residual
* config.main_node must therefore not switch the generator back to
* the preset path: LuCI hides that field in custom mode but does not
* clear it (`rmempty = false`), so a router that was configured in a
* preset mode and then switched to custom keeps the old value in
* UCI - and the pre-refactor code read it only when the mode was not
* custom. Reading it here anyway silently dropped every custom
* routing rule and made route.final 'main-out'. */
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
/* Custom-only scalars. The gate is not cosmetic: route.uc picks its
* branch with `isEmpty(default_outbound)` and outbound.uc does the
* same, while the preset branch keys off main_node. A residual
* default_outbound from an earlier custom configuration therefore
* switched a preset-mode router onto the custom branch with the
* preset DNS builder bailing out (it keys off main_node) - the DNS
* block then ended up with only default-dns/system-dns and no
* main-dns/china-dns, the route block with leftover custom rules -
* and the rule-set tags those rules reference are only built in
* custom mode, so `sing-box check` rejected the whole generation.
* Preset modes read custom-only fields nowhere; gate them all. */
default_outbound: (routing_mode === 'custom') ? (dm.routing.settings || {}).default_outbound : null,
default_outbound_dns: (routing_mode === 'custom')
? ((dm.routing.settings || {}).default_outbound_dns || 'default-dns') : null,
domain_strategy: (routing_mode === 'custom') ? (dm.routing.settings || {}).domain_strategy : null,
find_neighbor: (routing_mode === 'custom') ? (dm.routing.settings || {}).find_neighbor : null,
/* Main-line defaults, mirroring what the pre-refactor
* generate_client.uc did inline: an empty UCI value becomes 'wan'
* for the main DNS server, and 'wan' - the value the UI writes for
* "WAN DNS (read from interface)" - is replaced by the resolver the
* CLI read off the WAN interface (`wan_dns`, which itself falls back
* to a public resolver when ubus has no answer). Leaving the
* literal 'wan' in place makes sing-box resolve a host by that name.
* The China DNS server falls back to the Aliyun public resolver
* instead of leaking into the default-dns pipeline. */
dns_server: (isEmpty(dm.general.dns_server) || dm.general.dns_server === 'wan')
? wan_dns : dm.general.dns_server,
china_dns_server: (dm.general.china_dns_server && dm.general.china_dns_server !== 'wan')
? dm.general.china_dns_server : '223.5.5.5',
/* dns_default_strategy: in proxy mode, ipv4_only is the safe
* default whenever ipv6_support is off; in custom mode the
* UCI default_strategy field is the source of truth (no
* implicit fallback - missing UCI => strategy: null =>
* removeBlankAttrs strips it). */
dns_default_strategy: (routing_mode === 'custom')
? (dm.dns.settings || {}).default_strategy
: ((ipv6_support !== '1') ? 'ipv4_only' : null),
cn_ip_fallback: dm.general.cn_ip_fallback,
main_urltest_nodes: dm.general.main_urltest_nodes || [],
main_urltest_interval: dm.general.main_urltest_interval,
main_urltest_tolerance: dm.general.main_urltest_tolerance,
main_udp_urltest_nodes: dm.general.main_udp_urltest_nodes || [],
main_udp_urltest_interval: dm.general.main_udp_urltest_interval,
main_udp_urltest_tolerance: dm.general.main_udp_urltest_tolerance,
/* Custom-mode DNS settings */
dns_default_server: (dm.dns.settings || {}).default_server,
dns_disable_cache: (dm.dns.settings || {}).disable_cache,
dns_disable_cache_expire: (dm.dns.settings || {}).disable_cache_expire,
dns_client_subnet: (dm.dns.settings || {}).client_subnet,
};
/* Inline static rule-sets (direct-domain, proxy-domain) only fire
* when the routing mode has a non-empty domain list. Both lists are
* inputs now: the CLI reads the two files under
* /etc/homeproxy/resources/ and passes them in. Custom mode ignores
* them entirely, exactly as the pre-split read did. */
ctx.direct_domain_list = [];
ctx.proxy_domain_list = [];
if (routing_mode !== 'custom') {
ctx.direct_domain_list = env.direct_domain_list || [];
ctx.proxy_domain_list = env.proxy_domain_list || [];
}
/* `dedicated_udp_node` means main_udp_node was set to a different
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
* qualify). Kept on ctx so both build_outbounds and build_route
* see the same value. */
ctx.dedicated_udp_node = !isEmpty(ctx.main_udp_node) && !(ctx.main_udp_node in ['same', ctx.main_node]);
return ctx;
};
@@ -0,0 +1,368 @@
/*
* 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.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;
}
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
function append_proxy_dns(config, dm, ctx) {
if (isEmpty(ctx.main_node))
return;
/* Main DNS */
push(config.dns.servers, {
tag: 'main-dns',
domain_resolver: {
server: 'default-dns',
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
},
detour: 'main-out',
...parse_dnsserver(ctx.dns_server, 'tcp')
});
config.dns.final = 'main-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'
});
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
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',
strategy: '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: china-dns (223.5.5.5) has intermittent
multi-second first responses for NAPTR, causing 'context deadline
exceeded' (e.g. sipgz12.hbq.r.10086.cn) */
push(config.dns.rules, {
query_type: [35],
domain_suffix: ['r.10086.cn', '10086.cn', 'pub.3gppnetwork.org'],
action: 'route',
server: 'default-dns'
});
if (length(ctx.proxy_domain_list))
push(config.dns.rules, {
rule_set: 'proxy-domain',
action: 'route',
server: 'main-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'
});
}
}
}
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
function append_custom_dns(config, dm, ctx) {
/* 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: cfg.headers,
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
} : null,
domain_resolver: (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 = [];
for (let cfg in dm.dns.rules) {
if (!cfg.enabled)
continue;
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),
race: strToBool(cfg.race),
speculative: strToBool(cfg.speculative),
action: cfg.action,
server: get_resolver(cfg.server, dm),
disable_cache: strToBool(cfg.dns_disable_cache),
disable_optimistic_cache: strToBool(cfg.disable_optimistic_cache),
rewrite_ttl: strToInt(cfg.rewrite_ttl),
timeout: strToTime(cfg.dns_timeout),
client_subnet: cfg.client_subnet,
remove_client_subnet: strToBool(cfg.remove_client_subnet),
method: cfg.reject_method,
no_drop: strToBool(cfg.reject_no_drop),
rcode: cfg.predefined_rcode,
answer: cfg.predefined_answer,
ns: cfg.predefined_ns,
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;
}
push(builtin_dns_rules, rule);
}
config.dns.rules = builtin_dns_rules;
config.dns.final = get_resolver(ctx.dns_default_server, dm);
}
/* --- 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);
if (ctx.routing_mode === 'custom')
append_custom_dns(config, dm, ctx);
else
append_proxy_dns(config, dm, ctx);
return config;
};
@@ -0,0 +1,106 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/inbound.uc: build the sing-box `inbounds` block.
*
* The client always needs the dns-in and mixed-in inbounds, plus
* whichever of redirect-in / tproxy-in / tun-in matches proxy_mode.
* udp_* fields on tproxy-in and tun-in come from the same UCI settings
* (a single source of truth in init.d, but the orchestrator extracts
* them and re-shapes them for sing-box here).
*/
'use strict';
import { isEmpty, strToInt, strToTime, strToBool } from '../homeproxy.uc';
/* Append the always-on inbounds. dns-in is the local listener the DNS chain
* hands queries to - runtime/dns.sh points dnsmasq at 127.0.0.1#<dns_port>,
* so nothing outside this host has any reason to reach it. It used to
* listen on '::', every interface, and a `direct` inbound forwards without
* consulting the route rules: any client that connected to the DNS port got
* its traffic dialled straight out instead of routed. A router log showed
* exactly that - connections to a blocked address timing out through the
* built-in `direct` outbound while every rule pointed at main-out.
* Loopback-only removes the entry point; mixed-in stays on '::' because that
* one is the SOCKS/HTTP listener clients are meant to reach. Their order
* matters because dns-in has to come first when sing-box matches inbounds in
* declaration order. */
function pushBaseInbounds(inbounds, ctx) {
push(inbounds, {
type: 'direct',
tag: 'dns-in',
listen: '127.0.0.1',
listen_port: int(ctx.dns_port)
});
push(inbounds, {
type: 'mixed',
tag: 'mixed-in',
listen: '::',
listen_port: int(ctx.mixed_port),
udp_timeout: strToTime(ctx.udp_timeout),
set_system_proxy: false
});
}
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box `inbounds` block. The caller supplies an empty
* array on `config.inbounds` so this can be a pure builder. */
export function build_inbounds(config, ctx) {
const inbounds = config.inbounds || [];
pushBaseInbounds(inbounds, ctx);
if (match(ctx.proxy_mode, /redirect/))
push(inbounds, {
type: 'redirect',
tag: 'redirect-in',
listen: '::',
listen_port: int(ctx.redirect_port)
});
/* tproxy-in is gated on the port existing, not only on the proxy mode:
* context.uc only assigns tproxy_port when a dedicated UDP node is
* configured (or in custom mode), because that is exactly when
* firewall_post.ut emits the tproxy chain. Emitting the inbound anyway
* bound a UDP socket to listen_port 0 - a random port that no nft rule
* ever points at - so the two layers now share one condition. */
if (match(ctx.proxy_mode, /tproxy/) && !isEmpty(ctx.tproxy_port))
push(inbounds, {
type: 'tproxy',
tag: 'tproxy-in',
listen: '::',
listen_port: int(ctx.tproxy_port),
network: 'udp',
udp_timeout: strToTime(ctx.udp_timeout),
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
udp_nat_max: ctx.udp_nat_max,
});
if (match(ctx.proxy_mode, /tun/))
push(inbounds, {
type: 'tun',
tag: 'tun-in',
interface_name: ctx.tun_name,
address: (ctx.ipv6_support === '1') ? [ctx.tun_addr4, ctx.tun_addr6] : [ctx.tun_addr4],
mtu: strToInt(ctx.tun_mtu),
auto_route: false,
endpoint_independent_nat: strToBool(ctx.endpoint_independent_nat),
udp_timeout: strToTime(ctx.udp_timeout),
dns_mode: !isEmpty(ctx.tun_dns_mode) ? ctx.tun_dns_mode : null,
dns_address: !isEmpty(ctx.tun_dns_address) ? ctx.tun_dns_address : null,
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
udp_nat_max: ctx.udp_nat_max,
stack: ctx.tcpip_stack,
});
config.inbounds = inbounds;
return config;
};
@@ -0,0 +1,323 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/outbound.uc: build the sing-box `outbounds` and `endpoints`
* blocks, plus the urltest pruning the route
* builder depends on.
*
* Two builders live here:
*
* generate_endpoint(node, ctx) -> sing-box endpoint object (WireGuard only
* at the moment; the tag convention is the same as for outbounds).
*
* generate_outbound(node, mark, direct_overrides) -> sing-box outbound
* object. Side effect: a direct node carrying override_address /
* override_port is recorded into the caller-supplied `direct_overrides`
* map so the route builder can later emit the route-options action.
* The Adapter (config/adapter.uc) already handles every protocol's
* payload; this module is just the orchestration and the override side
* effect.
*
* build_outbounds(dm, ctx, direct_overrides) -> { outbounds, endpoints }
* Implements the routing-mode split (proxy vs custom), the urltest
* pruning via keep_candidate / buildable_candidates, and the
* routing_node -> outbound / endpoint resolution.
*
* The `direct_overrides` parameter is a mutable map the orchestrator owns
* and threads through the generators. Making it explicit avoids the
* "module-level side effect" the old generate_client.uc had, which was
* the only thing tying outbound building to route building.
*/
'use strict';
import { isEmpty, strToInt, strToBool, strToTime } from '../homeproxy.uc';
import { ConfigQuery } from '../config/model.uc';
import { OutboundFactory, EndpointFactory } from '../config/adapter.uc';
import { get_outbound, get_resolver } from './common.uc';
/* --- single-endpoint / single-outbound builders ------------------------ */
/* WireGuard goes through the same Node as every other protocol; the
* fields come from node.protocol_options (Loader's wireguard row) and
* the cross-protocol options from node.common.
*
* This used to read the flat UCI keys (node.wireguard_*, and
* node.tcp_fast_open / node.tcp_multi_path / node.udp_fragment) off a
* Node. Those keys only existed on the flat section dict the
* pre-refactor call sites passed; once A2/A3 made every call site pass
* a Node they were simply null, so every WireGuard main / UDP /
* urltest node emitted an endpoint with no private key and a peer with
* no public key. tests/fixtures/generators/wireguard.uci guards it.
*
* The endpoint *shaping* lives in EndpointFactory in
* config/adapter.uc, so the generator no longer holds protocol business
* logic for WireGuard. This stays as the call sites' entry point, which
* keeps the module's endpoint vocabulary without making every caller
* import the adapter directly. */
export function generate_endpoint(node, ctx) {
return EndpointFactory.create(node, ctx);
};
/* Build one outbound from a Node via the Adapter layer. A direct node
* with override_address / override_port also gets recorded into the
* caller's `direct_overrides` map so the route builder can emit the
* route-options action; this is the only side effect of the function
* beyond returning the outbound object. */
export function generate_outbound(node, mark, direct_overrides) {
/* Stage A3+A5: this used to be a 110-line ternary soup that built the
* sing-box outbound by hand for every protocol. It now takes a
* Node (looked up via ConfigQuery.node_by_id) and hands it straight
* to OutboundFactory.create() (the Adapter layer).
*
* The direct-node override table is the one side effect that survives;
* the Adapter layer has no place to record it, and the route builder
* reads it later. */
if (type(node) !== 'object' || isEmpty(node))
return null;
if (node.type === 'direct' && (!isEmpty(node.protocol_options.override_address) || !isEmpty(node.protocol_options.override_port)))
direct_overrides[node.id] = {
override_address: node.protocol_options.override_address,
override_port: strToInt(node.protocol_options.override_port)
};
return OutboundFactory.create(node, mark);
};
/* Keep only the candidate ids the Adapter can actually build.
*
* urltest member lists are the one place a broken node must not be fatal:
* a single bad node in a 200-node subscription used to make the Adapter
* die(), which aborted the whole generator, so the service could not start
* at all. The node stays in UCI (the UI shows it and the next
* subscription update can repair or drop it); it is only left out of the
* urltest group, and the reason is logged.
*
* Nodes that routing rules or DNS actually resolve through are NOT pruned:
* a rule pointing at a missing tag is invalid sing-box config, so those
* stay fatal on purpose. */
function keep_candidate(dm, id) {
const node = ConfigQuery.node_by_id(dm, id);
if (!node) {
warn(sprintf("homeproxy: urltest candidate '%s' no longer exists, skipping.", id));
return false;
}
const problems = OutboundFactory.problems(node);
if (length(problems)) {
warn(sprintf("homeproxy: skipping urltest candidate '%s': %s.", id, join(', ', problems)));
return false;
}
return true;
}
/* Map over a candidate id list, dropping the unbuildable ones. */
function buildable_candidates(dm, ids) {
return filter(ids || [], (id) => keep_candidate(dm, id));
}
/* --- main-outbound building (proxy mode) ------------------------------- */
/* Decide whether a candidate node should be emitted as a sing-box endpoint
* (WireGuard) or as an outbound (everything else). Returns the populated
* object so the caller can retag it. */
function emitNode(node, mark, tag, direct_overrides, ctx) {
if (node && node.type === 'wireguard') {
const endpoint = generate_endpoint(node, ctx);
endpoint.tag = tag;
return { kind: 'endpoint', object: endpoint };
}
const outbound = generate_outbound(node, mark, direct_overrides);
/* generate_outbound() returns null for an empty node, and a reference can
* point at a section that no longer exists: main_node and main_udp_node
* are stored by name, and deleting a node in the UI does not rewrite
* them. Setting `.tag` on null raised "left-hand side expression is
* null", which says nothing about which reference is stale. */
if (outbound == null)
die(sprintf("cannot build %s: the node it refers to does not exist (it may have been deleted, or the reference is stale).\n", tag));
outbound.tag = tag;
return { kind: 'outbound', object: outbound };
}
function buildMainOutbounds(dm, config, ctx, direct_overrides) {
const outbounds = config.outbounds;
const endpoints = config.endpoints;
const mark = ctx.self_mark;
let urltest_nodes = [];
if (ctx.main_node === 'urltest') {
const main_urltest_nodes = buildable_candidates(dm, ctx.main_urltest_nodes);
const main_urltest_interval = ctx.main_urltest_interval;
const main_urltest_tolerance = ctx.main_urltest_tolerance;
/* An empty group means every candidate was unbuildable: there is
* nothing left to route through, so fail with the reason instead
* of emitting an urltest outbound with no members. */
if (isEmpty(main_urltest_nodes))
die("no buildable node in the main urltest group, please check your configuration.\n");
push(outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
urltest_nodes = main_urltest_nodes;
} else {
const main_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_node);
const emitted = emitNode(main_node_cfg, mark, 'main-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
if (ctx.main_udp_node === 'urltest') {
const main_udp_urltest_nodes = buildable_candidates(dm, ctx.main_udp_urltest_nodes);
const main_udp_urltest_interval = ctx.main_udp_urltest_interval;
const main_udp_urltest_tolerance = ctx.main_udp_urltest_tolerance;
if (isEmpty(main_udp_urltest_nodes))
die("no buildable node in the main UDP urltest group, please check your configuration.\n");
push(outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else if (ctx.dedicated_udp_node) {
const main_udp_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_udp_node);
const emitted = emitNode(main_udp_node_cfg, mark, 'main-udp-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
for (let i in urltest_nodes) {
const urltest_node = ConfigQuery.node_by_id(dm, i);
if (!urltest_node)
continue;
const emitted = emitNode(urltest_node, mark, 'cfg-' + i + '-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
}
/* --- custom-mode outbound building (routing_node iteration) ------------- */
function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
const outbounds = config.outbounds;
const endpoints = config.endpoints;
const mark = ctx.self_mark;
let urltest_nodes = [],
routing_nodes = [];
for (let cfg in dm.routing.nodes) {
if (!cfg.enabled)
continue;
if (cfg.node === 'urltest') {
const cfg_urltest_nodes = buildable_candidates(dm, cfg.urltest_nodes);
push(outbounds, {
type: 'urltest',
tag: 'cfg-' + cfg.name + '-out',
outbounds: map(cfg_urltest_nodes, (k) => `cfg-${k}-out`),
url: cfg.urltest_url,
interval: strToTime(cfg.urltest_interval),
tolerance: strToInt(cfg.urltest_tolerance),
idle_timeout: strToTime(cfg.urltest_idle_timeout),
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
});
urltest_nodes = [...urltest_nodes, ...filter(cfg_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else {
const outbound = ConfigQuery.node_by_id(dm, cfg.node);
/* Same stale-reference case as emitNode(), but here the reference
* comes from a routing_node. `|| {}` made it look non-null and the
* failure surfaced as a null dereference two lines later. */
if (isEmpty(outbound))
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
if (outbound.type === 'wireguard') {
/* WireGuard goes through generate_endpoint() which tags
* the endpoint as cfg-<node_id>-out (same convention as
* generate_outbound()). The routing_node section name is
* used by routing rules via get_outbound() but not as the
* tag itself; both paths agree on cfg-<node_id>-out. */
const endpoint = generate_endpoint(outbound, ctx);
endpoint.bind_interface = cfg.bind_interface;
endpoint.detour = get_outbound(cfg.outbound, dm);
if (cfg.domain_resolver)
endpoint.domain_resolver = {
server: get_resolver(cfg.domain_resolver, dm),
strategy: cfg.domain_strategy
};
push(endpoints, endpoint);
} else {
const ob = generate_outbound(outbound, mark, direct_overrides);
if (ob == null)
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
ob.bind_interface = cfg.bind_interface;
ob.detour = get_outbound(cfg.outbound, dm);
if (cfg.domain_resolver)
ob.domain_resolver = {
server: get_resolver(cfg.domain_resolver, dm),
strategy: cfg.domain_strategy
};
push(outbounds, ob);
}
push(routing_nodes, cfg.node);
}
}
for (let i in filter (urltest_nodes, (l) => !~index(routing_nodes, l))) {
const urltest_node = ConfigQuery.node_by_id(dm, i) || {};
if (urltest_node.type === 'wireguard')
push(endpoints, generate_endpoint(urltest_node, ctx));
else
push(outbounds, generate_outbound(urltest_node, mark, direct_overrides));
}
}
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box `outbounds` and `endpoints` blocks. Mutates
* config.endpoints and config.outbounds; the caller initialises them
* with the always-on default outbounds (direct-out + block-out) and
* passes the mutable accumulator in `ctx.acc` so the same arrays can
* be threaded through this module, the ruleset module and the route
* module without copying.
*
* `direct_overrides` is the same mutable map the route module will read
* later; populate it here so the override information is available
* when the route builder runs. */
export function build_outbounds(config, dm, ctx, direct_overrides) {
if (isEmpty(ctx.main_node) && isEmpty(ctx.default_outbound))
return config;
if (!isEmpty(ctx.main_node))
buildMainOutbounds(dm, config, ctx, direct_overrides);
else if (!isEmpty(ctx.default_outbound))
buildCustomOutbounds(dm, config, ctx, direct_overrides);
return config;
};
@@ -0,0 +1,308 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/route.uc: build the sing-box `route` block.
*
* The route block has the same routing-mode split as the DNS block:
*
* proxy mode - dns-in hijack, sniff, the bypass_mainland_china
* resolve + geoip-cn split, dedicated UDP route,
* main_override route-options, main-out final,
* plus the static inline + remote rule_sets
* (direct-domain / proxy-domain / geoip-cn /
* geosite-cn).
* custom mode - the user-defined routing_node -> outbound graph,
* routing_rule iteration with the 1.14 action-specific
* fields, find_neighbor, the optional resolve prefix,
* final_outbound resolution, final_override
* route-options, and the rule_set user-defined loop
* (the loop itself is in ruleset.uc).
*
* The orchestrator owns config.route.rule_set: route.uc may push static
* entries (proxy mode), ruleset.uc may push user entries (custom mode),
* and build_http_clients() runs at the end to normalise the legacy
* download_detour into 1.14's http_clients / http_client pairing.
*/
'use strict';
import { isEmpty, strToInt, strToTime, strToBool, parse_port } from '../homeproxy.uc';
import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
/* --- shared initial block (every routing mode) ------------------------- */
/* Initialise config.route with the always-on rules and the defaults
* that every mode shares (dns-in hijack, sniff, optional auto_detect).
* The caller must have created an empty config.route; we mutate it. */
function initRoute(config, ctx) {
config.route = {
rules: [
{
inbound: 'dns-in',
action: 'hijack-dns'
},
{
action: 'sniff',
timeout: '300ms'
}
],
rule_set: [],
auto_detect_interface: isEmpty(ctx.default_interface) ? true : null,
default_interface: ctx.default_interface
};
}
/* --- proxy mode route rules + rule_set --------------------------------- */
function build_route_proxy(config, dm, ctx, direct_overrides) {
/* Resolve outbound server domains through the WAN default resolver.
Do not use china-dns here: china-dns is for resolving mainland China
destinations, not the proxy node itself. Coupling node bootstrap to
china_dns_server can break dialing when that resolver is polluted or
unsuitable for the node domain. */
config.route.default_domain_resolver = {
server: 'default-dns',
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
};
/* Direct list */
if (length(ctx.direct_domain_list))
push(config.route.rules, {
rule_set: 'direct-domain',
action: 'route',
outbound: 'direct-out'
});
/* Bypass CN traffic: resolve the destination first, then route by IP.
sing-box does not match an IP-based rule set (geoip-cn) against a domain
destination unless it is resolved first, so add an explicit resolve
action; geoip-cn then sends China IPs to direct and everything else falls
through to main-out (proxy). This avoids relying on the geosite-* domain
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
beacons) as "cn" and send them direct to time out. Keep the direct-domain
fast-path above for known direct domains. */
if (ctx.routing_mode === 'bypass_mainland_china') {
push(config.route.rules, {
action: 'resolve',
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
});
push(config.route.rules, {
rule_set: 'geoip-cn',
action: 'route',
outbound: 'direct-out'
});
}
/* Main UDP out */
if (ctx.dedicated_udp_node) {
const udp_override = direct_overrides[ctx.main_udp_node] || null;
push(config.route.rules, {
network: 'udp',
action: 'route',
outbound: 'main-udp-out',
override_address: udp_override ? udp_override.override_address : null,
override_port: udp_override ? udp_override.override_port : null
});
}
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const main_override = direct_overrides[ctx.main_node] || null;
if (main_override)
push(config.route.rules, {
action: 'route-options',
override_address: main_override.override_address,
override_port: main_override.override_port
});
config.route.final = 'main-out';
/* --- proxy-mode rule_set block ----------------------------------- */
/* Direct list */
if (length(ctx.direct_domain_list))
push(config.route.rule_set, {
type: 'inline',
tag: 'direct-domain',
rules: [
{
domain_keyword: ctx.direct_domain_list,
}
]
});
/* Proxy list */
if (length(ctx.proxy_domain_list))
push(config.route.rule_set, {
type: 'inline',
tag: 'proxy-domain',
rules: [
{
domain_keyword: ctx.proxy_domain_list,
}
]
});
if (ctx.routing_mode === 'bypass_mainland_china') {
/*
* Fetched straight from the upstream SagerNet repositories and
* downloaded through the selected node. A direct fetch depends on
* the CDN staying reachable from mainland China, where DNS pollution
* makes it fail intermittently; the files total ~250 KB per
* day, so proxying the download costs almost nothing.
*
* Only geosite-cn and geoip-cn are emitted: they are the two the
* DNS and route blocks actually reference. A third geosite-noncn
* used to be declared here as well, but no rule ever referenced it -
* sing-box creates (and keeps updating, and holds in memory) every
* declared rule-set regardless of use, so it was a daily ~100 KB
* download through the proxy for nothing.
*/
push(config.route.rule_set, {
type: 'remote',
tag: 'geoip-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
push(config.route.rule_set, {
type: 'remote',
tag: 'geosite-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
}
if (isEmpty(config.route.rule_set))
config.route.rule_set = null;
}
/* --- custom-mode route rules ------------------------------------------- */
function build_route_custom(config, dm, ctx, direct_overrides) {
config.route.default_domain_resolver = {
server: get_resolver(ctx.default_outbound_dns, dm)
};
if (ctx.find_neighbor === '1')
config.route.find_neighbor = true;
if (ctx.domain_strategy)
push(config.route.rules, {
action: 'resolve',
strategy: ctx.domain_strategy
});
for (let cfg in dm.routing.rules) {
if (!cfg.enabled)
continue;
const rule_outbound = get_outbound(cfg.outbound, dm);
const rule_direct_override = get_direct_override(cfg.outbound, dm, direct_overrides);
let rule_override_address = cfg.override_address,
rule_override_port = strToInt(cfg.override_port);
if (isEmpty(rule_override_address) && isEmpty(rule_override_port) && rule_direct_override) {
rule_override_address = rule_direct_override.override_address;
rule_override_port = rule_direct_override.override_port;
}
/* sing-box routing rule: emit match fields plus action-specific fields.
client is the sniffed client type (only set with protocol quic/ssh);
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
reject adds method/no_drop. Emitted conditionally so a given action
never carries fields sing-box rejects for it. */
const rule = {
ip_version: strToInt(cfg.ip_version),
protocol: cfg.protocol,
network: cfg.network,
client: cfg.client,
domain: cfg.domain,
domain_suffix: cfg.domain_suffix,
domain_keyword: cfg.domain_keyword,
domain_regex: cfg.domain_regex,
source_ip_cidr: cfg.source_ip_cidr,
source_ip_is_private: strToBool(cfg.source_ip_is_private),
ip_cidr: cfg.ip_cidr,
ip_is_private: strToBool(cfg.ip_is_private),
source_port: parse_port(cfg.source_port),
source_port_range: cfg.source_port_range,
port: parse_port(cfg.port),
port_range: cfg.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,
source_mac_address: cfg.source_mac_address,
source_hostname: cfg.source_hostname
};
/* Route-option fields belong to the actions that dial out (`route` and
* `route-options`). They used to be assigned unconditionally, which
* stayed invisible as long as the UI kept them hidden per action - but
* changing a rule's action leaves the previous values in UCI, and
* sing-box then refuses the whole configuration with "unknown field" on
* the first one the new action does not take. */
if (cfg.action !== 'resolve' && cfg.action !== 'reject') {
rule.outbound = rule_outbound;
rule.override_address = rule_override_address;
rule.override_port = rule_override_port;
rule.udp_disable_domain_unmapping = strToBool(cfg.udp_disable_domain_unmapping);
rule.udp_connect = strToBool(cfg.udp_connect);
rule.udp_timeout = strToTime(cfg.udp_timeout);
rule.tls_fragment = strToBool(cfg.tls_fragment);
rule.tls_fragment_fallback_delay = strToTime(cfg.tls_fragment_fallback_delay);
rule.tls_record_fragment = strToBool(cfg.tls_record_fragment);
rule.tls_spoof = cfg.tls_spoof || null;
rule.tls_spoof_method = cfg.tls_spoof_method || null;
}
if (cfg.action === 'resolve') {
rule.server = get_resolver(cfg.resolve_server, dm);
rule.strategy = cfg.resolve_strategy;
rule.disable_cache = strToBool(cfg.resolve_disable_cache);
rule.disable_optimistic_cache = strToBool(cfg.resolve_disable_optimistic_cache);
rule.rewrite_ttl = strToInt(cfg.resolve_rewrite_ttl);
rule.timeout = strToTime(cfg.resolve_timeout);
rule.client_subnet = cfg.resolve_client_subnet;
}
if (cfg.action === 'reject') {
rule.method = cfg.reject_method;
rule.no_drop = strToBool(cfg.reject_no_drop);
}
push(config.route.rules, rule);
}
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const final_override = get_direct_override(ctx.default_outbound, dm, direct_overrides);
if (final_override)
push(config.route.rules, {
action: 'route-options',
override_address: final_override.override_address,
override_port: final_override.override_port
});
config.route.final = get_outbound(ctx.default_outbound, dm);
}
/* --- public entry ------------------------------------------------------ */
export function build_route(config, dm, ctx, direct_overrides) {
initRoute(config, ctx);
if (!isEmpty(ctx.main_node))
build_route_proxy(config, dm, ctx, direct_overrides);
else if (!isEmpty(ctx.default_outbound))
build_route_custom(config, dm, ctx, direct_overrides);
return config;
};
@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/ruleset.uc: user-defined rule_set entries + the sing-box
* 1.14 http_clients normalisation.
*
* Two distinct pieces share this module because they both iterate over
* `config.route.rule_set` after every other module has had a chance to
* append:
*
* build_user_rulesets(rule_set_array, dm, ctx)
* The custom-mode `dm.routing.rulesets` loop, which builds the
* user-defined rule_set entries (cfg-<name>-rule tag) and pre-1.14
* download_detour. The legacy field is stripped later, in the same
* module, by build_http_clients(); leaving it here would make
* sing-box 1.14 reject the config.
*
* build_http_clients(rule_set_array, dm, ctx)
* sing-box 1.14 dropped download_detour from local/inline rule-sets
* and moved the remote rule-set fetch into top-level http_clients.
* This walks every entry, strips download_detour (sing-box 1.14
* rejects it on local/inline), and emits a http_clients array whose
* `detour` is dropped for pure-TUN setups (direct outbound with no
* self_mark; sing-box rejects that detour).
*/
'use strict';
import { isEmpty, validateHomeProxyPath } from '../homeproxy.uc';
import { get_outbound, isDirectOutboundTag } from './common.uc';
/* --- user-defined rule_set entries (custom mode only) ----------------- */
/* Append user-defined rule_set entries to `rule_set_array`. Each cfg
* produces one entry; `extra_tags` adds additional rule_set tags that
* share the same fetch source (sing-box 1.14 multi-tag). */
export function build_user_rulesets(rule_set_array, dm, ctx) {
for (let cfg in dm.routing.rulesets) {
if (!cfg.enabled)
continue;
const extra_tags = cfg.extra_tags || [];
let rs_tag = 'cfg-' + cfg.name + '-rule';
if (length(extra_tags) && cfg.type !== 'inline') {
rs_tag = [rs_tag];
for (let t in extra_tags)
push(rs_tag, 'cfg-' + t + '-rule');
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
(remote: url and initial_path, local: path) */
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
if (!match(fetch_ref, /\{tag\}/))
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder.", cfg.name, cfg.type));
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
}
const ruleset = {
type: cfg.type,
tag: rs_tag,
format: cfg.format,
/* Local rule-set path is read by sing-box as root. The
* whitelist is enforced here because the LuCI form's
* datatype='file' is only UX - UCI can be set from anywhere
* on the LAN, and an arbitrary /etc/passwd would leak the
* file to anyone who could write UCI. Drop the field rather
* than die(): a missing path on a local ruleset becomes
* sing-box invalid (and known-good stays), same effect as a
* hard fail. */
path: (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
? null
: cfg.path,
url: cfg.url,
update_interval: cfg.update_interval
};
/* download_detour is a pre-1.14 option that only makes sense for
remote rule-sets; emitting it for local/inline ones makes sing-box
* 1.14 reject the whole config. It is translated into http_clients
right below. */
if (cfg.type === 'remote')
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
/* initial_path is read off the local disk as root; same
* whitelist as the local ruleset path. */
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
push(rule_set_array, ruleset);
}
};
/* --- http_clients normalisation (every routing mode) ------------------ */
/* Strip the legacy download_detour from every entry (sing-box 1.14
* rejects it on local/inline), and for remote rule-sets translate it
* into the http_client tag pointing at the corresponding http_clients
* entry. Returns the http_clients array (caller attaches it to
* config.http_clients) and mutates the rule_set entries in place. */
export function build_http_clients(rule_set_array, dm, ctx) {
const http_clients = [];
const http_seen = {};
for (let rs in (rule_set_array || [])) {
/* Strip the legacy field from every entry first, including the
local/inline ones: sing-box 1.14 rejects it everywhere except on
remote rule-sets, where it is replaced by http_client below. */
let detour = rs.download_detour;
delete rs.download_detour;
if (rs.type !== 'remote')
continue;
if (isEmpty(detour))
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
const tag = 'hp-' + detour;
rs.http_client = tag;
if (!http_seen[detour]) {
http_seen[detour] = true;
/* sing-box 1.14 rejects detouring to an empty direct outbound
(pure TUN mode has no self_mark on direct-out). Omitting detour
uses the same system direct dialer, so behavior is unchanged. */
const client = { tag: tag };
if (!(isEmpty(ctx.self_mark) && isDirectOutboundTag(detour, dm)))
client.detour = detour;
push(http_clients, client);
}
}
return http_clients;
};
@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/server.uc: server-side orchestrator.
*
* This module no longer shapes
* any protocol. It used to read the flat UCI section directly
* (`cfg.snell_version`, `cfg.shadowsocks_encrypt_method`,
* `cfg.hysteria_obfs_min_packet_size`, ...) and spell out every
* per-protocol sing-box field inline, which meant the server path had
* no domain model and adding a protocol touched the generator.
*
* Now:
*
* UCI -> Loader.load_inbound() -> Inbound (domain) -> InboundFactory
* -> sing-box inbound JSON
*
* The protocol knowledge lives in config/model.uc (the canonical <->
* UCI tables) and config/adapter.uc (InboundFactory's sing-box field
* tables). What is left here is the orchestration: gate on `enabled`,
* build the log block, and decide that "no inbound" means "server
* disabled" (the CLI shell turns the null into exit 1).
*
* generate_server(dm) -> config object. Pure: no UCI, no file I/O.
*/
'use strict';
import { InboundFactory } from '../config/adapter.uc';
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box server config object. Returns null when no
* enabled inbound exists (the caller treats that as "service disabled",
* which the runtime interprets as "do not start the server instance"). */
export function generate_server(dm) {
const log_level = ((dm.server || {}).settings || {}).log_level || 'warn';
const config = {
log: {
disabled: false,
level: log_level,
output: '/var/run/homeproxy/sing-box-s.log',
timestamp: true
},
inbounds: []
};
for (let inbound in ((dm.server || {}).inbounds || [])) {
if (!inbound.enabled)
continue;
/* create() dies on a broken section, which is the previous
* behaviour too: the old code would emit a sing-box config
* that `sing-box check` then rejected, and the runtime's
* candidate/known-good path caught it. Failing here instead
* names the offending section in the log. */
push(config.inbounds, InboundFactory.create(inbound));
}
if (length(config.inbounds) === 0)
return null;
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
return config;
};
@@ -0,0 +1,794 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
import { urldecode_params } from 'luci.http';
/* Global variables start */
export const HP_DIR = '/etc/homeproxy';
export const RUN_DIR = '/var/run/homeproxy';
/* Where the UCI configuration lives. This is /etc/config - NOT HP_DIR/config.
* The package ships its config as the conffile /etc/config/homeproxy, and
* ucode's `cursor(dir)` treats `dir` as the *confdir*, so
* cursor(HP_DIR + '/config') would read /etc/homeproxy/config/homeproxy, a
* path that exists nowhere. uci.load() then returns null, every uci.get()
* returns null, and the Loader silently yields pure defaults: no main-out,
* no route/direct final, none of the user's ports or nodes.
* Verified on a device: cursor('/etc/homeproxy/config').get(...) is empty
* while cursor().get(...) returns the configured value.
* The test suite stages a rewritten copy of this constant instead of the
* old `__LOADER_DIR__` source sed. */
export const UCICONFIG_DIR = '/etc/config';
/* Largest subscription body we will fetch, in bytes. See wGETVerbose(). */
export const HP_FETCH_CAP = 5 * 1024 * 1024;
/* Global variables end */
/* Utilities start */
/* Kanged from luci-app-commands */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
export function isBinary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
};
/* Whitelist absolute paths the generators are allowed to put into
* sing-box config. sing-box runs as root and reads the file itself; an
* arbitrary UCI value of e.g. /etc/passwd would leak the file to anyone
* who could write to the UCI tree. The LuCI UI already gates the input
* via validateCertificatePath() in homeproxy.js, but UCI can be set from
* any node on the LAN (subscription updates, scripted edits) and the
* UI check is only UX - we have to enforce on the backend.
*
* /etc/homeproxy/... - project-managed certs / ruleset paths / etc.
* /tmp/homeproxy_* - upload staging files (cert_upload_* writes)
*
* Anything else (relative paths, /etc/passwd, /var/run/...) returns false
* and the caller is expected to die() / warn() / strip the value. */
export function validateHomeProxyPath(p) {
if (!p || type(p) !== 'string')
return false;
/* Reject traversal *before* the prefix checks below: a plain prefix
* comparison accepts '/etc/homeproxy/../../etc/shadow', and sing-box reads
* these paths as root. */
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
/* Reject anything that does not start with '/' - relative paths in
* sing-box resolve against the process CWD, which is /tmp at boot
* but is not a position we want any UCI value to land in. */
if (substr(p, 0, 1) !== '/')
return false;
if (substr(p, 0, length('/etc/homeproxy/')) === '/etc/homeproxy/')
return true;
if (substr(p, 0, length('/tmp/homeproxy_')) === '/tmp/homeproxy_')
return true;
return false;
};
/* Certificates the sing-box instances are allowed to read.
*
* TLS certificate_path / key_path are not rule-set paths: a server's
* certificate normally lives under /etc/ssl/ or under the ACME state in
* /etc/acme/, and the two sing-box jails mount exactly those directories
* (see runtime/service.sh's procd_add_jail_mount calls). validateHomeProxyPath()
* only knows /etc/homeproxy/ and /tmp/homeproxy_, so a certificate the user
* legitimately picked from /etc/ssl/ was accepted by the LuCI validator and
* then silently dropped here - certificate_path became null and the TLS
* listener failed with no error pointing at the path.
*
* The list is mirrored by HP_CERT_PATH_ROOTS in
* htdocs/luci-static/resources/homeproxy.js; guard 29 in tests/arch-guard.sh
* keeps the two in step.
*/
export const CERT_PATH_ROOTS = ['/etc/homeproxy/certs/', '/etc/acme/', '/etc/ssl/'];
/* validateCertificatePath(p) - the TLS certificate/key path gate.
*
* Same shape as validateHomeProxyPath(): reject traversal and relative paths,
* then require one of CERT_PATH_ROOTS. Kept separate rather than folded into
* validateHomeProxyPath() so a rule-set path cannot be pointed at /etc/ssl/
* and a certificate cannot be pointed at /tmp/homeproxy_*. */
export function validateCertificatePath(p) {
if (!p || type(p) !== 'string')
return false;
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
if (substr(p, 0, 1) !== '/')
return false;
for (let root in CERT_PATH_ROOTS)
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
return true;
return false;
};
/* Read at most `limit` bytes from a file, or '' when it does not exist.
* The cap is deliberate: a command's output is not trustworthy input. */
function read_capped(path, limit) {
const f = open(path);
if (!f)
return '';
const data = f.read(limit) ?? '';
f.close();
return data;
};
/* Remove the scratch files and the directory created for one run. Best
* effort: a command the shell could not even parse leaves no files behind,
* and a failed cleanup must never mask the command's own result. */
function cleanup_exec_dir(dir, outpath, errpath) {
try {
if (access(outpath))
unlink(outpath);
if (access(errpath))
unlink(errpath);
rmdir(dir);
} catch (e) {
/* nothing useful to do - the results are already captured */
}
};
export function executeCommand(...args) {
const command = join(' ', args);
const dir = mkdtemp();
const outpath = dir + '/stdout';
const errpath = dir + '/stderr';
let exitcode = null, stdout = '', stderr = '';
try {
/* Redirect to real paths, not to the descriptors of two mkstemp()
* files. The old form appended `>&N 2>&N`, which only works when
* the child shell can see those descriptors; /bin/sh reports
* "Bad file descriptor" (bash) / "Bad fd number" (dash) when it
* cannot, so the command was never executed and every caller got
* an empty result with exit status 2. That is what happened in
* CI, where /bin/sh is dash. Redirecting to paths is plain POSIX
* and behaves identically under busybox ash, dash and bash. */
exitcode = system(sprintf('%s >%s 2>%s', command, outpath, errpath));
/* The reader has to be able to return one byte more than
* HP_FETCH_CAP, or wGETVerbose()'s "response exceeds the limit"
* branch below is unreachable: the reader used to stop at 512 KiB,
* so every subscription between 512 KiB and 5 MiB was handed to the
* parser as a truncated body with error: null - the size check
* could never fire and the caller had no way to tell. stderr keeps
* the smaller cap: it is a diagnostic message, not a payload. The
* stderr_truncated flag on the result tells the caller the cap
* actually fired, so a "reload exited with status N" line is not
* the only signal that something went wrong upstream. */
stdout = read_capped(outpath, HP_FETCH_CAP + 1);
stderr = read_capped(errpath, 1024 * 512);
} catch (e) {
/* Never leave the scratch directory behind on a failing run.
* ucode has no `finally` clause, so the exception is re-raised
* by hand. */
cleanup_exec_dir(dir, outpath, errpath);
die(e);
}
const binary = isBinary(stdout);
/* Did stderr actually overflow the 512 KiB cap? The temp file is
* unlinked right after this, so size is read while it still exists.
* lstat() returns null on missing files, so the .size fallback is
* defensive against an exception-driven early cleanup. */
const stderr_size = (lstat(errpath) || {}).size || 0;
const stderr_truncated = stderr_size > 1024 * 512;
cleanup_exec_dir(dir, outpath, errpath);
return {
command,
stdout: binary ? null : stdout,
stderr,
stderr_truncated,
exitcode,
binary
};
};
export function getTime(epoch) {
const local_time = localtime(epoch);
return replace(replace(sprintf(
'%d-%2d-%2d@%2d:%2d:%2d',
local_time.year,
local_time.mon,
local_time.mday,
local_time.hour,
local_time.min,
local_time.sec
), ' ', '0'), '@', ' ');
};
/* Redact the credential-bearing parts of a URL before logging it. Any
* subscription URL we ship into /var/run/homeproxy/homeproxy.log is
* readable by anyone who can read /var/run/homeproxy - including UCI
* defaults that ship on the device and anyone with shell on the LAN.
* (Confirmed on the device: the log is 0644.)
*
* Only the scheme, host and port are kept - enough to answer "which
* provider/endpoint failed?". The userinfo, the path and the query string
* all carry subscription tokens in practice: many providers hand out
* `https://host:port/<user>/<token>` with no query string at all, which is
* exactly what the device this was found on was configured with, and the
* path used to survive the redaction. The original URL is still passed to
* wGETVerbose.
*
* https://user:token@host.example.com/path/sub?q=abc&token=secret
* -> https://***@host.example.com/***?*** */
export function redactUrl(url) {
if (!url || type(url) !== 'string')
return '';
let u = url;
/* userinfo: scheme://user:pass@host -> scheme://***@host */
const at = index(u, '@');
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (scheme && at !== -1 && at > length(scheme[0]))
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
/* path and query: keep the authority, mark the rest. Splitting them
* rather than masking from the first '/' keeps the structural markers
* (`/***?***`) so a reader can still tell a path from a query, and it is
* why the authority end is the *earlier* of the two separators. */
const scheme_end = scheme ? length(scheme[0]) : 0;
const rest = substr(u, scheme_end);
const path_at = index(rest, '/');
const query_at = index(rest, '?');
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
let authority_end = length(rest);
if (has_path)
authority_end = path_at;
else if (query_at !== -1)
authority_end = query_at;
let tail = '';
if (has_path)
tail += '/***';
if (query_at !== -1)
tail += '?***';
u = substr(u, 0, scheme_end + authority_end) + tail;
return u;
};
/* Scan a free-form error string and redact every URL in it. GNU wget's
* stderr writes the requested URL back into the message:
*
* https://host/path?q=token=secret: Bad port '80080'.
*
* The trailing `:` separator is greedy-matched here, which is harmless -
* redactUrl leaves it as-is. Doing this here, in wGETVerbose, means the
* returned `error` is safe no matter where the caller ships it - the
* fetcher logs it, the orchestrator returns it, anything that prints it
* afterwards has already lost the token. Centralising the redaction at
* the source is the point: every call site used to have to
* remember to redact, and the one that forgot was the original bug.
*
* A non-string input is returned unchanged so this is safe to apply to
* the trimmed wget stderr even when it is empty.
*
* Both definitions sit above wGETVerbose on purpose: ucode does not hoist
* `export function`, so a call compiled before the declaration is bound
* fails at runtime with "access to undeclared variable". */
export function redactReason(reason) {
if (!reason || type(reason) !== 'string')
return reason;
return replace(reason, /https?:\/\/\S+/g, (url) => redactUrl(url));
};
/*
* Fetch a URL and report both the body and, on failure, the reason. The
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
* caller can tell a DNS failure from a timeout or a TLS handshake error.
*/
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return { content: null, error: 'invalid URL' };
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
/* -nv (not -q) so wget still reports *why* a fetch failed on stderr.
*
* The size cap is enforced by piping through `head -c`, NOT with wget's
* --max-filesize: that option does not exist in busybox wget (the target's
* /usr/bin/wget), which exits 2 with "unrecognized option" before making a
* single request - so every subscription fetch failed. GNU wget has no
* such option either. `head` closing the pipe stops wget early, which
* bounds the download; the cap is therefore CAP+1 bytes rather than
* exactly CAP, and one byte past the limit means "too large".
*
* 5 MiB covers a 10 000-node subscription with ~3 KB per node plus the
* base64 inflation. Anything larger is almost certainly an attack or a
* misconfiguration.
*
* The pipeline does cost the exit status: `system()` returns head's, which
* is always 0. A wget failure therefore arrives as an empty body plus
* wget's own message on stderr, and that is reported below. */
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
* and in `a | b >out 2>err` those redirections bind to b only - wget's
* stderr would go to the caller's terminal and the failure message would
* be lost. Grouping the pipeline makes both stream to the capture files. */
const output = executeCommand(`{ /usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
let reason = trim(output.stderr || '');
reason = reason ? replace(reason, /\s+/g, ' ') : '';
/* Review H3: an HTTP-level wget failure echoes the full URL - query
* string and subscription token included - as in
* `https://host/path?token=secret: 404 Not Found`. (A pure connection
* failure prints only `failed: Connection refused.` and leaks nothing,
* but the 404/403 case is enough.) Redact at the source so every caller
* of wGETVerbose gets a safe `error` whether or not it remembers to call
* redactUrl itself. */
if (reason)
reason = redactReason(reason);
if (length(output.stdout || '') > HP_FETCH_CAP)
return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` };
if (output.exitcode !== 0) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `wget exited with status ${output.exitcode}: ${reason || 'no error output'}` };
}
/* head() masks wget's status, so a failed fetch shows up here instead. */
if (!length(trim(output.stdout)) && reason) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `wget failed: ${reason}` };
}
return { content: trim(output.stdout), error: null };
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export function strToBool(str) {
return str === '1' ? true : (str === '0' ? false : null);
};
export function strToInt(str) {
/* Zero reads back as "not set" on purpose: several sing-box fields mean
* "omit me" at zero - a vmess user's alterId, for instance - and the
* generators depend on removeBlankAttrs() dropping the null this produces.
* A review flagged it as "a legitimate 0 becomes null"; the golden outbound
* snapshot and the vmess alterId assertion both say otherwise. */
return !isEmpty(str) ? (int(str) || null) : null;
};
export function strToTime(str) {
if (isEmpty(str))
return null;
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
};
/* Turn a UCI list of port strings into the int array sing-box wants
* (e.g. the WireGuard `reserved` list, a routing rule's `port` /
* `source_port`). Returns null for anything that is not a non-empty
* array, so a caller can let removeBlankAttrs() drop the field.
*
* Lives here rather than in generator/common.uc because the Adapter
* layer needs it too (EndpointFactory builds the WireGuard endpoint)
* and an adapter must not import from generator/. */
export function parse_port(strport) {
if (type(strport) !== 'array' || isEmpty(strport))
return null;
let ports = [];
for (let i in strport) {
/* Only a bare decimal is a port: int() truncates, so '80-90' used to
* become 80 and '443/tcp' become 443, silently widening the rule
* instead of dropping the malformed entry. */
if (match(i, /^[0-9]+$/) && int(i) >= 1 && int(i) <= 65535)
push(ports, int(i));
}
return ports;
};
export function removeBlankAttrs(res) {
let content;
if (type(res) === 'object') {
content = {};
map(keys(res), (k) => {
if (type(res[k]) in ['array', 'object'])
content[k] = removeBlankAttrs(res[k]);
else if (res[k] !== null && res[k] !== '')
content[k] = res[k];
});
} else if (type(res) === 'array') {
content = [];
map(res, (k, i) => {
if (type(k) in ['array', 'object'])
push(content, removeBlankAttrs(k));
else if (k !== null && k !== '')
push(content, k);
});
} else
return res;
return content;
};
export function validation(datatype, data) {
if (!datatype || !data)
return null;
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
return (ret === 0);
};
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
export function isValidCIDR(addr, family) {
if (isEmpty(addr))
return false;
/* Strip leading/trailing whitespace */
addr = trim(addr);
if (!addr)
return false;
/* Split address and optional prefix */
const parts = split(addr, '/');
const ip = parts[0];
const prefix = parts[1];
/* Validate IP part */
if (family === 4) {
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
return false;
/* Validate octet ranges */
const octets = split(ip, '.');
for (let o in octets)
if (int(o) > 255)
return false;
/* Validate prefix if present */
if (prefix && (int(prefix) < 0 || int(prefix) > 32))
return false;
} else if (family === 6) {
/* Only hex digits and colons */
if (!match(ip, /^[0-9a-fA-F:]+$/))
return false;
/* At most one "::" */
if (match(ip, /::.*::/))
return false;
/* Validate group structure */
const dcolon = index(ip, '::');
if (dcolon === -1) {
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
const groups = split(ip, ':');
if (length(groups) !== 8)
return false;
for (let g in groups)
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
} else {
/* Compressed form: "::" expands to at least one zero group */
let count = 0;
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
if (part === '')
continue;
for (let g in split(part, ':')) {
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
return false;
count++;
}
}
if (count > 7)
return false;
}
if (prefix && (int(prefix) < 0 || int(prefix) > 128))
return false;
} else {
return false;
}
return true;
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
str = trim(str);
str = replace(str, /_/g, '/');
str = replace(str, /-/g, '+');
const padding = length(str) % 4;
if (padding)
str = str + substr('====', padding);
return b64dec(str);
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
const services = {
http: '80',
https: '443'
};
const objurl = {};
objurl.href = url;
url = replace(url, /#(.+)$/, (_, val) => {
objurl.hash = val;
return '';
});
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
objurl.protocol = val;
return '';
});
url = replace(url, /\?(.+)/, (_, val) => {
objurl.search = val;
objurl.searchParams = urldecode_params(val);
return '';
});
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
val = replace(val, /^([^@]+)@/, (_, val) => {
objurl.userinfo = val;
return '';
});
val = replace(val, /:(\d+)$/, (_, val) => {
objurl.port = val;
return '';
});
if (validation('ip4addr', val) ||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
validation('hostname', val))
objurl.hostname = val;
return '';
});
objurl.pathname = url || '/';
if (!objurl.protocol || !objurl.hostname)
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
delete objurl.password;
}
};
if (!objurl.port)
objurl.port = services[objurl.protocol];
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
objurl.origin = `${objurl.protocol}://${objurl.host}`;
return objurl;
};
/* String parser end */
/* Config generator helper start */
/*
* Shared sing-box TLS object builder for the client outbound and the server
* inbound. The property order below is significant: the generated JSON keeps
* the insertion order of this object after removeBlankAttrs() drops the blank
* attributes, and both generators used to emit the fields in exactly this
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
* server-only fields (key_path/certificate_provider) at their current
* positions when editing.
*
* P1-B: the first arg is now a Node.tls-shape structured sub-object
* (the same shape the Loader produces) rather than a flat UCI section.
* The Adapter passes node.tls; the server generator builds a
* structured view of its UCI inbound before calling. Server-only
* fields (key material, ACME, server-side reality handshake) still
* live on the flat UCI section - they are not part of the Node model
* because no client outbound needs them - so the server passes them
* through `server_extras` instead of expecting the structured tls
* to carry them.
*/
export function buildTLSObject(tls, is_server, server_extras) {
if ((tls && tls.enabled) !== '1')
return null;
/* When is_server the caller hands a flat UCI section's server-only
* tail. We accept either an object or null; null means "no server
* extras" which only matters when the caller still wants a TLS
* object built for an inbound that does not actually serve TLS
* itself (the enabled check above returns null first in that case). */
const extras = is_server ? (server_extras || {}) : {};
return {
enabled: true,
server_name: tls.server_name,
insecure: is_server ? null : strToBool(tls.insecure),
alpn: tls.alpn,
min_version: tls.min_version,
max_version: tls.max_version,
handshake_timeout: is_server ? null : strToTime(tls.handshake_timeout),
cipher_suites: tls.cipher_suites,
certificate_path: tls.cert_path && validateCertificatePath(tls.cert_path) ? tls.cert_path : null,
key_path: (is_server && extras.tls_key_path && validateCertificatePath(extras.tls_key_path)) ? extras.tls_key_path : null,
certificate_provider: (is_server && extras.tls_acme === '1') ? {
type: 'acme',
domain: (type(extras.tls_acme_domain) === 'array') ? extras.tls_acme_domain
: (isEmpty(extras.tls_acme_domain) ? [] : [extras.tls_acme_domain]),
data_directory: HP_DIR + '/certs',
default_server_name: extras.tls_acme_dsn,
email: extras.tls_acme_email,
provider: extras.tls_acme_provider,
account_key: extras.tls_acme_account_key,
key_type: extras.tls_acme_key_type,
profile: extras.tls_acme_profile,
disable_http_challenge: strToBool(extras.tls_acme_dhc),
disable_tls_alpn_challenge: strToBool(extras.tls_acme_dtac),
alternative_http_port: strToInt(extras.tls_acme_ahp),
alternative_tls_port: strToInt(extras.tls_acme_atp),
external_account: (extras.tls_acme_external_account === '1') ? {
key_id: extras.tls_acme_ea_keyid,
mac_key: extras.tls_acme_ea_mackey
} : null,
dns01_challenge: (extras.tls_dns01_challenge === '1') ? {
provider: extras.tls_dns01_provider,
access_key_id: extras.tls_dns01_ali_akid,
access_key_secret: extras.tls_dns01_ali_aksec,
region_id: extras.tls_dns01_ali_rid,
api_token: extras.tls_dns01_cf_api_token
} : null
} : null,
ech: is_server ? (extras.tls_ech_key ? {
enabled: true,
key: split(extras.tls_ech_key, '\n')
/* config: split(extras.tls_ech_config, '\n') */
} : null) : ((tls.ech && tls.ech.enabled === '1') ? {
enabled: true,
config: tls.ech.config,
/* Same gate as certificate_path: this is a file sing-box opens as
* root, and it used to reach the config unvalidated. */
config_path: tls.ech.config_path && validateCertificatePath(tls.ech.config_path) ? tls.ech.config_path : null
} : null),
utls: (is_server || isEmpty(tls.utls && tls.utls.fingerprint)) ? null : {
enabled: true,
fingerprint: tls.utls.fingerprint
},
reality: ((tls.reality && tls.reality.enabled) !== '1') ? null : (is_server ? {
enabled: true,
private_key: extras.tls_reality_private_key,
short_id: tls.reality.short_id,
max_time_difference: strToTime(extras.tls_reality_max_time_difference),
handshake: {
server: extras.tls_reality_server_addr,
server_port: strToInt(extras.tls_reality_server_port)
}
} : {
enabled: true,
public_key: tls.reality.public_key,
short_id: tls.reality.short_id
})
};
};
/* Shared sing-box transport object builder; the client transport additionally
supports the gRPC keepalive hint, the server one does not.
P1-B: the first arg is now a Node.transport-shape structured sub-object. */
export function buildTransportObject(transport, is_server) {
if (isEmpty(transport && transport.type))
return null;
return {
type: transport.type,
host: transport.host,
path: transport.path,
headers: transport.headers,
method: transport.method,
max_early_data: strToInt(transport.max_early_data),
early_data_header_name: transport.early_data_header_name,
service_name: transport.service_name,
idle_timeout: strToTime(transport.idle_timeout),
ping_timeout: strToTime(transport.ping_timeout),
permit_without_stream: is_server ? null : strToBool(transport.permit_without_stream)
};
};
/* Config generator helper end */
/* PEM validation start */
/*
* Check that `content` is a PEM certificate (is_private_key = false) or an
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
* the rpcd certificate upload and available for future certificate features.
*/
/* Shared by the certificate, private-key and ECH-config validators: only the
* markers differ, the body rule does not (base64 lines - or a single 64
* character line - between a BEGIN and an END marker). */
function validatePEM(content, beg, end) {
if (isEmpty(content))
return false;
const lines = split(trim(content), /[\r\n]/);
let start = false, i;
for (i = 0; i < length(lines); i++) {
if (match(lines[i], beg))
start = true;
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
break;
}
if (!start || i < length(lines) - 1 || !match(lines[i], end))
return false;
return true;
};
export function isValidPEM(content, is_private_key) {
const beg = is_private_key ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
end = is_private_key ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/;
return validatePEM(content, beg, end);
};
/* A client ECH config list is neither a certificate nor a private key - it is
* wrapped in its own markers ("-----BEGIN ECH CONFIGS-----"), so isValidPEM()
* rejects it. The node form's "Upload ECH config" button has always sent
* certificate_write('client_ech_conf'); the backend had no case for that name,
* and would have rejected the body here even with one. */
export function isValidECHConfig(content) {
return validatePEM(content,
/^-----BEGIN ECH CONFIGS-----$/,
/^-----END ECH CONFIGS-----$/);
};
/* PEM validation end */
@@ -0,0 +1,318 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*/
'use strict';
import { cursor } from 'uci';
import { isEmpty, parseURL, shellQuote, validation } from './homeproxy.uc';
const uci = cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
const uciinfra = 'infra',
ucimigration = 'migration',
ucimain = 'config',
ucinode = 'node',
ucidns = 'dns',
ucidnsserver = 'dns_server',
ucidnsrule = 'dns_rule',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
uciserver = 'server';
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
/* chinadns server now only accepts single server */
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
} else {
if (china_dns_server === 'wan_114')
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
else if (match(china_dns_server, /,/))
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
}
/* github_token option has been moved to config section */
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
if (github_token) {
uci.set(uciconfig, ucimain, 'github_token', github_token);
uci.delete(uciconfig, uciinfra, 'github_token')
}
/* ntp_server was introduced */
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
/* tun_gso was deprecated in sb 1.11 */
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
uci.delete(uciconfig, uciinfra, 'tun_gso');
/* create migration section */
if (!uci.get(uciconfig, ucimigration))
uci.set(uciconfig, ucimigration, uciconfig);
/* delete old crontab command.
*
* Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host
* with BSD sed it fails with "invalid command code". The old form sent stderr
* to /dev/null as well, so the failure was invisible and the stale entry stayed
* behind - which is exactly what the shell twin (hp_crontab_drop in
* runtime/service.sh) documents and avoids. Editing through a temporary file
* behaves the same on busybox, GNU and BSD sed, and lets the failure be seen. */
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
if (!migration_crontab) {
const crontab = '/etc/crontabs/root';
const tmp = crontab + '.hp-new';
if (system(sprintf('sed "/update_crond.sh/d" %s > %s', shellQuote(crontab), shellQuote(tmp))) === 0
&& system(sprintf('mv -f %s %s', shellQuote(tmp), shellQuote(crontab))) === 0) {
uci.set(uciconfig, ucimigration, 'crontab', '1');
}
else {
/* Leave a trace instead of a silent no-op; the marker is not set, so
* the next run tries again. */
system(sprintf('rm -f %s', shellQuote(tmp)));
warn('migrate_config: could not drop the legacy update_crond.sh crontab entry\n');
}
}
/* log_level was introduced */
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
uci.set(uciconfig, ucimain, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
/* experimental section was removed */
if (uci.get(uciconfig, 'experimental'))
uci.delete(uciconfig, 'experimental');
/* block-dns was removed from built-in dns servers */
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
if (default_dns_server === 'block-dns') {
/* append a rule at last to block all DNS queries */
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
uci.set(uciconfig, '_migration_dns_final_block', 'mode', 'default');
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
}
else if (default_dns_server === 'local-dns')
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
const dns_server_migration = {};
/* DNS servers options */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
/* legacy format was deprecated in sb 1.12 */
if (cfg.address) {
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
/* parseURL() gives null for an address it cannot parse; the old code
* dereferenced it immediately and aborted the migration. */
if (!addr)
return;
/* RCode was moved into DNS rules */
if (addr.protocol === 'rcode') {
dns_server_migration[cfg['.name']] = { action: 'predefined' };
switch (addr.hostname) {
case 'success':
dns_server_migration[cfg['.name']].rcode = 'NOERROR';
break;
case 'format_error':
dns_server_migration[cfg['.name']].rcode = 'FORMERR';
break;
case 'server_failure':
dns_server_migration[cfg['.name']].rcode = 'SERVFAIL';
break;
case 'name_error':
dns_server_migration[cfg['.name']].rcode = 'NXDOMAIN';
break;
case 'not_implemented':
dns_server_migration[cfg['.name']].rcode = 'NOTIMP';
break;
case 'refused':
default:
dns_server_migration[cfg['.name']].rcode = 'REFUSED';
break;
}
uci.delete(uciconfig, cfg['.name']);
return;
}
uci.set(uciconfig, cfg['.name'], 'type', addr.protocol);
uci.set(uciconfig, cfg['.name'], 'server', addr.hostname);
uci.set(uciconfig, cfg['.name'], 'server_port', addr.port);
uci.set(uciconfig, cfg['.name'], 'path', (addr.pathname !== '/') ? addr.pathname : null);
uci.delete(uciconfig, cfg['.name'], 'address');
}
if (cfg.strategy) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'default_strategy', cfg.strategy);
dns_server_migration[cfg['.name']] = { strategy: cfg.strategy };
uci.delete(uciconfig, cfg['.name'], 'strategy');
}
if (cfg.client_subnet) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'client_subnet', cfg.client_subnet);
if (isEmpty(dns_server_migration[cfg['.name']]))
dns_server_migration[cfg['.name']] = {};
dns_server_migration[cfg['.name']].client_subnet = cfg.client_subnet;
uci.delete(uciconfig, cfg['.name'], 'client_subnet');
}
});
/* DNS rules options */
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
/* outbound was removed in sb 1.12 */
if (cfg.outbound) {
uci.delete(uciconfig, cfg['.name']);
if (!cfg.enabled)
return;
/* uci.foreach hands a single-valued option over as a string, and map()
* returns null for anything that is not an array - so a rule with one
* outbound was deleted on the line above and then never migrated. */
const outbounds = (type(cfg.outbound) === 'array') ? cfg.outbound : [ cfg.outbound ];
map(outbounds, (outbound) => {
switch (outbound) {
case 'direct-out':
case 'block-out':
break;
case 'any-out':
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
break;
default:
uci.set(uciconfig, outbound, 'domain_resolver', cfg.server);
break;
}
});
return;
}
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-dns was moved into action in sb 1.11 */
if (cfg.server === 'block-dns') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'server');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
/* strategy and client_subnet were moved into dns rules */
if (dns_server_migration[cfg.server]) {
if (dns_server_migration[cfg.server].strategy)
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
if (dns_server_migration[cfg.server].client_subnet)
uci.set(uciconfig, cfg['.name'], 'client_subnet', dns_server_migration[cfg.server].client_subnet);
if (dns_server_migration[cfg.server].rcode) {
/* The key the generator and the form both read is
* `predefined_rcode`; writing `rcode` left the migrated rule with
* no rcode at all, so a NXDOMAIN / REFUSED predefined answer
* silently became the default. */
uci.set(uciconfig, cfg['.name'], 'action', 'predefined');
uci.set(uciconfig, cfg['.name'], 'predefined_rcode', dns_server_migration[cfg.server].rcode);
uci.delete(uciconfig, cfg['.name'], 'server');
}
}
});
/* nodes options */
uci.foreach(uciconfig, ucinode, (cfg) => {
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_enable_pqss))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
/* wireguard_gso was deprecated in sb 1.11 */
if (!isEmpty(cfg.wireguard_gso))
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
if (!isEmpty(cfg.hysteria_revc_window))
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
});
/* sing-box 1.14: remove deprecated DNS semantics */
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
uci.delete(uciconfig, ucidns, 'independent_cache');
const legacy_store_rdrc = uci.get(uciconfig, ucidns, 'cache_file_store_rdrc');
if (!isEmpty(legacy_store_rdrc)) {
uci.set(uciconfig, ucidns, 'cache_file_store_dns', legacy_store_rdrc);
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
}
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
if (!isEmpty(cfg.rule_set_ip_cidr_accept_empty))
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
});
/* routing rules options */
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-out was moved into action in sb 1.11 */
if (cfg.outbound === 'block-out') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'outbound');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
});
/* server options */
/* auto_firewall was moved into server options */
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
if (!isEmpty(auto_firewall))
uci.delete(uciconfig, uciserver, 'auto_firewall');
uci.foreach(uciconfig, uciserver, (cfg) => {
/* auto_firewall was moved into server options */
if (auto_firewall === '1')
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
/* sniff_override was deprecated in sb 1.11 */
if (!isEmpty(cfg.sniff_override))
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
/* domain_strategy is now pointless without sniff override */
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
});
if (!isEmpty(uci.changes(uciconfig)))
uci.commit(uciconfig);
@@ -0,0 +1,244 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The inverse of
* parser/normalize.uc. Where normalize() turns the parser's flat
* UCI-key output into the canonical Node shape the Adapter reads,
* flatten() turns a canonical Node back into the flat UCI-key dict
* UCI needs.
*
* The Repository's apply_nodes() is the only production caller: it is
* the one boundary where a canonical Node becomes UCI keys, which is
* what lets the orchestrator, the filter and the policy step work on
* canonical Nodes alone.
*
* Same single-source-of-truth discipline: every canonical -> UCI
* mapping comes from parser/mapping.uc's PROTOCOL_TO_UCI. Adding a
* protocol option is a one-place change in mapping.uc, and both
* directions pick it up.
*
* The output is the same shape the parser emits plus whatever
* metadata fields the orchestrator (or Repository) added
* (label, grouphash, isExisting). flatten() does not invent or
* strip them: it only does the canonical-sub-object -> flat-key
* expansion, then layers identity on top, then copies metadata
* through unchanged.
*
* `null` / `undefined` canonical values are written as null too -
* UCI serialises null as an empty option, which the Loader reads
* back as null, so the round-trip is preserved.
*/
'use strict';
import { PROTOCOL_TO_UCI } from './mapping.uc';
/* Per-protocol option fields: canonical -> UCI keys. Mirrors the
* loader's load_protocol_options() / normalize.uc's
* build_protocol_options(). */
function flatten_protocol_options(protocol, node) {
const map = PROTOCOL_TO_UCI[protocol] || {};
const opts = node.protocol_options || {};
const out = {};
for (let canonical, uci in map)
out[uci] = opts[canonical];
return out;
}
/* TLS fields: shared across protocols, sourced from the canonical
* `tls` sub-object. Mirrors normalize.uc's build_tls(). */
function flatten_tls(node) {
const tls = node.tls || {};
const ech = tls.ech || {};
const utls = tls.utls || {};
const reality = tls.reality || {};
return {
tls: tls.enabled,
tls_sni: tls.server_name,
tls_insecure: tls.insecure,
tls_alpn: tls.alpn,
tls_min_version: tls.min_version,
tls_max_version: tls.max_version,
tls_handshake_timeout: tls.handshake_timeout,
tls_cipher_suites: tls.cipher_suites,
tls_cert_path: tls.cert_path,
tls_ech: ech.enabled,
tls_ech_config: ech.config,
tls_ech_config_path: ech.config_path,
tls_utls: utls.fingerprint,
tls_reality: reality.enabled,
tls_reality_public_key: reality.public_key,
tls_reality_short_id: reality.short_id
};
}
/* Transport: share-link parsers spread transport fields across
* multiple UCI keys (http_host, ws_host, ws_path, httpupgrade_host,
* http_path, http_method, websocket_early_data(_header),
* grpc_servicename, http_idle_timeout, http_ping_timeout,
* grpc_permit_without_stream). Mirrors normalize.uc's
* build_transport(). */
function flatten_transport(node) {
const t = (node.transport || {}).type;
const out = { transport: t || null };
if (!t)
return out;
/* The host / path UCI option names depend on the transport: a ws
* transport stores them as ws_path / ws_host, an httpupgrade as
* http_path / httpupgrade_host, plain http as http_host / http_path.
* normalize() collapses both path spellings into transport.path, so
* flatten() has to pick the right one back or a re-written node
* would sprout an http_path the parser never produced (and drop the
* ws_path the loader's `http_path || ws_path` fallback had read). */
if (t === 'ws') {
out.ws_host = node.transport.headers?.Host;
out.ws_path = node.transport.path;
}
if (t === 'httpupgrade') {
out.httpupgrade_host = node.transport.host;
out.http_path = node.transport.path;
}
if (t === 'http') {
out.http_host = node.transport.host;
out.http_path = node.transport.path;
out.http_method = node.transport.method;
}
if (t === 'grpc')
out.grpc_servicename = node.transport.service_name;
out.http_idle_timeout = node.transport.idle_timeout;
out.http_ping_timeout = node.transport.ping_timeout;
out.grpc_permit_without_stream = node.transport.permit_without_stream;
out.websocket_early_data_header = node.transport.early_data_header_name;
out.websocket_early_data = node.transport.max_early_data;
return out;
}
/* Common: every outbound protocol carries these. */
function flatten_common(node) {
const c = node.common || {};
return {
proxy_protocol: c.proxy_protocol,
tcp_fast_open: c.tcp_fast_open,
tcp_multi_path: c.tcp_multi_path,
udp_fragment: c.udp_fragment
};
}
/* Multiplex: shares a brutal nested sub-shape with normalize.uc. */
function flatten_multiplex(node) {
const m = node.multiplex || {};
const brutal = m.brutal || {};
return {
multiplex: m.enabled,
multiplex_protocol: m.protocol,
multiplex_max_connections: m.max_connections,
multiplex_min_streams: m.min_streams,
multiplex_max_streams: m.max_streams,
multiplex_padding: m.padding,
multiplex_brutal: brutal.enabled,
multiplex_brutal_up: brutal.up_mbps,
multiplex_brutal_down: brutal.down_mbps
};
}
/* Credentials: same CREDENTIALS table as normalize.uc, traversed
* in reverse: canonical credential name -> UCI option. */
const CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
socks: { username: 'username', password: 'password' },
http: { username: 'username', password: 'password' },
snell: { psk: 'password', userkey: 'snell_userkey' },
ssh: { user: 'username', password: 'password',
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
anytls: { password: 'password' },
shadowtls: { password: 'password' },
direct: {}
};
function flatten_credentials(protocol, node) {
const map = CREDENTIALS[protocol] || {};
const creds = node.credentials || {};
const out = {};
for (let canonical, uci in map)
out[uci] = creds[canonical];
return out;
}
/* The full pipeline, returning a flat UCI-key dict. Metadata that
* the orchestrator / Repository attaches to the canonical Node
* (label, grouphash) is not a canonical sub-object, so we copy
* those fields through explicitly rather than walk `keys(node)`
* and pull whatever happens to be there.
*
* `isExisting` is deliberately NOT copied: it is a within-run
* marker Repository sets after a successful write, used to skip
* re-adding in the same loop. It is never written to / read from
* UCI. Identity (id / type / address / port) and `name` (which
* becomes the UCI `label` option) are the Adapter's identity
* half; everything else is the metadata tail. */
function copy_meta(out, node) {
if ('label' in node)
out.label = node.label;
if ('grouphash' in node)
out.grouphash = node.grouphash;
}
/* Top-level: take a canonical Node, return a flat UCI-key dict
* suitable for uci.set() per key. Returns null when the input is
* null / empty, mirroring normalize()'s `null -> null` contract
* so callers can compose `flatten(normalize(parse_uri(uri)))`. */
export function flatten(node) {
if (!node)
return null;
const protocol = node.type;
const out = {
type: protocol,
address: node.address,
port: node.port,
label: node.name
};
/* Identity sub-fields: drop empties so a freshly-saved
* canonical Node (which always carries the full shape, even
* when `null`) does not suddenly litter /etc/config with
* `multiplex_max_connections ''` entries. The Loader treats
* null and absent the same, so this is observably equivalent. */
const sub = {
...flatten_common(node),
...flatten_credentials(protocol, node),
...flatten_tls(node),
...flatten_transport(node),
...flatten_multiplex(node),
...flatten_protocol_options(protocol, node)
};
/* ucode has no `undefined`: an absent key reads as null, so a
* single null check covers both. */
for (let k, v in sub)
if (v !== null)
out[k] = v;
copy_meta(out, node);
return out;
};
@@ -0,0 +1,140 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Single source of truth for the canonical-name <-> UCI-option-name
* mapping per protocol. The same mapping used to live in three places:
*
* - parse_uri.uc (wrote UCI-flat keys directly: `tls`, `tls_sni`,
* `vless_flow`, `shadowsocks_encrypt_method`, ...)
* - config/loader.uc PROTOCOL_OPTIONS (canonical -> UCI)
* - config/adapter.uc OPTION_FIELDS (canonical -> adapter code)
*
* The Loader's PROTOCOL_OPTIONS is derived from the table in this file,
* and both directions of the pipeline read it: normalize.uc renames UCI
* keys to canonical names, flatten.uc renames them back on the way to
* UCI. Adding a protocol option is one edit here, not one per layer.
*
* `keys()` direction: every row's left side is the canonical name the
* Loader hands the Adapter; the right side is the UCI option name the
* Loader reads and the Repository writes. Keep them sorted by
* canonical name so a diff that adds a row is obvious.
*/
'use strict';
/* `canonical -> uci` for per-protocol options. Anything not listed here
* is not part of the domain contract for that protocol (the form might
* still surface it, the Adapter ignores it). */
export const PROTOCOL_TO_UCI = {
vless: {
flow: 'vless_flow',
packet_encoding: 'packet_encoding'
/* udp_over_tcp used to be here. It is a shadowsocks/socks
* option: sing-box 1.14 rejects it on a vless outbound, so
* mapping it here turned a stale UCI option into an
* unusable config. */
},
snell: {
version: 'snell_version',
reuse: 'snell_reuse',
obfs_mode: 'snell_obfs_mode',
obfs_host: 'snell_obfs_host'
/* snell_mode is not mapped: sing-box 1.14 has no `mode`
* field on a snell outbound (or inbound). It was v6-only
* in the form, and v6 is not supported by the target
* sing-box. */
},
shadowsocks: {
plugin: 'shadowsocks_plugin',
plugin_opts: 'shadowsocks_plugin_opts',
udp_over_tcp: 'udp_over_tcp',
udp_over_tcp_version: 'udp_over_tcp_version'
},
anytls: {
idle_session_check_interval: 'anytls_idle_session_check_interval',
idle_session_timeout: 'anytls_idle_session_timeout',
min_idle_session: 'anytls_min_idle_session'
},
http: {},
socks: {
version: 'socks_version',
udp_over_tcp: 'udp_over_tcp',
udp_over_tcp_version: 'udp_over_tcp_version'
},
tuic: {
congestion_control: 'tuic_congestion_control',
udp_relay_mode: 'tuic_udp_relay_mode',
udp_over_stream: 'tuic_udp_over_stream',
zero_rtt_handshake: 'tuic_enable_zero_rtt',
heartbeat: 'tuic_heartbeat'
},
trojan: {},
shadowtls: {
version: 'shadowtls_version'
},
hysteria: {
protocol: 'hysteria_protocol',
auth_type: 'hysteria_auth_type',
auth_payload: 'hysteria_auth_payload',
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
obfs_password: 'hysteria_obfs_password',
hopping_port: 'hysteria_hopping_port',
hop_interval: 'hysteria_hop_interval'
},
hysteria2: {
obfs_type: 'hysteria_obfs_type',
obfs_password: 'hysteria_obfs_password',
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
hop_interval: 'hysteria_hop_interval',
hop_interval_max: 'hysteria_hop_interval_max',
hopping_port: 'hysteria_hopping_port',
auth_payload: 'hysteria_auth_payload',
bbr_profile: 'hysteria_bbr_profile',
disable_chrome_parrot: 'hysteria_disable_chrome_parrot'
},
vmess: {
alter_id: 'vmess_alterid',
security: 'vmess_encrypt',
global_padding: 'vmess_global_padding',
auth_payload: 'vmess_auth_payload',
/* packet_encoding was missing here, so a vmess node's UCI option
* never reached protocol_options and the outbound had none - even
* though the node form offers the option for vmess and the
* subscription default is applied to it. vless declares it too. */
packet_encoding: 'packet_encoding'
},
ssh: {
client_version: 'ssh_client_version',
host_key: 'ssh_host_key',
host_key_algorithms: 'ssh_host_key_algo'
},
wireguard: {
local_address: 'wireguard_local_address',
private_key: 'wireguard_private_key',
peer_public_key: 'wireguard_peer_public_key',
pre_shared_key: 'wireguard_pre_shared_key',
reserved: 'wireguard_reserved',
mtu: 'wireguard_mtu',
persistent_keepalive_interval: 'wireguard_persistent_keepalive_interval'
},
direct: {
override_address: 'override_address',
override_port: 'override_port'
}
};
/* Reverse direction: uci -> canonical. Built once at module load so
* the parser can look up "what canonical name is `tls_sni` for
* <protocol>" without hand-coding the inverse in protocols.uc.
*
* A protocol with no per-protocol options maps to an empty object,
* which is intentional: `for ... in {}` is a no-op and the loader
* still has the protocol-shape boundary on the Node. */
export const UCI_TO_PROTOCOL = {};
for (let proto, row in PROTOCOL_TO_UCI) {
UCI_TO_PROTOCOL[proto] = {};
for (let canonical, uci in row)
UCI_TO_PROTOCOL[proto][uci] = canonical;
}
@@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The per-scheme parsers in
* parser/protocols.uc produce a FLAT UCI-key dict; that is their output
* shape. The Adapter, the Loader, the policy step and the Repository's
* write path all think in the canonical Node shape instead: nested tls /
* transport / multiplex / credentials / protocol_options sub-objects.
*
* normalize() is the crossing point: every caller that needs the
* canonical Node - the subscription orchestrator, the RPC that imports a
* share link, tests - runs the parsed config through it. parser/flatten.uc
* is the inverse and is used only where UCI has to be written, which is
* the Repository's apply_nodes().
*
* The mapping is sourced from parser/mapping.uc (canonical -> UCI)
* so the Loader, the Adapter and the parser cannot drift apart.
*/
'use strict';
import { PROTOCOL_TO_UCI } from './mapping.uc';
/* Pick every field that PROTOCOL_TO_UCI maps for this protocol off
* the flat config and rename it from UCI to its canonical name. The
* result is the protocol_options sub-object. Fields the parser wrote
* that are not mapped (e.g. a stale `vless_udp_over_tcp` left over
* from a shadowsocks -> vless type switch) are dropped here, which is
* the same "stale UCI option becomes a config error" failure the
* Loader has been guarding against via PROTOCOL_OPTIONS. */
function build_protocol_options(protocol, flat) {
const map = PROTOCOL_TO_UCI[protocol] || {};
const out = {};
for (let canonical, uci in map) {
if (uci in flat) {
const value = flat[uci];
/* Empty-string or null means "absent": do not propagate
* to the canonical side either. */
if (value !== '' && value !== null)
out[canonical] = value;
}
}
return out;
}
/* TLS fields are shared across protocols, not per-protocol, so they
* live outside PROTOCOL_TO_UCI. The Loader uses the same shape
* (load_tls() in config/loader.uc), so we mirror it. */
function build_tls(flat) {
const tls = {
enabled: flat.tls,
server_name: flat.tls_sni,
insecure: flat.tls_insecure,
alpn: flat.tls_alpn,
min_version: flat.tls_min_version,
max_version: flat.tls_max_version,
handshake_timeout: flat.tls_handshake_timeout,
cipher_suites: flat.tls_cipher_suites,
cert_path: flat.tls_cert_path,
ech: {
enabled: flat.tls_ech,
config: flat.tls_ech_config,
config_path: flat.tls_ech_config_path
},
utls: {
fingerprint: flat.tls_utls
},
reality: {
enabled: flat.tls_reality,
public_key: flat.tls_reality_public_key,
short_id: flat.tls_reality_short_id
}
};
return tls;
}
/* Transport fields are also shared; mirror the shape the Loader's
* load_transport() produces. Returns `{ type: null }` when the parser
* did not write a transport. */
function build_transport(flat) {
const t = flat.transport;
if (t == null || t === '')
return { type: null };
return {
type: t,
host: flat.http_host || flat.httpupgrade_host,
path: flat.http_path || flat.ws_path,
headers: flat.ws_host ? { Host: flat.ws_host } : null,
method: flat.http_method,
max_early_data: flat.websocket_early_data,
early_data_header_name: flat.websocket_early_data_header,
service_name: flat.grpc_servicename,
idle_timeout: flat.http_idle_timeout,
ping_timeout: flat.http_ping_timeout,
permit_without_stream: flat.grpc_permit_without_stream
};
}
/* Common fields that every outbound protocol carries. Mirrors the
* Loader's load_common(). */
function build_common(flat) {
return {
proxy_protocol: flat.proxy_protocol,
tcp_fast_open: flat.tcp_fast_open,
tcp_multi_path: flat.tcp_multi_path,
udp_fragment: flat.udp_fragment
};
}
/* Multiplex fields - mirrors the Loader's load_multiplex(). */
function build_multiplex(flat) {
return {
enabled: flat.multiplex,
protocol: flat.multiplex_protocol,
max_connections: flat.multiplex_max_connections,
min_streams: flat.multiplex_min_streams,
max_streams: flat.multiplex_max_streams,
padding: flat.multiplex_padding,
brutal: {
enabled: flat.multiplex_brutal,
up_mbps: flat.multiplex_brutal_up,
down_mbps: flat.multiplex_brutal_down
}
};
}
/* Credentials: pull every UCI key the protocol's CREDENTIALS row lists
* (canonical -> UCI) and put them under the canonical name. Empty /
* null values stay null so the Adapter's null-strip pass keeps the
* field out of the emitted sing-box JSON. */
const CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
socks: { username: 'username', password: 'password' },
http: { username: 'username', password: 'password' },
snell: { psk: 'password', userkey: 'snell_userkey' },
ssh: { user: 'username', password: 'password',
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
anytls: { password: 'password' },
shadowtls: { password: 'password' },
direct: {}
};
function build_credentials(protocol, flat) {
const map = CREDENTIALS[protocol] || {};
const out = {};
for (let canonical, uci in map)
out[canonical] = flat[uci];
return out;
}
/* Top-level: produce a Node-shaped object from the parser's flat
* output. Returns null when the input is null / empty so callers can
* do `normalize(parse_uri(uri))` and propagate parse failures
* unchanged. */
export function normalize(flat) {
if (!flat)
return null;
const protocol = flat.type;
return {
/* identity */
id: null,
name: flat.label,
type: protocol,
/* endpoint */
address: flat.address,
port: flat.port,
/* sub-objects */
common: build_common(flat),
credentials: build_credentials(protocol, flat),
tls: build_tls(flat),
transport: build_transport(flat),
multiplex: build_multiplex(flat),
protocol_options: build_protocol_options(protocol, flat)
};
};
@@ -0,0 +1,450 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Every protocol's parse_<scheme>_uri()
* used to live in the top-level parse_uri.uc. They are now grouped
* here so the dispatch (parser/uri.uc), the validation
* (parser/validator.uc), and the canonical normalisation
* (parser/normalize.uc) can each be edited in isolation.
*
* Each parser returns a FLAT UCI-key dict - the key names UCI uses,
* produced through the mapping table (parser/mapping.uc) so a protocol
* option has one definition. That is the parser's output shape, not the
* pipeline's: normalize() turns it into a canonical Node, the policy step
* and the Repository work on that Node, and the Repository flattens it
* back to UCI keys on the way out. Nothing downstream of normalize() sees
* these flat dicts.
*
* `features` is the sing-box feature map (with_quic / with_utls / ...),
* `log` a logging callback, both injected so the parsers stay free of
* ubus / uci.
*/
'use strict';
import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, isEmpty, parseURL } from '../homeproxy.uc';
/* https://shadowsocks.org/guide/sip008.html */
export function parse_sip008_uri(uri) {
return {
label: uri.remarks,
type: 'shadowsocks',
address: uri.server,
port: uri.server_port,
shadowsocks_encrypt_method: uri.method,
password: uri.password,
shadowsocks_plugin: uri.plugin,
shadowsocks_plugin_opts: uri.plugin_opts
};
};
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
export function parse_anytls_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'anytls',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
tls: '1',
tls_sni: params.sni,
tls_insecure: (params.insecure === '1') ? '1' : '0'
};
};
export function parse_http_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'http',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'https') ? '1' : '0'
};
};
export function parse_hysteria_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria',
address: url.hostname,
port: url.port,
hysteria_protocol: params.protocol || 'udp',
hysteria_auth_type: params.auth ? 'string' : null,
hysteria_auth_payload: params.auth,
hysteria_obfs_password: params.obfsParam,
hysteria_down_mbps: params.downmbps,
hysteria_up_mbps: params.upmbps,
tls: '1',
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
tls_sni: params.peer,
tls_alpn: params.alpn
};
};
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
export function parse_hysteria2_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria2',
address: url.hostname,
port: url.port,
password: url.username ? (
urldecode(url.username + (url.password ? (':' + url.password) : ''))
) : null,
hysteria_obfs_type: params.obfs,
hysteria_obfs_password: params['obfs-password'],
tls: '1',
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
};
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
export function parse_snell_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'snell',
address: url.hostname,
port: url.port,
password: url.username ? urldecode(url.username)
: (params.psk ? urldecode(params.psk) : null),
snell_version: params.version || '4',
snell_userkey: params.userkey,
snell_obfs_mode: (params.obfs === 'http') ? 'http' : null,
snell_obfs_host: params['obfs-host'],
snell_reuse: (params.reuse === '1') ? '1' : '0'
};
};
export function parse_socks_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'socks',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
socks_version: (match(uri[0], /4/)) ? '4' : '5'
};
};
export function parse_ss_uri(uri) {
uri = split(trim(uri), '://');
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(url.password)];
else if (url.username) {
/* User info encoded with base64. decodeBase64Str() returns null for
* anything that is not valid base64 (a '.' in the userinfo is
* enough), and ucode's split(null, ...) is null, so indexing it
* raised a ReferenceError. parse_uri() does not catch that: the RPC
* import path happens to, update_subscriptions.uc does not, so one
* malformed link aborted an entire subscription update. A link with
* no usable userinfo is simply not a node we can build. */
const decoded = decodeBase64Str(urldecode(url.username));
ss_userinfo = decoded ? split(decoded, ':', 2) : {};
}
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
if (isEmpty(ss_userinfo[0]))
return null;
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: ss_userinfo[0],
password: ss_userinfo[1],
shadowsocks_plugin: ss_plugin,
shadowsocks_plugin_opts: ss_plugin_opts
};
};
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
export function parse_trojan_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'trojan',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
transport: (params.type !== 'tcp') ? params.type : null,
tls: '1',
tls_sni: params.sni
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/daeuniverse/dae/discussions/182 */
export function parse_tuic_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'tuic',
address: url.hostname,
port: url.port,
uuid: url.username,
password: url.password ? urldecode(url.password) : null,
tuic_congestion_control: params.congestion_control,
tuic_udp_relay_mode: params.udp_relay_mode,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
};
};
/* https://github.com/XTLS/Xray-core/discussions/716 */
export function parse_vless_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'vless',
address: url.hostname,
port: url.port,
uuid: url.username,
transport: (params.type !== 'tcp') ? params.type : null,
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: features.with_utls ? params.fp : null,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
export function parse_vmess_uri(uri, features, log) {
uri = split(trim(uri), '://');
/* "Lovely" shadowrocket format. `uri` is the split() result, so the old
* match(uri, /&/) could never fire - match() takes a string. */
if (length(uri) > 1 && match(uri[1], /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
let payload;
try {
payload = json(decodeBase64Str(uri[1])) || {};
} catch(e) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
if (payload.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (payload.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
return null;
} else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (payload.aid && int(payload.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
* return null;
* }
*/
const config = {
label: payload.ps ? urldecode(payload.ps) : null,
type: 'vmess',
address: payload.add,
port: payload.port,
uuid: payload.id,
vmess_alterid: payload.aid,
vmess_encrypt: payload.scy || 'auto',
vmess_global_padding: '1',
transport: (payload.net !== 'tcp') ? payload.net : null,
tls: (payload.tls === 'tls') ? '1' : '0',
tls_sni: payload.sni || payload.host,
tls_alpn: payload.alpn ? split(payload.alpn, ',') : null,
tls_utls: features.with_utls ? payload.fp : null
};
switch (payload.net) {
case 'grpc':
config.grpc_servicename = payload.path;
break;
case 'h2':
case 'tcp':
if (payload.net === 'h2' || payload.type === 'http') {
config.transport = 'http';
config.http_host = payload.host ? split(payload.host, ',') : null;
config.http_path = payload.path;
}
break;
case 'httpupgrade':
config.httpupgrade_host = payload.host;
config.http_path = payload.path;
break;
case 'ws':
config.ws_host = payload.host;
config.ws_path = payload.path;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
@@ -0,0 +1,119 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* parse_uri() is a thin dispatcher.
* The 11 per-scheme parsers moved to parser/protocols.uc; the
* categorised post-parse checks moved to parser/validator.uc; the
* canonical-shape conversion lives in parser/normalize.uc; and the
* canonical <-> UCI mapping table lives in parser/mapping.uc.
*
* This file is the only one parse_uri.uc's old callers should reach
* for. The output shape is unchanged (flat UCI-key dict) so the
* Repository's contract is preserved; the next PR can flip the
* parser to canonical Node output and teach the Repository to
* flatten, with parser/mapping.uc as the single source of truth.
*/
'use strict';
import { isEmpty, validation } from '../homeproxy.uc';
import {
parse_sip008_uri, parse_anytls_uri, parse_http_uri,
parse_hysteria_uri, parse_hysteria2_uri, parse_snell_uri,
parse_socks_uri, parse_ss_uri, parse_trojan_uri,
parse_tuic_uri, parse_vless_uri, parse_vmess_uri
} from './protocols.uc';
import { validate } from './validator.uc';
/* Default label: ip6addr uses brackets for readability, otherwise
* `address:port`. Exported because every scheme benefits and because a
* caller that mutates a parsed config's address (subscription
* apply_policy does not, but a future one could) can re-derive it.
*
* Declared above parse_uri on purpose: ucode does not hoist
* `export function`, so the call below would otherwise fail at runtime
* with "access to undeclared variable". */
export function derive_label(config) {
if (config.label)
return config.label;
if (!config.address || !config.port)
return null;
return (validation('ip6addr', config.address) ?
`[${config.address}]` : config.address) + ':' + config.port;
};
export function parse_uri(uri, features, log) {
if (!features) features = {};
if (!log) log = function() {};
let config;
if (type(uri) === 'object') {
if (uri.nodetype === 'sip008')
config = parse_sip008_uri(uri);
} else if (type(uri) === 'string') {
switch (split(trim(uri), '://')[0]) {
case 'anytls':
config = parse_anytls_uri(uri, features, log);
break;
case 'http':
case 'https':
config = parse_http_uri(uri, features, log);
break;
case 'hysteria':
config = parse_hysteria_uri(uri, features, log);
break;
case 'hysteria2':
case 'hy2':
config = parse_hysteria2_uri(uri, features, log);
break;
case 'snell':
config = parse_snell_uri(uri, features, log);
break;
case 'socks':
case 'socks4':
case 'socks4a':
case 'socks5':
case 'socks5h':
config = parse_socks_uri(uri, features, log);
break;
case 'ss':
config = parse_ss_uri(uri, features, log);
break;
case 'trojan':
config = parse_trojan_uri(uri, features, log);
break;
case 'tuic':
config = parse_tuic_uri(uri, features, log);
break;
case 'vless':
config = parse_vless_uri(uri, features, log);
break;
case 'vmess':
config = parse_vmess_uri(uri, features, log);
break;
}
}
if (isEmpty(config))
return null;
/* Strip the brackets UCI still allows around IPv6 literals - they
* survive into a sing-box `server` field verbatim otherwise. */
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
/* validate() returns null (and logs) when the host or port is not
* usable, so the result has to be tested rather than merged with
* `|| config` - that fallback would hand the invalid config back. */
const checked = validate(config, log);
if (!checked)
return null;
if (!checked.label)
checked.label = derive_label(checked);
return checked;
};
@@ -0,0 +1,79 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* parse_uri() used to do its host/port
* check inline at the end of the dispatcher, just before returning the
* config. Per the guidance doc (section 八, Parse -> Normalize ->
* Validate three-layer separation), the post-parse checks belong in
* their own module so:
*
* - failures are categorised (host / port / credential / tls /
* scheme), so the orchestrator's log line can name the failure
* instead of dumping the whole config;
* - new rules (REQUIRED_CREDENTIALS, TLS sanity) can land without
* touching the dispatcher or the per-scheme parsers;
* - tests can exercise the validator without going through any
* scheme-specific parser.
*
* Today only the original two checks (host / port) are implemented,
* so the categorisation is mostly plumbing. Adding REQUIRED_CREDENTIALS
* in a follow-up is now a one-place change.
*/
'use strict';
import { validation } from '../homeproxy.uc';
/* Each entry carries a stable `kind` (so callers / tests can branch on
* it) and a `message` for the log line. Adding a new rule is:
*
* { kind: 'missing_credential', field: 'uuid',
* message: sprintf('%s requires %s', type, field) }
*/
export function validate(config, log) {
let errors = [];
if (!validation('host', config.address)) {
errors = [...errors, {
kind: 'invalid_host',
message: sprintf('Skipping invalid %s node: %s.',
config.type, config.label || 'NULL')
}];
}
if (!validation('port', config.port)) {
errors = [...errors, {
kind: 'invalid_port',
message: sprintf('Skipping invalid %s node: %s.',
config.type, config.label || 'NULL')
}];
}
if (length(errors)) {
/* Stay log-compatible with the old code path: one warn line
* per failure. The orchestrator already swallows `null`
* returns from parse_uri(), so it does not need to learn
* about the categorised shape yet. */
for (let err in errors)
log(err.message);
return null;
}
return config;
};
/* Exposed for tests so they can assert categorisation without going
* through the full dispatcher. Returns the (possibly empty) array of
* error descriptors that `validate()` would log, but does not log
* anything itself. */
export function check(config) {
let errors = [];
if (!validation('host', config.address))
errors = [...errors, { kind: 'invalid_host', type: config.type, address: config.address }];
if (!validation('port', config.port))
errors = [...errors, { kind: 'invalid_port', type: config.type, port: config.port }];
return errors;
};
@@ -0,0 +1,51 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Print a file's git blob id: sha1("blob <byte-length>\0" + content).
*
* That is the identifier GitHub reports for a path in a commit (the `.sha`
* field of the contents API), so the resource updater can check that the bytes
* it just downloaded are the bytes that commit contains. The download URL is
* already pinned to a commit, which is a guarantee about *where* the bytes
* come from, not about their content - a mirror, a CDN edge or anything else
* between the router and it can answer for that URL with something else.
*
* It lives in its own file rather than as a few lines inside
* update_resources.sh because the shell has no SHA-1: the target's busybox
* ships sha256sum but not sha1sum, and the GitHub API only offers SHA-1 blob
* ids. ucode-mod-digest is already a package dependency (md5() is used by the
* subscription updater), so this adds no new requirement.
*
* Usage: ucode -S resource_blob_sha.uc <file>
* Prints the hex digest and exits 0, or writes a message to stderr and exits 1.
*
* The failures use warn() + exit(1) rather than die(): on the target's ucode
* (2026.01.16) die() exits 254, and the caller's contract is "nothing on stdout
* means cannot verify", so a predictable status is worth having for anything
* that does look at it.
*/
'use strict';
import { sha1 } from 'digest';
import { readfile } from 'fs';
if (!length(ARGV) || ARGV[0] == null) {
warn('usage: resource_blob_sha.uc <file>\n');
exit(1);
}
const data = readfile(ARGV[0]);
/* readfile() returns null when the file cannot be read and '' for an empty
* one. The empty file has a real blob id (e69de29b...), so the two must not be
* conflated - and neither may be reported as a digest. */
if (type(data) !== 'string') {
warn(sprintf('cannot read %s\n', ARGV[0]));
exit(1);
}
/* length() counts bytes, which is what git hashes; chr(0) is the NUL git puts
* between the header and the content. */
printf('%s\n', sha1(sprintf('blob %d', length(data)) + chr(0) + data));
@@ -0,0 +1,80 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Configuration transaction helpers for homeproxy.
#
# The generated sing-box configuration is the only thing standing between the
# router and a working service, so it is handled as a transaction:
#
# generate the generator writes atomically and only after
# `sing-box check` accepted the result, so a failed
# generation leaves the previous file in place
# -> known-good every accepted file is copied aside, so there is
# always something to fall back to
# -> ensure-live if generation produced nothing, restore the
# known-good copy instead of failing the start
# -> rollback a reload that comes up unhealthy puts the known-good
# copy back (see reload_service in init.d/homeproxy)
#
# Sourced by /etc/init.d/homeproxy. The functions only take explicit paths
# and never call procd or ubus, so tests/runtime/test_config_transaction.sh
# can exercise them without a router.
# hp_known_good <live> <known-good>
# Record <live> as the new known-good copy. Non-zero when <live> is missing
# or empty, in which case the previous known-good copy is left untouched.
hp_known_good() {
local live="$1"
local good="$2"
[ -s "$live" ] || return 1
mkdir -p "$(dirname "$good")" 2>/dev/null
cp -f "$live" "$good" 2>/dev/null || return 1
# The known-good copy holds the same credentials as the live file, so the
# two have to stay in step: otherwise a rollback restores a copy that is
# world-readable whenever the source was.
chmod 600 "$good" 2>/dev/null
return 0
}
# hp_ensure_live <live> <known-good>
# 0 <live> is present and usable
# 1 <live> was missing and the known-good copy was restored
# 2 nothing usable: neither file exists
hp_ensure_live() {
local live="$1"
local good="$2"
[ -s "$live" ] && return 0
if [ -s "$good" ]; then
cp -f "$good" "$live" 2>/dev/null && return 1
fi
return 2
}
# hp_rollback <live> <known-good>
# Replace <live> with the known-good copy. Non-zero when there is nothing to
# roll back to, so the caller can distinguish "restored" from "no fallback".
hp_rollback() {
local live="$1"
local good="$2"
[ -s "$good" ] || return 1
cp -f "$good" "$live" 2>/dev/null || return 1
return 0
}
# hp_same_file <a> <b>
# 0 when both files exist and have identical content. Used to skip the
# rollback when the failing configuration is already the known-good one.
hp_same_file() {
[ -s "$1" ] && [ -s "$2" ] || return 1
cmp -s "$1" "$2" 2>/dev/null
}
@@ -0,0 +1,270 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# dnsmasq integration for homeproxy.
#
# homeproxy does not run its own DNS front end: it points dnsmasq at the
# sing-box DNS listener and, in the list-based routing modes, feeds it the
# per-domain `server=` / `nftset=` mappings. All of that used to live inline
# in /etc/init.d/homeproxy.
#
# Layering note: this is a device-side module. It calls `log` and, in
# hp_dnsmasq_write_snippets, relies on `config_load` having run (see
# runtime/service.sh's header for the rationale).
#
# Sourced by /etc/init.d/homeproxy.
# hp_dnsmasq_resolve_dir
# Locate the directory dnsmasq reads the homeproxy snippets from and store it
# in the global DNSMASQ_DIR.
#
# The UCI section name of the dnsmasq config is part of
# /tmp/etc/dnsmasq.conf.<name>, but `uci get` cannot return a section name
# (there is no `__name__`), so it is derived from the first `uci show` line.
# Either step can fail on an unusual setup; fall back to the generic
# directory and say so in the log instead of silently writing dnsmasq
# snippets nobody reads.
#
# Called ONCE, at source time. stop_service deliberately reuses the value
# instead of recomputing it: the directory must be the one the snippets were
# written to, and a second resolution could log the warning twice or pick a
# different directory after a dnsmasq reconfiguration.
hp_dnsmasq_resolve_dir() {
local dnsmasq_uci_config dnsmasq_conf_dir
dnsmasq_uci_config="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)"
dnsmasq_uci_config="${dnsmasq_uci_config#*.}"
dnsmasq_uci_config="${dnsmasq_uci_config%%=*}"
DNSMASQ_DIR=""
if [ -n "$dnsmasq_uci_config" ] && [ -f "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config" ]; then
dnsmasq_conf_dir="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config")"
[ -n "$dnsmasq_conf_dir" ] && DNSMASQ_DIR="$dnsmasq_conf_dir/dnsmasq-homeproxy.d"
fi
if [ -z "$DNSMASQ_DIR" ]; then
log "WARNING: unable to derive the dnsmasq conf-dir (section '${dnsmasq_uci_config:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy.d."
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
fi
}
# hp_dnsmasq_has_nftset
# 0 when the running dnsmasq understands `nftset=`. The base dnsmasq variant
# is built without HAVE_NFTSET, and it does not ignore the directive: it aborts
# with "recompile with HAVE_NFTSET defined", and because the snippet is read
# through conf-dir that abort takes dnsmasq itself down - the LAN loses DNS and
# DHCP, not just the domain routing. Probe once and degrade instead.
hp_dnsmasq_has_nftset() {
if [ -z "${HP_DNSMASQ_NFTSET:-}" ]; then
# Look it up through PATH so the off-target harness can stand in a
# dnsmasq of either flavour; fall back to the absolute path for an
# init environment with a bare PATH.
local bin
bin="$(command -v dnsmasq 2>"/dev/null" || echo /usr/sbin/dnsmasq)"
if "$bin" --version 2>"/dev/null" | grep -qw nftset; then
HP_DNSMASQ_NFTSET=1
else
HP_DNSMASQ_NFTSET=0
fi
fi
[ "$HP_DNSMASQ_NFTSET" = "1" ]
}
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
# restarts nothing: the caller decides whether the result differs from what is
# already installed.
#
# The input list gates are `-s` rather than the original unconditional sed:
# when the resource file is missing the old code produced an empty snippet
# (or a stale one never got removed), which made "is this the same as last
# time" undecidable. An empty list now means "no snippet for this mode",
# which is also what a fresh install means.
hp_dnsmasq_render_snippets() {
local stage="$1"
local hp_dir="$2"
local routing_mode="$3"
local dns_port="$4"
local ipv6="$5"
local gfw_nftset_v6="" wan_nftset_v6=""
local nftset_ok=1
# dns_port comes from `infra`, a section with no UI and no validator, and
# it is interpolated both into the snippet text and into sed's replacement
# below. A value carrying a newline reaches sed as `\n` and becomes a real
# dnsmasq directive (the generator accepts it too: int() is strtoll-based,
# so '5333\nlog-queries' is simply 5333 there), and a value with a `/`
# makes sed fail - while `> "$stage/x.conf"` has already truncated the
# file, so an empty snippet used to be installed and then cached by the
# "unchanged" comparison. Keep the numeric prefix, refuse the rest.
dns_port="$(printf '%s' "$dns_port" | sed -n 's/^\([0-9]\{1,\}\).*/\1/p')"
case "$dns_port" in
''|*[!0-9]*)
log "Warning: infra.dns_port is not a port (${4:-}); skipping the dnsmasq snippets."
return 1
;;
esac
if [ "$dns_port" -lt 1 ] || [ "$dns_port" -gt 65535 ]; then
log "Warning: infra.dns_port=$dns_port is out of range; skipping the dnsmasq snippets."
return 1
fi
hp_dnsmasq_has_nftset || nftset_ok=0
case "$routing_mode" in
"bypass_mainland_china"|"custom"|"global")
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
no-poll
no-resolv
server=127.0.0.1#$dns_port
EOF
;;
"gfwlist")
if [ -s "$hp_dir/resources/gfw_list.txt" ]; then
if [ "$nftset_ok" = "1" ]; then
[ "$ipv6" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6"
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
else
log "Warning: this dnsmasq has no nftset support; gfwlist routing degrades to plain server= entries (no address sets)."
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
fi
fi
;;
"proxy_mainland_china")
if [ -s "$hp_dir/resources/china_list.txt" ]; then
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
fi
;;
esac
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
if [ "$nftset_ok" = "1" ]; then
[ "$ipv6" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6"
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
else
log "Warning: this dnsmasq has no nftset support; the proxy list degrades to plain server= entries."
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
fi
fi
}
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
# 0 when the installed snippet set is not exactly the staged one, 1 when it is
# identical. Compares file names as well as contents, so a snippet that the
# current routing mode no longer produces (but a previous one did) counts as a
# change and gets cleaned up.
hp_dnsmasq_dir_differs() {
local stage="$1"
local live="$2"
local staged installed f
[ -d "$live" ] || return 0
staged="$(ls -1 "$stage" 2>"/dev/null" | sort)"
installed="$(ls -1 "$live" 2>"/dev/null" | sort)"
[ "$staged" = "$installed" ] || return 0
for f in $staged; do
cmp -s "$stage/$f" "$live/$f" || return 0
done
return 1
}
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
# Write the include file plus the mode-specific snippet, then restart
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
#
# The snippets are rendered into a staging directory first and compared with
# what is installed. dnsmasq restart drops every client's DNS cache, and the
# list files it feeds on change once a day at most while the routing mode and
# the port stay the same - so the previous unconditional rewrite-and-restart
# flushed the LAN's DNS cache on every reload for no reason.
hp_dnsmasq_write_snippets() {
local dnsmasq_dir="$1"
local hp_dir="$2"
local routing_mode="$3"
local ipv6_support dns_port
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
local stage changed=0
config_get_bool ipv6_support "config" "ipv6_support" "0"
config_get dns_port "infra" "dns_port" "5333"
stage="$(mktemp -d "${TMPDIR:-/tmp}/hp-dnsmasq.XXXXXX")" || {
log "Warning: failed to create a staging directory for the dnsmasq snippets."
return 1
}
# A refused render (an unusable dns_port, or sed failing) must leave the
# installed snippet set untouched: the staged directory is discarded, so
# the caller keeps the snippets that are already in place instead of
# installing a truncated one.
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
rm -rf "$stage"
return 1
fi
if [ ! -f "$include" ] || [ "$(cat "$include" 2>"/dev/null")" != "conf-dir=$dnsmasq_dir" ]; then
changed=1
fi
if hp_dnsmasq_dir_differs "$stage" "$dnsmasq_dir"; then
changed=1
fi
if [ "$changed" -eq 0 ]; then
rm -rf "$stage"
log "dnsmasq snippets unchanged, skipping the restart."
return 0
fi
mkdir -p "$(dirname "$dnsmasq_dir")" 2>"/dev/null" \
|| log "Warning: failed to create the dnsmasq conf-dir parent."
rm -rf "$dnsmasq_dir"
if ! mv "$stage" "$dnsmasq_dir"; then
log "Warning: failed to install the dnsmasq snippets into ${dnsmasq_dir}."
rm -rf "$stage"
return 1
fi
# printf, not `echo -e`: busybox ash honours -e but a POSIX sh does not,
# and on one the file would start with a literal "-e " and dnsmasq would
# refuse to parse it. The file is one line of conf-dir= either way.
printf 'conf-dir=%s\n' "$dnsmasq_dir" > "$include" \
|| log "Warning: failed to write the dnsmasq conf-dir file."
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|| log "Warning: failed to restart dnsmasq, DNS-based routing may be stale."
}
# hp_dnsmasq_remove_snippets <dns-dir>
# Remove the include file and the snippet directory, then restart dnsmasq.
# The snippets are removed rather than restored: nothing ever captures the
# pre-existing state, so there is no original to put back.
hp_dnsmasq_remove_snippets() {
local dnsmasq_dir="$1"
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
# Nothing to undo when nothing was written - which is the normal case on a
# router whose outbound node is 'nil', or one that never started. Removing
# paths that do not exist is not an error, and restarting dnsmasq for it is
# pure noise (it drops every client's DNS cache for no reason).
if [ ! -e "$include" ] && [ ! -d "$dnsmasq_dir" ]; then
return 0
fi
rm -rf "$include" "$dnsmasq_dir"
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|| log "Warning: failed to restart dnsmasq after removing the homeproxy snippets."
}
reload_service() {
restart
}
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# fw4 integration for homeproxy.
#
# homeproxy owns a fixed set of nft chains and sets inside the `fw4` table.
# They are created by the rendered template (firewall_post.ut, applied by
# `fw4 reload`) and torn down one object at a time on stop. All of that used
# to live inline in /etc/init.d/homeproxy.
#
# Layering note: this is a device-side module. It calls `log` and, for the
# teardown, requires the HP_FW4_CHAINS / HP_FW4_SETS inventory to be in scope
# (root/etc/homeproxy/scripts/fw4_names.sh, sourced by init.d).
#
# Sourced by /etc/init.d/homeproxy.
# hp_restore_upnp_mappings
# fw4 reload rewrites the whole ruleset, which drops the miniupnpd mappings,
# so ask miniupnpd to reinstall them - but only when it is actually enabled
# and running.
hp_restore_upnp_mappings() {
[ -x /etc/init.d/miniupnpd ] && /etc/init.d/miniupnpd enabled && /etc/init.d/miniupnpd running \
&& /etc/init.d/miniupnpd restart >"/dev/null" 2>&1
}
# hp_firewall_apply <hp-dir> <run-dir> <client-enabled>
# Run the pre-script, render the post-template for the client side, reload
# fw4 and restore the upnp mappings. <client-enabled> is "1" when a client
# outbound exists; the post-template is client-only.
hp_firewall_apply() {
local hp_dir="$1"
local run_dir="$2"
local client_enabled="$3"
local failed=0
# Truncate the include fragments first. firewall_pre.uc only writes them
# when it has something to put in them, so a start that produces no
# forward/input rules would leave the previous run's files in place and fw4
# would keep loading them. teardown already truncates for the same reason.
: > "$run_dir/fw4_forward.nft"
: > "$run_dir/fw4_input.nft"
ucode "$hp_dir/scripts/firewall_pre.uc" 2>"/dev/null" \
|| { log "Error: firewall pre-script failed."; failed=1; }
if [ "$client_enabled" = "1" ]; then
# Render to a temp file and install it only on success. The old
# `> "$run_dir/fw4_post.nft"` truncated the very file fw4 is about to
# include, so a failed render left fw4 loading an empty ruleset.
if utpl -S "$hp_dir/scripts/firewall_post.ut" > "$run_dir/fw4_post.nft.new" 2>"/dev/null"; then
mv -f "$run_dir/fw4_post.nft.new" "$run_dir/fw4_post.nft"
else
rm -f "$run_dir/fw4_post.nft.new"
log "Error: firewall post-script failed."
failed=1
fi
fi
fw4 reload >"/dev/null" 2>&1 \
|| { log "Error: fw4 reload failed."; failed=1; }
# The return value has to describe this function, not whatever
# hp_restore_upnp_mappings happened to return last.
hp_restore_upnp_mappings
return "$failed"
}
# hp_firewall_teardown <run-dir> [keep-pre]
# Flush and delete every fw4 object homeproxy owns. Deleting one at a time
# matters: `nft -f` batches are atomic, so a single object that does not
# exist in the current proxy mode would roll back the rest.
#
# "keep-pre" leaves fw4_forward.nft / fw4_input.nft alone. Those two are the
# firewall_pre.uc fragments - the server's inbound accepts and the TUN
# forwards - and they are not what black-holes the LAN when the client fails.
# A failed client has to release the redirect/tproxy layer without closing
# the server's ports, so that path passes keep-pre; stop_service does not.
hp_firewall_teardown() {
local run_dir="$1"
local keep_pre="${2:-}"
local fw4_name
for fw4_name in $HP_FW4_CHAINS; do
nft flush chain inet fw4 "$fw4_name" 2>"/dev/null"
nft delete chain inet fw4 "$fw4_name" 2>"/dev/null"
done
for fw4_name in $HP_FW4_SETS; do
nft flush set inet fw4 "$fw4_name" 2>"/dev/null"
nft delete set inet fw4 "$fw4_name" 2>"/dev/null"
done
if [ "$keep_pre" != "keep-pre" ]; then
: > "$run_dir/fw4_forward.nft"
: > "$run_dir/fw4_input.nft"
fi
: > "$run_dir/fw4_post.nft"
fw4 reload >"/dev/null" 2>&1 || log "Warning: fw4 reload failed during stop."
hp_restore_upnp_mappings
}
reload_service() {
restart
}
@@ -0,0 +1,377 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Runtime health helpers for homeproxy.
#
# After a reload the service must be able to answer "did the instance
# actually come up with the new configuration?". That answer decides whether
# the transaction in runtime/config.sh rolls back, so it is kept here instead
# of being inlined in init.d/homeproxy.
#
# A single "is a matching process alive?" probe is NOT a health check, and the
# first version of this file learned that the hard way. Measured on the test
# device with a configuration whose mixed_port was already taken:
#
# * procd restarts the crashing instance about once a second, and a STALE
# sing-box process from an earlier run kept matching `pgrep -f`, so the
# process check returned success for the whole observation window while
# the service was dead;
# * `ubus service list` was correct throughout: `running` flipped false and
# `exit_code` was 1 whenever it was down;
# * the configured port WAS listening - owned by the process that had taken
# it, not by sing-box. Checking "the port is in the listen table" would
# therefore have passed too; the listener has to be attributed to an owner.
#
# So the gate is: procd says running, procd does not report a failed exit
# code, the declared listeners are owned by sing-box, and that holds for
# several consecutive samples (a service that binds and then dies must not be
# able to pass on a single lucky poll).
#
# Sourced by /etc/init.d/homeproxy; testable off-target by stubbing pgrep /
# ubus / jsonfilter / netstat (see tests/runtime/).
: "${HP_SERVICE:=homeproxy}"
# How many consecutive healthy samples hp_wait_service requires, and how many
# seconds it may spend trying.
#
# Measured on the test device: procd waits out `procd_set_param respawn`'s
# timeout (5s) before restarting a dead instance, and after a crash-restart
# sequence has exhausted its retries the next start takes longer still. A
# crash-restart loop produced at most one healthy sample in a row, so 3
# separates the two cases while a healthy start only costs ~3s; but the BUDGET
# has to cover procd's backoff, otherwise the gate gives up on an instance that
# is still coming up (observed with a 15s budget: the rollback restart was
# healthy moments after the gate had already declared failure).
: "${HP_HEALTH_STABLE:=3}"
: "${HP_HEALTH_BUDGET:=30}"
# The rollback restart runs right after a crash-restart sequence has exhausted
# procd's retries, so it gets a longer budget than a normal start.
: "${HP_HEALTH_ROLLBACK_BUDGET:=60}"
# The process name a listening socket must be attributed to.
: "${HP_HEALTH_OWNER:=sing-box}"
# hp_procd_available
# 0 when the procd/ubus state can be queried at all.
hp_procd_available() {
command -v ubus > "/dev/null" 2>&1 && command -v jsonfilter > "/dev/null" 2>&1
}
# hp_procd_running <instance-name>
# 0 when procd reports the instance as running AND without a non-zero exit
# code. Both halves matter: during a crash-restart loop `running` flickers
# and `exit_code` is set, and a `running: true` sample taken between two
# failures must not be read as success on its own.
hp_procd_running() {
local name="$1"
local state running exit_code
hp_procd_available || return 1
state="$(ubus call service list "{\"name\":\"$HP_SERVICE\"}" 2>"/dev/null" \
| jsonfilter -e "@['$HP_SERVICE'].instances['$name']" 2>"/dev/null")"
[ -n "$state" ] || return 1
running="$(printf '%s' "$state" | jsonfilter -e '@.running' 2>"/dev/null")"
[ "$running" = "true" ] || return 1
exit_code="$(printf '%s' "$state" | jsonfilter -e '@.exit_code' 2>"/dev/null")"
case "$exit_code" in
""|"0") return 0 ;;
*) return 1 ;;
esac
}
# hp_instance_running <instance-name> <config-path>
# 0 when the instance is alive.
#
# procd's view comes first: it is the authority on whether the instance it
# supervises is up, and unlike a process scan it cannot be satisfied by a
# stale or orphaned process. The process scan is only a fallback for hosts
# without ubus, and it is deliberately the weaker evidence.
hp_instance_running() {
local name="$1"
local config="$2"
if hp_procd_running "$name"; then
return 0
fi
# Fallback: accept a matching process only when procd could not be asked
# at all. When procd *was* asked and said "not running", that answer
# wins - a stale process must not override it.
if ! hp_procd_available; then
if command -v pgrep > "/dev/null" 2>&1; then
pgrep -f "run --config $config" > "/dev/null" 2>&1 && return 0
fi
fi
return 1
}
# hp_listener_owned <owner> <port>...
# 0 every port is listening and attributed to <owner>
# 1 a port is missing, or is listening but belongs to something else
# 2 the check cannot be performed here (no netstat, or no owner column)
#
# Attribution is the whole point: in the failure this gate exists for, the
# port was listening - because the process that had taken it was still
# running. "The port is in the listen table" would have reported success for
# a service that could not bind it.
hp_listener_owned() {
local owner="$1"
shift
local table port
command -v netstat > "/dev/null" 2>&1 || return 2
# -p adds the owning pid/name; it needs root, which is how init.d runs.
table="$(netstat -ltnup 2>"/dev/null")"
[ -n "$table" ] || return 2
# Without a "<pid>/<name>" column the attribution cannot be made at all.
# The header row says "PID/Program name", so the probe has to require a
# digit before the slash - matching a bare "/" would accept the header.
case "$table" in
*[0-9]/*) ;;
*) return 2 ;;
esac
for port in "$@"; do
[ -n "$port" ] || continue
if ! printf '%s\n' "$table" | awk -v p="$port" -v o="$owner" '
$1 ~ /^(tcp|udp)/ {
# Local address is field 4; its last :/. component
# is the port. This reads both ":::5330" and
# "127.0.0.1.5330".
n = split($4, a, /[:.]/)
if (a[n] != p)
next
if ($NF ~ ("/" o "$")) {
found = 1
exit
}
}
END { exit(found ? 0 : 1) }
'; then
return 1
fi
done
return 0
}
# hp_config_ports <config-file>
# Print the ports a generated configuration declares as listeners.
#
# The gate must validate the configuration that is ACTUALLY running, not the
# one UCI currently describes. After a rollback the live file is the older
# known-good copy, and checking it against the candidate's ports made the gate
# wait forever for a port the restored configuration never binds - a rollback
# that had already brought the service back was reported as failed.
hp_config_ports() {
grep -o '"listen_port"[[:space:]]*:[[:space:]]*[0-9]\+' "$1" 2>"/dev/null" \
| grep -o '[0-9]\+$'
}
# hp_config_port_for_tag <config-file> <tag>
# Print the listen_port of the inbound whose "tag" is <tag>, or nothing.
#
# The functional probes need to know which listener is the DNS inbound and
# which is the mixed one, and they have to read that from the same place the
# gate does - the running configuration. Using the UCI values instead would
# point the probes at the candidate's ports after a rollback restored the older
# known-good file: a warning in the log at best, and a rollback of a healthy
# service when health_probe_strict is on.
#
# The generated JSON is pretty-printed with one field per line and `tag`
# precedes `listen_port` inside each inbound object (generator/inbound.uc), so
# a two-state walk is enough. The sed first splits any compact JSON onto the
# same one-field-per-line shape, so the helper does not silently depend on the
# generator's formatting: with both keys on one line the greedy tag match would
# keep the LAST tag and find no port at all.
hp_config_port_for_tag() {
sed -e 's/[{},]/\n/g' "$1" 2>"/dev/null" | awk -v tag="$2" '
/"tag"[[:space:]]*:/ {
t = $0
sub(/.*"tag"[[:space:]]*:[[:space:]]*"/, "", t)
sub(/".*/, "", t)
}
/"listen_port"[[:space:]]*:/ && t == tag {
p = $0
sub(/.*"listen_port"[[:space:]]*:[[:space:]]*/, "", p)
sub(/[^0-9].*/, "", p)
print p
exit
}
'
}
# hp_service_healthy <instance-name> <config-path> [port...]
# One sample of the gate. Ports are optional (the server side has no fixed
# listening port worth checking).
hp_service_healthy() {
local name="$1"
local config="$2"
shift 2
hp_instance_running "$name" "$config" || return 1
if [ "$#" -gt 0 ]; then
hp_listener_owned "$HP_HEALTH_OWNER" "$@"
case "$?" in
0) ;;
2)
# No netstat owner column here. Say so once instead of
# silently pretending the listeners were verified.
if [ -z "${HP_HEALTH_LISTENER_WARNED:-}" ]; then
HP_HEALTH_LISTENER_WARNED=1
export HP_HEALTH_LISTENER_WARNED
log "Warning: cannot verify the listeners on this target (netstat -p unavailable); relying on the procd check only."
fi
;;
*) return 1 ;;
esac
fi
return 0
}
# hp_wait_service <instance-name> <config-path> [budget] [stable] [port...]
# 0 once the instance has been healthy for <stable> consecutive samples,
# within <budget> seconds. 1 on timeout.
hp_wait_service() {
local name="$1"
local config="$2"
local budget="${3:-$HP_HEALTH_BUDGET}"
local stable="${4:-$HP_HEALTH_STABLE}"
local good=0 tries=0
[ "$#" -ge 4 ] && shift 4 || shift "$#"
while [ "$tries" -lt "$budget" ]; do
if hp_service_healthy "$name" "$config" "$@"; then
good=$((good + 1))
[ "$good" -ge "$stable" ] && return 0
else
good=0
fi
tries=$((tries + 1))
[ "$tries" -ge "$budget" ] && break
sleep 1
done
return 1
}
# hp_wait_instance <instance-name> <config-path> [seconds]
# Kept for callers and tests that only want the plain liveness poll. New code
# should use hp_wait_service: this one cannot tell a crash-restart loop from a
# healthy service.
hp_wait_instance() {
local name="$1"
local config="$2"
local tries="${3:-10}"
while [ "$tries" -gt 0 ]; do
hp_instance_running "$name" "$config" && return 0
sleep 1
tries=$((tries - 1))
done
return 1
}
# --- functional probes -----------------------------------------------------
#
# Everything above answers "is the instance up, and does it own the ports it
# declared?". None of it answers "does it actually serve?". A configuration
# can pass `sing-box check`, bind every listener, hold them for the whole
# observation window - and still not resolve a name, because the DNS router
# failed to initialize or a jail mount is missing.
#
# The probes below add that layer, deliberately as *evidence* first and as a
# gate only when asked for:
#
# * "cannot probe" is reported separately from "the probe failed", so a
# target without nslookup/nc does not look like a broken service;
# * a failure only fails the gate when health_probe_strict is on. A probe
# can fail for reasons that have nothing to do with sing-box - no uplink,
# a captive portal, an upstream resolver outage - and rolling a perfectly
# good configuration back because of that is worse than the condition
# being probed for. A rollback loop is the failure mode to avoid here.
#
# The client side is the side with a fixed capability worth probing (its DNS
# inbound and the mixed inbound). The server side's inbounds are
# protocol-specific: a bare TCP connect to a REALITY/trojan port proves
# nothing, so it is not probed.
: "${HP_PROBE_STRICT:=0}"
: "${HP_PROBE_NAME:=example.com}"
# hp_probe_dns <port> [name]
# 0 the DNS inbound answered, 1 it did not, 2 cannot probe here.
hp_probe_dns() {
local port="$1"
local name="${2:-$HP_PROBE_NAME}"
command -v nslookup > "/dev/null" 2>&1 || return 2
[ -n "$port" ] || return 2
nslookup -port="$port" "$name" 127.0.0.1 > "/dev/null" 2>&1 && return 0
return 1
}
# hp_probe_tcp <port>
# 0 something accepted a connection on loopback:<port>, 1 nothing did,
# 2 cannot probe here.
hp_probe_tcp() {
local port="$1"
[ -n "$port" ] || return 2
command -v nc > "/dev/null" 2>&1 || return 2
nc 127.0.0.1 "$port" < "/dev/null" > "/dev/null" 2>&1 && return 0
return 1
}
# hp_run_probes <label> <dns-port> <mixed-port>
# Report the probe results for one side. Returns 1 only when a probe failed
# AND HP_PROBE_STRICT=1; a probe that could not be performed never fails it.
hp_run_probes() {
local label="$1"
local dns_port="$2"
local mixed_port="$3"
local failed=0 rc
hp_probe_dns "$dns_port" "$HP_PROBE_NAME"
case "$?" in
0) ;;
1)
failed=1
log "Warning: the ${label} DNS inbound (127.0.0.1:${dns_port}) did not answer a query for ${HP_PROBE_NAME}." ;;
2)
log "Warning: cannot probe the ${label} DNS inbound (no nslookup, or no dns-in listener in the running configuration)." ;;
esac
hp_probe_tcp "$mixed_port"
case "$?" in
0) ;;
1)
failed=1
log "Warning: nothing accepted a connection on the ${label} mixed inbound (127.0.0.1:${mixed_port})." ;;
2)
log "Warning: cannot probe the ${label} mixed inbound (no nc, or no mixed-in listener in the running configuration)." ;;
esac
if [ "$failed" = "1" ] && [ "${HP_PROBE_STRICT:-0}" = "1" ]; then
log "Error: the ${label} functional probes failed and health_probe_strict is on."
return 1
fi
return 0
}
@@ -0,0 +1,165 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Routing/network plumbing for homeproxy.
#
# The tproxy and TUN paths need ip rules, a routing table and (for TUN) the
# device itself before procd starts sing-box, and they need to be torn down
# again on stop. All of that used to live inline in /etc/init.d/homeproxy.
#
# Layering note: this is a device-side module. It calls `log` and, like the
# other runtime modules, requires `config_load` to have run (it reads the
# infra marks and the TUN device name itself).
#
# Sourced by /etc/init.d/homeproxy.
# hp_net_wait_wan [seconds]
# Block until a default route or an "up" wan interface exists. Bounded: after
# the timeout sing-box is started anyway, because a missing default route is
# a common transient state at boot and refusing to start would be worse.
hp_net_wait_wan() {
local limit="${1:-60}"
local wan_waited=0
while ! { ip route show default 2>"/dev/null" | grep -q . \
|| ip -6 route show default 2>"/dev/null" | grep -q . \
|| ifstatus wan 2>"/dev/null" | grep -q '"up": true'; }; do
wan_waited=$((wan_waited + 1))
if [ "$wan_waited" -ge "$limit" ]; then
log "WARNING: No network route after ${limit}s, starting sing-box anyway."
break
fi
sleep 1
done
}
# hp_net_try <描述> <命令...>
# Run a step that may legitimately fail, logging the wrapped message.
hp_net_try() {
local message="$1"; shift
"$@" 2>"/dev/null" || log "Error: ${message}"
}
# hp_net_rule_add <family> <fwmark> <table> <描述>
# Install one ip rule idempotently. `ip rule add` has no replace form and the
# kernel appends a second identical rule instead of reporting a conflict, so a
# repeated start (rc.common's `start` never calls stop_service) used to leave
# duplicates behind. The teardown side already drains them with `while del`;
# this is the matching install side. family is "" for ip, "-6" for ip -6.
hp_net_rule_add() {
local family="$1" mark="$2" table="$3" message="$4"
while ip $family rule del fwmark "$mark" table "$table" 2>"/dev/null"; do :; done
hp_net_try "$message" ip $family rule add fwmark "$mark" table "$table"
}
# hp_net_setup <proxy-mode> <routing-mode>
# Create the tproxy or TUN routing state for the configured proxy mode.
# Requires config_load.
#
# Every step is idempotent on purpose: `start` on an already-running service
# re-runs this function without a preceding teardown. The old code reported
# "failed to add the tproxy local route (table 100)" on every such start
# (EEXIST from `ip route add`) while silently appending a duplicate ip rule.
# Routes use `replace`, rules are drained before they are added, and the TUN
# device is only created when it does not already exist.
hp_net_setup() {
local proxy_mode="$1"
local routing_mode="$2"
local ipv6_support table_mark outbound_udp_node tproxy_mark tun_name tun_mark
config_get_bool ipv6_support "config" "ipv6_support" "0"
config_get table_mark "infra" "table_mark" "100"
case "$proxy_mode" in
"redirect_tproxy")
config_get outbound_udp_node "config" "main_udp_node" "nil"
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
config_get tproxy_mark "infra" "tproxy_mark" "101"
hp_net_rule_add "" "$tproxy_mark" "$table_mark" \
"failed to add the tproxy ip rule (fwmark ${tproxy_mark}, table ${table_mark})."
hp_net_try "failed to add the tproxy local route (table ${table_mark})." \
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
if [ "$ipv6_support" -eq "1" ]; then
hp_net_rule_add "-6" "$tproxy_mark" "$table_mark" \
"failed to add the tproxy IPv6 rule (fwmark ${tproxy_mark}, table ${table_mark})."
hp_net_try "failed to add the tproxy IPv6 local route (table ${table_mark})." \
ip -6 route replace local ::/0 dev lo table "$table_mark"
fi
fi
;;
"redirect_tun"|"tun")
config_get tun_name "infra" "tun_name" "singtun0"
config_get tun_mark "infra" "tun_mark" "102"
# Only wait after a real creation: the old unconditional `sleep 1s`
# delayed every start (including reloads) even when the device was
# already present.
if ! ip link show "$tun_name" >"/dev/null" 2>&1; then
hp_net_try "failed to create the TUN device ${tun_name}." \
ip tuntap add mode tun user root name "$tun_name"
sleep 1s
fi
hp_net_try "failed to bring up the TUN device ${tun_name}." \
ip link set "$tun_name" up
hp_net_try "failed to set the TUN default route (table ${table_mark})." \
ip route replace default dev "$tun_name" table "$table_mark"
hp_net_rule_add "" "$tun_mark" "$table_mark" \
"failed to add the TUN ip rule (fwmark ${tun_mark}, table ${table_mark})."
if [ "$ipv6_support" -eq "1" ]; then
hp_net_try "failed to set the TUN IPv6 default route (table ${table_mark})." \
ip -6 route replace default dev "$tun_name" table "$table_mark"
hp_net_rule_add "-6" "$tun_mark" "$table_mark" \
"failed to add the TUN IPv6 rule (fwmark ${tun_mark}, table ${table_mark})."
fi
;;
esac
}
# hp_net_teardown
# Remove every tproxy/TUN rule and route homeproxy added. Requires
# config_load.
#
# ip rule del removes a single matching rule, so a repeated start (or an
# upgrade that starts before the old instance stopped) leaves duplicates
# behind. Delete every match instead of only the first one.
hp_net_teardown() {
local table_mark tproxy_mark tun_mark tun_name
config_get table_mark "infra" "table_mark" "100"
config_get tproxy_mark "infra" "tproxy_mark" "101"
config_get tun_mark "infra" "tun_mark" "102"
config_get tun_name "infra" "tun_name" "singtun0"
while ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
while ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
while ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
while ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
}
# hp_net_remove_tun
# service_stopped() hook: procd calls it after the instances are gone, so the
# TUN device can be removed. Requires config_load.
hp_net_remove_tun() {
local tun_name
config_get tun_name "infra" "tun_name" "singtun0"
ip link set "$tun_name" down 2>"/dev/null"
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
}
reload_service() {
restart
}
@@ -0,0 +1,461 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Service-side runtime helpers for homeproxy.
#
# These are the device-side orchestration pieces that used to be inlined in
# /etc/init.d/homeproxy: the sing-box version gate, the auto-update cron
# entry, the runtime-file preparation, the procd instance registrations and
# the "generate -> known-good" transaction that start_service runs for each
# side.
#
# Layering note: unlike
# runtime/config.sh and runtime/health.sh - which are deliberately pure and
# only take explicit paths - the extracted modules are device-side
# by nature. They call `log`, `config_get` and `procd_*`, so a caller must
# have run `config_load` and must be inside a procd init context. That is
# exactly why they were moved out of init.d and not turned into pure helpers.
#
# Sourced by /etc/init.d/homeproxy.
# hp_require_singbox
# Refuse to start on a sing-box older than 1.14: the generated configuration
# uses 1.14-only fields, and an older binary would reject it at run time
# instead of failing here. Returns 1 (and logs) when unusable.
#
# `sing-box` is looked up through PATH, exactly as the previous init script
# did - it is deliberately not PROG, so a PATH mismatch between the two is
# not silently introduced by this refactor.
hp_require_singbox() {
local sb_ver sb_major sb_minor
sb_ver="$(sing-box version -n 2>/dev/null)"
if [ -z "$sb_ver" ]; then
log "Error: cannot detect sing-box version, abort."
return 1
fi
sb_major="${sb_ver%%.*}"
sb_minor="${sb_ver#*.}"
sb_minor="${sb_minor%%.*}"
if [ "$sb_major" -lt 1 ] || { [ "$sb_major" -eq 1 ] && [ "$sb_minor" -lt 14 ]; }; then
log "Error: sing-box >= 1.14.0 required, found ${sb_ver}."
return 1
fi
return 0
}
# hp_crontab_drop <crontab>
# Remove the auto-update entry from <crontab>.
#
# Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with
# BSD sed it fails ("invalid command code"), which silently left the stale entry
# behind with only a warning in the log. Editing through a temporary file
# behaves identically on busybox, GNU and BSD sed, and it is what lets this
# module be exercised off-target at all
# (tests/runtime/test_runtime_extraction.sh stages and drives it).
hp_crontab_drop() {
local crontab="$1"
local tmp="${crontab}.hp-new"
sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
# mv replaces the *file*, not its contents: the temporary was created by the
# shell (0666 & ~umask = 0644) while procd creates /etc/crontabs/root as
# 0600, so every stop/start left the root crontab world-readable - verified
# on the device (0600 after boot, 0644 after one reload). Only root needs
# to read it (crond runs as root), and a root crontab can hold other jobs'
# secrets, so set the mode rather than inherit it - which also repairs a
# file an older version already loosened.
chmod 600 "$crontab" 2>"/dev/null"
return 0
}
# hp_sync_autoupdate_cron <enabled> <hour>
# Install (or drop) the subscription auto-update cron entry. <enabled> is a
# config_get_bool result; the entry is only touched when it is "1".
hp_sync_autoupdate_cron() {
local auto_update="$1"
local auto_update_time="$2"
[ "$auto_update" = "1" ] || return 0
# auto_update_time comes from UCI, which anything on the LAN can write, and
# it used to be interpolated into a root crontab line verbatim: a value
# like '2 * * * * root echo pwned #' appends a second job. Validate it
# before the line exists, and write it with printf rather than `echo -e`
# so no backslash sequence is interpreted either.
case "$auto_update_time" in
''|*[!0-9]*)
log "Warning: auto_update_time='$auto_update_time' is not an hour of the day, skipping the cron entry."
return 1
;;
esac
if [ "$auto_update_time" -gt 23 ]; then
log "Warning: auto_update_time=$auto_update_time is out of range (0-23), skipping the cron entry."
return 1
fi
hp_crontab_drop "/etc/crontabs/root" \
|| log "Warning: failed to drop the previous auto-update cron entry."
printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \
"$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|| log "Warning: failed to install the auto-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
}
# hp_clear_autoupdate_cron
# Drop the auto-update cron entry and restart cron. Used by stop_service.
hp_clear_autoupdate_cron() {
hp_crontab_drop "/etc/crontabs/root" \
|| log "Warning: failed to drop the auto-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
}
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
# Create the mode-specific working files, truncate the instance logs and hand
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
hp_prepare_runtime_files() {
local hp_dir="$1"
local run_dir="$2"
local routing_mode="$3"
local client_enabled="$4"
local server_enabled="$5"
# cache_file is enabled for bypass_mainland_china and custom alike
# (generator/common.uc:attachExperimental). The file has to exist before
# the client jail binds it, or sing-box cannot create it in there.
case "$routing_mode" in
"bypass_mainland_china"|"custom")
[ -e "$hp_dir/cache.db" ] || touch "$hp_dir/cache.db" \
|| log "Warning: failed to create ${hp_dir}/cache.db."
;;
esac
if [ "$routing_mode" = "custom" ]; then
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|| log "Warning: failed to create ${hp_dir}/ruleset."
# A local rule-set is opened by the client, which no longer runs as
# root now that the jail is on for this mode too, so the files have to
# be readable by the sing-box user. Only the documented directory is
# claimed here; a rule-set placed elsewhere under HP_DIR has to be
# readable already.
chown -R sing-box:sing-box "$hp_dir/ruleset" 2>"/dev/null" \
|| log "Warning: failed to hand ${hp_dir}/ruleset to sing-box."
fi
[ "$client_enabled" = "1" ] && echo > "$run_dir/sing-box-c.log"
if [ "$server_enabled" = "1" ]; then
echo > "$run_dir/sing-box-s.log"
if mkdir -p "$hp_dir/certs" 2>"/dev/null"; then
# The server runs jailed as the sing-box user and reads its
# certificate, key and ECH config from this directory, while an
# upload lands as root:0600 (luci.homeproxy). Hand the directory
# and its files to that user and keep them 0700/0600: the jailed
# server can read them and nobody else can reach the private key.
# Without this the choice was "world-readable key" or "jailed
# server cannot start".
chown sing-box:sing-box "$hp_dir/certs" 2>"/dev/null" \
|| log "Warning: failed to hand ${hp_dir}/certs to sing-box."
chmod 700 "$hp_dir/certs" 2>"/dev/null"
for f in "$hp_dir/certs"/*.pem "$hp_dir/certs"/*.crt "$hp_dir/certs"/*.key; do
[ -f "$f" ] || continue
chown sing-box:sing-box "$f" 2>"/dev/null" \
|| log "Warning: failed to hand ${f} to sing-box."
chmod 600 "$f" 2>"/dev/null"
done
else
log "Warning: failed to create ${hp_dir}/certs."
fi
fi
# chown each path that is actually there.
#
# The old one-shot chown listed every path unconditionally. A mode whose
# start path does not create one of them (custom mode and cache.db, before
# that file became shared with it) then failed and logged "failed to
# change the ownership of the runtime files" on every single start - a
# warning that was always there and never meant anything. A missing path is
# not worth reporting; failing to chown a file that exists still is.
for f in "$run_dir"/sing-box-*.json "$run_dir"/sing-box-*.log "$hp_dir"/cache.db; do
[ -e "$f" ] || continue
chown sing-box:sing-box "$f" 2>"/dev/null" \
|| log "Warning: failed to change the ownership of ${f} to sing-box."
done
}
# hp_procd_client_instance <prog> <hp-dir> <run-dir> <routing-mode> <disable-gso>
# Register the sing-box client instance. The ujail gate is client-specific:
# a wireguard/tun outbound cannot be jailed (it needs the host network stack),
# and custom routing needs HP_DIR inside the jail because a local rule-set may
# point at any path validateHomeProxyPath() accepts. The `procd_append_param
# command` text below is what runtime/health.sh's pgrep pattern matches -
# change both together.
hp_procd_client_instance() {
local prog="$1"
local hp_dir="$2"
local run_dir="$3"
local routing_mode="$4"
local disable_gso="$5"
procd_open_instance "sing-box-c"
procd_set_param command "$prog"
procd_append_param command run --config "$run_dir/sing-box-c.json"
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$run_dir/sing-box-c.json"; then
procd_add_jail "sing-box-c" log procfs
procd_add_jail_mount "$run_dir/sing-box-c.json"
procd_add_jail_mount_rw "$run_dir/sing-box-c.log"
# A custom routing table may point a local rule-set anywhere under
# HP_DIR, so the whole directory goes in read-only rather than a
# guessed subset. procd orders the mounts by path, so this parent is
# bound before the read-write cache.db below and cannot shadow it.
procd_add_jail_mount "$hp_dir/"
# cache_file is enabled for bypass_mainland_china and custom alike
# (generator/common.uc:attachExperimental), so both need the file
# writable inside the jail.
case "$routing_mode" in
"bypass_mainland_china"|"custom")
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
esac
procd_add_jail_mount "$hp_dir/certs/"
# The certificate path gate accepts /etc/acme as well (a client TLS
# certificate managed by acme.sh), so the client jail has to carry it
# too - it was missing here while the server jail already had it.
procd_add_jail_mount "/etc/acme/"
procd_add_jail_mount "/etc/ssl/"
# custom routing's find_neighbor resolves LAN hostnames out of the
# dnsmasq lease file; its MAC lookups go over netlink and need no file,
# but a `source_hostname` rule silently stops matching without this.
procd_add_jail_mount "/tmp/dhcp.leases"
procd_add_jail_mount "/etc/localtime"
procd_add_jail_mount "/etc/TZ"
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
fi
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
# hp_procd_server_instance <prog> <hp-dir> <run-dir> <disable-gso>
# Register the sing-box server instance. The jail only depends on ujail
# being installed, and it needs the certificate/ACME material instead of the
# client's cache.db.
hp_procd_server_instance() {
local prog="$1"
local hp_dir="$2"
local run_dir="$3"
local disable_gso="$4"
procd_open_instance "sing-box-s"
procd_set_param command "$prog"
procd_append_param command run --config "$run_dir/sing-box-s.json"
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
if [ -x "/sbin/ujail" ]; then
procd_add_jail "sing-box-s" log procfs
procd_add_jail_mount "$run_dir/sing-box-s.json"
procd_add_jail_mount_rw "$run_dir/sing-box-s.log"
procd_add_jail_mount_rw "$hp_dir/certs/"
procd_add_jail_mount "/etc/acme/"
procd_add_jail_mount "/etc/ssl/"
procd_add_jail_mount "/etc/localtime"
procd_add_jail_mount "/etc/TZ"
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
procd_set_param no_new_privs 1
procd_set_param user sing-box
procd_set_param group sing-box
fi
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
# hp_procd_log_cleaner <hp-dir>
# Register the log-rotator instance. It is unconditional: it also has to run
# when neither client nor server is enabled.
hp_procd_log_cleaner() {
local hp_dir="$1"
procd_open_instance "log-cleaner"
procd_set_param command "$hp_dir/scripts/clean_log.sh"
procd_set_param respawn
procd_close_instance
}
# hp_capture_candidate <run-dir> <side>
# Stage the configuration the reload preflight just generated and validated.
#
# The copy is necessary because stop_service() removes $RUN_DIR/sing-box-*.json
# on its way to start_service (they are runtime state, not configuration), so
# the file the preflight validated is gone by the time the new instance would
# use it - which is exactly why the old reload regenerated it. The staged copy
# lives in $RUN_DIR/candidate/, which stop_service() does not touch, and it is
# what hp_start_generated_config() activates. Non-zero when there is nothing
# staged, and the caller aborts the reload rather than continuing without a
# validated candidate.
hp_capture_candidate() {
local run_dir="$1"
local side="$2"
local live="$run_dir/sing-box-${side}.json"
local cand_dir="$run_dir/candidate"
local staged="$cand_dir/sing-box-${side}.json"
[ -s "$live" ] || return 1
mkdir -p "$cand_dir" 2>/dev/null || return 1
cp -f "$live" "$staged" 2>/dev/null || return 1
# The staged file carries the same credentials as the live one; the
# generator already wrote it 0600 and this copy has to stay that way.
chmod 600 "$staged" 2>/dev/null
return 0
}
# hp_start_generated_config <side> <hp-dir> <run-dir> <good-dir>
# The start-path transaction for one side ("c" or "s"):
#
# HP_USE_KNOWN_GOOD=1 -> copy the recorded configuration over the live one
# instead of regenerating (a rollback: regenerating
# would rebuild the very file that just failed)
# HP_CANDIDATE_<SIDE>=1 -> the reload preflight generated and `sing-box
# check`ed this side; activate the copy
# hp_capture_candidate() staged, do not generate again
# otherwise -> generate, then make sure a live file exists,
# falling back to the known-good copy when it does not
#
# Returns 1 only when there is nothing to run at all (generation failed and
# no known-good copy exists), which is what made start_service bail out.
#
# It deliberately does NOT touch the known-good copy any more. It used to
# record the freshly generated file immediately, which meant that by the time
# the health gate ran, the "previous" configuration the rollback would need had
# already been overwritten by the candidate - so a failed reload had nothing to
# roll back to. Promotion is now a separate step, taken only after the gate
# has passed (hp_promote_known_good, called from start_service).
#
# A3: the candidate flag exists because the generator is no longer guaranteed to
# produce the same bytes twice - its env carries the WAN resolver and the two
# domain-resource lists, either of which can change between two invocations. A
# reload that generated the config, validated it, and then let start_service
# generate it *again* would run an artifact that had never been checked:
# validate A -> discard A -> generate B -> run B. The flag makes the validated
# bytes the ones that run. It is per side, so a config_load disagreement between
# reload_service and start_service (a concurrent UCI change) generates the side
# the preflight did not cover instead of activating a stale file.
hp_start_generated_config() {
local side="$1"
local hp_dir="$2"
local run_dir="$3"
local good_dir="$4"
local label generator live good candidate staged
case "$side" in
c)
label="client"
generator="$hp_dir/scripts/generate_client.uc"
candidate="${HP_CANDIDATE_CLIENT:-0}"
;;
s)
label="server"
generator="$hp_dir/scripts/generate_server.uc"
candidate="${HP_CANDIDATE_SERVER:-0}"
;;
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
esac
live="$run_dir/sing-box-${side}.json"
good="$good_dir/sing-box-${side}.json"
if [ "${HP_USE_KNOWN_GOOD:-0}" = "1" ] && [ -s "$good" ]; then
# Rollback path: start from the recorded configuration instead
# of regenerating. Regenerating would rebuild the very config
# that just failed to come up, because the generator is a pure
# function of UCI.
log "Starting with the last known-good ${label} configuration."
cp -f "$good" "$live"
return 0
fi
if [ "$candidate" = "1" ]; then
# The reload preflight's artifact is the one that passed `sing-box
# check`. stop_service() removed the live copy on the way here, so the
# staged copy is what gets activated - the same bytes that were
# validated, not a second generation.
staged="$run_dir/candidate/sing-box-${side}.json"
if [ -s "$staged" ] && cp -f "$staged" "$live" 2>/dev/null; then
# One shot: the staged copy must not survive to be activated by a
# later start that never validated it.
rm -f "$staged"
log "Activating the ${label} configuration validated by the reload preflight."
return 0
fi
# Defensive: the flag was set but nothing was staged. Generate rather
# than activate an unvalidated or stale file.
log "Warning: no validated ${label} candidate is staged; generating the configuration again."
fi
ucode -S "$generator" 2>>"$LOG_PATH"
# The generator is transactional on its own: it writes a temporary file
# and only renames it over the live one once `sing-box check` accepted
# it. A failed generation therefore leaves the previous file untouched -
# and that is the only `sing-box check` in this path (the runtime used to
# run a second one on the same file).
hp_ensure_live "$live" "$good"
case "$?" in
0)
;;
1)
log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;;
*)
log "Error: failed to generate ${label} configuration."
return 1 ;;
esac
return 0
}
# hp_promote_known_good <side> <run-dir> <good-dir>
# Record the live configuration as the new known-good copy. Called only after
# the health gate has proven that the configuration actually runs, so the
# rollback target is always something that came up.
hp_promote_known_good() {
local side="$1"
local run_dir="$2"
local good_dir="$3"
local label
case "$side" in
c) label="client" ;;
s) label="server" ;;
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
esac
hp_known_good "$run_dir/sing-box-${side}.json" "$good_dir/sing-box-${side}.json" \
|| log "Warning: could not refresh the known-good ${label} configuration."
}
@@ -0,0 +1,71 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Pure subscription decoder. The fetcher returns raw bytes
* and the orchestrator used to inline the JSON / base64 / SIP008
* handling. Pulling it out gives the orchestrator a single
* `decode(content, log, url)` call and lets us unit-test each input
* shape without involving the network or UCI.
*
* Three input shapes are recognised, in order:
* 1. JSON with a top-level 'servers' array (clash proxy-provider)
* 2. JSON array directly (the same data, just unwrapped)
* 3. Shadowsocks SIP008 JSON: top-level array of objects with
* server+method. Each entry is tagged nodetype='sip008' so the
* orchestrator can dispatch them differently downstream.
*
* On JSON failure, decode the content as base64 and split on '\n'
* to get one share-link URI per line (sing-box subscription format).
*
* On total failure (no JSON, no base64), return an empty list and
* let the caller log the empty result.
*
* `url` is optional and only used to enrich the parse-failed log
* line; the decoder itself never makes a network call.
*/
'use strict';
import { isEmpty, decodeBase64Str, redactUrl } from '../homeproxy.uc';
export function decode(content, log, url) {
if (isEmpty(content))
return [];
let nodes;
try {
const parsed = json(content);
nodes = parsed.servers || parsed;
/* Shadowsocks SIP008: each entry is a JSON object with
* server + method, not a URI string.
*
* The probe has to check the type first. It used to be a bare
* `nodes[0].server`, which THROWS in ucode when nodes[0] is a
* string ("left-hand side expression is not an array or
* object") - and the throw was swallowed by the JSON
* try/catch below, so a perfectly valid `{"servers":["vless://…"]}`
* or `["vless://…"]` subscription fell through to the base64
* fallback, failed there too and decoded to an empty list. Two
* real subscription shapes were silently discarded, and the
* decoder test had recorded that as the contract. The same
* guard applies inside the loop so a mixed list (SIP008 objects
* plus share links) keeps both halves instead of losing
* everything on the first string. */
if (type(nodes[0]) === 'object' && nodes[0].server && nodes[0].method) {
for (let i = 0; i < length(nodes); i++)
if (type(nodes[i]) === 'object')
nodes[i].nodetype = 'sip008';
}
} catch (e) {
/* The URL carries the subscription token and this line lands in
* homeproxy.log, which is world-readable. fetcher.uc and the
* orchestrator both redact; this path was missed. */
const tag = url ? sprintf('for %s, ', redactUrl(url)) : '';
log(sprintf('JSON parse failed %strying base64: %s', tag, e.message));
const decoded = decodeBase64Str(content);
nodes = decoded ? split(trim(decoded), '\n') : [];
}
return nodes;
};
@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Thin wrapper around homeproxy's wGETVerbose that logs
* the failure and returns a flat { content, error } shape. The
* orchestrator used to inline the empty-content check + log
* message right next to the call, which made the loop body hard
* to read and conflated the fetch's success/failure decision with
* the downstream decode step.
*
* On success: returns { content: <string>, error: null }.
* On failure: returns { content: null, error: <string> }, having
* already logged the failure with the URL. The caller can keep
* the loop flat: 'if (result.content === null) continue;'.
*
* No caching, no retry, no backoff - the orchestrator decides
* those if it ever needs them. The Fetcher is a single HTTP GET
* + error log, nothing more.
*/
'use strict';
import { isEmpty, redactUrl, wGETVerbose } from '../homeproxy.uc';
export function fetch(url, user_agent, log) {
const result = wGETVerbose(url, user_agent);
if (isEmpty(result.content)) {
/* Redact the URL before logging: the query string is the
* subscription token and homeproxy.log is world-readable on
* a default install. The original URL goes back to the caller
* via result.error so error propagation still works. */
log(sprintf('Failed to fetch resources from %s: %s',
redactUrl(url), result.error || 'empty response'));
return { content: null, error: result.error || 'empty response' };
}
return { content: result.content, error: null };
};
@@ -0,0 +1,95 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Pure subscription filter. The orchestrator
* (update_subscriptions.uc) used to inline both the keyword
* whitelist/blacklist check and the post-parse policy application
* (tls.insecure override, protocol_options.packet_encoding for
* vless/vmess). They live here so the orchestrator stays focused on
* the fetch/cache/repository flow and the pure logic is unit-testable
* without UCI access.
*
* Neither function touches module state: mode / keywords / opts
* come in as arguments, and a logger is passed in for the
* invalid-regex warning. That makes the module callable from a
* plain test stub and means the orchestrator can keep its own
* file-writer log() around the call.
*/
'use strict';
import { isEmpty } from '../homeproxy.uc';
/* Decide whether a node named `name` should be dropped. `mode` is
* one of:
* 'disabled' - filter is off, return false (keep)
* 'blacklist' - drop if any keyword regex matches
* 'whitelist' - drop unless any keyword regex matches
*
* `keywords` is a list of strings; each is compiled as a regexp.
* One bad regex is logged and skipped, not treated as fatal - this
* matches the pre-B1 behaviour, where a stray `[` would not block
* the whole subscription.
*
* Returns true when the node should be skipped. */
export function check(name, mode, keywords, log) {
if (isEmpty(name) || mode === 'disabled' || isEmpty(keywords))
return false;
let matched = false;
for (let i in keywords) {
let patten;
try {
patten = regexp(i);
} catch (e) {
log(sprintf('Skipping invalid filter keyword regex: %s.', i));
continue;
}
if (patten && match(name, patten))
matched = true;
}
if (mode === 'whitelist')
matched = !matched;
return matched;
};
/* Apply the two policy tweaks the orchestrator used to do inline. The subject
* is a canonical Node - the same shape parser/normalize.uc produces and
* parser/flatten.uc turns back into UCI keys - not the parser's flat
* UCI-key dict:
*
* - tls.insecure is set when the node has TLS enabled and the user opted in
* via subscription.allow_insecure='1'
* - protocol_options.packet_encoding is set on vless/vmess nodes to the
* subscription's default
*
* This used to run on the flat dict, which is why the orchestrator applied
* policy on one representation and handed a different one to the Repository.
* The canonical Node is now the only business object between the parser and
* the Repository, and this was the last step still forcing the flat shape.
* The tweaks are a property of the subscription, not of the parsed link, so
* they deliberately do not take part in the duplicate fingerprint - which is
* computed from the parser output before this runs.
*
* Mutates and returns the node; the return value is the same object as the
* input, so callers can chain. */
export function apply_policy(node, opts) {
if (!node)
return node;
if (node.tls && node.tls.enabled === '1' && opts.allow_insecure === '1')
node.tls.insecure = '1';
if (node.type in ['vless', 'vmess']) {
/* normalize() always builds protocol_options; a hand-made node
* (a test fixture, a future caller) may not have it, and the flat
* version simply created the key. */
if (!node.protocol_options)
node.protocol_options = {};
node.protocol_options.packet_encoding = opts.packet_encoding;
}
return node;
};
@@ -0,0 +1,322 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Every UCI write the
* subscription pipeline performs now lives in this module. Three
* entry points cover what used to be six uci.set+commit sites in
* update_subscriptions.uc plus the existing node add/update/remove
* walk:
*
* apply_nodes - the add / update / remove walk
* for the nodes the subscription
* fetch returned this run.
* apply_main_node_refs - main_node / main_udp_node cleanup:
* remove stale urltest members, switch
* main_node if its target is gone,
* reset both to 'nil' when the
* subscription has no nodes.
* scrub_stale_urltest_refs - one-shot pass over every UCI
* routing_node that purges dead
* urltest_nodes entries left behind by
* nodes that have since been removed.
*
* Writes are driven by canonical Node objects (post- parser/normalize).
* UCI is flat, so apply_nodes expands each node with parser/flatten.uc -
* and that call is the module's only canonical -> UCI boundary: callers
* hand this module canonical Nodes, never flat dicts, and every UCI key
* name comes from parser/mapping.uc. The orchestrator
* (update_subscriptions.uc) never writes UCI directly; it orchestrates
* fetch + parse + normalize + filter + policy + reload and owns the one
* commit.
*
* A2 (single commit): this module mutates the caller's cursor and never
* commits. It used to commit at nine sites - once at the end of
* apply_nodes, seven inside apply_main_node_refs, and once per scrubbed
* routing_node - while update_subscriptions.uc's own comments called
* that "the repository's single commit". A crash between two of them
* left the nodes written but main_node still pointing at a deleted
* section, and there was no single point at which the run became
* visible. The transaction boundary is now the caller's one commit,
* after every mutation is staged.
*/
'use strict';
import { md5 } from 'digest';
import { isEmpty } from '../homeproxy.uc';
/* Relative, like config/loader.uc's '../parser/mapping.uc': ucode's
* resolver only searches top-level module names in the -L tree, so a
* bare 'parser/flatten.uc' does not resolve. */
import { flatten } from '../parser/flatten.uc';
/* --- apply_nodes -------------------------------------------------------- */
/* Add / update / remove the subscription-owned nodes against UCI.
*
* node_cache - { [groupHash]: { [nameHash]: canonical_node } }
* A lookup table keyed by subscription URL group
* and then by the per-node name hash. The fetch
* loop populates this for de-duplication.
* node_result - [[canonical_node, ...]] (one inner list per URL)
* The de-duplicated, policy-applied nodes in the
* order they should appear in UCI.
*
* Behaviour parity with the previous code:
* - user-created nodes (no grouphash) are never touched
* - nodes whose subscription has no cache entry (network
* failure for that URL) are left in place; we don't delete
* a node just because today's fetch didn't return it
* - a node in UCI but not in the cache is deleted
* - a node in both has every field of the new config written
* (including options the stored section did not have yet),
* and the options the new config no longer carries are removed
* - new nodes are added under name = md5(groupHash + label), the
* same hash the cache uses, so a subsequent re-fetch recognises
* them as existing
*
* Returns the { added, removed } counts. No commit here: the caller's
* single commit is the boundary of "this run wrote something". */
function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
let added = 0, removed = 0;
/* Mark the canonical Node objects the foreach loop above has
* already updated in place, so the second loop skips them. We
* use object identity (the same Node object is held in node_cache
* and in node_result, so `seen[node] === node` works as a set),
* not the section name or nameHash: the orchestrator hands the
* Repository the same Node instances, and tying the marker to the
* object means a stale string key cannot match a renamed node. */
const seen = {};
uci.foreach(uciconfig, ucinode, (cfg) => {
/* User-created nodes do not have a grouphash. The
* repository must never touch them, even if the
* subscription fetch returns nothing for today. */
if (!cfg.grouphash)
return null;
/* No cache entry for this subscription group means
* the fetch failed for that URL. Leave the existing
* nodes in place so the user does not lose them on
* a transient network blip. */
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
return null;
const incoming = node_cache[cfg.grouphash][cfg['.name']];
if (!incoming) {
uci.delete(uciconfig, cfg['.name']);
removed++;
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
return null;
}
/* Canonical Node -> flat UCI keys. flatten() drops
* nulls so a freshly-saved canonical Node does not
* litter /etc/config with `tls_sni ''` entries for
* fields the parser never set; the Loader treats null
* and absent the same, so this is observably
* equivalent. */
const flat = flatten(incoming);
/* Write every option the new config carries, including
* ones the stored section did not have yet. Walking only
* the OLD section's keys used to miss new options
* (plugin, tls_sni, packet_encoding) the subscription
* started sending after the node was first imported. */
for (let v in keys(flat))
uci.set(uciconfig, cfg['.name'], v, flat[v]);
/* Then drop the options the new config no longer
* carries. `.name` / `.type` / `.index` are section
* metadata, not options; deleting them is meaningless
* at best. */
for (let v in keys(cfg)) {
if (substr(v, 0, 1) === '.')
continue;
if (!(v in flat))
uci.delete(uciconfig, cfg['.name'], v);
}
seen[incoming] = true;
});
for (let nodes in node_result)
map(nodes, (node) => {
if (seen[node])
return null;
/* normalize() exposes the source label as `name`; `label`
* is only set when the orchestrator carries it over. Relying
* on `label` alone hashed every node of a subscription to the
* same section name, because undefined stringifies
* identically - four nodes collapsed into one section mixing
* several protocols' options. Accept either. */
const label = node.label || node.name;
const nameHash = md5(node.grouphash + label);
/* The one boundary where a canonical Node becomes UCI keys.
*
* UCI is a flat key/value store, so this expansion has to
* happen somewhere; keeping it inside the Repository's write
* path is what lets every other layer - the orchestrator, the
* filter, the policy step - speak canonical only. If a field
* must not reach UCI, or its UCI name must change, this call
* and parser/mapping.uc are the places to look; no caller
* hands this function a flat dict. */
const flat = flatten(node);
uci.set(uciconfig, nameHash, 'node');
for (let v in keys(flat))
uci.set(uciconfig, nameHash, v, flat[v]);
added++;
log(sprintf('Adding node: %s.', label));
});
return { added, removed };
}
/* --- apply_main_node_refs ---------------------------------------------- */
/* Prune the entries of an UCI list that no longer point at a live
* node. Used twice below for `main_urltest_nodes` and
* `main_udp_urltest_nodes`; the helper writes the cleaned list back
* only when it actually changed, and returns it for the caller to
* inspect (an empty list is the trigger to switch to first_server). */
function maybeScrubUrltestNodes(uci, uciconfig, ucimain, listOption, log) {
const old_list = uci.get(uciconfig, ucimain, listOption) || [];
const cleaned = filter(old_list, (v) => {
if (!uci.get(uciconfig, v)) {
log(sprintf('Node %s is gone, removing from urltest list.', v));
return false;
}
return true;
});
if (length(cleaned) !== length(old_list))
uci.set(uciconfig, ucimain, listOption, cleaned);
return cleaned;
}
/* Switch a main_* field off the dangling reference and onto the first
* available node. The two callers below differ only in the UCI option
* name (`main_node` vs `main_udp_node`) and the user-facing log line,
* so this is what made the pre-refactor code mirror itself.
*
* The new value is also pushed onto `result.log` so the orchestrator
* can replay it (it uses `result.log` to log the same lines verbatim;
* the homeproxy.log capture goes through the separate `log` arg). */
function switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, field, first_server, result, log) {
if (uci.get(uciconfig, field))
return field;
uci.set(uciconfig, ucimain, field, first_server);
const msg = sprintf('Main%s node is gone, switching to the first node.',
field === 'main_udp_node' ? ' UDP' : '');
log(msg);
push(result.log, msg);
return first_server;
}
/* Replace the 4 inline uci.set/commit sites (plus the 2-line
* 'reset to nil' block) in update_subscriptions.uc. Logic parity:
*
* - if main_node is set and at least one subscription node exists:
* * when main_node === 'urltest', scrub main_urltest_nodes down
* to nodes that still exist, then switch main_node to the first
* surviving one if the urltest set is empty
* * otherwise, switch main_node to first_server if its target is
* gone
* * same shape for main_udp_node, only when main_udp_node is set
* AND not 'same'
* - if main_node is set but no subscription nodes exist, reset
* both main_node and main_udp_node to 'nil'
*
* `ctx` carries the inputs the orchestrator used to read from UCI:
* { main_node, main_udp_node, has_nodes }
*
* Mutates only, no commit. The orchestrator commits once after
* apply_nodes, this method and scrub_stale_urltest_refs have all
* staged their changes, so a run is either fully visible or not at all.
*
* Returns { main_node: <new>, main_udp_node: <new>, log: [..] } so
* the orchestrator can log "Main node is gone, switching to ..." /
* "No available node, disable tproxy." exactly as before. */
function apply_main_node_refs(uci, uciconfig, ucimain, ucinode, ctx, log) {
/* Object destructuring is not part of the dialect the ucode on the
* target accepts (ImmortalWrt ucode 2026.01.16 rejects
* `const { a } = x` with "Expecting variable name"), so pull the
* fields out by name. tests/ucode/test_ucode_grammar.sh guards this. */
const main_node = ctx.main_node,
main_udp_node = ctx.main_udp_node;
const result = { main_node, main_udp_node, log: [] };
if (isEmpty(main_node))
return result;
const first_server = uci.get_first(uciconfig, ucinode);
if (!first_server) {
/* No nodes left at all: reset both to 'nil'. */
uci.set(uciconfig, ucimain, 'main_node', 'nil');
uci.set(uciconfig, ucimain, 'main_udp_node', 'nil');
result.main_node = 'nil';
result.main_udp_node = 'nil';
push(result.log, 'No available node, disable tproxy.');
return result;
}
if (main_node === 'urltest') {
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_urltest_nodes', log);
if (!length(cleaned))
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', first_server, result, log);
} else {
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', first_server, result, log);
}
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
if (main_udp_node === 'urltest') {
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_udp_urltest_nodes', log);
if (!length(cleaned))
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', first_server, result, log);
} else {
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', first_server, result, log);
}
}
return result;
}
/* --- scrub_stale_urltest_refs ----------------------------------------- */
/* Walk every routing_node and prune the urltest_nodes list to drop
* entries that no longer point at a live node. Mutates only: the caller
* commits once after this returns. Returns { changed } - the number of
* routing_nodes it rewrote - which is what the old `commits` counter
* measured (it counted its own commits; the previous code committed
* inside the foreach loop, once per scrubbed routing_node). */
function scrub_stale_urltest_refs(uci, uciconfig, log) {
let changed = 0;
uci.foreach(uciconfig, 'routing_node', (cfg) => {
if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes))
return null;
const cleaned_nodes = filter(cfg.urltest_nodes, (v) => uci.get(uciconfig, v));
if (length(cleaned_nodes) === length(cfg.urltest_nodes))
return null;
uci.set(uciconfig, cfg['.name'], 'urltest_nodes', cleaned_nodes);
changed++;
log(sprintf('Routing node %s: removed gone nodes from urltest list.', cfg['.name']));
});
return { changed };
}
export const Repository = {
apply_nodes,
apply_main_node_refs,
scrub_stale_urltest_refs
};
@@ -0,0 +1,12 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2023 ImmortalWrt.org
SCRIPTS_DIR="/etc/homeproxy/scripts"
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
done
"$SCRIPTS_DIR"/update_subscriptions.uc
@@ -0,0 +1,229 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2025 ImmortalWrt.org
#
# Fetch and install one resource list (china_ip4 / china_ip6 / gfw_list /
# china_list). Each list comes from a fixed upstream repository; the current
# commit is resolved through the GitHub API, the file is downloaded from the
# first reachable mirror at that exact commit, and the result is installed only
# after its git blob id has been checked against the one the API reports for
# that path and commit (resource_blob_sha.uc). A list that fails the check is
# discarded and the installed copy is left in place.
#
# Driven by the rpcd method `resources_update` and by the auto-update cron
# entry; tests/runtime/test_resource_update.sh exercises the verification path
# off-target with stubbed wget/jsonfilter/ucode.
NAME="homeproxy"
# The blob-digest helper sits next to this script and is invoked through it, so
# its location follows however the script was started (rpcd, cron, a shell).
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>"/dev/null" && pwd)"
RESOURCES_DIR="/etc/$NAME/resources"
mkdir -p "$RESOURCES_DIR"
RUN_DIR="/var/run/$NAME"
LOG_PATH="$RUN_DIR/$NAME.log"
mkdir -p "$RUN_DIR"
log() {
printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH"
}
to_upper() {
printf '%s' "$1" | tr "[a-z]" "[A-Z]"
}
# Review M7: each entry is tried in order, success stops the loop. Order
# matters: fastly.jsdelivr.net is the CDN edge closest to the original
# report and used to be the only mirror; gcore and cdn are sibling edges;
# raw.githubusercontent.com is the upstream fallback (the file lives in
# the same GitHub repo we just queried for the SHA, so it is reachable
# whenever the version query was reachable). Putting the CDN edges first
# keeps the common case fast; the GitHub fallback catches the case where
# the CDN is blocked but GitHub is reachable - which is the shape of the
# `api.github.com` rate-limit problem the report calls out.
MIRRORS="fastly.jsdelivr.net gcore.jsdelivr.net cdn.jsdelivr.net raw.githubusercontent.com"
# Pick the first mirror that responds to a HEAD with HTTP 200 in 10s.
# Called with: <path-suffix>. Echoes the chosen base URL on stdout, or
# fails the script if every mirror timed out.
pick_mirror() {
local path_suffix="$1"
for base in $MIRRORS; do
if [ "$base" = "raw.githubusercontent.com" ]; then
# GitHub raw serves paths from the repo root, not the
# /gh/<repo>@<sha>/<file> shape jsdelivr uses. The caller
# passes the suffix already split out, so we re-stitch it.
local probe_url="https://$base/$listrepo/$list_sha/$listname"
else
local probe_url="https://$base/gh/$listrepo@$list_sha/$listname"
fi
if wget --timeout=10 --spider -q "$probe_url" 2>"/dev/null"; then
printf '%s\n' "$base"
return 0
fi
done
return 1
}
check_list_update() {
local listtype="$1"
local listrepo="$2"
local listref="$3"
local listname="$4"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local github_token="$(uci -q get homeproxy.config.github_token)"
local wget="wget --timeout=10 -q"
# fd 9, not 200: POSIX only guarantees 0-9, and dash fails the whole
# `exec 200>"$lock"` with "exec: 200: not found". busybox ash and bash
# accept multi-digit descriptors, so this only ever showed up off-target -
# but the shebang says /bin/sh, and the off-target driver runs it there.
# `&>` is a bashism for the same reason; `> file 2>&1` is the POSIX form.
exec 9>"$lock"
if ! flock -n 9 >"/dev/null" 2>&1; then
log "[$(to_upper "$listtype")] A task is already running."
return 2
fi
# The token travels in argv, which the previous form avoided by writing it
# to a 0600 file and passing --header-file - an option neither busybox nor
# GNU wget has, so it failed with "unrecognized option" and the version
# query never worked at all with a token configured. There is no
# file-based header option in either wget, so argv is the only way; on a
# single-user router that is the right trade for a feature that otherwise
# does not function. `wget` is invoked with the header only when a token
# is set, and nothing logs the command line.
local github_header=""
[ -n "$github_token" ] && github_header="Authorization: Bearer $github_token"
local list_info="$($wget ${github_header:+--header "$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local wget_exit=$?
if [ $wget_exit -ne 0 ]; then
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
return 1
fi
local list_sha="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].sha")"
local list_date="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].commit.committer.date" | cut -d 'T' -f1)"
if [ -z "$list_sha" ]; then
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
return 1
fi
local list_ver="${list_date:+$list_date }$list_sha"
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT_FOUND")"
local local_list_sha="${local_list_ver##* }"
local local_list_disp="${local_list_ver%% *}"
if [ "$local_list_sha" = "$list_sha" ]; then
[ "$local_list_ver" = "$local_list_sha" ] && [ -n "$list_date" ] && \
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
log "[$(to_upper "$listtype")] Current version: ${list_ver%% *}."
log "[$(to_upper "$listtype")] You're already at the latest version."
return 3
else
log "[$(to_upper "$listtype")] Local version: $local_list_disp, latest version: ${list_ver%% *}."
fi
# Pick a mirror, then download. pick_mirror walks the list and uses
# the first reachable one; raw.githubusercontent.com is a separate
# path shape so the helper handles it.
local mirror="$(pick_mirror)"
if [ -z "$mirror" ]; then
log "[$(to_upper "$listtype")] All mirrors unreachable (tried: $MIRRORS)."
return 1
fi
local mirror_url
if [ "$mirror" = "raw.githubusercontent.com" ]; then
mirror_url="https://raw.githubusercontent.com/$listrepo/$list_sha/$listname"
else
mirror_url="https://$mirror/gh/$listrepo@$list_sha/$listname"
fi
log "[$(to_upper "$listtype")] Downloading from $mirror."
if ! $wget -O "$RUN_DIR/$listname" "$mirror_url" || [ ! -s "$RUN_DIR/$listname" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Download failed ($mirror)."
return 1
fi
# Integrity: the URL is pinned to $list_sha, so the mirror is *supposed* to
# serve the bytes that commit contains. That is a statement about the URL,
# not about the response - a mirror, a CDN edge or anything else in the
# path can answer with different content, and the resource lists drive
# nft sets and DNS snippets. Compare the file's git blob id against the one
# GitHub reports for the same path and commit.
#
# This fails closed. The version query above already requires
# api.github.com, so a router that cannot reach the API could not update
# resources before this check either; the alternative - installing bytes
# nobody vouched for - is the thing being fixed.
local api_blob local_blob
api_blob="$($wget ${github_header:+--header "$github_header"} -O- \
"https://api.github.com/repos/$listrepo/contents/$listname?ref=$list_sha" \
| jsonfilter -qe '@.sha')"
if [ -z "$api_blob" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Refusing to install: GitHub reports no blob id for $listname at $list_sha."
return 1
fi
local_blob="$(ucode -S "$SCRIPT_DIR/resource_blob_sha.uc" "$RUN_DIR/$listname" 2>"/dev/null")"
if [ -z "$local_blob" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Refusing to install: cannot compute the blob id of the downloaded $listname."
return 1
fi
if [ "$local_blob" != "$api_blob" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Refusing to install: $listname from $mirror does not match the content of commit $list_sha (expected $api_blob, got $local_blob)."
return 1
fi
if mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"; then
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
# Review M7: persist the time *this router* last succeeded.
# $list_date is the upstream commit date and can be months old
# even on a successful run, so it is not a stand-in. Stored in
# the same directory as the .ver file so resources_get_version
# can read it.
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$RESOURCES_DIR/$listtype.updated_at"
log "[$(to_upper "$listtype")] Successfully updated via $mirror (blob $local_blob)."
else
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
return 1
fi
return 0
}
case "$1" in
"china_ip4")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
;;
"china_ip6")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
;;
"gfw_list")
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
;;
"china_list")
# Not `sed -i`: the bare -i form is a busybox/GNU extension, and the same
# script is exercised off-device where a non-busybox sed fails it with
# "invalid command code". Edit through a temp file, the way the crontab
# helper in runtime/service.sh does.
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" && \
mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt" || \
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
;;
*)
printf '%s\n' "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
exit 1
;;
esac
@@ -0,0 +1,440 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*
* The orchestrator no
* longer touches UCI directly. All UCI writes live in
* subscription/repository.uc. The pipeline is:
*
* Loader.load() -> read subscription + main_node refs
* parse_uri() -> flat UCI keys (the parser's output shape)
* normalize() -> canonical Node (parser/normalize.uc)
* apply_policy() -> policy tweaks on the canonical Node
* (tls.insecure, protocol_options.packet_encoding)
* Repository.apply_nodes -> writes UCI for subscription nodes
* (internally flatten()s the canonical
* nodes, so the orchestrator never
* touches UCI).
* Repository.apply_main_node_refs -> writes UCI for main_node /
* main_udp_node / urltest cleanup.
* Repository.scrub_stale_urltest_refs -> one-shot pass over UCI
* routing_nodes for stale urltest_nodes
* entries.
*
* The canonical Node is the only object the orchestrator hands the
* Repository, and the only one policy is applied to. The parser's flat
* UCI-key dict survives here for one thing only: the duplicate
* fingerprint, which identifies the parsed link and must not move when a
* subscription-level policy changes.
*
* Every UCI write the run performs is therefore in
* subscription/repository.uc. The orchestrator's only UCI side
* effect is passing its cursor to the Repository methods.
*
* A2 (single commit): the Repository commits nothing; this script owns the
* one uci.commit() the run performs, after every mutation is staged and
* before the reload. Read the comment above that commit for what the
* multi-commit order used to allow.
*
* A4 (lock before snapshot): the cursor, the domain model and the recovery
* snapshot are all read inside the lock. See load_locked_state().
*/
'use strict';
import { md5 } from 'digest';
import { lstat, open, readfile, writefile } from 'fs';
import { connect } from 'ubus';
import { cursor } from 'uci';
import {
executeCommand, getTime, isEmpty, HP_DIR, RUN_DIR, redactUrl, shellQuote,
UCICONFIG_DIR
} from './homeproxy.uc';
import { parse_uri } from './parser/uri.uc';
import { normalize } from './parser/normalize.uc';
import { check as filter_check, apply_policy } from './subscription/filter.uc';
import { decode as decode_subscription } from './subscription/decoder.uc';
import { fetch as fetch_subscription } from './subscription/fetcher.uc';
import { Repository } from './subscription/repository.uc';
import { Loader } from './config/loader.uc';
/* UCI cursor, configuration and recovery snapshot the run works on.
*
* A4: none of this is read at module scope any more. The lock used to guard
* only the write phase - the subscription list, the domain model and the
* backup were all read first - so a run that read while another one held the
* lock and then acquired it after that one finished would commit against the
* base it had read before, and, on failure, restore a snapshot older than the
* run that had just committed. Everything below is now filled by
* load_locked_state(), which is called only after acquire_lock() returns true.
*
* A2: the run performs exactly one uci.commit(). The Repository modules stage
* mutations only.
*
* UCICONFIG_DIR (homeproxy.uc) is the config *directory* Loader.load() takes,
* and the sandboxed test points it at its own config instead of /etc/config
* (tests/ucode/test_subscription_updater_runs.sh rewrites that line in
* homeproxy.uc and fails loudly if the rewrite did not apply). */
const CONFIG_FILE = UCICONFIG_DIR + '/homeproxy';
const uciconfig = 'homeproxy';
const ucimain = 'config',
ucinode = 'node';
/* Filled by load_locked_state(). `let`, so the reads could move into the lock
* without threading a state object through every use site. */
let uci, loaded, sub, routing_mode,
allow_insecure, filter_mode, filter_keywords, packet_encoding,
subscription_urls, user_agent, main_node, main_udp_node,
config_backup = null;
/* Read everything the run needs, under the lock. Read-only: nothing here
* mutates, so a failure cannot leave a partial state behind. */
function load_locked_state() {
uci = cursor();
uci.load(uciconfig);
/* The orchestrator used to hold its own
* uci.cursor() and re-read subscription.* / config.* one field at
* a time. Now it goes through Loader.load(), which is read-only
* and exposes the canonical sub-objects
* (`config.access_control.subscription`). The Repository methods
* continue to take the raw cursor for the writes because the
* cursor is the UCI writer contract. */
loaded = Loader.load(UCICONFIG_DIR);
sub = loaded.access_control.subscription;
routing_mode = loaded.general.routing_mode;
allow_insecure = sub.allow_insecure || '0';
filter_mode = sub.filter_nodes || 'disabled';
/* subscription_urls and filter_keywords are siblings of `subscription`,
* not members of it: load_access_control() puts them straight on
* access_control (loader.uc:303-304), and test_domain_model_skeleton.uc
* asserts that shape. Reading them through `sub.` yielded [] forever, so
* the guard below never called main() and the updater exited 0 having done
* nothing - the LuCI button and the cron entry were both silent no-ops. */
filter_keywords = loaded.access_control.filter_keywords || [];
packet_encoding = sub.packet_encoding || 'xudp';
subscription_urls = loaded.access_control.subscription_urls || [];
user_agent = sub.user_agent;
main_node = null;
main_udp_node = null;
if (routing_mode !== 'custom') {
main_node = loaded.general.main_node;
main_udp_node = loaded.general.main_udp_node;
}
/* The snapshot the failure path restores. It is read here, under the
* lock, so it is the state this run actually started from - not whatever
* was on disk while the process was still waiting for the lock. */
config_backup = readfile(CONFIG_FILE);
}
function log(...args) {
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`);
logfile.close();
}
/* One updater at a time.
*
* The cron entry and the LuCI button can both start this script, and the update
* is a sequence of commits (nodes, then main_node refs, then the repository's
* single commit). Two runs interleaving them is last-writer-wins, and a failing
* run restores ITS snapshot over whatever the other just wrote - so a
* concurrent update can lose a whole node set.
*
* mkdir is the lock: it is atomic on every filesystem this runs on and needs no
* flock binding. A lock older than HP_LOCK_STALE is treated as abandoned (a
* killed process cannot clean up after itself) and broken, so a crash cannot
* block every future update. */
const LOCK_DIR = RUN_DIR + '/update_subscriptions.lock';
const LOCK_STALE = 600;
function lock_age() {
const st = lstat(LOCK_DIR);
if (!st)
return null;
/* time() is the epoch; getTime() is this package's *formatter*
* (getTime(epoch) -> 'YYYY-MM-DD@HH:MM:SS'), so subtracting it produced
* NaN and the stale check never fired. */
return max(0, time() - (st.mtime || 0));
};
function acquire_lock() {
system(sprintf('mkdir -p %s', shellQuote(RUN_DIR)));
if (system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0)
return true;
const age = lock_age();
if (age != null && age > LOCK_STALE) {
log(sprintf('Breaking a stale update lock (%s seconds old).', age));
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
return system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0;
}
return false;
};
function release_lock() {
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
};
function main() {
const node_cache = {};
const node_result = [];
const ubus = connect();
/* ubus is unreachable when no ubusd is running (dev host)
* and the feature query is optional: fall back to the same
* empty feature set the code below already handles. */
const sing_features = (ubus?.call('luci.homeproxy', 'singbox_get_features', {})) || {};
if (isEmpty(sing_features))
log('Warning: Failed to query sing-box features via ubus, assuming defaults.');
/* Fetch, decode, parse and filter everything BEFORE touching
* the service or UCI. The previous version stopped the proxy
* first, so a slow or failing subscription left the router
* without a proxy for the whole fetch - the stop -> modify
* -> discover-an-error pattern the refactor guide forbids.
* Nothing below runs until a full candidate set exists in
* memory. */
for (let url in subscription_urls) {
url = replace(url, /#.*$/, '');
const groupHash = md5(url);
node_cache[groupHash] = {};
/* Keep the lock's mtime current for as long as this run is alive.
* The stale window (LOCK_STALE) is what lets a later run reclaim a
* lock a killed process left behind, but nothing refreshed it while
* the fetch was in progress - and the fetch is one wget per URL,
* ten seconds each. A run that crosses the threshold while still
* working can therefore have its lock broken underneath it, and the
* two runs then interleave their read-modify-write exactly like the
* comment above acquire_lock() describes. */
system(sprintf('touch %s 2>/dev/null', shellQuote(LOCK_DIR)));
const fetched = fetch_subscription(url, user_agent, log);
if (fetched.content === null)
continue;
const nodes = decode_subscription(fetched.content, log, url);
let count = 0;
for (let node in nodes) {
let flat;
if (!isEmpty(node))
flat = parse_uri(node, sing_features, log);
if (isEmpty(flat))
continue;
const label = flat.label;
flat.label = null;
const confHash = md5(sprintf('%J', flat)),
nameHash = md5(groupHash + label);
flat.label = label;
if (filter_check(flat.label, filter_mode, filter_keywords, log))
log(sprintf('Skipping blacklist node: %s.', flat.label));
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
log(sprintf('Skipping duplicate node: %s.', flat.label));
else {
/* normalize() first: the canonical Node is the only
* business object this orchestrator hands the Repository.
* The policy tweaks used to be applied to `flat` before
* this, which meant the pipeline was flat -> flat(tweaked)
* -> canonical, and only the Repository's internal
* flatten() brought it back. Now it is
* parse -> normalize -> apply_policy -> Repository.
*
* `flat` stays in scope for the fingerprint above only.
* That fingerprint is taken from the parser's output on
* purpose: it identifies the parsed link, and the policy
* tweaks belong to the subscription rather than to the
* node. */
const node_canonical = normalize(flat);
node_canonical.grouphash = groupHash;
/* normalize() exposes the source label as `name`; the
* Repository identifies a node by
* md5(grouphash + label), so without this every node in
* one subscription hashes to the SAME section name and
* they overwrite each other - a 4-node subscription
* collapsed into a single section carrying one protocol's
* `type` with several protocols' options mixed in.
* The cache is keyed by the same hash, so this also makes
* an unchanged node match on the next run instead of
* being deleted and re-added every time. */
node_canonical.label = flat.label;
apply_policy(node_canonical, { allow_insecure, packet_encoding });
push(node_result, [ node_canonical ]);
node_cache[groupHash][confHash] = node_canonical;
node_cache[groupHash][nameHash] = node_canonical;
count++;
}
}
if (count === 0)
log(sprintf('No valid node found in %s.', redactUrl(url)));
else
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), redactUrl(url)));
}
if (isEmpty(node_result)) {
/* Nothing was touched yet (the fetch phase never writes),
* so the running service and the stored configuration
* stay as they are. */
log('Failed to update subscriptions: no valid node found.');
return false;
}
/* The add / update / remove walk and the
* final commit now live in subscription/repository.uc. The
* orchestrator just hands it the canonical Node cache +
* result built during the fetch phase and uses the
* { added, removed } counts for the end-of-run log. */
const repository_result = Repository.apply_nodes(
uci, uciconfig, ucinode, node_cache, node_result, log
);
const added = repository_result.added,
removed = repository_result.removed;
/* The 6 inline uci.set/commit sites
* (main_urltest_nodes cleanup, main_node switch on missing
* target, main_udp_urltest_nodes cleanup, main_udp_node
* switch, reset-to-'nil', routing_node urltest scrub) moved
* into subscription/repository.uc. The orchestrator now
* drives three Repository methods and replays the log lines
* the Repository attached to its result. */
if (!isEmpty(main_node)) {
const main_refs = Repository.apply_main_node_refs(
uci, uciconfig, ucimain, ucinode,
{ main_node, main_udp_node, has_nodes: added > 0 },
log
);
for (let line in main_refs.log)
log(line);
}
Repository.scrub_stale_urltest_refs(uci, uciconfig, log);
/* A2: the run's single transaction boundary.
*
* apply_nodes(), apply_main_node_refs() and scrub_stale_urltest_refs()
* stage every mutation on this one cursor and commit nothing. Before
* this, each of them committed for itself - eight sites inside the
* repository - so a crash (or a kill) between two of them could leave
* the subscription's nodes written while main_node still pointed at a
* section that had just been deleted. The commit below is the only
* point at which the run becomes visible, and the reload only ever sees
* a fully applied state.
*
* commit() returns true on success, so a failure is detected here rather
* than by generating a configuration from a half-written file. */
if (uci.commit(uciconfig) !== true) {
log('FAILED to commit the new configuration; the previous configuration is kept.');
return false;
}
/* Reload once, after the whole candidate set is committed
* and stale references are scrubbed. The old code stopped
* the service before fetching and then did stop+start; the
* reload path now validates the new configuration and rolls
* back if an instance fails to come up, so the service is
* only ever restarted onto a config that passed `sing-box
* check`. */
log('Reloading service...');
/* Run the init script directly. The previous code called
* `init_action('homeproxy', 'reload')` imported from luci.sys, but
* neither openwrt/luci nor immortalwrt/luci exports an `init_action`
* from that module (it has process_list / conntrack_list /
* init_list / init_index / init_enabled), so the import failed to
* resolve and this script could not load on a router at all.
*
* executeCommand() is the package's own runner (homeproxy.uc); unlike
* a bare system() call it returns the exit status and the captured
* stderr, so a failed reload is recorded instead of disappearing. */
const reload = executeCommand('/etc/init.d/homeproxy', 'reload');
if (reload.exitcode !== 0)
log(sprintf('Warning: reload exited with status %d: %s',
reload.exitcode, trim(reload.stderr || '')));
else if (reload.stderr_truncated)
/* Non-zero exit + stderr_truncated is unambiguous; the
* log line above already carries the message. The truncated
* case is the subtle one: a clean exit can still hide a
* sing-box check warning that was truncated out of view. */
log('Warning: reload stderr was truncated (>512 KiB); the captured output is incomplete.');
log(sprintf('%s nodes added, %s removed.', added, removed));
log('Successfully updated subscriptions.');
}
/* A4: the lock comes first, and every read happens inside it.
*
* This block used to test the subscription list before taking the lock, so
* the read of the configuration happened outside the critical section. The
* order is now: acquire -> read -> mutate -> commit -> reload -> release.
* The pre-lock "nothing configured" shortcut is preserved in meaning (no
* fetch, no write, no log) but it is decided after the lock is held; taking
* and releasing an uncontended mkdir lock costs nothing measurable next to
* the fetch that would follow. */
if (!acquire_lock()) {
log('Another subscription update is already running; skipping this one.');
}
else {
/* ucode has no `finally`, so the release is written out on both paths by
* the explicit call after the try/catch. */
try {
load_locked_state();
if (isEmpty(subscription_urls)) {
/* Nothing configured: no fetch, no write, no log line - the same
* silent no-op the pre-lock check performed. */
}
else {
call(main);
}
} catch(e) {
log('[FATAL ERROR] An error occurred during updating subscriptions:');
log(sprintf('%s: %s', e.type, e.message));
log(e.stacktrace[0].context);
if (config_backup != null) {
/* Write the snapshot beside the config and rename it into place.
* A bare writefile() truncates first, so a crash or a full disk
* part-way through the restore leaves /etc/config/homeproxy
* partial or empty - losing the very configuration this is
* meant to protect. rename() is atomic within a directory. */
const restore_tmp = CONFIG_FILE + '.hp-restore';
if (writefile(restore_tmp, config_backup) != null
&& system(sprintf('mv -f %s %s', shellQuote(restore_tmp), shellQuote(CONFIG_FILE))) === 0) {
log('Restored the previous configuration; the running service was not stopped.');
}
else {
system(sprintf('rm -f %s', shellQuote(restore_tmp)));
log('FAILED to restore the previous configuration - the file on disk may be incomplete.');
}
}
}
release_lock();
}
+616
View File
@@ -0,0 +1,616 @@
#!/bin/sh /etc/rc.common
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
#
# This file is the lifecycle orchestrator only. Everything
# that is not "read the configuration, decide, and drive procd" now lives in
# /etc/homeproxy/scripts/runtime/:
#
# config.sh generated-configuration transaction (known-good, restore,
# rollback) - pure, takes explicit paths
# health.sh the health gate: procd state + listener ownership, sustained
# over several samples
# service.sh sing-box version gate, auto-update cron, runtime-file
# preparation, procd instance registration, the
# generate->known-good transaction
# dns.sh dnsmasq snippet directory, snippet writing, teardown
# firewall.sh fw4 pre/post scripts, reload, owned-object teardown, upnp
# net.sh tproxy / TUN ip rules and routes, teardown
#
# What deliberately stays here: USE_PROCD and the start/stop/reload/
# service_triggers skeletons, the configuration reads that choose between
# client, server and neither, and the ordering that makes a reload safe
# (generate first, tear down later, prove it came up, roll back otherwise).
#
# The transaction, in order:
#
# start_service generate -> register with procd -> PROVE IT RUNS ->
# record known-good
# reload_service generate (abort before teardown if invalid) -> stop ->
# start -> if start could not prove the new configuration,
# roll back to the known-good copy and restart
#
# A3: on the reload path the generation happens exactly once. reload_service
# generates each enabled side and hands start_service the per-side
# HP_CANDIDATE_* flags; hp_start_generated_config() then activates that exact
# file instead of generating a second one. The generator's env (WAN resolver,
# domain-resource lists) can change between two invocations, so "validate A,
# discard A, generate B, run B" is no longer possible.
#
# "Prove it runs" is the health gate in runtime/health.sh. It used to be a
# single "is a matching process alive?" probe, which was satisfied by a stale
# process and by procd's own respawn loop while the service was dead - and
# because the generated file was recorded as known-good *before* that probe
# ran, the rollback had nothing left to restore.
#
# What the rollback does NOT cover: the dnsmasq snippets, the ip rules/routes
# and the nft rules are rebuilt from the *current* UCI on every start, so a
# reload that fails because of a UCI-level change (a port that is now taken, a
# proxy-mode switch) restores the sing-box configuration and leaves the network
# layer describing the new settings. Rolling that back as well would mean
# snapshotting UCI at promotion time; that is deliberately not attempted here,
# which is why this paragraph exists rather than a note in a plan document.
USE_PROCD=1
START=99
STOP=10
CONF="homeproxy"
PROG="/usr/bin/sing-box"
HP_DIR="/etc/homeproxy"
RUN_DIR="/var/run/homeproxy"
LOG_PATH="$RUN_DIR/homeproxy.log"
# Last configuration that was proven to run. start_service records it only
# after the health gate passed, and reload_service restores it when a new
# configuration fails to come up.
GOOD_DIR="$RUN_DIR/known-good"
# fw4 chain/set inventory; single source of truth shared with firewall_post.ut
[ -f "$HP_DIR/scripts/fw4_names.sh" ] && . "$HP_DIR/scripts/fw4_names.sh"
# Configuration transaction (known-good copy, restore, rollback) and the
# health gate that decides whether a configuration may be recorded or must be
# rolled back.
[ -f "$HP_DIR/scripts/runtime/config.sh" ] && . "$HP_DIR/scripts/runtime/config.sh"
[ -f "$HP_DIR/scripts/runtime/health.sh" ] && . "$HP_DIR/scripts/runtime/health.sh"
# Device-side orchestration extracted from the init script.
[ -f "$HP_DIR/scripts/runtime/service.sh" ] && . "$HP_DIR/scripts/runtime/service.sh"
[ -f "$HP_DIR/scripts/runtime/dns.sh" ] && . "$HP_DIR/scripts/runtime/dns.sh"
[ -f "$HP_DIR/scripts/runtime/firewall.sh" ] && . "$HP_DIR/scripts/runtime/firewall.sh"
[ -f "$HP_DIR/scripts/runtime/net.sh" ] && . "$HP_DIR/scripts/runtime/net.sh"
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
# hp_client_outbound_node <routing-mode> <dest-var>
# Read the client's "main node" reference into <dest-var>.
#
# The reference is mode-dependent: the built-in routing modes keep it on
# config.main_node, while custom routing keeps it on routing.default_outbound.
# start_service, service_started and reload_service all need this value to
# decide whether a client instance exists at all, and each of them used to
# spell the branch out itself.
hp_client_outbound_node() {
if [ "$1" != "custom" ]; then
config_get "$2" "config" "main_node" "nil"
else
config_get "$2" "routing" "default_outbound" "nil"
fi
}
# Resolve the dnsmasq snippet directory once, at load time. stop_service
# reuses the stored value rather than recomputing it, so a dnsmasq
# reconfiguration between start and stop cannot make it delete the wrong
# directory (see runtime/dns.sh).
hp_dnsmasq_resolve_dir
# hp_intercept_installed
# True when a previous start actually installed the intercept layer: the fw4
# post fragment holds the redirect/tproxy chains, the dnsmasq include file
# points at the snippets. Both live in tmpfs, so after a reboot there is
# nothing stale to release.
hp_intercept_installed() {
[ -s "$RUN_DIR/fw4_post.nft" ] || [ -e "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" ]
}
# hp_release_intercept
# Take the intercept layer back down, without touching the service's own
# instances (procd keeps supervising them).
#
# A start that cannot prove the client came up must not leave the redirect
# rules and the DNS snippets in place: they point the LAN's TCP and DNS at
# listeners that are not there, so the routers' clients lose both instead of
# merely not being proxied. The reload path gets that for free through its
# rollback; the start path (boot, a manual start, LuCI's Start button) has no
# rollback at all, which is the asymmetry this closes.
#
# The server's inbound accepts (fw4_forward/fw4_input) are left in place: a
# failed *client* does not make the server unreachable, and the two sides are
# independent instances.
#
# The release is recorded in $RUN_DIR/intercept-released so that service_started
# can put the layer back once the instance does come up. Without that marker the
# release is one-way: procd keeps respawning the instance, the health check can
# start passing on a later attempt, and nothing reinstalls the rules - the router
# then reports the service as running while the LAN is not proxied at all.
hp_release_intercept() {
log "Reverting the intercept layer; traffic will go out unproxied until the client is up."
hp_net_teardown
hp_firewall_teardown "$RUN_DIR" keep-pre
hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
printf '%s\n' "1" > "$RUN_DIR/intercept-released"
}
# hp_rearm_intercept <proxy-mode> <routing-mode>
# Reinstall the layer a failed start released, once the client is proven up.
# It only runs when that failure actually happened - on a healthy start there is
# nothing to redo - which is what makes the release in hp_release_intercept()
# reversible instead of a silent, permanent downgrade to direct routing.
hp_rearm_intercept() {
[ -f "$RUN_DIR/intercept-released" ] || return 0
rm -f "$RUN_DIR/intercept-released"
log "Client is up again; reinstalling the intercept layer."
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2"
hp_net_setup "$1" "$2"
hp_firewall_apply "$HP_DIR" "$RUN_DIR" "1" \
|| log "Error: the firewall rules could not be reinstalled; the proxy may not be enforced."
}
# hp_release_stale_intercept
# The same, but only when a previous start left something installed. Without
# the check every start of an unconfigured router would pay a full fw4 reload
# and a dnsmasq restart to undo rules that were never installed.
hp_release_stale_intercept() {
hp_intercept_installed || return 0
hp_release_intercept
}
start_service() {
config_load "$CONF"
# Every refusal below happens *before* this run installs anything, so a
# stale intercept layer is released rather than re-applied. Without it,
# a router whose node was removed (or whose sing-box just failed its
# version gate) keeps redirecting the LAN into a listener that no instance
# is serving any more.
if ! hp_require_singbox; then
hp_release_stale_intercept
return 1
fi
local routing_mode proxy_mode outbound_node server_enabled
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
hp_client_outbound_node "$routing_mode" outbound_node
config_get_bool server_enabled "server" "enabled" "0"
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
log "Neither client nor server is configured. Service not started."
hp_release_stale_intercept
return 1
fi
local client_enabled=0
[ "$outbound_node" = "nil" ] || client_enabled=1
# Client switched off (main_node cleared, or custom routing without a
# default outbound): the redirect rules and the DNS snippets from the
# previous start have to go, or the LAN keeps pointing at listeners no
# instance is serving. This matters more since a reload keeps the DNS
# layer across its stop (see stop_service): without it, a reload that
# disables the client would leave the snippets installed forever.
# The server's accepts (fw4_forward/fw4_input) are left alone.
if [ "$client_enabled" != "1" ]; then
hp_release_stale_intercept
fi
mkdir -p "$RUN_DIR"
# Kernel 6.6.x has a TUN/GSO bug that needs GSO turned off for the tun
# inbound; other releases either carry the fix or never had the problem.
local disable_gso=0
uname -r | grep -Eq "^6\.6" && disable_gso=1
# The health gate reads the ports the running configuration declares from
# sing-box-c.json (service_started -> hp_config_ports), not from UCI: after
# a rollback the live file is the older known-good copy and may bind
# different ports. Reading mixed_port/dns_port here as well only produced
# two unused locals.
if [ "$client_enabled" = "1" ]; then
hp_net_wait_wan 60
if ! hp_start_generated_config c "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
hp_release_stale_intercept
return 1
fi
local auto_update auto_update_time
config_get_bool auto_update "subscription" "auto_update" "0"
config_get auto_update_time "subscription" "auto_update_time" "2"
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
# The DNS snippets are the other half of the intercept layer: without
# them the LAN resolves through the ISP while the nft rules still send
# its DNS to sing-box's dns-in, which the redirect chain does not cover.
# The writer already reports failures (render or install) instead of
# leaving an empty snippet behind; record it the same way a firewall
# failure is recorded, so the configuration is not promoted to
# known-good and the log names the real problem.
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
fi
hp_net_setup "$proxy_mode" "$routing_mode"
# R-08: gfwlist mode routes by the nft set that dnsmasq populates
# (`nftset=`), and the post-template gates every proxy chain on
# `ip daddr != @homeproxy_gfw_list_v4`. On a dnsmasq without nftset the
# set is never filled, so that gate is true for every packet and the
# whole mode silently degrades to direct routing. Record the capability
# where the template can read it (it renders before this point on the
# next start, so the file has to exist by then) and let the template
# omit the gate instead: traffic then reaches sing-box, which routes it
# by geoip exactly like the nftset gate would have.
hp_dnsmasq_has_nftset && printf '%s\n' "1" > "$HP_DIR/resources/.dnsmasq-nftset" \
|| rm -f "$HP_DIR/resources/.dnsmasq-nftset"
hp_procd_client_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$routing_mode" "$disable_gso"
fi
if [ "$server_enabled" = "1" ]; then
if ! hp_start_generated_config s "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
# Only the server side failed. hp_release_stale_intercept() tears
# down the *client* layer (net rules + redirect chains + DNS
# snippets), so calling it here took a healthy, already-verified
# client off the air because a server certificate or port was bad -
# the opposite of the "the two sides are independent instances"
# rule this file states above. service_started()'s
# failed="sing-box-s" path handles the server side and already
# leaves the client layer alone.
return 1
fi
hp_procd_server_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$disable_gso"
fi
hp_procd_log_cleaner "$HP_DIR"
hp_prepare_runtime_files "$HP_DIR" "$RUN_DIR" "$routing_mode" "$client_enabled" "$server_enabled"
# A firewall that did not apply means traffic is not actually being
# intercepted. sing-box still runs (the service stays up), but the
# configuration must not become the rollback target - service_started()
# reads this marker before promoting.
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "$client_enabled"; then
log "Error: the firewall rules were not applied completely; the proxy may not be enforced."
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
fi
# Nothing is verified here on purpose. With USE_PROCD=1 the
# procd_open_instance/procd_close_instance calls above only *register* the
# instances; procd starts them when rc.common closes the service, which
# happens after this function returns. Gating here would sample a service
# that has not been started yet and always time out - the health gate
# therefore lives in service_started(), which rc.common calls at the point
# where the instances are actually up.
return 0
}
# service_started() is rc.common's post-start hook:
#
# start() { rc_procd start_service "$@"; service_started; }
#
# so this runs after procd_close_service. It is where a configuration earns
# the right to be called good.
service_started() {
config_load "$CONF"
local routing_mode outbound_node server_enabled proxy_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
hp_client_outbound_node "$routing_mode" outbound_node
config_get_bool server_enabled "server" "enabled" "0"
local client_enabled=0
[ "$outbound_node" = "nil" ] || client_enabled=1
[ "$client_enabled" = "1" ] || [ "$server_enabled" = "1" ] || return 0
# The listeners to verify are the ones the running configuration declares,
# read from the file rather than from UCI: after a rollback the live file
# is the older known-good copy, so UCI describes ports this configuration
# does not use.
#
# The gate checks that sing-box OWNS them: a port that is listening but
# belongs to another process is exactly the failure it has to catch.
#
# `sing-box check` cannot see a port that is already taken or a
# certificate it cannot read, and a single process probe cannot tell a
# healthy instance from procd restarting a dying one.
local failed="" client_ports server_ports
if [ "$client_enabled" = "1" ]; then
client_ports="$(hp_config_ports "$RUN_DIR/sing-box-c.json")"
hp_wait_service "sing-box-c" "$RUN_DIR/sing-box-c.json" \
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $client_ports \
|| failed="sing-box-c"
# The probes read their ports from the running configuration, not from
# UCI: after a rollback the live file is the older known-good copy and
# may bind different ports, and probing the candidate's ports would
# warn about a healthy service (or, with health_probe_strict on, roll
# it back again).
if [ -z "$failed" ]; then
local probe_dns_port probe_mixed_port probe_strict probe_name
probe_dns_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'dns-in')"
probe_mixed_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'mixed-in')"
config_get_bool probe_strict "config" "health_probe_strict" "0"
config_get probe_name "config" "health_probe_name" "example.com"
HP_PROBE_STRICT="$probe_strict"
HP_PROBE_NAME="$probe_name"
export HP_PROBE_STRICT HP_PROBE_NAME
hp_run_probes "client" "$probe_dns_port" "$probe_mixed_port" \
|| failed="sing-box-c"
fi
fi
if [ "$server_enabled" = "1" ] && [ -z "$failed" ]; then
server_ports="$(hp_config_ports "$RUN_DIR/sing-box-s.json")"
hp_wait_service "sing-box-s" "$RUN_DIR/sing-box-s.json" \
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $server_ports \
|| failed="sing-box-s"
fi
if [ -n "$failed" ]; then
# start() returns procd's status, not start_service's, so the failure
# is recorded where reload_service cannot miss it. A file is used
# rather than a shell variable because service_started and
# reload_service are not the same shell invocation.
printf '%s\n' "$failed" > "$RUN_DIR/failed-side"
log "Error: $failed did not come up with the new configuration."
# The intercept rules were installed by start_service before this
# gate ran, and on the *start* path nothing else takes them back
# down: the LAN would keep sending TCP to the redirect port and DNS
# to the dnsmasq snippets while no instance is listening. Only a
# failed client black-holes the LAN - the server's accepts are
# inbound and a failed *server* leaves the client working - so this
# is scoped to the client side. On the reload path the rollback
# below re-applies everything on its way to the old configuration.
[ "$failed" = "sing-box-c" ] && hp_release_intercept
# The rollback is driven by reload_service, which can stop and
# restart the service.
return 1
fi
rm -f "$RUN_DIR/failed-side"
# A previous start may have released the intercept layer because the client
# did not come up (hp_release_intercept). procd respawns the instance, and
# now that it is proven healthy the rules have to come back - otherwise the
# service runs and reports "running" while the LAN is not proxied.
[ "$client_enabled" = "1" ] && hp_rearm_intercept "$proxy_mode" "$routing_mode"
# Proven good: this is what a later failed reload rolls back to. A
# configuration whose intercept layer is incomplete is not "good" - traffic
# is not being intercepted the way the configuration says - so it is not
# recorded, and the previous known-good survives as the rollback target.
# Two independent markers, because a successful hp_firewall_apply() must not
# be able to mask a failed snippet install (or the other way round).
if [ -s "$RUN_DIR/failed-firewall" ] || [ -s "$RUN_DIR/failed-dns" ]; then
[ -s "$RUN_DIR/failed-firewall" ] \
&& log "Warning: the firewall rules did not apply; keeping the previous known-good configuration."
[ -s "$RUN_DIR/failed-dns" ] \
&& log "Warning: the DNS snippets were not installed; keeping the previous known-good configuration."
rm -f "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
else
[ "$client_enabled" = "1" ] && hp_promote_known_good c "$RUN_DIR" "$GOOD_DIR"
[ "$server_enabled" = "1" ] && hp_promote_known_good s "$RUN_DIR" "$GOOD_DIR"
fi
log "sing-box $(sing-box version -n 2>/dev/null || echo "unknown") started."
return 0
}
stop_service() {
hp_clear_autoupdate_cron
config_load "$CONF"
# P1-8: on a reload the network layer is left in place too. The ip rules and
# the routing table are the same before and after a configuration change
# (they derive from the infra marks, not from the nodes), hp_net_setup() is
# idempotent since r20, and tearing them down here only opened a window in
# which the tproxy rules were gone. A real stop still removes them.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_net_teardown
hp_firewall_teardown "$RUN_DIR"
# A reload is a restart, not a shutdown, and it keeps the DNS layer: the
# snippets are re-rendered by start_service moments later, and their writer
# compares content before restarting dnsmasq. Taking them down here would
# leak the LAN's queries to the ISP resolvers for the whole stop/start
# window and restart dnsmasq twice (flushing every client's cache) for a
# configuration that did not change. The cost is that DNS points at
# sing-box's dns-in while the instances are down - a few seconds of failed
# lookups instead of a few seconds of plaintext queries. A real stop (no
# flag) removes them, and a reload that disables the client releases them
# through hp_release_stale_intercept() in start_service.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log" \
"$RUN_DIR/failed-side" "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
# A released marker only means something within one start; a stop clears it
# so the next start does not reinstall a layer it is about to install anyway.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || rm -f "$RUN_DIR/intercept-released"
log "Service stopped."
}
service_stopped() {
config_load "$CONF"
hp_net_remove_tun
}
reload_service() {
log "Reloading service..."
config_load "$CONF"
local routing_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
local outbound_node
hp_client_outbound_node "$routing_mode" outbound_node
# Generate first, tear down later. If the new configuration is invalid
# the generator leaves the running one in place, so the reload is
# aborted *before* stop_service() - the old configuration keeps running
# instead of the router ending up with no service at all.
#
# A3: whichever sides are generated here are the candidates start_service
# must activate - it must not generate them a second time. Two invocations
# of the generator are not guaranteed to write the same bytes (its env
# carries the WAN resolver and the domain-resource lists), so regenerating
# in start_service would run a file that was never `sing-box check`ed:
# validate A -> discard A -> generate B -> run B.
#
# The flags are per side on purpose: reload_service and start_service each
# do their own config_load, and if the two disagree (a concurrent UCI
# change) the side this preflight did not generate must still be generated
# rather than activated from a stale file.
HP_CANDIDATE_CLIENT=0
HP_CANDIDATE_SERVER=0
if [ "$outbound_node" != "nil" ]; then
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
return 1
fi
HP_CANDIDATE_CLIENT=1
fi
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
if [ "$server_enabled" = "1" ]; then
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
return 1
fi
HP_CANDIDATE_SERVER=1
fi
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
# file that was just validated would not survive to start_service. Stage a
# copy of each validated side first; the stop that follows cannot reach it,
# and start_service activates those exact bytes. A staging failure aborts
# the reload while the running configuration is still untouched.
if [ "$HP_CANDIDATE_CLIENT" = "1" ]; then
if ! hp_capture_candidate "$RUN_DIR" c; then
log "Error: could not stage the validated client configuration; reload aborted; the running configuration is kept."
return 1
fi
fi
if [ "$HP_CANDIDATE_SERVER" = "1" ]; then
if ! hp_capture_candidate "$RUN_DIR" s; then
log "Error: could not stage the validated server configuration; reload aborted; the running configuration is kept."
return 1
fi
fi
# `start` runs start_service in this same shell, but export them anyway so
# the contract does not depend on that: HP_USE_KNOWN_GOOD is exported the
# same way in the rollback path below.
export HP_CANDIDATE_CLIENT HP_CANDIDATE_SERVER
# Both configurations passed `sing-box check`; now it is safe to restart.
# start_service owns the rest of the transaction: it proves the instances
# came up and only then records the new known-good, so the previous one
# survives until that point - which is what makes the rollback below
# possible.
#
# The flag tells stop_service that this stop belongs to a reload, so the
# DNS layer is left in place for start_service to re-render (it compares
# content and only restarts dnsmasq when something actually changed). It
# stays set for the rollback path below, which is also a reload.
HP_RELOAD_IN_PROGRESS=1
export HP_RELOAD_IN_PROGRESS
stop
start
# start_service proved the candidate does not run (it left the marker).
# The known-good copy still holds the previous configuration, because a
# candidate is no longer recorded before it has proven itself.
local failed=""
[ -s "$RUN_DIR/failed-side" ] && failed="$(cat "$RUN_DIR/failed-side")"
# reload_service and start_service each do their own config_load. If the
# two disagree - a concurrent reload, or a read that fell back to the UCI
# defaults - the marker can be missing while nothing was started at all.
# The filesystem is the tie-breaker: a side this reload believed was
# enabled must have produced a live configuration.
if [ -z "$failed" ] && [ "$outbound_node" != "nil" ] && [ ! -s "$RUN_DIR/sing-box-c.json" ]; then
failed="sing-box-c"
fi
if [ -z "$failed" ] && [ "$server_enabled" = "1" ] && [ ! -s "$RUN_DIR/sing-box-s.json" ]; then
failed="sing-box-s"
fi
if [ -z "$failed" ]; then
log "Reload completed."
return 0
fi
log "Error: $failed did not come up with the new configuration, rolling back."
local good="$GOOD_DIR/$failed.json"
local live="$RUN_DIR/$failed.json"
local rolled_back=0
if hp_same_file "$live" "$good"; then
log "Error: the failing configuration is already the known-good one; nothing to roll back to."
elif hp_rollback "$live" "$good"; then
rolled_back=1
else
log "Error: no known-good configuration to roll back to."
fi
if [ "$rolled_back" = "0" ]; then
log "Error: rollback was not possible; the service may be down. Check $LOG_PATH."
return 1
fi
# Start from the recorded file rather than regenerating: the generator
# is a pure function of UCI, so it would rebuild the config that just
# failed.
HP_USE_KNOWN_GOOD=1
# The failed candidate has just exhausted procd's respawn retries, so the
# restart below can take noticeably longer than a normal start.
HP_HEALTH_BUDGET="$HP_HEALTH_ROLLBACK_BUDGET"
export HP_USE_KNOWN_GOOD HP_HEALTH_BUDGET
stop
start
if [ ! -s "$RUN_DIR/failed-side" ]; then
log "Rollback completed; the previous configuration is running."
return 0
fi
log "Error: rollback did not restore the service; manual intervention required."
return 1
}
service_triggers() {
procd_add_reload_trigger "$CONF"
# `wan` is the logical name netifd and the firewall use on every target
# (a PPPoE uplink is `wan` at the logical layer, `pppoe-wan` only at the
# device layer), so this does not need a per-device lookup.
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
}
@@ -0,0 +1,31 @@
#!/bin/sh
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
uci -q batch <<-EOF >"/dev/null"
delete firewall.homeproxy_pre
delete firewall.homeproxy_forward
set firewall.homeproxy_forward=include
set firewall.homeproxy_forward.type=nftables
set firewall.homeproxy_forward.path="/var/run/homeproxy/fw4_forward.nft"
set firewall.homeproxy_forward.position="chain-pre"
set firewall.homeproxy_forward.chain="forward"
delete firewall.homeproxy_input
set firewall.homeproxy_input=include
set firewall.homeproxy_input.type=nftables
set firewall.homeproxy_input.path="/var/run/homeproxy/fw4_input.nft"
set firewall.homeproxy_input.position="chain-pre"
set firewall.homeproxy_input.chain="input"
delete firewall.homeproxy_post
set firewall.homeproxy_post=include
set firewall.homeproxy_post.type=nftables
set firewall.homeproxy_post.path="/var/run/homeproxy/fw4_post.nft"
set firewall.homeproxy_post.position="table-post"
commit firewall
EOF
exit 0
@@ -0,0 +1,5 @@
#!/bin/sh
ucode "/etc/homeproxy/scripts/migrate_config.uc"
exit 0
@@ -0,0 +1,45 @@
{
"admin/services/homeproxy": {
"title": "HomeProxy",
"order": 10,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-homeproxy" ],
"uci": { "homeproxy": true }
}
},
"admin/services/homeproxy/client": {
"title": "Client Settings",
"order": 10,
"action": {
"type": "view",
"path": "homeproxy/client"
}
},
"admin/services/homeproxy/node": {
"title": "Node Settings",
"order": 15,
"action": {
"type": "view",
"path": "homeproxy/node"
}
},
"admin/services/homeproxy/server": {
"title": "Server Settings",
"order": 20,
"action": {
"type": "view",
"path": "homeproxy/server"
}
},
"admin/services/homeproxy/status": {
"title": "Service Status",
"order": 30,
"action": {
"type": "view",
"path": "homeproxy/status"
}
}
}
@@ -0,0 +1,43 @@
{
"luci-app-homeproxy": {
"description": "Grant access to homeproxy configuration",
"read": {
"file": {
"/var/run/homeproxy/homeproxy.log": [ "read" ],
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
},
"ubus": {
"service": [ "list" ],
"luci.homeproxy": [
"acllist_read",
"connection_check",
"node_parse",
"resources_get_version",
"singbox_get_features",
"update_subscriptions_status"
]
},
"uci": [ "homeproxy" ]
},
"write": {
"file": {
"/tmp/homeproxy_cert_server_publickey.tmp": [ "write" ],
"/tmp/homeproxy_cert_server_privatekey.tmp": [ "write" ],
"/tmp/homeproxy_cert_client_ca.tmp": [ "write" ],
"/tmp/homeproxy_cert_client_ech_conf.tmp": [ "write" ]
},
"ubus": {
"luci.homeproxy": [
"acllist_write",
"certificate_write",
"log_clean",
"resources_update",
"singbox_generator",
"update_subscriptions"
]
},
"uci": [ "homeproxy" ]
}
}
}
@@ -0,0 +1,442 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023-2024 ImmortalWrt.org
*/
'use strict';
import { access, error, lstat, popen, readfile, writefile } from 'fs';
import { isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy/scripts/homeproxy.uc';
import { parse_uri } from '/etc/homeproxy/scripts/parser/uri.uc';
/* Kanged from ucode/luci - replaced by the imported shellQuote() so every shell
* argument in this file goes through one helper. */
function hasKernelModule(kmod) {
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
}
const HP_DIR = '/etc/homeproxy';
const RUN_DIR = '/var/run/homeproxy';
const LOG_PATH = `${RUN_DIR}/homeproxy.log`;
/* Queried by singbox_get_features and by node_parse: the share-link parser
* skips QUIC protocols and uTLS fingerprints the running sing-box cannot
* serve, so it needs the same feature set the UI displays. */
function collectFeatures() {
let features = {};
const fd = popen('/usr/bin/sing-box version');
if (fd) {
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (match(trim(line), /^sing-box version (.*)/))
features.version = match(trim(line), /^sing-box version (.*)/)[1];
let tags = match(trim(line), /^Tags: (.*)/);
if (tags)
for (let i in split(tags[1], ','))
features[i] = true;
}
fd.close();
}
features.hp_has_ip_full = access('/usr/libexec/ip-full');
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
return features;
}
const methods = {
acllist_read: {
args: { type: 'type' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { content: null, error: 'illegal type' };
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
return { content: filecontent };
}
},
acllist_write: {
args: { type: 'type', content: 'content' },
call: function(req) {
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
return { result: false, error: 'illegal type' };
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
let content = req.args?.content;
/* Sanitize content */
if (content) {
content = trim(content);
content = replace(content, /\r\n?/g, '\n');
if (!match(content, /\n$/))
content += '\n';
/* Domains only: reject lines that could break the downstream
dnsmasq/nftset injection (whitespace / shell or directive chars).
Review L1: the rejection used to say "invalid character in
domain list" with no line number, so a 200-line list with one
bad entry forced the user to bisect by hand. Report line 1
for the first bad row so the user can jump straight to it. */
const lines = split(content, /\n/);
for (let i = 0; i < length(lines); i++) {
const line = lines[i];
if (!trim(line))
continue;
if (match(line, /[ \t\/\\;<>="'&#|]/))
return { result: false, error: `line ${i + 1}: invalid character in domain list (only domains, no spaces/comments)` };
}
}
/* writefile() with a null body writes nothing useful; an empty
* body is what clearing the field means. */
if (content == null)
content = '';
system(`mkdir -p ${shellQuote(HP_DIR)}/resources`);
writefile(file, content);
return { result: true };
}
},
certificate_write: {
args: { filename: 'filename' },
call: function(req) {
/* `kind` is 'cert', 'key' or 'ech'. Each certificate button
* has its own PEM shape; an ECH config list in particular is
* neither a certificate nor a key, so reusing isValidPEM()
* for it would reject every valid upload with a misleading
* "does not look like a correct PEM file". */
const writeCertificate = (filename, kind) => {
/* Per-button staging path. The previous code used a
* single /tmp/homeproxy_certificate.tmp shared by every
* certificate button, which was a race: the second upload
* landed on top of the first while the backend was still
* reading it, and a slow upload could overwrite a faster
* one. The frontend (homeproxy.js uploadCertificate)
* derives the same path from the UCI option name, and
* the ACL write list enumerates all four. */
const tmpcert = `/tmp/homeproxy_cert_${filename}.tmp`;
const filestat = lstat(tmpcert);
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
system(`rm -f ${shellQuote(tmpcert)}`);
return { result: false, error: 'empty certificate file' };
}
let filecontent = readfile(tmpcert);
if (isBinary(filecontent)) {
system(`rm -f ${shellQuote(tmpcert)}`);
return { result: false, error: 'illegal file type: binary' };
}
/* Validate the PEM structure before storing it */
const wellformed = kind === 'ech'
? isValidECHConfig(filecontent)
: isValidPEM(filecontent, kind === 'key');
if (!wellformed) {
system(`rm -f ${shellQuote(tmpcert)}`);
return { result: false, error: 'this does not look like a correct PEM file' };
}
/* Sanitize certificate */
filecontent = trim(filecontent);
filecontent = replace(filecontent, /\r\n?/g, '\n');
if (!match(filecontent, /\n$/))
filecontent += '\n';
system(`mkdir -p ${shellQuote(HP_DIR)}/certs`);
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
/* writefile() is fopen(path, "w"): 0666 & ~umask, i.e. 0644 at
* the usual 022 - world-readable, on a file that can be a TLS
* private key. Start it at 0600; the start path
* (runtime/service.sh, hp_prepare_runtime_files) hands the
* directory and its files to the sing-box user so the jailed
* server can read them, with the mode kept at 0600. */
system(`chmod 600 ${shellQuote(HP_DIR)}/certs/${shellQuote(filename + '.pem')}`);
system(`rm -f ${shellQuote(tmpcert)}`);
return { result: true };
};
const filename = req.args?.filename;
switch (filename) {
case 'client_ca':
case 'server_publickey':
return writeCertificate(filename, 'cert');
break;
case 'server_privatekey':
return writeCertificate(filename, 'key');
break;
/* The node form's fourth upload button. It was missing here
* from the initial commit, so the button always failed with
* "illegal cerificate filename" while the ACL granted the
* two write paths - the frontend assumed the backend case
* existed and nothing tested that it did. */
case 'client_ech_conf':
return writeCertificate(filename, 'ech');
break;
default:
return { result: false, error: 'illegal cerificate filename' };
break;
}
}
},
connection_check: {
args: { site: 'site' },
call: function(req) {
let url;
switch(req.args?.site) {
case 'baidu':
url = 'https://www.baidu.com';
break;
case 'google':
url = 'https://www.google.com';
break;
default:
return { result: false, error: 'illegal site' };
break;
}
return { result: (system(`/usr/bin/wget --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0) };
}
},
log_clean: {
args: { type: 'type' },
call: function(req) {
if (index(['homeproxy', 'sing-box-c', 'sing-box-s'], req.args?.type) === -1)
return { result: false, error: 'illegal type' };
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
if (filestat)
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
return { result: true };
}
},
singbox_generator: {
args: { type: 'type', params: 'params' },
call: function(req) {
if (index(['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair'], req.args?.type) === -1)
return { result: false, error: 'illegal type' };
const type = req.args?.type;
let result = {};
const fd = popen(`/usr/bin/sing-box generate ${shellQuote(type)} ${shellQuote(req.args?.params || '')}`);
if (fd) {
let ech_cfg_set = false;
let ech_key_set = false;
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
if (type === 'uuid')
result.uuid = trim(line);
else if (index(['reality-keypair', 'vapid-keypair', 'wg-keypair'], type) !== -1) {
let priv = match(trim(line), /PrivateKey: (.*)/);
if (priv)
result.private_key = priv[1];
let pub = match(trim(line), /PublicKey: (.*)/);
if (pub)
result.public_key = pub[1];
} else if (type === 'ech-keypair') {
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
ech_cfg_set = true;
else if (trim(line) === '-----BEGIN ECH KEYS-----')
ech_key_set = true;
if (ech_cfg_set)
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
if (ech_key_set)
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
if (trim(line) === '-----END ECH CONFIGS-----')
ech_cfg_set = false;
else if (trim(line) === '-----END ECH KEYS-----')
ech_key_set = false;
}
}
fd.close();
}
return { result };
}
},
singbox_get_features: {
call: function() {
return collectFeatures();
}
},
/* Parse one share link with the same parser the subscription pipeline
* uses (parse_uri.uc). This exists so the browser does not need a second
* implementation: the LuCI form used to carry a 388-line JavaScript copy
* of the parser that had already drifted from the backend (the vmess
* branch lost vmess_global_padding, anytls defaulted a missing port to
* 80) and that wrote nodes into UCI without the /sbin/validate_data
* host/port checks parse_uri() applies. */
node_parse: {
args: { uri: 'string' },
call: function(req) {
const uri = req.args?.uri;
if (type(uri) !== 'string' || !length(uri) || length(uri) > 4096)
return { config: null, error: 'illegal share link' };
let config;
try {
/* parse_uri() reports skipped nodes through the logger;
* the caller only needs the verdict. */
config = parse_uri(trim(uri), collectFeatures(), function() {});
} catch (e) {
return { config: null, error: 'parse failed: ' + e.message };
}
if (!config)
return { config: null, error: 'unsupported or invalid share link' };
return { config: config };
}
},
resources_get_version: {
args: { type: 'type' },
call: function(req) {
/* whitelist resource types to prevent path traversal */
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
return { error: 'illegal type', version: null, updated_at: null };
let version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
/* Review M7: the version file holds the upstream commit date
* and SHA; .updated_at is when *this router* last succeeded.
* The two are not the same - a successful run on a stale
* router still records the same upstream date, so the UI
* shows both. */
/* The shipped .ver files hold a single timestamp (the format the
* resource updater used before it started recording the upstream
* commit); a runtime update writes "<date> <sha>". Show the date
* part when there is one, and the value as it stands otherwise.
*
* No non-capturing groups: ucode's regex engine rejects `(?:...)`
* with "Repetition not preceded by valid expression" when the
* literal is evaluated, so it compiles fine and then takes this
* method down at call time. */
const fields = version ? match(version, /^(\S+) \S+$/) : null;
version = fields ? fields[1] : version;
/* Only a missing .ver means "not found"; that is the case the
* UI paints red. */
if (!version)
return { version: null, updated_at: null, error: 'no version file' };
/* .updated_at is written by update_resources.sh after a
* successful run, so a device that has never updated anything
* legitimately has none. Returning error() here reported the
* errno left behind by *that* missing file, which made every
* fresh install show "not found" in red next to a perfectly
* good version string. */
const updated_at = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.updated_at`));
return { version: version, updated_at: updated_at || null };
}
},
resources_update: {
args: { type: 'type' },
call: function(req) {
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
return { status: 255, error: 'illegal type' };
if (req.args?.type) {
const exit_code = system(`${shellQuote(HP_DIR)}/scripts/update_resources.sh ${shellQuote(req.args?.type)}`);
return { status: exit_code };
} else
return { status: 255, error: 'illegal type' };
}
},
update_subscriptions: {
/* Run the subscription updater once and report how it went.
*
* The LuCI button used fs.exec_direct() on
* /etc/homeproxy/scripts/update_subscriptions.uc, which is the only
* reason the package ACL had to grant `file exec` on a root-owned
* entry point: the browser session was authorised to execute that
* path. Through one named ubus method the session is granted the
* action instead, and the ACL lists no executable path at all
* (arch-guard 30).
*
* The previous synchronous shape returned the orchestrator's exit
* status and captured stderr, but the full pipeline (wget fetch
* through a CN -> overseas link, UCI commit, /etc/init.d/homeproxy
* reload, the 30 s sing-box health gate) regularly took 10-15 s,
* which exceeded the browser XHR timeout on every browser we tried.
* The UI button then surfaced "XHR request timed out" while the
* backend happily completed the work; the user was left with a
* stale page and no obvious way to know whether anything happened.
*
* The fix: spawn the script detached via "sh -c '... &'", return
* immediately, and let the frontend poll update_subscriptions_status
* until the orchestrator's lock file is gone (created in
* acquire_lock(), removed on every exit path). The lock is also
* what serialises two simultaneous updates, so refusing a second
* call while the lock is present is consistent with the script's
* own behaviour. The script's stdio is redirected so it cannot
* block on a closed pipe when the rpcd process exits. */
call: function() {
const lock = `${RUN_DIR}/update_subscriptions.lock`;
if (lstat(lock))
return { result: false, error: 'Subscription update already running', running: true };
/* Trailing '&' is what makes this return immediately: /bin/sh
* backgrounds the script before the shell exits, and system()
* sees only the (sub-)shell's exit. Redirections (<dev/null,
* >dev/null, 2>&1) are the other half: the orphaned script
* must not hold open any fd inherited from rpcd, or it
* keeps rpcd's stdio alive across the call. */
const script = shellQuote(`${HP_DIR}/scripts/update_subscriptions.uc`);
system(`sh -c ${shellQuote(`${script} </dev/null >/dev/null 2>&1 &`)}`);
return { result: true, async: true };
}
},
update_subscriptions_status: {
/* Status companion to update_subscriptions. Reports whether the
* updater is still running (lock file present) and the last ~12
* lines of the homeproxy log so the page can show what the run
* said without re-reading everything. The log is world-readable
* and is rotated by runtime/service.sh -> log-cleaner.sh.
*
* `readfile()` returns null (not throws) on a missing file in this
* ucode build, and the downstream split/slice/join then propagate
* that null straight through. Guard with a truthy check on the
* raw text so log_tail stays a string even when the log has never
* been written - the test_rpc_methods shape contract pins it as
* `string`, and the UI otherwise shows "null" in the toast. */
call: function() {
const lock = `${RUN_DIR}/update_subscriptions.lock`;
const running = !!lstat(lock);
let tail = '';
try {
const text = readfile(`${LOG_PATH}`);
if (text) {
const lines = split(text, '\n');
const start = length(lines) > 12 ? length(lines) - 12 : 0;
tail = join('\n', slice(lines, start)) || '';
}
} catch (e) {
tail = '';
}
return { running, log_tail: tail };
}
}
};
return { 'luci.homeproxy': methods };