⛅ Sync 2026-09-29 21:45:00

This commit is contained in:
github-actions[bot] committed 2026-09-29 21:45:00 +08:00
1 parent a4d6e3e4b9
commit da77daf88e
191 files changed
+8077 -6247

No files matched your search

@@ -0,0 +1,19 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
NAME="homeproxy-pro"
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-pro.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,441 @@
/*
* 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-pro';
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-pro.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-pro');
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'),
sniffer_advanced_mode: opt(uci, 'main', 'sniffer_advanced_mode'),
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-pro.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-pro uses to clean them up
* on stop; add/remove entries there together with any change below.
* tests/ucode/test_fw4_names.sh compares both lists, and
* tests/ucode/test_firewall_template.sh renders this template and checks the
* objects actually survive into the generated nftables script.
*
* Every UCI-derived value (IPv4, MAC, interface name, port) goes through
* one of the *_to_nftarr() helpers in firewall_utils.uc - not because the
* UCI writers are untrusted (UI validation is there), but because UCI is
* writable from anywhere on the LAN and the package principle is "backend
* must enforce". Why those helpers exist, and why dropping silently
* matches the IPv6 path, is pinned by tests/ucode/test_firewall_validators.uc
* and guard 11 in tests/arch-guard.sh.
*
* Keep this comment inside the ucode block: text placed before `{%-` has its
* trailing newline trimmed, which would glue it onto the first nft statement.
*/
import { readfile } from 'fs';
import { cursor } from 'uci';
import { isEmpty, isValidCIDR } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc';
import {
ipv4_to_nftarr,
mac_to_nftarr,
iface_to_nftarr,
ports_to_nftarr,
isValidPort,
isValidIface
} from '/etc/homeproxy-pro/scripts/firewall_utils.uc';
const fw4 = require('fw4');
function array_to_nftarr(array) {
if (type(array) !== 'array')
return null;
return `{ ${join(', ', uniq(array))} }`;
}
function resolve_ipv6(str) {
if (isEmpty(str))
return null;
let ipv6 = fw4.parse_subnet(str)?.[0];
if (!ipv6 || ipv6.family !== 6)
return null;
if (ipv6.bits > -1)
return `${ipv6.addr}/${ipv6.bits}`;
else
return ipv6.addr;
}
/* 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-pro/resources';
/* UCI config start */
const cfgname = 'homeproxy-pro';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
if (routing_mode !== 'custom') {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
} else {
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
}
let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
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-pro.uc';
const cfgname = 'homeproxy-pro';
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-pro section
* in /etc/config/homeproxy-pro 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-pro.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-pro.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-pro.
#
# * root/etc/homeproxy-pro/scripts/firewall_post.ut declares these objects and
# decides which of them actually exist for the current proxy mode.
# * root/etc/init.d/homeproxy-pro 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-pro 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-pro.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-pro.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-pro.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,207 @@
/*
* 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-pro.uc';
import { ConfigQuery } from '../config/model.uc';
/* parse_port() lives in homeproxy-pro.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-pro.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. Every routing mode benefits
* from it: bypass_mainland_china and custom match a lot of DNS, and the
* other modes still resolve the proxy node hostname and resolve any
* domain the user clicks through. cache_file is a no-op on a fresh
* install (no cache to read) and the file is the only piece of state the
* generator writes outside /etc/config/homeproxy-pro. Routing-mode gating
* used to limit it to bypass_mainland_china + custom, which left
* gfwlist / proxy_mainland_china / global with cold-start DNS on every
* reload and made the difference between modes hard to explain.
*
* Note: docs/linux.json 与 pro 的差距分析.md §2.5 once asked for an
* explicit `reverse_mapping: true` here (so a future sing-box change to
* the default would not silently move us off the mapping table). The
* sing-box 1.14.0-r1 we test against rejects that field as
* "json: unknown field" - the field is recognised by newer builds but
* not the floor pinned by arch-guard 31. sing-box's default is already
* `true`, so leaving it implicit matches runtime behaviour and keeps the
* generated config compatible with the test target. Revisit if the
* floor moves past the release where the field was introduced. */
export function attachExperimental(config, routing_mode, dns_store_dns) {
config.experimental = {
cache_file: {
enabled: true,
path: '/etc/homeproxy-pro/cache.db',
store_dns: (dns_store_dns === '1') ? true : null
}
};
return config;
};
@@ -0,0 +1,240 @@
/*
* 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-pro/resources/direct_list.txt
* proxy_domain_list, /etc/homeproxy-pro/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-pro.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.
*
* The proxy/custom asymmetry the generator then exposes to users:
* proxy mode's route.default_domain_resolver is the literal
* 'default-dns' (route.uc line 78-81), while custom mode's is the
* get_resolver(default_outbound_dns, dm) lookup (route.uc line
* 217-220). The two are deliberately different: proxy mode does
* not surface a user-facing "default outbound DNS" choice (the
* preset path always resolves through the WAN DNS), while custom
* mode lets the user pick. README does not promise otherwise. */
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: evaluate/match_response fallback for unknown-host
* queries (tutorial §2.1 / docs/linux.json 与 pro 的差距分析.md).
* The v28.9.1.16 flip turns this on by default for fresh installs;
* migrate_config.uc writes '0' explicitly for upgrade-existing users
* so they keep the prior behaviour until they opt in. '0' is truthy
* in JS so the || picks it over '1' when UCI sets it to '0' - a
* user who has cn_ip_fallback='0' written (the upgrade path) gets
* exactly what they had. */
cn_ip_fallback: dm.general.cn_ip_fallback || '1',
/* sniffer_advanced_mode: when '1' (opt-in), the route sniff rule
* gets the universal protocol list + 100ms timeout (tutorial §2.7 /
* docs/linux.json 与 pro 的差距分析.md). Default '0' preserves the
* 300ms / default-list behaviour so an upgrade is invisible. */
sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0',
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-pro/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,482 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/dns.uc: build the sing-box `dns` block.
*
* Two routing-mode families exist:
*
* 1. bypass_mainland_china / proxy_mainland_china / global /
* gfwlist - the proxy path. The orchestrator passes main_node
* (possibly 'urltest'), main_udp_node, china_dns_server,
* direct_domain_list and proxy_domain_list in ctx; the legacy
* NAPTR trick (qtype 35) and the CN-IP fallback (cn_ip_fallback)
* are part of this path.
*
* 2. custom - the user-defined rules path. The orchestrator passes
* default_outbound, default_outbound_dns, the dns settings
* (default_strategy, default_server, disable_cache, ...) and the
* dns_server / dns_rule section lists under dm.dns.
*
* The dns_server / dns_rule iteration is the part that is mode-agnostic
* and lives in this module rather than the orchestrator because every
* field comes straight from UCI and the legacy 1.14 evaluate/match_response
* rewrite is the per-field payload.
*/
'use strict';
import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy-pro.uc';
import { get_outbound, get_resolver, get_ruleset } from './common.uc';
/* Build a DNS server spec from a UCI address string. sing-box wants the
* server name, the protocol kind, an optional path, and an integer port;
* the UCI representation is a single string, optionally without a
* scheme, which is why a default_protocol is supplied. */
function parse_dnsserver(server_addr, default_protocol) {
if (isEmpty(server_addr))
return null;
if (!match(server_addr, /:\/\//))
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
/* parseURL() returns null for an address it cannot make sense of (a bad
* host, an unknown scheme). The old code dereferenced the result straight
* away, so one malformed UCI value took the whole generator down with
* "left-hand side expression is null" instead of naming the dns_server. */
server_addr = parseURL(server_addr);
if (!server_addr)
return null;
return {
type: server_addr.protocol,
server: server_addr.hostname,
server_port: strToInt(server_addr.port),
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
}
}
/* Parse the DNS rule query_type list UCI emits. A single value comes as
* a string, a list as an array of strings; both forms are turned into an
* array of strings or integers (mixing the two is intentional - the type
* can be a name or a numeric code in UCI). */
function parse_dnsquery(strquery) {
if (type(strquery) !== 'array' || isEmpty(strquery))
return null;
let querys = [];
for (let i in strquery)
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
return querys;
}
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
* (china-dns's default backend, 223.5.5.5) returns NAPTR with multi-
* second first responses for these suffixes, which sing-box reports as
* 'context deadline exceeded' (sipgz12.hbq.r.10086.cn being the
* reproducible offender); routing the qtype to default-dns (the ISP
* resolver or the wan_dns fallback) keeps IMS/VoLTE registration fast.
*
* The list is deliberately small: every entry is a suffix match, so
* adding a single host name can broaden the rule by accident. Add new
* suffixes here when a fresh NAPTR offender is observed, not when
* someone reads the comment and thinks "I know a domain that should
* go direct". */
const NAPTR_BYPASS_SUFFIXES = [
'r.10086.cn',
'10086.cn',
'pub.3gppnetwork.org'
];
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',
/* Mirror default-dns's strategy: china-dns itself is
* overwhelmingly IPv4-only in practice (223.5.5.5 etc.),
* so the field mostly affects the upstream chain; the
* two should still track each other for the same reason
* default-dns follows ipv6_support. */
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : 'prefer_ipv6'
},
detour: ctx.self_mark ? 'direct-out' : null,
...parse_dnsserver(ctx.china_dns_server)
});
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
default-dns directly: see NAPTR_BYPASS_SUFFIXES for the
rationale and the explicit offenders. */
push(config.dns.rules, {
query_type: [35],
domain_suffix: NAPTR_BYPASS_SUFFIXES,
action: 'route',
server: 'default-dns'
});
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'
});
}
}
}
/* Known DoH/DoT/DoQ hostnames and the IPs we should pin them to. The
* table is consulted by append_custom_dns() to detect when a user
* configured one of these encrypted upstreams; without the predefined
* entry, the resolver itself has to be resolved through the system DNS,
* and a DNS outage of any kind taken once out leaves the resolver
* unreachable - a chicken-and-egg loop. Mirrors the hosts server in
* linux.json line 10; see docs/linux.json 与 pro 的差距分析.md §2.4.
*
* Additions go here when the user's pool of encrypted resolvers grows;
* a new entry needs no code change beyond this table. */
const KNOWN_ENCRYPTED_DNS_HOSTS = {
'doh.pub': ['1.12.12.21', '120.53.53.53'],
'dns.pub': ['1.12.12.21', '120.53.53.53'],
'dns.alidns.com': ['223.5.5.5', '223.6.6.6'],
'dns.google': ['8.8.8.8', '8.8.4.4'],
'one.one.one.one': ['1.1.1.1', '1.0.0.1']
};
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
function append_custom_dns(config, dm, ctx) {
/* §2.4 DoH fallback. Pre-emit a hosts-type DNS server pinning any
* encrypted-resolver hostname the user configured to its real IPs. The
* loop also remembers which server sections hit the table, so the user
* server loop below can stamp `domain_resolver: 'hp-dns-hosts'` onto
* them - sing-box only consults the hosts table for resolvers that
* explicitly point at it, mirroring linux.json line 7
* (`{"domain_resolver": "hosts"}`). Without the stamp, the resolver
* still has to look up its own hostname through the system DNS and the
* chicken-and-egg loop we are trying to break stays open. */
let doh_predefined = null;
const doh_resolved_hosts = {};
for (let cfg in (dm.dns.servers || [])) {
if (!cfg.enabled)
continue;
if (cfg.type !== 'https' && cfg.type !== 'tls' && cfg.type !== 'quic')
continue;
const host = cfg.server;
if (!host || !KNOWN_ENCRYPTED_DNS_HOSTS[host])
continue;
if (!doh_predefined)
doh_predefined = {};
doh_predefined[host] = KNOWN_ENCRYPTED_DNS_HOSTS[host];
doh_resolved_hosts[cfg.name] = true;
}
if (doh_predefined) {
push(config.dns.servers, {
tag: 'hp-dns-hosts',
type: 'hosts',
predefined: doh_predefined
});
}
/* DNS servers */
for (let cfg in dm.dns.servers) {
if (!cfg.enabled)
continue;
let outbound = get_outbound(cfg.outbound, dm);
if (outbound === 'direct-out' && isEmpty(ctx.self_mark))
outbound = null;
push(config.dns.servers, {
tag: 'cfg-' + cfg.name + '-dns',
type: cfg.type,
server: cfg.server,
server_port: strToInt(cfg.server_port),
path: cfg.path,
headers: cfg.headers,
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
} : null,
domain_resolver: doh_resolved_hosts[cfg.name]
? 'hp-dns-hosts'
: ((cfg.address_resolver || cfg.address_strategy) ? {
server: get_resolver(cfg.address_resolver || ctx.dns_default_server, dm),
strategy: cfg.address_strategy
} : null),
detour: outbound
});
}
/* DNS rules */
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
evaluate action; deprecated strategy/accept_empty fields are dropped. */
const builtin_dns_rules = [];
/* §2.2 First safe rule: reject SVCB (qtype 64) and HTTPS (qtype 65)
* queries. A client that learns the answer from these record types
* connects to the embedded IP without going through the resolver again,
* which makes Fake-IP and any route that hinges on A/AAAA resolution
* bypass. Mirrors append_proxy_dns() line 102; emit here as the FIRST
* entry so a user rule that targets the same query_type cannot shadow
* it (sing-box walks the array in order and stops at the first match).
* Without this prefix, a user who adds a higher-priority rule on the
* same query_type in custom mode loses Fake-IP integrity. */
push(builtin_dns_rules, {
query_type: [64, 65],
action: 'reject'
});
for (let cfg in dm.dns.rules) {
if (!cfg.enabled)
continue;
/* Match fields are valid for every action; action-specific fields
* are emitted below per sing-box 1.14's per-action schema. The
* previous code dumped server/method/no_drop/rcode/answer/ns/extra
* (and the cache / TTL / client-subnet family) onto every rule
* regardless of action, and sing-box then rejected the whole
* configuration with "json: unknown field" the moment a user
* changed a rule's action and left the old fields in UCI -
* LuCI's form value parsing does not delete the previous value
* when a depends() guard hides it, the same path route.uc
* already had to fix. */
const rule = {
ip_version: strToInt(cfg.ip_version),
query_type: parse_dnsquery(cfg.query_type),
network: cfg.network,
protocol: cfg.protocol,
domain: cfg.domain,
domain_suffix: cfg.domain_suffix,
domain_keyword: cfg.domain_keyword,
domain_regex: cfg.domain_regex,
port: parse_port(cfg.port),
port_range: cfg.port_range,
source_ip_cidr: cfg.source_ip_cidr,
source_ip_is_private: strToBool(cfg.source_ip_is_private),
source_port: parse_port(cfg.source_port),
source_port_range: cfg.source_port_range,
process_name: cfg.process_name,
process_path: cfg.process_path,
process_path_regex: cfg.process_path_regex,
user: cfg.user,
rule_set: get_ruleset(cfg.rule_set, dm),
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
invert: strToBool(cfg.invert),
race: strToBool(cfg.race),
speculative: strToBool(cfg.speculative),
action: cfg.action
};
/* `route` (and `evaluate`, which needs a server for the eval step).
* `resolve` is also a possibility but is not currently exposed in
* the UI; if it ever is, this branch is where it lands. */
if (cfg.action === 'route' || cfg.action === 'evaluate' || cfg.action === 'resolve') {
rule.server = get_resolver(cfg.server, dm);
rule.disable_cache = strToBool(cfg.dns_disable_cache);
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
rule.client_subnet = cfg.client_subnet;
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
}
if (cfg.action === 'route' || cfg.action === 'resolve')
rule.timeout = strToTime(cfg.dns_timeout);
/* `reject` only accepts method + no_drop. Anything else here is
* an unknown field for the action and the whole config fails. */
if (cfg.action === 'reject') {
rule.method = cfg.reject_method;
rule.no_drop = strToBool(cfg.reject_no_drop);
}
/* `predefined` only accepts rcode + answer + ns + extra. */
if (cfg.action === 'predefined') {
rule.rcode = cfg.predefined_rcode;
rule.answer = cfg.predefined_answer;
rule.ns = cfg.predefined_ns;
rule.extra = cfg.predefined_extra;
}
if (cfg.action === 'evaluate')
rule.tag = cfg.evaluate_tag || null;
if (cfg.match_response && cfg.match_response !== '0')
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
rule.query_client_subnet = cfg.query_client_subnet;
rule.query_dnssec = strToBool(cfg.query_dnssec);
rule.source_mac_address = cfg.source_mac_address;
rule.source_hostname = cfg.source_hostname;
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
/* Every action needs the evaluate prefix to see the response, not just
* `route`: with the old `&& cfg.action === 'route'` an address-filtered
* reject rule lost the filter entirely and became an unconditional
* reject - a DNS black hole for every query. */
if (legacy_filter && !rule.match_response) {
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
paradigm. Carry the original query-matching fields onto the evaluate
prefix rule so only queries that would have hit this rule get
pre-resolved; an unconditional evaluate would resolve every query. */
const eval_tag = '_hp_eval_' + cfg.name;
const eval_rule = {
action: 'evaluate',
server: get_resolver(cfg.server, dm),
tag: eval_tag
};
const eval_match_fields = [
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
'source_port', 'source_port_range', 'process_name', 'process_path',
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
'source_hostname'
];
for (let f in eval_match_fields)
eval_rule[f] = rule[f];
push(builtin_dns_rules, eval_rule);
rule.match_response = eval_tag;
}
/* Response-match fields are only valid together with match_response in
* 1.14: sing-box rejects the rule with "Response Match Fields (...)
* require match_response to be enabled". ip_cidr / ip_is_private were
* already gated here; the response_* quartet was emitted
* unconditionally next door. */
if (rule.match_response) {
rule.ip_cidr = cfg.ip_cidr;
rule.ip_is_private = strToBool(cfg.ip_is_private);
rule.response_rcode = cfg.response_rcode;
rule.response_answer = cfg.response_answer;
rule.response_ns = cfg.response_ns;
rule.response_extra = cfg.response_extra;
}
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-pro.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-pro.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-pro: urltest candidate '%s' no longer exists, skipping.", id));
return false;
}
const problems = OutboundFactory.problems(node);
if (length(problems)) {
warn(sprintf("homeproxy-pro: 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,341 @@
/*
* 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-pro.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.
*
* Sniff tuning follows §2.7 of docs/linux.json 与 pro 的差距分析.md:
* the default rule is the conservative 300ms / unconstrained sniffer list
* (tutorial demo recommends 100ms + ['http','tls','stun','quic','dns'],
* which costs less CPU but is a behaviour change). sniffer_advanced_mode
* is the opt-in switch that flips to the tutorial profile. */
function initRoute(config, ctx) {
const sniff_rule = (ctx.sniffer_advanced_mode === '1')
? {
action: 'sniff',
sniffer: ['http', 'tls', 'stun', 'quic', 'dns'],
timeout: '100ms'
}
: {
action: 'sniff',
timeout: '300ms'
};
config.route = {
rules: [
{
inbound: 'dns-in',
action: 'hijack-dns'
},
sniff_rule
],
rule_set: [],
/* auto_detect_interface: explicit true when the user did not
* pin a default_interface, explicit false when they did - not
* null (the previous "null means no field" shape silently kept
* the auto-detect on when the user expected it off). */
auto_detect_interface: isEmpty(ctx.default_interface) ? true : false,
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.
*
* Both geosite-cn and geoip-cn are still emitted, but they are
* consumed by different blocks:
*
* geoip-cn - referenced by the route rule above (line 105) to
* split CN destinations to direct-out.
* geosite-cn - referenced only by the DNS rule in
* generator/dns.uc line 135-139 (rule_set: geosite-cn
* -> server: china-dns); the route layer never
* matches it. Sing-box still loads and keeps the
* rule-set in memory even when only one block
* references it, so removing it would also break the
* DNS split.
*
* History: a third geosite-noncn used to be declared here as well,
* but no rule referenced it and sing-box still loaded and updated
* it daily, so it was removed (the comment above was rewritten
* during the 2026-09-29 review to clarify that geosite-cn is still
* in use - the DNS side - just not from this block).
*/
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-options` only takes the override_* fields; `outbound` is the
* `route` action's field. The two used to share one branch and
* `route-options` ended up carrying an `outbound` sing-box then
* refused as "unknown field" - the configuration as a whole was
* rejected and the user only saw "setting did not take".
* Match fields above are valid for every action, so the gates below
* cover the action-specific payload only. */
if (cfg.action === 'route' || cfg.action === 'route-options') {
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;
}
/* `route` alone is the action that picks a destination - `route-options`
* adjusts the existing connection, so it does not take `outbound`. */
if (cfg.action === 'route')
rule.outbound = rule_outbound;
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,134 @@
/*
* 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-pro.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). A missing
placeholder makes sing-box try to literal-substitute the
first tag name and reject the whole config; the reload then
keeps the previous one and the user only sees "my change did
not take". die() early so the saved UCI is rejected at
apply time, the same way get_resolver/get_ruleset already
fail loud on a missing/disabled reference. */
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
if (!match(fetch_ref, /\{tag\}/))
die(sprintf("homeproxy-pro: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder; add {tag} to the source or drop the extra tags.", cfg.name, cfg.type));
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
die(sprintf("homeproxy-pro: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
}
/* 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. die() early (same as get_resolver on a
* missing/disabled dns_server): silently dropping path left
* sing-box checking whatever the field evaluated to, and the
* user only saw the reload keep the previous config. */
if (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
die(sprintf("homeproxy-pro: rule-set '%s' path '%s' is outside the homeproxy-pro whitelist; choose a path under /etc/homeproxy-pro/.", cfg.name, cfg.path));
const ruleset = {
type: cfg.type,
tag: rs_tag,
format: cfg.format,
path: 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-pro/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,819 @@
/*
* 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-pro';
export const RUN_DIR = '/var/run/homeproxy-pro';
/* Where the UCI configuration lives. This is /etc/config - NOT HP_DIR/config.
* The package ships its config as the conffile /etc/config/homeproxy-pro, and
* ucode's `cursor(dir)` treats `dir` as the *confdir*, so
* cursor(HP_DIR + '/config') would read /etc/homeproxy-pro/config/homeproxy-pro, 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-pro/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-pro.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-pro/... - 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-pro/../../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-pro/')) === '/etc/homeproxy-pro/')
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-pro/ 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-pro.js; guard 29 in tests/arch-guard.sh
* keeps the two in step.
*/
export const CERT_PATH_ROOTS = ['/etc/homeproxy-pro/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-pro/homeproxy-pro.log is
* readable by anyone who can read /var/run/homeproxy-pro - 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. Reject a second '/' outright:
* the only legal form is "ip" or "ip/prefix", and a resource-list
* entry like "1.2.3.4/24/32" or "1.2.3.4/;}" would otherwise split
* into a benign-looking ip and a prefix the next checks would then
* have to defend. Drop it here so all the following regexes only have
* to police a one-or-zero-slash input. */
const parts = split(addr, '/');
if (length(parts) > 2)
return false;
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. Anchor with a digit-only regex:
* without it, `int(prefix)` happily parses `24;}` as 24 (strtoll
* stops at the first non-digit), so a poisoned china_ip4.txt line
* `1.2.3.4/24;}` sailed past this check and ended up verbatim
* in the fw4 ruleset fw4 then loaded as root.
*
* ucode has no `undefined` (an absent index reads as null), and
* `if (prefix)` is also false for the empty string, so a
* trailing-slash entry like `1.2.3.4/` would slip past a plain
* `if (prefix)` gate and pass overall. Gate on null, which
* keeps `''` from sneaking in; the regex naturally rejects the
* empty string and any non-digit. */
if (prefix !== null) {
if (!match(prefix, /^\d{1,3}$/) || 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;
}
/* Same anchor as the IPv4 branch: `1.2.3.4/24;}` would let
* `int('24;}')` slip through unanchored and end up in fw4. Same
* null-presence guard for `::1/` (empty prefix). */
if (prefix !== null) {
if (!match(prefix, /^\d{1,3}$/) || 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,327 @@
#!/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-pro.uc';
const uci = cursor();
const uciconfig = 'homeproxy-pro';
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');
/* v28.9.1.16: cn_ip_fallback default flipped to '1' (tutorial §2.1 / docs/linux.json 与 pro 的差距分析.md).
* Existing users get the behaviour change only when they ask for it, so write
* '0' explicitly into their config on upgrade; new installs read '1' straight
* from /etc/config/homeproxy-pro (the package ships with that as the default).
* isEmpty() is the same gate log_level uses, so an upgrade that already has
* the option set (the fresh-install path of a prior release) keeps its value. */
if (isEmpty(uci.get(uciconfig, ucimain, 'cn_ip_fallback')))
uci.set(uciconfig, ucimain, 'cn_ip_fallback', '0');
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-pro.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-pro.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-pro.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,151 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Configuration transaction helpers for homeproxy-pro.
#
# 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-pro)
#
# Sourced by /etc/init.d/homeproxy-pro. 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
}
# hp_restore_known_good_uci <good-dir>
# Re-apply the UCI snapshot written by hp_promote_known_good (the keys that
# steer the firewall / dnsmasq / nft layer - see HP_INTERCEPT_SCALARS in
# runtime/service.sh). Returns 0 on success, 1 when the snapshot is missing -
# the rollback path logs and continues, since a missing snapshot just means
# the install is older than the snapshot feature (best-effort).
#
# Two passes over the file, because the snapshot carries two shapes:
#
# homeproxy-pro.<section>.<option>=<value> scalar, replayed with `uci set`
# list homeproxy-pro.<section>.<option>=a,b UCI list, replayed with
# `uci delete` + `uci add_list`
#
# A list replayed through `uci set` becomes a *string*, and the firewall
# validators (ipv4_to_nftarr / iface_to_nftarr) reject a non-array by
# returning null - so the ACL rules reading it would silently disappear and
# the rollback would come back with the wrong clients exempted.
#
# The commit below is what makes this safe: every write is staged and only
# becomes visible when uci commit succeeds. A partial failure leaves UCI
# unchanged; the rollback caller logs and the next reload path will see the
# same failing configuration it just rolled back from, so the failure mode
# stays bounded (no new corruption).
hp_restore_known_good_uci() {
local good_dir="$1"
local snap="$good_dir/uci-snapshot.txt"
[ -s "$snap" ] || return 1
local key value lkey el saved=0
# Pass 1: scalars. `uci set key=` with an empty value is how UCI spells
# "the option was not set", so a key that was absent in the known-good
# state is removed again here rather than surviving as a failing value.
# stdin is closed for every uci call so none of them can swallow the
# file the loop is reading.
while IFS='=' read -r key value; do
case "$key" in
""|list\ *|"#"*) continue ;;
esac
uci set "$key=$value" </dev/null || return 1
saved=1
done < "$snap"
# Pass 2: list options. `uci delete` on an option that is not there is
# expected - the known-good state may simply never have had this list -
# so its exit status is deliberately ignored. An empty recorded value
# therefore means "absent or empty", and the delete is the whole
# operation: a list the failing configuration added does not survive.
while IFS='=' read -r key value; do
case "$key" in
list\ *) lkey="${key#list }" ;;
*) continue ;;
esac
uci delete "$lkey" >/dev/null 2>&1 </dev/null
saved=1
[ -n "$value" ] || continue
while :; do
case "$value" in
*,*) el="${value%%,*}"; value="${value#*,}" ;;
*) el="$value"; value="" ;;
esac
uci add_list "$lkey=$el" </dev/null || return 1
[ -n "$value" ] || break
done
done < "$snap"
[ "$saved" = "1" ] && uci commit homeproxy-pro </dev/null || return 1
return 0
}
@@ -0,0 +1,270 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# dnsmasq integration for homeproxy-pro.
#
# homeproxy-pro 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-pro.
#
# 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-pro.
# hp_dnsmasq_resolve_dir
# Locate the directory dnsmasq reads the homeproxy-pro 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-pro.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-pro.d."
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy-pro.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-pro.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-pro.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-pro snippets."
}
reload_service() {
restart
}
@@ -0,0 +1,114 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# fw4 integration for homeproxy-pro.
#
# homeproxy-pro 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-pro.
#
# 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-pro/scripts/fw4_names.sh, sourced by init.d).
#
# Sourced by /etc/init.d/homeproxy-pro.
# 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-pro 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"
# Stop-time fw4 reload is best-effort on purpose: every homeproxy-pro-owned
# chain and set has already been flushed and deleted above, so the
# live ruleset no longer references them. A reload that succeeds here
# also re-loads /etc/config/firewall; a reload that fails leaves the
# rest of the user's ruleset untouched, which is the safer outcome -
# logging a Warning used to suggest the stop had lost something when
# in fact nothing homeproxy-pro-owned was left to lose.
fw4 reload >"/dev/null" 2>&1 || true
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-pro.
#
# 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-pro.
#
# 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-pro; testable off-target by stubbing pgrep /
# ubus / jsonfilter / netstat (see tests/runtime/).
: "${HP_SERVICE:=homeproxy-pro}"
# 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,168 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Routing/network plumbing for homeproxy-pro.
#
# 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-pro.
#
# 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-pro.
# 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-pro 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 on the genuine-stop path only (HP_RELOAD_IN_PROGRESS=0):
# procd calls it after the instances are gone, so the TUN device can be removed.
# Requires config_load.
#
# On the reload path (HP_RELOAD_IN_PROGRESS=1) the caller skips this entirely,
# so the device survives across the stop;start that reload_service drives; the
# table-100 default route is therefore not pulled mid-reload - the
# "tun mode reload exposes the LAN with the real IP" failure that the previous
# TODO documented (review P2, 2026-09-28) is the issue the caller now closes.
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"
}
@@ -0,0 +1,584 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
#
# Service-side runtime helpers for homeproxy-pro.
#
# These are the device-side orchestration pieces that used to be inlined in
# /etc/init.d/homeproxy-pro: 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-pro.
# 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-pro). 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-pro.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-pro.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
}
# The UCI keys the intercept layer reads, split by UCI type.
#
# hp_restore_known_good_uci() replays the snapshot before the rollback
# restart, so anything that steers firewall_post.ut / firewall_pre.uc /
# net.sh / dns.sh has to be in here: a key left out survives the rollback as
# the *new* (failing) value and start_service reinstalls a layer that no
# longer matches the sing-box file it just restored.
#
# Review 2026-09-29 (docs/review-report-20260929-full.md §1.2) found the
# previous five-line list covered 4 real keys out of 22, and that the fifth
# line named `infra.tun_address` - an option that exists nowhere. The real
# keys are `tun_addr4` / `tun_addr6` (config/loader.uc:411), so that line was
# always empty and the restore was a silent no-op; the golden trace recorded
# the empty line as expected, which is how the defect survived. tests/
# arch-guard.sh guard 43 derives the read set from the layer consumers and
# compares it against this list in both directions.
#
# The two TUN address keys are the one deliberate inclusion that no layer
# consumer reads: the address is only ever installed by the generated sing-box
# file, so leaving UCI describing the failed address would make the next
# reload regenerate the configuration that just failed.
HP_INTERCEPT_SCALARS="config.main_node
config.main_udp_node
config.proxy_mode
config.routing_mode
config.routing_port
config.ipv6_support
config.china_dns_server
infra.self_mark
infra.redirect_port
infra.tproxy_port
infra.tproxy_mark
infra.tun_mark
infra.tun_name
infra.tun_addr4
infra.tun_addr6
infra.dns_redirect
infra.dns_port
infra.common_port
infra.table_mark
routing.bypass_cn_traffic
routing.default_outbound
server.enabled
control.lan_proxy_mode"
# List options are kept apart because `uci set key=a,b` stores a *string*:
# ipv4_to_nftarr() / iface_to_nftarr() reject a non-array (they return null),
# so a snapshot replayed through `uci set` would drop every ACL rule that
# reads the list - a rollback that silently un-proxies the LAN. The writer
# joins the elements with ',' and the replay uses `uci delete` +
# `uci add_list`. No value in this set can contain a comma: they are IPv4 /
# IPv6 addresses, MAC addresses or interface names.
HP_INTERCEPT_LISTS="control.listen_interfaces
control.lan_direct_ipv4_ips
control.lan_direct_ipv6_ips
control.lan_direct_mac_addrs
control.lan_proxy_ipv4_ips
control.lan_proxy_ipv6_ips
control.lan_proxy_mac_addrs
control.lan_gaming_mode_ipv4_ips
control.lan_gaming_mode_ipv6_ips
control.lan_gaming_mode_mac_addrs
control.lan_global_proxy_ipv4_ips
control.lan_global_proxy_ipv6_ips
control.lan_global_proxy_mac_addrs
control.wan_proxy_ipv4_ips
control.wan_proxy_ipv6_ips
control.wan_direct_ipv4_ips
control.wan_direct_ipv6_ips"
# hp_write_uci_snapshot <snap>
# Serialise HP_INTERCEPT_SCALARS / HP_INTERCEPT_LISTS into <snap>.
#
# One line per key so a reader without uci-tools can iterate the file:
#
# homeproxy-pro.<section>.<option>=<value> scalar
# list homeproxy-pro.<section>.<option>=a,b list, ','-joined
# list homeproxy-pro.<section>.<option>= list that was absent or empty
#
# An absent scalar is written as an empty value, which is how the replay
# spells "remove the option again" (UCI deletes an option set to an empty
# value) - so the snapshot round-trips absence as well as presence.
hp_write_uci_snapshot() {
local snap="$1"
local pair sec opt val el out
config_load "$CONF"
{
for pair in $HP_INTERCEPT_SCALARS; do
sec="${pair%%.*}"
opt="${pair#*.}"
config_get val "$sec" "$opt"
printf 'homeproxy-pro.%s.%s=%s\n' "$sec" "$opt" "$val"
done
for pair in $HP_INTERCEPT_LISTS; do
sec="${pair%%.*}"
opt="${pair#*.}"
config_get val "$sec" "$opt"
# config_get hands a UCI list over space-separated; rejoin it.
out=""
for el in $val; do
[ -z "$out" ] && out="$el" || out="$out,$el"
done
printf 'list homeproxy-pro.%s.%s=%s\n' "$sec" "$opt" "$out"
done
} > "$snap.tmp" 2>"/dev/null" && mv -f "$snap.tmp" "$snap"
}
# 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.
#
# Also writes $good_dir/uci-snapshot.txt (see HP_INTERCEPT_SCALARS above).
# The rollback path in init.d/homeproxy-pro reads that file back BEFORE its
# stop;start, otherwise start_service reinstalls the new (failing) layer on
# top of the rolled-back sing-box bytes - the classic "rollback restored the
# config but nft is still the new chains" silent failure that took a
# real-machine repro to spot (review P1-3, 2026-09-28). Snapshotting only
# the keys that steer the network layer keeps the rollback bounded: a stray
# key elsewhere never overwrites itself.
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."
# Written once on every successful promote. Both sides promote the same
# content, so the two calls cannot disagree.
hp_write_uci_snapshot "$good_dir/uci-snapshot.txt"
}
@@ -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-pro.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-pro.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-pro'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-pro.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-pro.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-pro.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,331 @@
/*
* 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-pro.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.
*
* `current_value` is what the field currently points at (the user-chosen
* section name, the literal 'urltest', or 'same' / 'nil'). We only
* overwrite the field when the section named by `current_value` no
* longer exists in UCI; the previous version asked `uci.get(uciconfig,
* field)` which is "does a section literally named `field` exist" - the
* config has cfg0xxxx names, never one named `main_node`, so the check
* was always false and the user's pick got silently replaced with
* first_server on every subscription update.
*
* 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-pro.log capture goes through the separate `log` arg). */
function switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, field, current_value, first_server, result, log) {
if (uci.get(uciconfig, current_value))
return current_value;
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', main_node, first_server, result, log);
} else {
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', 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', main_udp_node, first_server, result, log);
} else {
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', 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-pro/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-pro"
# 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-pro.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-pro.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-pro.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-pro.uc and fails loudly if the rewrite did not apply). */
const CONFIG_FILE = UCICONFIG_DIR + '/homeproxy-pro';
const uciconfig = 'homeproxy-pro';
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-pro.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-pro', '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-pro', '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-pro.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-pro', '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-pro
* 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();
}