mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-04 22:53:56 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
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
|
||||
+440
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user