mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 15:15:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"bounding": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"effective": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"ambient": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"permitted": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
],
|
||||
"inheritable": [
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
config homeproxy 'infra'
|
||||
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
|
||||
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
|
||||
option mixed_port '5330'
|
||||
option redirect_port '5331'
|
||||
option tproxy_port '5332'
|
||||
option dns_port '5333'
|
||||
option dns_redirect '1'
|
||||
option ntp_server 'nil'
|
||||
option udp_timeout ''
|
||||
option tun_name 'singtun0'
|
||||
option tun_addr4 '172.19.0.1/30'
|
||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||
option tun_mtu '9000'
|
||||
option table_mark '100'
|
||||
option self_mark '100'
|
||||
option tproxy_mark '101'
|
||||
option tun_mark '102'
|
||||
|
||||
config homeproxy 'migration'
|
||||
option crontab '1'
|
||||
|
||||
config homeproxy 'config'
|
||||
option main_node 'nil'
|
||||
option main_udp_node 'same'
|
||||
option dns_server '8.8.8.8'
|
||||
option china_dns_server '223.5.5.5'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option routing_port 'common'
|
||||
option proxy_mode 'redirect_tproxy'
|
||||
option ipv6_support '1'
|
||||
option github_token ''
|
||||
option log_level 'warn'
|
||||
# Functional health probes (runtime/health.sh). They run after the
|
||||
# process/listener gate and only log by default: a probe can fail because
|
||||
# the uplink is down or a captive portal answers, and rolling a good
|
||||
# configuration back for that would be worse than the condition probed
|
||||
# for. Set health_probe_strict to 1 only if a failing probe must fail the
|
||||
# start (and therefore trigger the rollback).
|
||||
option health_probe_strict '0'
|
||||
option health_probe_name 'example.com'
|
||||
option cn_ip_fallback '0'
|
||||
option tun_dns_mode ''
|
||||
option udp_mapping ''
|
||||
option udp_filtering ''
|
||||
option udp_nat_max ''
|
||||
|
||||
config homeproxy 'control'
|
||||
option lan_proxy_mode 'disabled'
|
||||
list wan_proxy_ipv4_ips '91.105.192.0/23'
|
||||
list wan_proxy_ipv4_ips '91.108.4.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.8.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.16.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.12.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.20.0/22'
|
||||
list wan_proxy_ipv4_ips '91.108.56.0/22'
|
||||
list wan_proxy_ipv4_ips '149.154.160.0/20'
|
||||
list wan_proxy_ipv4_ips '185.76.151.0/24'
|
||||
list wan_proxy_ipv4_ips '203.208.50.66/32'
|
||||
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
|
||||
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
|
||||
list wan_proxy_ipv6_ips '2a0a:f280::/32'
|
||||
|
||||
config homeproxy 'routing'
|
||||
option default_outbound 'direct-out'
|
||||
option default_outbound_dns 'default-dns'
|
||||
option find_neighbor '0'
|
||||
|
||||
config homeproxy 'dns'
|
||||
option default_strategy 'prefer_ipv4'
|
||||
option default_server 'default-dns'
|
||||
option disable_cache '0'
|
||||
option disable_cache_expire '0'
|
||||
option optimistic_cache '0'
|
||||
option optimistic_timeout ''
|
||||
option dns_timeout ''
|
||||
option cache_file_store_dns '0'
|
||||
|
||||
config homeproxy 'subscription'
|
||||
option auto_update '0'
|
||||
option allow_insecure '0'
|
||||
option packet_encoding 'xudp'
|
||||
option update_via_proxy '0'
|
||||
option filter_nodes 'blacklist'
|
||||
list filter_keywords '重置|到期|过期|剩余|套餐'
|
||||
list filter_keywords 'Expiration|Remaining'
|
||||
|
||||
config homeproxy 'server'
|
||||
option enabled '0'
|
||||
option log_level 'warn'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||
|
||||
NAME="homeproxy"
|
||||
|
||||
log_max_size="50" #KB
|
||||
main_log_file="/var/run/$NAME/$NAME.log"
|
||||
singc_log_file="/var/run/$NAME/sing-box-c.log"
|
||||
sings_log_file="/var/run/$NAME/sing-box-s.log"
|
||||
|
||||
while true; do
|
||||
sleep 180
|
||||
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
|
||||
[ -s "$i" ] || continue
|
||||
[ "$(( $(wc -c < "$i") / 1024 >= log_max_size))" -eq "0" ] || : > "$i"
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,765 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* Node -> Protocol Adapter -> SingBox Outbound
|
||||
*
|
||||
* This replaces the single 110-line `generate_outbound()` whose protocol
|
||||
* branches were expressed as ~60 ternaries inside one object literal. Three
|
||||
* properties are deliberate:
|
||||
*
|
||||
* kept the shared TLS/transport/multiplex code is still the existing
|
||||
* production builders - the adapter only supplies them a
|
||||
* Node-shaped view instead of a flat UCI dict.
|
||||
* pinned the emitted JSON is frozen by tests/snapshots/generator/
|
||||
* outbounds.json, so a field change for any protocol is a
|
||||
* reviewable diff (test_golden_outbounds.sh).
|
||||
* dropped a per-protocol Adapter class hierarchy. The protocols differ by
|
||||
* which *fields* they carry, not by behaviour, so the honest
|
||||
* extension point is the field tables below, not eleven classes.
|
||||
*
|
||||
* Adding a protocol means adding three entries: CREDENTIALS (model.uc),
|
||||
* PROTOCOL_OPTIONS (loader.uc) and OPTION_FIELDS here - and a node in
|
||||
* tests/fixtures/generators/outbounds.uci, which
|
||||
* tests/ucode/test_protocol_inventory.sh enforces.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
isEmpty, strToBool, strToInt, strToTime, removeBlankAttrs,
|
||||
buildTLSObject, buildTransportObject, parse_port
|
||||
} from '../homeproxy.uc';
|
||||
|
||||
import { Node, Inbound, INBOUND_TLS_SERVER } from './model.uc';
|
||||
|
||||
/* Per-protocol required credential fields. Node.validate() covers the
|
||||
* cross-protocol rules (type/address/port/TLS); this table is the
|
||||
* per-protocol half - "vless needs uuid, vmess needs uuid, trojan needs
|
||||
* password, snell needs psk, ..." - and it is the Adapter's job because
|
||||
* the requirement is shaped by what the sing-box outbound for each
|
||||
* protocol will actually accept, not by anything the model knows about
|
||||
* UCI. The keys are the canonical credential names the Loader uses
|
||||
* (see CREDENTIALS in model.uc), so the check is data-driven and adding
|
||||
* a protocol is a one-line change. */
|
||||
export const REQUIRED_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
ssh: ['user'],
|
||||
/* These three have no hard required credential - the protocol can
|
||||
* run anonymously (http / socks) or the auth is delivered out of
|
||||
* band (hysteria, direct). Node.validate()'s address/port check
|
||||
* is enough. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
socks: [],
|
||||
direct: []
|
||||
};
|
||||
|
||||
/* Run a Node through the per-protocol required-field check. Returns
|
||||
* the (possibly empty) list of human-readable problems.
|
||||
*
|
||||
* The same ucode quirk that bit Node.validate() bites here too: push()
|
||||
* returns the value pushed, not the new array, so we build the result
|
||||
* via spread instead of the obvious `problems = push(problems, X)`. */
|
||||
function protocol_problems(node) {
|
||||
const required = REQUIRED_CREDENTIALS[node.type] || [];
|
||||
const creds = node.credentials || {};
|
||||
let problems = [];
|
||||
|
||||
for (let field in required) {
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${node.type} requires ${field}`];
|
||||
}
|
||||
|
||||
/* WireGuard is the one protocol whose material does not live in
|
||||
* `credentials`: the endpoint builder reads node.protocol_options.
|
||||
* Checking it here means a broken WireGuard node is caught by the
|
||||
* same "is this node buildable?" path as every other protocol, which
|
||||
* is what lets a urltest list prune it instead of failing the whole
|
||||
* configuration. */
|
||||
if (node.type === 'wireguard') {
|
||||
const opts = node.protocol_options || {};
|
||||
for (let field in ['local_address', 'private_key', 'peer_public_key'])
|
||||
if (!opts[field])
|
||||
problems = [...problems, `wireguard requires ${field}`];
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* --- field transforms --------------------------------------------------- */
|
||||
|
||||
/* A table value is either a function of the domain object (a Node for the
|
||||
* outbound tables, an Inbound for the server tables), a literal, or a
|
||||
* string naming a transform (in which case the value is null - the spec
|
||||
* is present so the key still gets emitted, but the value will be
|
||||
* stripped by removeBlankAttrs()). */
|
||||
const TRANSFORMS = {
|
||||
raw: (value) => value,
|
||||
bool: (value) => strToBool(value),
|
||||
int: (value) => strToInt(value),
|
||||
time: (value) => strToTime(value)
|
||||
};
|
||||
|
||||
/* Shared by the outbound and the inbound field tables: the spec is a
|
||||
* plain function in every table except the outbound one, which carries a
|
||||
* few TRANSFORMS markers for keys that exist but are runtime-owned. */
|
||||
function resolve(spec, subject) {
|
||||
if (type(spec) === 'function')
|
||||
return spec(subject);
|
||||
|
||||
if (type(spec) === 'string' && spec in TRANSFORMS)
|
||||
return null;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* --- field tables ------------------------------------------------------- */
|
||||
|
||||
/* Fields every protocol emits. Each is null when absent, and nulls are
|
||||
* stripped once, at the end, by removeBlankAttrs(). Cross-protocol common
|
||||
* fields live in node.common (Loader's load_common); protocol-specific
|
||||
* canonical names live in node.protocol_options. The legacy
|
||||
* `node.raw` opaque bag was dropped - the Adapter reads only the explicit
|
||||
* sub-objects, so no escape hatch exists.
|
||||
*
|
||||
* This table is applied AFTER the literal in build_outbound(), so a key
|
||||
* listed here wins over whatever that literal set. A field the runtime owns
|
||||
* (routing_mark, from the `mark` argument) therefore must NOT be listed
|
||||
* here, not even as `null`: the loop would overwrite the value with null and
|
||||
* removeBlankAttrs() would drop the field entirely. routing_mark is what
|
||||
* exempts sing-box's own proxy connections from the nft redirect chain, so
|
||||
* losing it loops the traffic back into the redirect inbound. Guard 32 in
|
||||
* tests/arch-guard.sh keeps the literal's keys and this table disjoint. */
|
||||
const COMMON_FIELDS = {
|
||||
server: (node) => node.address,
|
||||
server_port: (node) => strToInt(node.port),
|
||||
proxy_protocol: (node) => strToInt(node.common.proxy_protocol),
|
||||
tcp_fast_open: (node) => strToBool(node.common.tcp_fast_open),
|
||||
tcp_multi_path: (node) => strToBool(node.common.tcp_multi_path),
|
||||
udp_fragment: (node) => strToBool(node.common.udp_fragment),
|
||||
packet_encoding: (node) => node.protocol_options.packet_encoding || null
|
||||
};
|
||||
|
||||
/* The node form stores an SSH private key in a DynamicList, i.e. one UCI
|
||||
* list entry per line of the OpenSSH/PEM blob (a UCI option cannot hold
|
||||
* newlines). sing-box wants the whole key as one string, so re-join the
|
||||
* lines. A single-entry value is passed through unchanged.
|
||||
*
|
||||
* Declared before CLAIM_FIELDS on purpose: ucode resolves module-level names
|
||||
* lexically, so a callee has to appear above its caller in the file. */
|
||||
function ssh_private_key(value) {
|
||||
if (type(value) === 'array')
|
||||
return length(value) ? join('\n', value) : null;
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/* Credential fields: one canonical set, emitted under the name each protocol
|
||||
* wants. This is where the old builder repeated itself. */
|
||||
function CLAIM_FIELDS(node) {
|
||||
switch (node.type) {
|
||||
case 'snell':
|
||||
return {
|
||||
psk: node.credentials.psk,
|
||||
userkey: node.credentials.userkey,
|
||||
reuse: strToBool(node.protocol_options.reuse)
|
||||
};
|
||||
case 'ssh':
|
||||
return {
|
||||
user: node.credentials.user,
|
||||
password: node.credentials.password,
|
||||
private_key: ssh_private_key(node.credentials.private_key),
|
||||
private_key_passphrase: node.credentials.private_key_passphrase
|
||||
};
|
||||
case 'hysteria':
|
||||
return {
|
||||
auth: (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null
|
||||
};
|
||||
default:
|
||||
return {
|
||||
username: node.credentials.username,
|
||||
uuid: node.credentials.uuid,
|
||||
password: node.credentials.password,
|
||||
method: node.credentials.method
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-protocol option fields, keyed by the same canonical names the model
|
||||
* exposes. Anything not modelled here is still in node.protocol_options
|
||||
* (which is itself populated from the protocol's PROTOCOL_OPTIONS row in
|
||||
* the Loader). */
|
||||
export const OPTION_FIELDS = {
|
||||
vless: {
|
||||
flow: (node) => node.protocol_options.flow
|
||||
/* No udp_over_tcp here: sing-box 1.14 rejects that field on a vless
|
||||
* outbound ("json: unknown field"). It used to be emitted, so a
|
||||
* node whose type was switched from shadowsocks to vless kept the
|
||||
* stale UCI option and produced a config sing-box refused outright.
|
||||
* The node form never offered the option for vless anyway. */
|
||||
},
|
||||
/* `mode` (snell v6 traffic shaping) is deliberately absent: sing-box
|
||||
* 1.14 rejects it as an outbound field, and the node form only offered
|
||||
* it for snell_version 6, which this sing-box does not support either. */
|
||||
snell: {
|
||||
version: (node) => strToInt(node.protocol_options.version) || 4,
|
||||
obfs_mode: (node) => node.protocol_options.obfs_mode || null,
|
||||
obfs_host: (node) => node.protocol_options.obfs_host || null
|
||||
},
|
||||
/* A4.1: shadowsocks. SIP002 plugin support is emitted only when both
|
||||
* plugin and plugin_opts are present, matching the generator's ternary
|
||||
* that emits `plugin: ...` unconditionally and lets removeBlankAttrs
|
||||
* drop empties. `udp_over_tcp` mirrors the vless shape. */
|
||||
shadowsocks: {
|
||||
plugin: (node) => node.protocol_options.plugin || null,
|
||||
plugin_opts: (node) => node.protocol_options.plugin_opts || null,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.2: anytls idle session tuning (sing-box 1.14 lets the client probe
|
||||
* upstream sessions and proactively close stuck ones). */
|
||||
anytls: {
|
||||
idle_session_check_interval: (node) => strToTime(node.protocol_options.idle_session_check_interval),
|
||||
idle_session_timeout: (node) => strToTime(node.protocol_options.idle_session_timeout),
|
||||
min_idle_session: (node) => strToInt(node.protocol_options.min_idle_session)
|
||||
},
|
||||
/* A4.3: http - no protocol-specific options; transport / tls / multiplex
|
||||
* are shared. */
|
||||
http: {},
|
||||
/* A4.4: socks. The node form offers UDP-over-TCP for socks and sing-box
|
||||
* accepts it on the socks outbound, but the adapter never emitted it, so
|
||||
* the setting was silently ignored. */
|
||||
socks: {
|
||||
version: (node) => node.protocol_options.version,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.5: tuic */
|
||||
tuic: {
|
||||
congestion_control: (node) => node.protocol_options.congestion_control,
|
||||
udp_relay_mode: (node) => node.protocol_options.udp_relay_mode,
|
||||
udp_over_stream: (node) => strToBool(node.protocol_options.udp_over_stream),
|
||||
zero_rtt_handshake: (node) => strToBool(node.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (node) => strToTime(node.protocol_options.heartbeat)
|
||||
},
|
||||
/* A4.6: trojan - no protocol-specific options. */
|
||||
trojan: {},
|
||||
/* A4.7: shadowtls */
|
||||
shadowtls: {
|
||||
version: (node) => strToInt(node.protocol_options.version)
|
||||
},
|
||||
/* A4.8: hysteria (v1). The generator emits two fields: `auth` when the
|
||||
* payload is base64, `auth_str` when it is a string. Node.validate()
|
||||
* only catches the obvious case; the conditional keeps the table-driven
|
||||
* shape.
|
||||
*
|
||||
* obfs is the plain obfuscation password string here. The node form
|
||||
* only offers the password for v1 (the type selector is hysteria2-only)
|
||||
* and sing-box rejects the {type, password} object on a hysteria
|
||||
* outbound - that shape belongs to hysteria2. Emitting the object made
|
||||
* any obfuscated hysteria node produce a config sing-box refused. */
|
||||
hysteria: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
obfs: (node) => node.protocol_options.obfs_password || null
|
||||
},
|
||||
/* A4.9: hysteria2 - extends hysteria with hop_interval_max /
|
||||
* server_ports / bbr_profile / disable_chrome_parrot. The obfs
|
||||
* shape is the same. Note sing-box 1.14 uses `server_ports`
|
||||
* (port hopping list), not `hopping_port` - the latter is the
|
||||
* UCI option name, so the rename happens here. */
|
||||
hysteria2: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
hop_interval_max: (node) => strToTime(node.protocol_options.hop_interval_max),
|
||||
server_ports: (node) => node.protocol_options.hopping_port,
|
||||
obfs: (node) => node.protocol_options.obfs_type ? {
|
||||
type: node.protocol_options.obfs_type,
|
||||
password: node.protocol_options.obfs_password
|
||||
} : null,
|
||||
bbr_profile: (node) => node.protocol_options.bbr_profile || null,
|
||||
disable_chrome_parrot: (node) => (node.protocol_options.disable_chrome_parrot === '1') ? true : null
|
||||
},
|
||||
/* A4.10: vmess */
|
||||
vmess: {
|
||||
alter_id: (node) => strToInt(node.protocol_options.alter_id),
|
||||
security: (node) => node.protocol_options.security,
|
||||
global_padding: (node) => strToBool(node.protocol_options.global_padding)
|
||||
},
|
||||
/* SSH: host-key pinning and the client banner, matching sing-box's ssh
|
||||
* outbound field names. */
|
||||
ssh: {
|
||||
client_version: (node) => node.protocol_options.client_version || null,
|
||||
host_key: (node) => node.protocol_options.host_key || null,
|
||||
host_key_algorithms: (node) => node.protocol_options.host_key_algorithms || null
|
||||
}
|
||||
};
|
||||
|
||||
/* --- shared builders ---------------------------------------------------- */
|
||||
|
||||
function build_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
protocol: mux.protocol,
|
||||
max_connections: strToInt(mux.max_connections),
|
||||
min_streams: strToInt(mux.min_streams),
|
||||
max_streams: strToInt(mux.max_streams),
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* --- adapter ------------------------------------------------------------ */
|
||||
|
||||
/* Both validation layers as one list. Standalone (not a method) because the
|
||||
* OutboundFactory literal below cannot reference itself during its own
|
||||
* initialisation: ucode resolves the binding lexically and throws
|
||||
* "Can't access lexical declaration before initialization". */
|
||||
function outbound_problems(node) {
|
||||
return [
|
||||
...Node.validate(node),
|
||||
...protocol_problems(node)
|
||||
];
|
||||
}
|
||||
|
||||
/* Node -> sing-box outbound object. Pure: no UCI, no module state, no file
|
||||
* access; `mark` is passed in instead of read from a global.
|
||||
*
|
||||
* Standalone for the same reason outbound_problems() is: the literal below
|
||||
* cannot name OutboundFactory in any nested scope, not even inside an arrow
|
||||
* body, without tripping ucode's "Can't access lexical declaration before
|
||||
* initialization" check. Validation is done by the callers. */
|
||||
function build_outbound(node, mark) {
|
||||
const outbound = {
|
||||
type: node.type,
|
||||
tag: Node.tag(node),
|
||||
routing_mark: strToInt(mark)
|
||||
};
|
||||
|
||||
for (let field, spec in COMMON_FIELDS)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
const claims = CLAIM_FIELDS(node);
|
||||
for (let field, value in claims)
|
||||
outbound[field] = value;
|
||||
|
||||
const options = OPTION_FIELDS[node.type] || {};
|
||||
for (let field, spec in options)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
outbound.multiplex = build_multiplex(node.multiplex);
|
||||
outbound.tls = buildTLSObject(node.tls, false);
|
||||
outbound.transport = buildTransportObject(node.transport, false);
|
||||
|
||||
if (node.type === 'direct')
|
||||
outbound.proxy_protocol = strToInt(node.common.proxy_protocol);
|
||||
|
||||
/* removeBlankAttrs() is what the generator applies before writing, and
|
||||
it drops every null the field tables produced. Cleaning here means
|
||||
this function returns the final artifact, so a golden-snapshot test
|
||||
can compare it against what sing-box would actually be handed. */
|
||||
return removeBlankAttrs(outbound);
|
||||
}
|
||||
|
||||
export const OutboundFactory = {
|
||||
/* Validation split out of create() so a caller can decide whether a
|
||||
* broken node is fatal. Two-layer: Node.validate() covers
|
||||
* cross-protocol rules, protocol_problems() the per-protocol ones. */
|
||||
problems: outbound_problems,
|
||||
|
||||
/* True when create() would succeed. Used to prune optional node lists
|
||||
* (urltest candidates) so one misconfigured node cannot abort the
|
||||
* entire configuration. */
|
||||
buildable: (node) => length(outbound_problems(node)) === 0,
|
||||
|
||||
/* Node -> { outbound, problems }. Same result as create() but never
|
||||
* dies, for nodes the user only *may* route through. */
|
||||
tryCreate: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
return { outbound: null, problems: problems };
|
||||
|
||||
return { outbound: build_outbound(node, mark), problems: [] };
|
||||
},
|
||||
|
||||
/* Node -> sing-box outbound object, or die() when the node is not
|
||||
* buildable. Use problems()/buildable()/tryCreate() for nodes whose
|
||||
* breakage must not be fatal (urltest candidates). */
|
||||
create: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
die(`node '${node.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_outbound(node, mark);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- WireGuard endpoint ------------------------------------------------- */
|
||||
|
||||
/* WireGuard is emitted as a
|
||||
* sing-box *endpoint*, not an outbound, so it needs its own builder.
|
||||
* That builder used to live in generator/outbound.uc, which meant the
|
||||
* generator still held protocol business logic for one protocol. It
|
||||
* now lives here, next to the outbound factory, and the generator only
|
||||
* decides *when* an endpoint is needed (routing-mode split, urltest
|
||||
* resolution).
|
||||
*
|
||||
* `ctx` carries the process-wide UDP tuning the runtime owns
|
||||
* (udp_mapping / udp_filtering / udp_nat_max) - same shape
|
||||
* build_outbounds() already passes around, so callers do not change. */
|
||||
function build_endpoint(node, ctx) {
|
||||
const opts = node.protocol_options || {};
|
||||
const common = node.common || {};
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
tag: 'cfg-' + node.id + '-out',
|
||||
address: opts.local_address,
|
||||
mtu: strToInt(opts.mtu),
|
||||
private_key: opts.private_key,
|
||||
peers: (node.type === 'wireguard') ? [
|
||||
{
|
||||
address: node.address,
|
||||
port: strToInt(node.port),
|
||||
allowed_ips: [
|
||||
'0.0.0.0/0',
|
||||
'::/0'
|
||||
],
|
||||
persistent_keepalive_interval: strToInt(opts.persistent_keepalive_interval),
|
||||
public_key: opts.peer_public_key,
|
||||
pre_shared_key: opts.pre_shared_key,
|
||||
reserved: parse_port(opts.reserved),
|
||||
}
|
||||
] : null,
|
||||
system: (node.type === 'wireguard') ? false : null,
|
||||
tcp_fast_open: strToBool(common.tcp_fast_open),
|
||||
tcp_multi_path: strToBool(common.tcp_multi_path),
|
||||
udp_fragment: strToBool(common.udp_fragment),
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max
|
||||
};
|
||||
}
|
||||
|
||||
export const EndpointFactory = {
|
||||
/* Node -> sing-box endpoint object, or null when there is nothing to
|
||||
* build. Unlike OutboundFactory.create() this never dies: an endpoint
|
||||
* is only ever reached for a node the routing layer already selected,
|
||||
* and the caller treats null as "skip this endpoint". */
|
||||
create: (node, ctx) => {
|
||||
if (type(node) !== 'object' || isEmpty(node))
|
||||
return null;
|
||||
|
||||
return build_endpoint(node, ctx);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- server inbound ----------------------------------------------------- */
|
||||
|
||||
/* The server half of the Adapter. Same data-table approach as
|
||||
* the outbound side: the protocol differences are *fields*, not
|
||||
* behaviour, so they are expressed as tables rather than a branch per
|
||||
* protocol. generator/server.uc used to spell every one of these out
|
||||
* inline while reading flat UCI keys off the section dict.
|
||||
*
|
||||
* Adding a protocol means adding two rows: INBOUND_CREDENTIALS and
|
||||
* INBOUND_OPTIONS in model.uc, and an INBOUND_OPTION_FIELDS row here. */
|
||||
|
||||
/* Listener-level fields every inbound emits. Each value is a function of
|
||||
* the Inbound, or null (which removeBlankAttrs() strips). */
|
||||
const INBOUND_COMMON_FIELDS = {
|
||||
listen: (inbound) => inbound.address || '::',
|
||||
listen_port: (inbound) => strToInt(inbound.port),
|
||||
bind_interface: (inbound) => inbound.common.bind_interface,
|
||||
reuse_addr: (inbound) => strToBool(inbound.common.reuse_addr),
|
||||
tcp_fast_open: (inbound) => strToBool(inbound.common.tcp_fast_open),
|
||||
tcp_multi_path: (inbound) => strToBool(inbound.common.tcp_multi_path),
|
||||
udp_fragment: (inbound) => strToBool(inbound.common.udp_fragment),
|
||||
udp_timeout: (inbound) => strToTime(inbound.common.udp_timeout),
|
||||
network: (inbound) => inbound.common.network
|
||||
};
|
||||
|
||||
/* Listener fields a protocol must NOT be given even when the UCI section
|
||||
* happens to carry the option. sing-box 1.14's snell inbound takes no
|
||||
* udp_fragment / udp_timeout / network; the previous generator had a
|
||||
* dedicated build_snell_inbound() that omitted them, and this table
|
||||
* keeps that behaviour instead of relying on the user not setting them. */
|
||||
const INBOUND_COMMON_OMIT = {
|
||||
snell: ['udp_fragment', 'udp_timeout', 'network']
|
||||
};
|
||||
|
||||
/* Protocols whose sing-box inbound has no users[] block. snell carries a
|
||||
* single top-level psk, shadowsocks a top-level password + method; giving
|
||||
* either of them a users[] entry makes sing-box reject the section
|
||||
* ("snell: bad user key" - the users entry is interpreted as an
|
||||
* additional user key). */
|
||||
const INBOUND_NO_USERS = ['shadowsocks', 'snell'];
|
||||
|
||||
/* Credential fields the inbound emits, keyed by the sing-box field
|
||||
* name. Most protocols put them in the users[] block (below); snell
|
||||
* wants a single top-level `psk`, and shadowsocks / shadowtls a
|
||||
* top-level password (plus method for shadowsocks), so those are
|
||||
* handled here. */
|
||||
function INBOUND_CLAIM_FIELDS(inbound) {
|
||||
switch (inbound.type) {
|
||||
case 'snell':
|
||||
return { psk: inbound.credentials.psk };
|
||||
case 'shadowsocks':
|
||||
return {
|
||||
method: inbound.credentials.method,
|
||||
password: inbound.credentials.password
|
||||
};
|
||||
case 'shadowtls':
|
||||
return { password: inbound.credentials.password };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/* The users[] entry. sing-box wants one user per inbound; every
|
||||
* protocol not listed in INBOUND_NO_USERS accepts the block. */
|
||||
function build_inbound_user(inbound) {
|
||||
const creds = inbound.credentials || {};
|
||||
const opts = inbound.protocol_options || {};
|
||||
|
||||
return {
|
||||
name: !(inbound.type in ['http', 'mixed', 'naive', 'socks']) ?
|
||||
'cfg-' + inbound.id + '-server' : null,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
|
||||
/* Hysteria (1) authentication: exactly one of the two
|
||||
* spellings, selected by the auth type. */
|
||||
auth: (opts.auth_type === 'base64') ? creds.auth_base64 : null,
|
||||
auth_str: (opts.auth_type === 'string') ? creds.auth_str : null,
|
||||
|
||||
uuid: creds.uuid,
|
||||
|
||||
/* VLESS / VMess. Both are per-user fields: sing-box has no
|
||||
* top-level `flow` on a vless inbound nor `alterId` on a vmess
|
||||
* one, which is why INBOUND_OPTION_FIELDS has no row for these
|
||||
* two protocols. */
|
||||
flow: opts.flow,
|
||||
alterId: strToInt(opts.alter_id)
|
||||
};
|
||||
}
|
||||
|
||||
/* Per-protocol inbound fields, keyed by the sing-box field name. Only
|
||||
* the protocols whose sing-box inbound carries something beyond the
|
||||
* shared listener/credential/TLS/transport set need a row. */
|
||||
const INBOUND_OPTION_FIELDS = {
|
||||
anytls: {
|
||||
padding_scheme: (inbound) => inbound.protocol_options.padding_scheme
|
||||
},
|
||||
/* Hysteria (1) and Hysteria2 share three fields and differ on
|
||||
* obfs: v1 carries the obfuscation as a plain password string,
|
||||
* v2 as a {type, password, min_packet_size, max_packet_size}
|
||||
* object. Emitting v1's obfs as a string is what the generator
|
||||
* always did - the {type, ...} shape on a v1 inbound is a
|
||||
* sing-box rejection. */
|
||||
hysteria: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_password,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_type ? {
|
||||
type: inbound.protocol_options.obfs_type,
|
||||
password: inbound.protocol_options.obfs_password,
|
||||
min_packet_size: strToInt(inbound.protocol_options.obfs_min_packet_size),
|
||||
max_packet_size: strToInt(inbound.protocol_options.obfs_max_packet_size)
|
||||
/* hysteria2 takes an obfs *object* here. Falling back to the bare
|
||||
* password string (which is what hysteria v1 wants) makes sing-box
|
||||
* reject the inbound with "cannot unmarshal string into Go struct
|
||||
* field Hysteria2InboundOptions.obfs" - so no type means no obfs. */
|
||||
} : null,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
snell: {
|
||||
version: (inbound) => strToInt(inbound.protocol_options.version) || 5,
|
||||
obfs_mode: (inbound) => inbound.protocol_options.obfs_mode
|
||||
/* no `mode`: sing-box 1.14 rejects it on a snell inbound; it
|
||||
* was a v6-only option and v6 is not supported. */
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: (inbound) => inbound.protocol_options.congestion_control,
|
||||
auth_timeout: (inbound) => strToTime(inbound.protocol_options.auth_timeout),
|
||||
zero_rtt_handshake: (inbound) => strToBool(inbound.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (inbound) => strToTime(inbound.protocol_options.heartbeat)
|
||||
}
|
||||
/* No row for vless / vmess: both protocols' only per-protocol knobs
|
||||
* (flow, alterId) are per-user fields, emitted by
|
||||
* build_inbound_user() above. sing-box has no top-level `flow` on a
|
||||
* vless inbound nor `alterId` on a vmess one, so adding a row here
|
||||
* would emit a field sing-box rejects. The Loader still loads them
|
||||
* into inbound.protocol_options (INBOUND_OPTIONS in model.uc), which
|
||||
* is where build_inbound_user() reads them from.
|
||||
*
|
||||
* No row for trojan / shadowtls either: everything they carry is
|
||||
* shared (credentials / TLS / transport). */
|
||||
};
|
||||
|
||||
/* The server-only TLS tail, re-keyed from the Inbound's canonical
|
||||
* names into the UCI option names buildTLSObject() reads. That shared
|
||||
* builder is exercised directly by tests/ucode/test_tls_transport.uc
|
||||
* and is used by both the client and the server path, so its signature
|
||||
* stays as it is; this is the single place the flat names survive. */
|
||||
function build_tls_server_extras(tls_server) {
|
||||
const extras = {};
|
||||
|
||||
for (let canonical, uci in INBOUND_TLS_SERVER)
|
||||
extras[uci] = tls_server[canonical];
|
||||
|
||||
return extras;
|
||||
}
|
||||
|
||||
/* Build the inbound `multiplex` block. The server side has no
|
||||
* max_connections / min_streams / max_streams (those are client
|
||||
* dialling knobs), so this is deliberately a smaller shape than the
|
||||
* outbound factory's build_multiplex(). */
|
||||
function build_inbound_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Inbound -> sing-box inbound object. Pure: no UCI, no file access. */
|
||||
function build_inbound(inbound) {
|
||||
const out = {
|
||||
type: inbound.type,
|
||||
tag: Inbound.tag(inbound)
|
||||
};
|
||||
|
||||
const omit = INBOUND_COMMON_OMIT[inbound.type] || [];
|
||||
|
||||
for (let field, spec in INBOUND_COMMON_FIELDS) {
|
||||
if (field in omit)
|
||||
continue;
|
||||
out[field] = resolve(spec, inbound);
|
||||
}
|
||||
|
||||
const claims = INBOUND_CLAIM_FIELDS(inbound);
|
||||
for (let field, value in claims)
|
||||
out[field] = value;
|
||||
|
||||
const options = INBOUND_OPTION_FIELDS[inbound.type] || {};
|
||||
for (let field, spec in options)
|
||||
out[field] = resolve(spec, inbound);
|
||||
|
||||
out.users = (inbound.type in INBOUND_NO_USERS) ? null : [ build_inbound_user(inbound) ];
|
||||
|
||||
out.multiplex = build_inbound_multiplex(inbound.multiplex);
|
||||
out.tls = buildTLSObject(inbound.tls, true, build_tls_server_extras(inbound.tls_server || {}));
|
||||
out.transport = buildTransportObject(inbound.transport, true);
|
||||
|
||||
/* Same contract as build_outbound(): return the final artifact so a
|
||||
* golden snapshot can compare it against what sing-box is handed. */
|
||||
return removeBlankAttrs(out);
|
||||
}
|
||||
|
||||
/* Per-protocol requirements, mirroring OutboundFactory's
|
||||
* REQUIRED_CREDENTIALS: what the sing-box inbound for this protocol
|
||||
* needs in order to be usable. */
|
||||
const REQUIRED_INBOUND_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
/* These run without a credential: http / mixed / socks / naive can
|
||||
* be anonymous, and hysteria (1) may carry its auth out of band. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
mixed: [],
|
||||
naive: [],
|
||||
socks: []
|
||||
};
|
||||
|
||||
function inbound_problems(inbound) {
|
||||
let problems = [...Inbound.validate(inbound)];
|
||||
|
||||
const required = REQUIRED_INBOUND_CREDENTIALS[inbound.type] || [];
|
||||
const creds = inbound.credentials || {};
|
||||
|
||||
for (let field in required)
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${inbound.type} requires ${field}`];
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export const InboundFactory = {
|
||||
problems: inbound_problems,
|
||||
|
||||
buildable: (inbound) => length(inbound_problems(inbound)) === 0,
|
||||
|
||||
/* Inbound -> { inbound, problems }. Never dies, so a single broken
|
||||
* server section can be reported by the caller instead of taking
|
||||
* the whole server instance down. */
|
||||
tryCreate: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
return { inbound: null, problems: problems };
|
||||
|
||||
return { inbound: build_inbound(inbound), problems: [] };
|
||||
},
|
||||
|
||||
/* Inbound -> sing-box inbound object, or die(). */
|
||||
create: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
die(`inbound '${inbound.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_inbound(inbound);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig
|
||||
*
|
||||
* This file originated as the demo loader and was promoted into the
|
||||
* production tree; the demo copy is gone, so this is the only one.
|
||||
*
|
||||
* What this layer does:
|
||||
* - owns the only `uci.cursor()` in the client configuration path
|
||||
* - reads general + infra + nodes + dns + routing + access_control + server
|
||||
* - never emits sing-box JSON
|
||||
* - never mutates/commits UCI
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import {
|
||||
Config, Node, CREDENTIALS, Inbound,
|
||||
INBOUND_CREDENTIALS, INBOUND_OPTIONS, INBOUND_COMMON, INBOUND_TLS_SERVER
|
||||
} from './model.uc';
|
||||
|
||||
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from '../parser/mapping.uc';
|
||||
|
||||
const UCICONFIG = 'homeproxy';
|
||||
|
||||
const SECTION = {
|
||||
main: 'config',
|
||||
infra: 'infra',
|
||||
node: 'node',
|
||||
dns: 'dns',
|
||||
dns_server: 'dns_server',
|
||||
dns_rule: 'dns_rule',
|
||||
routing: 'routing',
|
||||
routing_node: 'routing_node',
|
||||
routing_rule: 'routing_rule',
|
||||
ruleset: 'ruleset',
|
||||
control: 'control',
|
||||
subscription: 'subscription',
|
||||
server: 'server'
|
||||
};
|
||||
|
||||
/* Booleans are UCI '0'/'1'; keep the conversion in one place. */
|
||||
function bool(value) {
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
function opt(uci, section, name) {
|
||||
/* UCI returns null for an absent option; the old code papered over that
|
||||
* with `|| ''` at every call site, losing the distinction between "not
|
||||
* set" and "set to empty". The model keeps null instead. */
|
||||
return uci.get(UCICONFIG, SECTION[section], name);
|
||||
}
|
||||
|
||||
function node_opt(uci, section_id, name) {
|
||||
return uci.get(UCICONFIG, section_id, name);
|
||||
}
|
||||
|
||||
/* --- sub-object extraction --------------------------------------------- */
|
||||
|
||||
/* These three functions are the whole reason a Node is hierarchical: the
|
||||
* shared TLS/transport/multiplex builders (homeproxy.uc) take a flat,
|
||||
* prefixed dict today, so every caller has to hand them the right subset. A
|
||||
* sub-object makes the boundary explicit and testable.
|
||||
*
|
||||
* load_tls and load_transport are exported so the server generator can
|
||||
* shape its UCI inbound sections the same way; only the multiplex shape is
|
||||
* Loader-internal because no caller outside the client node path needs it. */
|
||||
|
||||
export function load_tls(get) {
|
||||
return {
|
||||
enabled: get('tls'),
|
||||
server_name: get('tls_sni'),
|
||||
insecure: get('tls_insecure'),
|
||||
alpn: get('tls_alpn'),
|
||||
min_version: get('tls_min_version'),
|
||||
max_version: get('tls_max_version'),
|
||||
handshake_timeout: get('tls_handshake_timeout'),
|
||||
cipher_suites: get('tls_cipher_suites'),
|
||||
cert_path: get('tls_cert_path'),
|
||||
ech: {
|
||||
enabled: get('tls_ech'),
|
||||
config: get('tls_ech_config'),
|
||||
config_path: get('tls_ech_config_path')
|
||||
},
|
||||
utls: {
|
||||
fingerprint: get('tls_utls')
|
||||
},
|
||||
reality: {
|
||||
enabled: get('tls_reality'),
|
||||
public_key: get('tls_reality_public_key'),
|
||||
short_id: get('tls_reality_short_id')
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function load_transport(get) {
|
||||
const transport = get('transport');
|
||||
|
||||
if (transport == null || transport === '')
|
||||
return { type: null };
|
||||
|
||||
return {
|
||||
type: transport,
|
||||
host: get('http_host') || get('httpupgrade_host'),
|
||||
path: get('http_path') || get('ws_path'),
|
||||
headers: get('ws_host') ? { Host: get('ws_host') } : null,
|
||||
method: get('http_method'),
|
||||
max_early_data: get('websocket_early_data'),
|
||||
early_data_header_name: get('websocket_early_data_header'),
|
||||
service_name: get('grpc_servicename'),
|
||||
idle_timeout: get('http_idle_timeout'),
|
||||
ping_timeout: get('http_ping_timeout'),
|
||||
permit_without_stream: get('grpc_permit_without_stream')
|
||||
};
|
||||
};
|
||||
|
||||
function load_multiplex(get) {
|
||||
return {
|
||||
enabled: get('multiplex'),
|
||||
protocol: get('multiplex_protocol'),
|
||||
max_connections: get('multiplex_max_connections'),
|
||||
min_streams: get('multiplex_min_streams'),
|
||||
max_streams: get('multiplex_max_streams'),
|
||||
padding: get('multiplex_padding'),
|
||||
brutal: {
|
||||
enabled: get('multiplex_brutal'),
|
||||
up_mbps: get('multiplex_brutal_up'),
|
||||
down_mbps: get('multiplex_brutal_down')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Cross-protocol common fields (every outbound protocol carries them, not
|
||||
* just one). Lives as its own sub-object instead of being repeated in
|
||||
* every PROTOCOL_OPTIONS row, and the Adapter reads from `node.common`. */
|
||||
function load_common(get) {
|
||||
return {
|
||||
proxy_protocol: get('proxy_protocol'),
|
||||
tcp_fast_open: get('tcp_fast_open'),
|
||||
tcp_multi_path: get('tcp_multi_path'),
|
||||
udp_fragment: get('udp_fragment')
|
||||
};
|
||||
}
|
||||
|
||||
/* --- protocol options --------------------------------------------------- */
|
||||
|
||||
/* The canonical <-> UCI mapping table lives in scripts/parser/mapping.uc and
|
||||
* is imported as PROTOCOL_OPTIONS above. The Loader no longer maintains its
|
||||
* own copy, so the parser, the Loader, the parser's normalize(), and the
|
||||
* Repository all read from the same source. Anything not listed in
|
||||
* parser/mapping.uc for a given protocol is not part of the domain
|
||||
* contract; a stale UCI option is now dropped at the parser side too.
|
||||
*/
|
||||
|
||||
function load_protocol_options(get, type) {
|
||||
const mapping = PROTOCOL_OPTIONS[type] || {};
|
||||
const options = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
options[canonical] = get(uci_name);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/* Pull the protocol's credential fields out under canonical names, using the
|
||||
* single CREDENTIALS table from the model. This is the loader half of the
|
||||
* mapping that the old code spread across ~20 ternaries in generate_outbound().
|
||||
* Declared before Loader: ucode resolves module-level names at call time but
|
||||
* not before they are declared. */
|
||||
function load_credentials(get, type) {
|
||||
const mapping = CREDENTIALS[type] || {};
|
||||
const credentials = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
credentials[canonical] = get(uci_name);
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/* --- domain sub-objects ------------------------------------------------ */
|
||||
|
||||
/* Settings: only the keys that exist in UCI are kept (null is dropped), so
|
||||
* the consumer can tell "not set" from "set to empty". A3 (Generator split)
|
||||
* will own the actual key list - this loader just preserves what UCI has. */
|
||||
function load_settings(uci, section, keys) {
|
||||
const settings = {};
|
||||
|
||||
for (let key in keys) {
|
||||
const v = uci.get(UCICONFIG, section, key);
|
||||
if (v != null)
|
||||
settings[key] = v;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/* The raw UCI section dict that
|
||||
* uci.foreach() yields carries internal fields prefixed with a dot
|
||||
* (`.name`, `.index`, `.type`); downstream generators had to read those
|
||||
* directly, and every consumer had to remember `cfg.enabled !== '1'`
|
||||
* to gate a section. Normalise once here so consumers see a flat
|
||||
* domain object: the dot-prefixed pseudo-fields are dropped, the UCI
|
||||
* section name is exposed as `name`, and `enabled` (when present) is
|
||||
* coerced to a real boolean.
|
||||
*
|
||||
* Fields with non-prefixed names that happen to collide with the UCI
|
||||
* metadata (e.g. an option literally called `name` or `index`) would
|
||||
* be shadowed; in this codebase that does not happen, and the Adapter keeps
|
||||
* it that way. */
|
||||
function normalize_section(cfg) {
|
||||
const item = {};
|
||||
|
||||
for (let k, v in cfg) {
|
||||
/* substr(), not k[0]: the target ucode (2026.01.16) rejects
|
||||
* string indexing with "left-hand side expression is not an
|
||||
* array or object". subscription/repository.uc already uses
|
||||
* the substr() form for the same check. */
|
||||
if (substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
item[k] = v;
|
||||
}
|
||||
|
||||
if ('enabled' in item)
|
||||
item.enabled = (item.enabled === '1');
|
||||
|
||||
item.name = cfg['.name'];
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/* Single-section + list sections grouped under a domain sub-object.
|
||||
* Each list entry is the normalised section dict produced by
|
||||
* normalize_section(): the UCI `.name`/`.index`/`.type` pseudo-fields
|
||||
* are gone, `name` is set to the section name, and `enabled` is a
|
||||
* boolean. Sing-box field names are still produced by the generator
|
||||
* modules; this loader just preserves what UCI has. */
|
||||
function load_sections(uci, type) {
|
||||
const items = [];
|
||||
|
||||
uci.foreach(UCICONFIG, type, (cfg) => push(items, normalize_section(cfg)));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function load_dns(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.dns, [
|
||||
'default_strategy', 'default_server',
|
||||
'disable_cache', 'disable_cache_expire',
|
||||
'client_subnet',
|
||||
'optimistic_cache', 'optimistic_timeout',
|
||||
'dns_timeout', 'cache_file_store_dns'
|
||||
]),
|
||||
servers: load_sections(uci, SECTION.dns_server),
|
||||
rules: load_sections(uci, SECTION.dns_rule)
|
||||
};
|
||||
}
|
||||
|
||||
function load_routing(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.routing, [
|
||||
'default_outbound', 'default_outbound_dns',
|
||||
'domain_strategy', 'find_neighbor',
|
||||
'udp_timeout', 'tcpip_stack', 'endpoint_independent_nat'
|
||||
]),
|
||||
nodes: load_sections(uci, SECTION.routing_node),
|
||||
rules: load_sections(uci, SECTION.routing_rule),
|
||||
rulesets: load_sections(uci, SECTION.ruleset)
|
||||
};
|
||||
}
|
||||
|
||||
/* access_control is two single sections: `control` (lan_proxy_mode,
|
||||
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
|
||||
function load_access_control(uci) {
|
||||
return {
|
||||
control: load_settings(uci, SECTION.control, [
|
||||
'bind_interface', 'lan_proxy_mode'
|
||||
]),
|
||||
/* wan_proxy_*_ips are list options on the control section; collect
|
||||
them in their canonical form. */
|
||||
wan_proxy_ipv4_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv4_ips') || [],
|
||||
wan_proxy_ipv6_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv6_ips') || [],
|
||||
subscription: load_settings(uci, SECTION.subscription, [
|
||||
'auto_update', 'allow_insecure',
|
||||
'packet_encoding', 'update_via_proxy',
|
||||
'filter_nodes', 'user_agent'
|
||||
]),
|
||||
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
|
||||
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
|
||||
};
|
||||
}
|
||||
|
||||
/* The two "read a table of canonical -> UCI names off a section"
|
||||
* helpers shared by the node and inbound paths. Same shape as
|
||||
* load_protocol_options() above; split out so the server side reads
|
||||
* the same way the client side does. */
|
||||
function load_table(get, mapping) {
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
out[canonical] = get(uci_name);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* A server inbound is now a
|
||||
* domain object like a Node, built here and shaped into sing-box JSON
|
||||
* by config/adapter.uc's InboundFactory. Before this the generator read
|
||||
* the flat UCI section directly (`cfg.snell_version`,
|
||||
* `cfg.shadowsocks_encrypt_method`, ...), so the server path had no
|
||||
* domain model at all.
|
||||
*
|
||||
* Same sub-object split as Node: common (listener-level), credentials,
|
||||
* tls (the shared shape the client uses too), tls_server (the
|
||||
* server-only tail), transport, multiplex, protocol_options. */
|
||||
function load_inbound(get, section) {
|
||||
const type = get('type');
|
||||
|
||||
return Inbound.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: type,
|
||||
enabled: section.enabled === '1',
|
||||
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
firewall: get('firewall'),
|
||||
|
||||
common: load_table(get, INBOUND_COMMON),
|
||||
credentials: load_table(get, INBOUND_CREDENTIALS[type] || {}),
|
||||
tls: load_tls(get),
|
||||
tls_server: load_table(get, INBOUND_TLS_SERVER),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_table(get, INBOUND_OPTIONS[type] || {})
|
||||
});
|
||||
}
|
||||
|
||||
/* server has one enabled/log_level single section + N inbound sections
|
||||
* (each with a `type` of vless / trojan / shadowsocks / ...). */
|
||||
function load_server(uci) {
|
||||
const inbounds = [];
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.server, (section) => {
|
||||
push(inbounds, load_inbound((name) => node_opt(uci, section['.name'], name), section));
|
||||
});
|
||||
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.server, [
|
||||
'enabled', 'log_level'
|
||||
]),
|
||||
inbounds
|
||||
};
|
||||
}
|
||||
|
||||
/* --- loader ------------------------------------------------------------- */
|
||||
|
||||
export const Loader = {
|
||||
/* Load the client side of the configuration. `dir` defaults to the
|
||||
* production UCI directory but can be overridden, which removes the need
|
||||
* for the test suite's `sed` of `const uci = cursor();`. */
|
||||
load: (dir) => {
|
||||
const uci = dir ? cursor(dir) : cursor();
|
||||
|
||||
uci.load(UCICONFIG);
|
||||
|
||||
const config = Config.create('homeproxy');
|
||||
|
||||
config.general = {
|
||||
routing_mode: opt(uci, 'main', 'routing_mode') || 'bypass_mainland_china',
|
||||
proxy_mode: opt(uci, 'main', 'proxy_mode') || 'redirect_tproxy',
|
||||
main_node: opt(uci, 'main', 'main_node') || 'nil',
|
||||
main_udp_node: opt(uci, 'main', 'main_udp_node') || 'nil',
|
||||
/* Booleans stay as raw UCI strings here, same as Node. The
|
||||
Adapter (A3) is the one that calls strToBool(); coercing in
|
||||
the Loader and again in the Adapter produced two different
|
||||
defaults for absent / garbage values during A1.1. */
|
||||
ipv6_support: opt(uci, 'main', 'ipv6_support'),
|
||||
|
||||
/* A2.1: scalar + list fields on the `config` section. */
|
||||
dns_server: opt(uci, 'main', 'dns_server'),
|
||||
china_dns_server: opt(uci, 'main', 'china_dns_server'),
|
||||
log_level: opt(uci, 'main', 'log_level') || 'warn',
|
||||
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
|
||||
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
|
||||
udp_mapping: opt(uci, 'main', 'udp_mapping'),
|
||||
udp_filtering: opt(uci, 'main', 'udp_filtering'),
|
||||
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
|
||||
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
|
||||
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
|
||||
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
|
||||
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
|
||||
main_udp_urltest_nodes: opt(uci, 'main', 'main_udp_urltest_nodes') || [],
|
||||
main_udp_urltest_interval: opt(uci, 'main', 'main_udp_urltest_interval'),
|
||||
main_udp_urltest_tolerance: opt(uci, 'main', 'main_udp_urltest_tolerance')
|
||||
};
|
||||
|
||||
/* udp_timeout lives on two UCI sections: routing.udp_timeout
|
||||
* (custom mode) and infra.udp_timeout (everything else). Mirror
|
||||
* that split here so A2 can read either path without falling back
|
||||
* to uci.get(). */
|
||||
config.infra = load_settings(uci, SECTION.infra, [
|
||||
'common_port', 'mixed_port', 'redirect_port', 'tproxy_port',
|
||||
'dns_port', 'dns_redirect',
|
||||
'tun_name', 'tun_addr4', 'tun_addr6', 'tun_mtu',
|
||||
'table_mark', 'self_mark', 'tproxy_mark', 'tun_mark',
|
||||
'ntp_server', 'udp_timeout'
|
||||
]);
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.node, (section) => {
|
||||
const get = (name) => node_opt(uci, section['.name'], name);
|
||||
|
||||
push(config.nodes, Node.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: get('type'),
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
common: load_common(get),
|
||||
credentials: load_credentials(get, get('type')),
|
||||
tls: load_tls(get),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_protocol_options(get, get('type'))
|
||||
}));
|
||||
});
|
||||
|
||||
config.dns = load_dns(uci);
|
||||
config.routing = load_routing(uci);
|
||||
config.access_control = load_access_control(uci);
|
||||
config.server = load_server(uci);
|
||||
|
||||
return config;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig / Node -> Adapter -> sing-box
|
||||
*
|
||||
* The point of this layer is that nothing below it may touch UCI, and nothing
|
||||
* in it may know about sing-box JSON. A Node therefore describes *what the
|
||||
* user configured*, not what sing-box wants: no `outbound`, no `type` field
|
||||
* named after a sing-box outbound kind, no `tag` arithmetic.
|
||||
*
|
||||
* Every field the Adapter used to read off
|
||||
* `node.raw.*` has been promoted to an explicit Node sub-object
|
||||
* (`credentials`, `tls`, `transport`, `multiplex`, `common`,
|
||||
* `protocol_options`), so the legacy `raw` opaque bag was dropped - no code
|
||||
* reads it and leaving it in only made the next reader assume it was
|
||||
* authoritative.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Canonical credential multiplexing for a Node. The current generator picks
|
||||
* these apart with ternaries per field (`username` vs `user` vs `password` vs
|
||||
* `psk`, keyed on every protocol); expressing them as data is what makes a new
|
||||
* protocol a table entry instead of another branch.
|
||||
*
|
||||
* The value is the UCI option name (as the loader sees it on the flat UCI
|
||||
* section), the key is the canonical credential name. The adapter later
|
||||
* emits the field under whatever name the sing-box outbound wants
|
||||
* (e.g. snell: credentials.psk is emitted as the outbound `psk` field,
|
||||
* shadowsocks: credentials.method becomes outbound `method`).
|
||||
*
|
||||
* Fixed during Stage A1.1: snell `userkey` actually comes from the
|
||||
* `snell_userkey` UCI option (not `username`), and shadowsocks `method`
|
||||
* actually comes from `shadowsocks_encrypt_method` (not `method`).
|
||||
* The earlier table assumed both mapped to bare option names and failed
|
||||
* the skeleton test against the real fixture. */
|
||||
export const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
/* The node form writes ssh_priv_key / ssh_priv_key_pp, not the bare
|
||||
* private_key this table used to claim, so the key the user pasted was
|
||||
* never read. (There was also no PROTOCOL_OPTIONS row for ssh, so the
|
||||
* rest of the SSH options were dropped as well.) */
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
/* Canonical credentials for a server Inbound. A different table from
|
||||
* the client's CREDENTIALS because the server form exposes a smaller
|
||||
* set (no ssh material, no snell userkey) and because snell's server
|
||||
* side reads the psk off the same `password` UCI option the client
|
||||
* node uses for its own psk. Keeping the two tables separate means a
|
||||
* change to one side cannot silently alter the other.
|
||||
*/
|
||||
export const INBOUND_CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'hysteria_auth_payload', auth_base64: 'hysteria_auth_payload' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
shadowtls: { password: 'password' },
|
||||
snell: { psk: 'password' },
|
||||
anytls: { password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
mixed: { username: 'username', password: 'password' },
|
||||
naive: { username: 'username', password: 'password' },
|
||||
socks: { username: 'username', password: 'password' }
|
||||
};
|
||||
|
||||
/* Per-protocol options on a server Inbound, canonical -> UCI. This is
|
||||
* the server-side counterpart of parser/mapping.uc's PROTOCOL_TO_UCI;
|
||||
* the two are NOT identical - the server side exposes inbound-only
|
||||
* knobs (anytls padding_scheme, hysteria masquerade / packet sizes /
|
||||
* ignore_client_bandwidth, tuic auth_timeout) and does not expose the
|
||||
* client's port-hopping options - so they stay separate tables. A
|
||||
* protocol whose server-side row is missing here produces no
|
||||
* protocol_options rather than reading a flat UCI key. */
|
||||
export const INBOUND_OPTIONS = {
|
||||
anytls: {
|
||||
padding_scheme: 'anytls_padding_scheme'
|
||||
},
|
||||
hysteria: {
|
||||
auth_type: 'hysteria_auth_type',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_type: 'hysteria_obfs_type',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
obfs_min_packet_size: 'hysteria_obfs_min_packet_size',
|
||||
obfs_max_packet_size: 'hysteria_obfs_max_packet_size',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
shadowsocks: {
|
||||
method: 'shadowsocks_encrypt_method'
|
||||
},
|
||||
snell: {
|
||||
version: 'snell_version',
|
||||
obfs_mode: 'snell_obfs_mode'
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: 'tuic_congestion_control',
|
||||
auth_timeout: 'tuic_auth_timeout',
|
||||
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||
heartbeat: 'tuic_heartbeat'
|
||||
},
|
||||
vless: {
|
||||
flow: 'vless_flow'
|
||||
},
|
||||
vmess: {
|
||||
alter_id: 'vmess_alterid'
|
||||
}
|
||||
};
|
||||
|
||||
/* Listener-level fields every server inbound carries. Inbound-only
|
||||
* (no client outbound emits them), so they are not part of the
|
||||
* client's load_common() table. */
|
||||
export const INBOUND_COMMON = {
|
||||
bind_interface: 'bind_interface',
|
||||
reuse_addr: 'reuse_addr',
|
||||
tcp_fast_open: 'tcp_fast_open',
|
||||
tcp_multi_path: 'tcp_multi_path',
|
||||
udp_fragment: 'udp_fragment',
|
||||
udp_timeout: 'udp_timeout',
|
||||
network: 'network'
|
||||
};
|
||||
|
||||
/* The server-only TLS tail. These options exist because a client
|
||||
* outbound never needs them (key material, ACME, the server half of
|
||||
* the REALITY handshake), so they live on the Inbound rather than
|
||||
* being folded into the shared `tls` sub-object the Loader builds for
|
||||
* both sides. Canonical -> UCI; the Adapter re-keys them back to the
|
||||
* UCI names buildTLSObject() reads, which is the one place the flat
|
||||
* names still exist. */
|
||||
export const INBOUND_TLS_SERVER = {
|
||||
key_path: 'tls_key_path',
|
||||
ech_key: 'tls_ech_key',
|
||||
reality_private_key: 'tls_reality_private_key',
|
||||
reality_max_time_difference: 'tls_reality_max_time_difference',
|
||||
reality_server_addr: 'tls_reality_server_addr',
|
||||
reality_server_port: 'tls_reality_server_port',
|
||||
acme: 'tls_acme',
|
||||
acme_domain: 'tls_acme_domain',
|
||||
acme_dsn: 'tls_acme_dsn',
|
||||
acme_email: 'tls_acme_email',
|
||||
acme_provider: 'tls_acme_provider',
|
||||
acme_account_key: 'tls_acme_account_key',
|
||||
acme_key_type: 'tls_acme_key_type',
|
||||
acme_profile: 'tls_acme_profile',
|
||||
acme_disable_http_challenge: 'tls_acme_dhc',
|
||||
acme_disable_tls_alpn_challenge: 'tls_acme_dtac',
|
||||
acme_alternative_http_port: 'tls_acme_ahp',
|
||||
acme_alternative_tls_port: 'tls_acme_atp',
|
||||
acme_external_account: 'tls_acme_external_account',
|
||||
acme_external_account_key_id: 'tls_acme_ea_keyid',
|
||||
acme_external_account_mac_key: 'tls_acme_ea_mackey',
|
||||
dns01_challenge: 'tls_dns01_challenge',
|
||||
dns01_provider: 'tls_dns01_provider',
|
||||
dns01_access_key_id: 'tls_dns01_ali_akid',
|
||||
dns01_access_key_secret: 'tls_dns01_ali_aksec',
|
||||
dns01_region_id: 'tls_dns01_ali_rid',
|
||||
dns01_api_token: 'tls_dns01_cf_api_token'
|
||||
};
|
||||
|
||||
/* --- Domain types ------------------------------------------------------- */
|
||||
|
||||
export const Config = {
|
||||
create: (name) => ({
|
||||
name: name,
|
||||
general: {},
|
||||
nodes: [],
|
||||
|
||||
/* The five domain sub-objects the Loader fills in from the
|
||||
* corresponding UCI sections (single + list). The Loader now
|
||||
* normalises the list sections; downstream generators read only
|
||||
* these sub-objects. */
|
||||
dns: {},
|
||||
routing: {},
|
||||
access_control: {},
|
||||
server: {}
|
||||
})
|
||||
};
|
||||
|
||||
export const Node = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
|
||||
/* endpoint */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* cross-protocol common fields (proxy_protocol, tcp_fast_open,
|
||||
* tcp_multi_path, udp_fragment). The Adapter reads from here so
|
||||
* it never has to reach back into UCI for these. */
|
||||
common: opts.common || {},
|
||||
|
||||
/* credentials, already canonical */
|
||||
credentials: opts.credentials || {},
|
||||
|
||||
/* transport/TLS are sub-objects, not flat prefixed keys: that is the
|
||||
boundary the adapter needs in order to reuse the shared builders. */
|
||||
tls: opts.tls || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
/* per-protocol extras, canonical names only */
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* A node is usable only if the adapter can build a valid outbound from it.
|
||||
* The old code discovers this by crashing inside the JSON builder.
|
||||
*
|
||||
* Scope: generic, cross-protocol sanity only. The Adapter layer owns
|
||||
* per-protocol required-field rules (a vless needs uuid, vmess needs
|
||||
* both uuid and a security cipher, snell needs a psk, ...) because
|
||||
* those rules are sing-box-outbound-shaped. Anything the model can
|
||||
* express without naming a protocol or a JSON field goes here.
|
||||
*
|
||||
* Booleans are still raw UCI strings at this layer; this validator
|
||||
* compares against the literal '1' the way UCI emits them.
|
||||
*
|
||||
* Implementation note: ucode's push() returns the value pushed, not
|
||||
* the new array, so the natural-looking `problems = push(problems, X)`
|
||||
* pattern reassigns problems to a string and silently corrupts the
|
||||
* accumulator. Build the array via spread instead - cheap because the
|
||||
* validator is at most four checks. */
|
||||
validate: (node) => {
|
||||
let problems = [];
|
||||
|
||||
if (!node.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* A direct outbound can run without a server/server_port - sing-box
|
||||
* uses the inbound's destination as the upstream, which is the
|
||||
* transparent-proxy case. For every other protocol the server
|
||||
* and port are mandatory. */
|
||||
if (node.type !== 'direct') {
|
||||
if (!node.address)
|
||||
problems = [...problems, 'missing address'];
|
||||
|
||||
if (node.port == null || int(node.port) < 1 || int(node.port) > 65535)
|
||||
problems = [...problems, `invalid port '${node.port}'`];
|
||||
}
|
||||
|
||||
/* TLS without server_name is a sing-box-side rejection, but the
|
||||
* rule itself is protocol-agnostic - the reality branch covers the
|
||||
* only legitimate escape - so it stays here. */
|
||||
if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1')
|
||||
problems = [...problems, 'TLS enabled without server_name'];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention lives here, not scattered through the generator */
|
||||
tag: (node) => 'cfg-' + node.id + '-out'
|
||||
};
|
||||
|
||||
/* The server-side counterpart of Node. Same layering discipline: it
|
||||
* describes what the user configured (listen address/port, protocol,
|
||||
* credentials, TLS, transport, per-protocol knobs) and knows nothing
|
||||
* about sing-box JSON. The Inbound adapter (config/adapter.uc,
|
||||
* InboundFactory) is what turns it into a sing-box inbound.
|
||||
*
|
||||
* Field grouping mirrors Node deliberately so the Adapter's two halves
|
||||
* read the same way:
|
||||
* common listener-level shared fields (bind_interface,
|
||||
* reuse_addr, tcp_fast_open, ...)
|
||||
* credentials canonical credential names
|
||||
* tls shared TLS shape (same load_tls() the client uses)
|
||||
* tls_server server-only TLS tail (key material, ACME, the
|
||||
* server half of the REALITY handshake)
|
||||
* transport shared transport shape (same load_transport())
|
||||
* multiplex shared multiplex shape
|
||||
* protocol_options per-protocol knobs
|
||||
*/
|
||||
export const Inbound = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
enabled: opts.enabled,
|
||||
|
||||
/* listener */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* firewall is consumed by firewall_pre.uc (which decides the
|
||||
* nft accept rules), not by the inbound adapter. It stays on the
|
||||
* model so the server section is described in one place. */
|
||||
firewall: opts.firewall,
|
||||
|
||||
common: opts.common || {},
|
||||
credentials: opts.credentials || {},
|
||||
tls: opts.tls || {},
|
||||
tls_server: opts.tls_server || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* Same scope as Node.validate(): cross-protocol sanity the model
|
||||
* can express without naming a sing-box field. The per-protocol
|
||||
* rules live in the Adapter (InboundFactory.problems). */
|
||||
validate: (inbound) => {
|
||||
let problems = [];
|
||||
|
||||
if (!inbound.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* An inbound with no listen address is legitimate (sing-box
|
||||
* defaults to all interfaces), but a port is required. */
|
||||
if (inbound.port == null || int(inbound.port) < 1 || int(inbound.port) > 65535)
|
||||
problems = [...problems, `invalid port '${inbound.port}'`];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention for a server inbound. Uses `id` (the UCI
|
||||
* section name) rather than `name` (the user's label) so renaming a
|
||||
* label does not change the tag. */
|
||||
tag: (inbound) => 'cfg-' + inbound.id + '-in'
|
||||
};
|
||||
|
||||
/* --- Config helpers ----------------------------------------------------- */
|
||||
|
||||
export const ConfigQuery = {
|
||||
node_by_id: (config, id) => {
|
||||
for (let node in config.nodes)
|
||||
if (node.id === id)
|
||||
return node;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Linear search by section name. The Loader normalises list sections so
|
||||
* `name` is the UCI section name; the dotted `.name` UCI uses
|
||||
* internally is gone from the loaded shape. */
|
||||
find_by_name: (items, name) => {
|
||||
for (let it in items)
|
||||
if (it.name === name)
|
||||
return it;
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Which UDP node the routing modes route through. The default (`nil`)
|
||||
* means "no proxy node", which is a legitimate configuration. The
|
||||
* equivalent main_node read is one-liner enough that no helper
|
||||
* exists. */
|
||||
main_udp_node_id: (config) => config.general.main_udp_node || 'nil'
|
||||
};
|
||||
@@ -0,0 +1,762 @@
|
||||
#!/usr/bin/utpl -S
|
||||
|
||||
{%-
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Declares every homeproxy_* fw4 chain/set. The inventory of those objects is
|
||||
* tracked in scripts/fw4_names.sh, which init.d/homeproxy uses to clean them up
|
||||
* on stop; add/remove entries there together with any change below.
|
||||
* tests/ucode/test_fw4_names.sh compares both lists, and
|
||||
* tests/ucode/test_firewall_template.sh renders this template and checks the
|
||||
* objects actually survive into the generated nftables script.
|
||||
*
|
||||
* Every UCI-derived value (IPv4, MAC, interface name, port) goes through
|
||||
* one of the *_to_nftarr() helpers in firewall_utils.uc - not because the
|
||||
* UCI writers are untrusted (UI validation is there), but because UCI is
|
||||
* writable from anywhere on the LAN and the package principle is "backend
|
||||
* must enforce". Why those helpers exist, and why dropping silently
|
||||
* matches the IPv6 path, is pinned by tests/ucode/test_firewall_validators.uc
|
||||
* and guard 11 in tests/arch-guard.sh.
|
||||
*
|
||||
* Keep this comment inside the ucode block: text placed before `{%-` has its
|
||||
* trailing newline trimmed, which would glue it onto the first nft statement.
|
||||
*/
|
||||
import { readfile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, isValidCIDR } from '/etc/homeproxy/scripts/homeproxy.uc';
|
||||
import {
|
||||
ipv4_to_nftarr,
|
||||
mac_to_nftarr,
|
||||
iface_to_nftarr,
|
||||
ports_to_nftarr,
|
||||
isValidPort,
|
||||
isValidIface
|
||||
} from '/etc/homeproxy/scripts/firewall_utils.uc';
|
||||
|
||||
const fw4 = require('fw4');
|
||||
|
||||
function array_to_nftarr(array) {
|
||||
if (type(array) !== 'array')
|
||||
return null;
|
||||
|
||||
return `{ ${join(', ', uniq(array))} }`;
|
||||
}
|
||||
|
||||
function resolve_ipv6(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
let ipv6 = fw4.parse_subnet(str)?.[0];
|
||||
if (!ipv6 || ipv6.family !== 6)
|
||||
return null;
|
||||
|
||||
if (ipv6.bits > -1)
|
||||
return `${ipv6.addr}/${ipv6.bits}`;
|
||||
else
|
||||
return ipv6.addr;
|
||||
}
|
||||
|
||||
/* Normalize a list of IPv6 entries into an nftables anonymous set
|
||||
(or null when empty), so IPv6 ACLs match in a single set lookup
|
||||
instead of one rule per address. */
|
||||
function ipv6_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let ip in list) {
|
||||
const r = resolve_ipv6(ip);
|
||||
if (!isEmpty(r))
|
||||
push(out, r);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', out)} }`;
|
||||
}
|
||||
|
||||
function resolve_mark(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
let mark = fw4.parse_mark(str);
|
||||
if (isEmpty(mark))
|
||||
return null;
|
||||
|
||||
if (mark.mask === 0xffffffff)
|
||||
return fw4.hex(mark.mark);
|
||||
else if (mark.mark === 0)
|
||||
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)}`;
|
||||
else if (mark.mark === mark.mask)
|
||||
return `mark or ${fw4.hex(mark.mark)}`;
|
||||
else if (mark.mask === 0)
|
||||
return `mark xor ${fw4.hex(mark.mark)}`;
|
||||
else
|
||||
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`;
|
||||
}
|
||||
|
||||
/* Single-value guards for the five fields that were still read straight from
|
||||
* UCI and interpolated into the nft text: self_mark, redirect_port,
|
||||
* tproxy_port, dns_port and tun_name. H1 fixed the *_to_nftarr() families but
|
||||
* missed these; a ';' in one of them is a statement boundary in nft, and a
|
||||
* non-numeric value makes fw4 reject the whole ruleset, which takes the
|
||||
* router's firewall down with it. A rejected value falls back to the built-in
|
||||
* default rather than being emitted, so the side keeps working. */
|
||||
function port_or(str, fallback) {
|
||||
return isValidPort(str) ? str : fallback;
|
||||
}
|
||||
|
||||
function iface_or(str, fallback) {
|
||||
return isValidIface(str) ? str : fallback;
|
||||
}
|
||||
|
||||
/* self_mark is used as a *match* value (`meta mark <x>`), so it has to be a
|
||||
* plain number. resolve_mark() also returns set-expressions such as
|
||||
* `mark and 0xff xor 0x100` for masked input, and those are only valid after
|
||||
* `mark set`; anything that is not a bare hex value falls back. */
|
||||
function mark_value_or(str, fallback) {
|
||||
const m = resolve_mark(str);
|
||||
return match(m || '', /^0x[0-9a-fA-F]+$/) ? m : fallback;
|
||||
}
|
||||
|
||||
/* gfwlist mode routes by the address set that dnsmasq fills through
|
||||
* `nftset=`, and the gates below compare the destination against it. A dnsmasq
|
||||
* built without HAVE_NFTSET never fills the set, and `ip daddr != @<empty set>`
|
||||
* is true for every packet - the whole mode then silently routes everything
|
||||
* direct while the DNS side keeps answering through sing-box. init.d records
|
||||
* the capability in resources/.dnsmasq-nftset before this template is rendered,
|
||||
* so the gates can be omitted there and sing-box decides by geoip instead.
|
||||
* A missing file means "capability unknown" and keeps the historical output. */
|
||||
function dnsmasq_nftset() {
|
||||
let marker = null;
|
||||
try {
|
||||
marker = trim(readfile(resources_dir + '/.dnsmasq-nftset'));
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
return marker === '' || marker === '1';
|
||||
}
|
||||
|
||||
/* Misc config */
|
||||
const resources_dir = '/etc/homeproxy/resources';
|
||||
|
||||
/* UCI config start */
|
||||
const cfgname = 'homeproxy';
|
||||
const uci = cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
|
||||
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
|
||||
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
|
||||
} else {
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
|
||||
}
|
||||
|
||||
let routing_port = uci.get(cfgname, 'config', 'routing_port');
|
||||
if (routing_port === 'common')
|
||||
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
|
||||
|
||||
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
|
||||
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
|
||||
|
||||
let self_mark, redirect_port,
|
||||
tproxy_port, tproxy_mark,
|
||||
tun_name, tun_mark;
|
||||
|
||||
if (match(proxy_mode, /redirect/)) {
|
||||
self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||
redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
|
||||
}
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||
tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332');
|
||||
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101') || resolve_mark('101');
|
||||
}
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0');
|
||||
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_mark('102');
|
||||
}
|
||||
|
||||
const control_options = [
|
||||
"listen_interfaces", "lan_proxy_mode",
|
||||
"lan_direct_mac_addrs", "lan_direct_ipv4_ips", "lan_direct_ipv6_ips",
|
||||
"lan_proxy_mac_addrs", "lan_proxy_ipv4_ips", "lan_proxy_ipv6_ips",
|
||||
"lan_gaming_mode_mac_addrs", "lan_gaming_mode_ipv4_ips", "lan_gaming_mode_ipv6_ips",
|
||||
"lan_global_proxy_mac_addrs", "lan_global_proxy_ipv4_ips", "lan_global_proxy_ipv6_ips",
|
||||
"wan_proxy_ipv4_ips", "wan_proxy_ipv6_ips",
|
||||
"wan_direct_ipv4_ips", "wan_direct_ipv6_ips"
|
||||
];
|
||||
const control_info = {};
|
||||
|
||||
for (let i in control_options)
|
||||
control_info[i] = uci.get(cfgname, 'control', i);
|
||||
|
||||
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
|
||||
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
|
||||
/* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port,
|
||||
* 5333). Both were called `dns_port` here and in the generator, which
|
||||
* made the hijack rule look like it pointed at the sing-box listener. */
|
||||
dnsmasq_port = port_or(uci.get('dhcp', '@dnsmasq[0]', 'port'), '53');
|
||||
/* UCI config end */
|
||||
-%}
|
||||
|
||||
{# Reserved addresses -#}
|
||||
set homeproxy_local_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
0.0.0.0/8,
|
||||
10.0.0.0/8,
|
||||
100.64.0.0/10,
|
||||
127.0.0.0/8,
|
||||
169.254.0.0/16,
|
||||
172.16.0.0/12,
|
||||
192.0.0.0/24,
|
||||
192.0.2.0/24,
|
||||
192.31.196.0/24,
|
||||
192.52.193.0/24,
|
||||
192.88.99.0/24,
|
||||
192.168.0.0/16,
|
||||
192.175.48.0/24,
|
||||
198.18.0.0/15,
|
||||
198.51.100.0/24,
|
||||
203.0.113.0/24,
|
||||
224.0.0.0/4,
|
||||
240.0.0.0/4
|
||||
}
|
||||
}
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_local_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
::/128,
|
||||
::1/128,
|
||||
::ffff:0:0/96,
|
||||
100::/64,
|
||||
64:ff9b::/96,
|
||||
2001::/32,
|
||||
2001:10::/28,
|
||||
2001:20::/28,
|
||||
2001:db8::/28,
|
||||
2002::/16,
|
||||
fc00::/7,
|
||||
fe80::/10,
|
||||
ff00::/8
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist'): %}
|
||||
set homeproxy_gfw_list_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
}
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_gfw_list_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %}
|
||||
set homeproxy_mainland_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
{% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %}
|
||||
{% if (isValidCIDR(cnip4, 4)): %}
|
||||
{{ cnip4 }},
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% if ((ipv6_support === '1') || china_dns_server): %}
|
||||
set homeproxy_mainland_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
{% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %}
|
||||
{% if (isValidCIDR(cnip6, 6)): %}
|
||||
{{ cnip6 }},
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{# WAN ACL addresses #}
|
||||
set homeproxy_wan_proxy_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_proxy_ipv4_ips) }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_wan_proxy_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} }
|
||||
{% endif /* wan_proxy_ipv6_ips*/ %}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
set homeproxy_wan_direct_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_wan_direct_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} }
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
}
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_port): %}
|
||||
set homeproxy_routing_port {
|
||||
type inet_service
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = { {{ ports_to_nftarr(routing_port) }} }
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# DNS hijack & TCP redirect #}
|
||||
chain dstnat {
|
||||
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }}
|
||||
{%- endif /* listen_interfaces */ %}
|
||||
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack"
|
||||
{% endif /* dns_hijacked */ %}
|
||||
{% if (match(proxy_mode, /redirect/)): %}
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
|
||||
{% endif /* proxy_mode */ %}
|
||||
}
|
||||
|
||||
{# TCP redirect #}
|
||||
{% if (match(proxy_mode, /redirect/)): %}
|
||||
chain homeproxy_redirect_proxy {
|
||||
meta l4proto tcp counter redirect to :{{ redirect_port }}
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_proxy_port {
|
||||
{% if (routing_port): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_redirect_proxy
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% endif %}
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_redirect
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_redirect {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_redirect_proxy_port
|
||||
}
|
||||
|
||||
chain homeproxy_output_redir {
|
||||
type nat hook output priority filter -105; policy accept
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# UDP tproxy #}
|
||||
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
|
||||
chain homeproxy_mangle_tproxy {
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
|
||||
{% if (ipv6_support === '1'): %}
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tproxy_port {
|
||||
{% if (routing_port): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_mangle_tproxy
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_mark {
|
||||
{% if (routing_port): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr([...control_info.listen_interfaces, 'lo']) }} counter return
|
||||
{% endif %}
|
||||
meta iifname != lo udp dport 53 counter return
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_mangle_prerouting
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_prerouting {
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tproxy_port
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_output {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_direct_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
counter goto homeproxy_mangle_mark
|
||||
}
|
||||
|
||||
chain mangle_prerouting {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac
|
||||
}
|
||||
|
||||
chain mangle_output {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# TUN #}
|
||||
{% if (match(proxy_mode, /tun/)): %}
|
||||
chain homeproxy_mangle_lanac {
|
||||
iifname {{ tun_name }} counter return
|
||||
udp dport 53 counter return
|
||||
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
|
||||
counter goto homeproxy_mangle_tun
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tun_mark {
|
||||
{% if (routing_port): %}
|
||||
{% if (proxy_mode === 'tun'): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif /* proxy_mode */ %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif /* routing_port */ %}
|
||||
|
||||
counter meta mark set {{ tun_mark }}
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tun {
|
||||
iifname {{ tun_name }} counter return
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_local_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_local_addr_v6 counter return
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
ip daddr {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
|
||||
{% endif /* wan_direct_ipv4_ips */ %}
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
ip6 daddr {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tun_mark
|
||||
}
|
||||
|
||||
chain mangle_prerouting {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac
|
||||
}
|
||||
|
||||
chain mangle_output {
|
||||
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun
|
||||
}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/ucode
|
||||
/* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { writefile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, RUN_DIR, validation } from './homeproxy.uc';
|
||||
|
||||
const cfgname = 'homeproxy';
|
||||
const uci = cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china',
|
||||
proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy';
|
||||
|
||||
let outbound_node, tun_name;
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
if (routing_mode === 'custom')
|
||||
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||
else
|
||||
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
|
||||
/* Validate the interface name before it is interpolated into an nft
|
||||
* ruleset that fw4 loads as root. It is a UCI value, and this file's own
|
||||
* threat model treats UCI as untrusted - the port and network fields a few
|
||||
* lines below are validated for exactly that reason, while tun_name was
|
||||
* not. `;` and `}` need no newline, so a value like
|
||||
* "singtun0 } ; chain evil { ..." would balance the template's trailing
|
||||
* brace and add a chain of the attacker's choosing.
|
||||
*
|
||||
* Linux interface names are at most 15 characters and cannot contain
|
||||
* whitespace or shell/nft metacharacters; anything outside that is not a
|
||||
* name we could have created, so the tun rules are simply not emitted. */
|
||||
if (outbound_node !== 'nil') {
|
||||
const candidate = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
|
||||
|
||||
if (match(candidate, /^[A-Za-z0-9_.-]{1,15}$/) && candidate !== '.' && candidate !== '..')
|
||||
tun_name = candidate;
|
||||
else
|
||||
print(sprintf('WARN: ignoring invalid tun_name "%s" (not a valid interface name).', candidate));
|
||||
}
|
||||
}
|
||||
|
||||
const server_enabled = uci.get(cfgname, 'server', 'enabled');
|
||||
|
||||
let forward = [],
|
||||
input = [];
|
||||
|
||||
if (tun_name) {
|
||||
push(forward, `oifname ${tun_name} counter accept comment "!${cfgname}: accept tun forward"`);
|
||||
push(input ,`iifname ${tun_name} counter accept comment "!${cfgname}: accept tun input"`);
|
||||
}
|
||||
|
||||
if (server_enabled === '1') {
|
||||
uci.foreach(cfgname, 'server', (s) => {
|
||||
if (s.enabled !== '1' || s.firewall !== '1')
|
||||
return;
|
||||
|
||||
/* The section name is interpolated into the nft comment below, in a
|
||||
* file fw4 loads as root. libuci only accepts [A-Za-z0-9_] in a
|
||||
* section name (a `"`, `}`, `;`, space, `.` or `-` is "invalid
|
||||
* character in name field" / "Invalid argument" on both the parser
|
||||
* and the `uci set` path), so today no name that could close the
|
||||
* comment can reach this loop through UCI at all - this mirrors the
|
||||
* tun_name guard above rather than trusting a writer's in-memory
|
||||
* cursor or a future libuci. The alphabet is exactly libuci's and
|
||||
* there is deliberately no length cap (libuci has none, and a cap
|
||||
* would skip a legal name and silently drop that server's rule).
|
||||
* The check runs first so the two warnings below only ever echo a
|
||||
* name that already passed it. */
|
||||
if (!match(s['.name'], /^[A-Za-z0-9_]+$/)) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid section name.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validation('port', s.port)) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid port "${s.port}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
let proto = s.network || '{ tcp, udp }';
|
||||
if (!(proto in ['tcp', 'udp', '{ tcp, udp }'])) {
|
||||
print(`WARN: skipping server ${s['.name']}: invalid network "${proto}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEmpty(forward))
|
||||
writefile(RUN_DIR + '/fw4_forward.nft', join('\n', forward) + '\n');
|
||||
|
||||
if (!isEmpty(input))
|
||||
writefile(RUN_DIR + '/fw4_input.nft', join('\n', input) + '\n');
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Field validators for the nftables statements firewall_post.ut renders.
|
||||
*
|
||||
* Why this module exists: every UCI value the template concatenates into an
|
||||
* nft expression is an injection surface. A malformed entry closes a set
|
||||
* expression with `}`, and the next thing nft sees is whatever the attacker
|
||||
* put after it - up to and including `accept` for a chain that should be a
|
||||
* drop, or a `reject` outside any chain that the fw4 reload fails to
|
||||
* compile, taking the whole router firewall with it. The homeproxy section
|
||||
* in /etc/config/homeproxy is UCI-writable from the subscription updater,
|
||||
* from any shell on the LAN and from any cron job the user has not locked
|
||||
* down; `tests/ucode/test_firewall_validators.uc` covers that the helpers
|
||||
* drop these inputs rather than letting them through, and `tests/arch-guard.sh`
|
||||
* guard 11 covers that firewall_post.ut routes every field of these families
|
||||
* through the helpers.
|
||||
*
|
||||
* Each helper returns a defensive shape:
|
||||
* - null when the input was empty or every entry was rejected
|
||||
* - otherwise `{ v1, v2, ... }` (the nftables anonymous-set spelling)
|
||||
* so the template can plug the result into a statement unchanged. Invalid
|
||||
* entries are dropped, matching the IPv6 helper's behaviour so the IPv4
|
||||
* path is finally as defensive as the IPv6 path was.
|
||||
*
|
||||
* No logging on the drop path: utpl templates render to stdout, and any
|
||||
* print() here would inject text into the nft ruleset. If a debug trail
|
||||
* is ever wanted, do it from the template itself, not from these helpers.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* isEmpty() lives in homeproxy.uc, not in the ucode globals. Without this
|
||||
* import every helper below threw "access to undeclared variable isEmpty" the
|
||||
* moment it was called, which no host-side test caught because the test
|
||||
* harness stages its own stand-in module. */
|
||||
import { isEmpty } from './homeproxy.uc';
|
||||
|
||||
/* IPv4 (with optional /CIDR) - the regex is the standard four-octet body
|
||||
* followed by an optional /0..32. Anything outside that - "1.2.3.4.5",
|
||||
* "1.2.3", " 1.2.3.4 ", "1.2.3.4 " - is rejected.
|
||||
*
|
||||
* Spelled out as one literal rather than built with `new RegExp(...)`: ucode
|
||||
* has no `new` keyword, so the constructor form was a compile error
|
||||
* ("Unexpected token") and the module never loaded. The capture groups are
|
||||
* plain `(...)` because ucode regexes go through POSIX regcomp(), which has
|
||||
* no `(?:...)` non-capturing group; nothing here reads the captures, so the
|
||||
* extra ones are harmless. */
|
||||
const IPV4_CIDR_RE = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/(3[0-2]|[12]?[0-9]))?$/;
|
||||
|
||||
export function isValidIPv4(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, IPV4_CIDR_RE);
|
||||
};
|
||||
|
||||
export function ipv4_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let ip in list) {
|
||||
if (isValidIPv4(ip))
|
||||
push(out, ip);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* MAC address: six colon-separated hex octets, case-insensitive. */
|
||||
const MAC_RE = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/;
|
||||
|
||||
export function isValidMAC(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, MAC_RE);
|
||||
};
|
||||
|
||||
export function mac_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let m in list) {
|
||||
if (isValidMAC(m))
|
||||
push(out, m);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* Linux interface names: alnum, dot, underscore, hyphen. Anything that
|
||||
* would survive `ip link show` parsing is accepted here. */
|
||||
const IFACE_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
export function isValidIface(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
return !!match(s, IFACE_RE);
|
||||
};
|
||||
|
||||
export function iface_to_nftarr(list) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let i in list) {
|
||||
if (isValidIface(i))
|
||||
push(out, i);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
|
||||
/* Routing-port validator. UCI delivers routing_port as a comma-separated
|
||||
* string ('22,53,80,443,...'), so we split first and then validate each
|
||||
* piece as a single port. The old template did `split(routing_port, ',')`
|
||||
* and `join(', ', ...)` directly: an entry like '7890; reject' or '22,foo'
|
||||
* would land verbatim in an nft inet_service set. */
|
||||
const PORT_RE = /^[0-9]+$/;
|
||||
|
||||
export function isValidPort(s) {
|
||||
if (!s || type(s) !== 'string')
|
||||
return false;
|
||||
if (!match(s, PORT_RE))
|
||||
return false;
|
||||
const n = +s;
|
||||
/* Port 0 is not a port: nft rejects `th dport 0` and `redirect to :0`, and a
|
||||
* ruleset that carries one is rolled back as a whole by fw4. The callers
|
||||
* use this as "the value is usable", so 0 has to be a rejection. */
|
||||
return n >= 1 && n <= 65535;
|
||||
};
|
||||
|
||||
export function ports_to_nftarr(s) {
|
||||
if (isEmpty(s) || type(s) !== 'string')
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let p in split(s, ',')) {
|
||||
const t = trim(p);
|
||||
if (isValidPort(t))
|
||||
push(out, t);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Single source of truth for every fw4 chain and set owned by homeproxy.
|
||||
#
|
||||
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
|
||||
# decides which of them actually exist for the current proxy mode.
|
||||
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
|
||||
#
|
||||
# When you add or remove a chain/set in firewall_post.ut, update this list in
|
||||
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
|
||||
# fails when they drift.
|
||||
#
|
||||
# Each object is cleaned up independently: the cleanup loop deletes one at a
|
||||
# time so that a chain/set which does not exist in the current mode cannot
|
||||
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
|
||||
# every preceding command in that case.
|
||||
|
||||
HP_FW4_CHAINS="
|
||||
homeproxy_output_redir
|
||||
homeproxy_redirect
|
||||
homeproxy_redirect_proxy
|
||||
homeproxy_redirect_proxy_port
|
||||
homeproxy_redirect_lanac
|
||||
homeproxy_mangle_prerouting
|
||||
homeproxy_mangle_output
|
||||
homeproxy_mangle_tproxy
|
||||
homeproxy_mangle_tproxy_port
|
||||
homeproxy_mangle_lanac
|
||||
homeproxy_mangle_mark
|
||||
homeproxy_mangle_tun
|
||||
homeproxy_mangle_tun_mark
|
||||
"
|
||||
|
||||
HP_FW4_SETS="
|
||||
homeproxy_local_addr_v4
|
||||
homeproxy_local_addr_v6
|
||||
homeproxy_gfw_list_v4
|
||||
homeproxy_gfw_list_v6
|
||||
homeproxy_mainland_addr_v4
|
||||
homeproxy_mainland_addr_v6
|
||||
homeproxy_wan_proxy_addr_v4
|
||||
homeproxy_wan_proxy_addr_v6
|
||||
homeproxy_wan_direct_addr_v4
|
||||
homeproxy_wan_direct_addr_v6
|
||||
homeproxy_routing_port
|
||||
"
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* scripts/generate_client.uc: process-level entry point.
|
||||
*
|
||||
* The actual client generator is generator/client.uc; this script is the
|
||||
* thin entry point that init.d/homeproxy execve's. Responsibilities:
|
||||
*
|
||||
* 1. Load UCI into the HomeProxyConfig domain model.
|
||||
* 2. Resolve the GenerationContext env: the WAN resolver (ubus) and the two
|
||||
* domain-resource lists (fs). This is the process-level boundary, which
|
||||
* is why it lives here and not under generator/.
|
||||
* 3. Call generate(dm, env) to get the sing-box JSON object.
|
||||
* 4. Write atomically: candidate tmp -> `sing-box check` -> mv to live.
|
||||
*
|
||||
* The check is here (and not in the runtime, and not in the generator)
|
||||
* because the architecture guide mandates the generator produce
|
||||
* "atomic write candidate + serialization" and the runtime is already
|
||||
* structured around "a failing generation leaves the previous file in
|
||||
* place" (see runtime/config.sh's hp_ensure_live). Having the shell do
|
||||
* the check keeps that contract single-sourced.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { connect } from 'ubus';
|
||||
import { mkdtemp, readfile, writefile } from 'fs';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate } from './generator/client.uc';
|
||||
import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||
|
||||
/* Resolve the GenerationContext inputs. This is the only impure step on the
|
||||
* client generation path, and it is deliberately here rather than under
|
||||
* generator/:
|
||||
*
|
||||
* - wan_dns is the upstream the default-dns server detours to. ubus may be
|
||||
* unreachable (no ubusd, a dev host, no WAN lease); build_context() then
|
||||
* applies the same mode-dependent public fallback the pre-split generator
|
||||
* used, so an unresolved value stays safe rather than fatal.
|
||||
* - direct_domain_list / proxy_domain_list are the two files the resource
|
||||
* updater maintains. Custom mode ignores both, so they are not read
|
||||
* there - matching the pre-split read exactly.
|
||||
*
|
||||
* The extra parentheses around the ubus call keep the ?. chain guarded when
|
||||
* connect() returns null. */
|
||||
function resolve_env(dm) {
|
||||
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||
const ubus = connect();
|
||||
|
||||
const env = {
|
||||
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
|
||||
direct_domain_list: [],
|
||||
proxy_domain_list: []
|
||||
};
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
|
||||
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
|
||||
|
||||
const proxy_list_raw = readfile(HP_DIR + '/resources/proxy_list.txt');
|
||||
env.proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
const dm = Loader.load(UCICONFIG_DIR);
|
||||
const config = removeBlankAttrs(generate(dm, resolve_env(dm)));
|
||||
|
||||
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||
|
||||
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||
*
|
||||
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||
* the same file, and `sing-box check` could be validating a file the other run
|
||||
* was still writing - the winner then installed a half-written config.
|
||||
*
|
||||
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||
* the "all shell args quoted" rule is the machine-checkable
|
||||
* invariant, not a comment about today's safety. */
|
||||
const work_dir = mkdtemp();
|
||||
const tmp = work_dir + '/sing-box-c.json';
|
||||
|
||||
/* writefile() returns null on failure, and ignoring that turned a full disk or
|
||||
* a permission error into a later "sing-box check failed", which points at the
|
||||
* wrong thing entirely. */
|
||||
if (writefile(tmp, sprintf('%.J\n', config)) == null) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
die('failed to write the generated client configuration to ' + tmp);
|
||||
}
|
||||
|
||||
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The generated config carries every node credential - passwords, UUIDs,
|
||||
* private keys - and writefile() has no mode argument, so it lands with the
|
||||
* process umask (world-readable at the usual 022). sing-box runs as its own
|
||||
* user and reads the file directly, so 0600 is enough. */
|
||||
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-c.json');
|
||||
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* scripts/generate_server.uc: process-level entry point.
|
||||
*
|
||||
* Mirror of scripts/generate_client.uc for the server instance. The
|
||||
* server generator (generator/server.uc) returns null when no inbound
|
||||
* is enabled; the shell then exits 1 so init.d can detect
|
||||
* "server disabled" without a separate UCI check.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { mkdtemp, writefile } from 'fs';
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate_server } from './generator/server.uc';
|
||||
import { removeBlankAttrs, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||
|
||||
const dm = Loader.load(UCICONFIG_DIR);
|
||||
const config = generate_server(dm);
|
||||
if (!config)
|
||||
exit(1);
|
||||
|
||||
const cleaned = removeBlankAttrs(config);
|
||||
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||
|
||||
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||
*
|
||||
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||
* the same file, and `sing-box check` could be validating a file the other run
|
||||
* was still writing - the winner then installed a half-written config.
|
||||
*
|
||||
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||
* the "all shell args quoted" rule is the machine-checkable
|
||||
* invariant, not a comment about today's safety. */
|
||||
const work_dir = mkdtemp();
|
||||
const tmp = work_dir + '/sing-box-s.json';
|
||||
|
||||
/* Same reason as the client path: a failed write used to be reported as a
|
||||
* failed `sing-box check`. */
|
||||
if (writefile(tmp, sprintf('%.J\n', cleaned)) == null) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
die('failed to write the generated server configuration to ' + tmp);
|
||||
}
|
||||
|
||||
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-s.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The server config carries inbound credentials and the REALITY/ECH key
|
||||
* material, and writefile() has no mode argument, so it lands with the
|
||||
* process umask. 0600 matches what sing-box needs to read it as its own
|
||||
* user. */
|
||||
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-s.json');
|
||||
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/client.uc: client-side orchestrator.
|
||||
*
|
||||
* This file is the only place the client generator still knows the
|
||||
* overall shape. Each section builder lives in its own module under
|
||||
* generator/; this file glues them together.
|
||||
*
|
||||
* generate(dm, env)
|
||||
* -> build_context(dm, env)
|
||||
* -> build_dns
|
||||
* -> build_inbounds
|
||||
* -> build_outbounds (mutates direct_overrides)
|
||||
* -> build_route
|
||||
* -> build_user_rulesets (custom mode only)
|
||||
* -> build_http_clients
|
||||
* -> attachSchema, attachExperimental
|
||||
* -> final config object (caller writes + sing-box check)
|
||||
*
|
||||
* Every section builder takes (config, ctx, ...) where `ctx` carries
|
||||
* the routing-mode- and proxy-mode-derived scalars (main_node,
|
||||
* dns_server, proxy_mode, ...) plus the mutable accumulators (outbounds,
|
||||
* endpoints, ...) the builders append to. The orchestrator owns them
|
||||
* and threads them through, which is what kept the original
|
||||
* generate_client.uc at 1240 lines: a 1100-line config blob with a
|
||||
* single closure scope.
|
||||
*
|
||||
* The output is the final config object: no `$schema`, no atomic write,
|
||||
* no `sing-box check`. The CLI shell (`scripts/generate_client.uc`)
|
||||
* is responsible for those - per the architecture guide, the generator
|
||||
* must not double as the runner.
|
||||
*
|
||||
* It is also responsible for resolving `env` (the WAN DNS via ubus and the
|
||||
* two resource lists) and passing it in. This module imports no ubus and no
|
||||
* fs: `generate(dm, env)` is a pure function, so the same arguments always
|
||||
* produce the same bytes. tests/arch-guard.sh guard 27 keeps it that way.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, RUN_DIR } from '../homeproxy.uc';
|
||||
|
||||
import { build_context } from './context.uc';
|
||||
import { build_dns } from './dns.uc';
|
||||
import { build_inbounds } from './inbound.uc';
|
||||
import { build_outbounds } from './outbound.uc';
|
||||
import { build_route } from './route.uc';
|
||||
import { build_user_rulesets, build_http_clients } from './ruleset.uc';
|
||||
import { attachSchema, attachExperimental } from './common.uc';
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box client config object. Pure function: takes the
|
||||
* HomeProxyConfig from the Loader plus the GenerationContext inputs the CLI
|
||||
* resolved (`env`), and returns the JSON object the caller writes and
|
||||
* `sing-box check`s. No UCI, no file I/O, no ubus, no procd. */
|
||||
export function generate(dm, env) {
|
||||
const ctx = build_context(dm, env);
|
||||
|
||||
const config = {
|
||||
log: {
|
||||
disabled: false,
|
||||
level: ctx.log_level,
|
||||
output: RUN_DIR + '/sing-box-c.log',
|
||||
timestamp: true
|
||||
}
|
||||
};
|
||||
|
||||
if (!isEmpty(ctx.ntp_server))
|
||||
config.ntp = {
|
||||
enabled: true,
|
||||
server: ctx.ntp_server,
|
||||
detour: 'direct-out',
|
||||
domain_resolver: 'default-dns',
|
||||
};
|
||||
|
||||
/* `direct_overrides` is the single side effect that survives the
|
||||
* split: a direct node with override_address / override_port
|
||||
* records the override here so build_route can emit the
|
||||
* route-options action later. The orchestrator owns the map and
|
||||
* passes it to both builders. */
|
||||
const direct_overrides = {};
|
||||
|
||||
build_dns(config, dm, ctx);
|
||||
build_inbounds(config, ctx);
|
||||
|
||||
/* direct_outbounds live on config.outbounds regardless of mode:
|
||||
* the DNS path always references them, and a custom-mode config
|
||||
* without any direct-out/block-out would not have a fallback for
|
||||
* a routing rule that emits a missing tag. Initialised AFTER
|
||||
* build_dns / build_inbounds so the JSON key order is
|
||||
* log, ntp, dns, inbounds, outbounds, ... matching the
|
||||
* pre-refactor generator. The builder appends to the same array
|
||||
* (no reassignment), which preserves the key order that
|
||||
* removeBlankAttrs() emits and keeps golden snapshots stable. */
|
||||
config.outbounds = [
|
||||
{
|
||||
type: 'direct',
|
||||
tag: 'direct-out',
|
||||
routing_mark: strToInt(ctx.self_mark)
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
tag: 'block-out'
|
||||
}
|
||||
];
|
||||
config.endpoints = [];
|
||||
|
||||
build_outbounds(config, dm, ctx, direct_overrides);
|
||||
|
||||
build_route(config, dm, ctx, direct_overrides);
|
||||
|
||||
/* User-defined rulesets are custom-mode only; passing an empty
|
||||
* dm.routing.rulesets through build_user_rulesets is a no-op
|
||||
* because every cfg in it is filtered by !cfg.enabled. */
|
||||
if (ctx.routing_mode === 'custom')
|
||||
build_user_rulesets(config.route.rule_set, dm, ctx);
|
||||
|
||||
const http_clients = build_http_clients(config.route.rule_set, dm, ctx);
|
||||
if (length(http_clients))
|
||||
config.http_clients = http_clients;
|
||||
|
||||
/* A direct node used as the main node yields a main-out with no fields of
|
||||
* its own: generate_outbound() turns its override_address / override_port
|
||||
* into a route action rather than outbound fields, leaving a bare
|
||||
* {"type":"direct","tag":"main-out"}. sing-box refuses to detour into
|
||||
* such an outbound and the service never starts:
|
||||
*
|
||||
* FATAL start service: start dns/tcp[main-dns]: detour to an empty
|
||||
* direct outbound makes no sense
|
||||
* FATAL start service: initialize rule-set[0]: ... detour to an empty
|
||||
* direct outbound makes no sense
|
||||
*
|
||||
* Dropping the detour is the equivalent behaviour - the query or the
|
||||
* rule-set download leaves through the host's own network, which is what a
|
||||
* direct outbound does - and mirrors the `ctx.self_mark ? 'direct-out' :
|
||||
* null` that build_dns already applies to the other DNS servers. Only
|
||||
* main-out is touched: direct-out is deliberate and carries a routing
|
||||
* mark.
|
||||
*
|
||||
* This has to run after build_http_clients(): the rule-set downloads
|
||||
* reach main-out through an http_client, not through a detour of their
|
||||
* own. */
|
||||
let bare_main_out = false;
|
||||
for (let i = 0; i < length(config.outbounds); i++) {
|
||||
const ob = config.outbounds[i];
|
||||
/* build_outbounds leaves a null in the array for a node it could not
|
||||
* emit (a pruned urltest candidate); skip those. */
|
||||
if (ob && ob.tag === 'main-out' && ob.type === 'direct' && length(keys(ob)) === 2)
|
||||
bare_main_out = true;
|
||||
}
|
||||
if (bare_main_out) {
|
||||
for (let i = 0; i < length(config.dns.servers); i++) {
|
||||
const s = config.dns.servers[i];
|
||||
if (s && s.detour === 'main-out')
|
||||
delete s.detour;
|
||||
}
|
||||
for (let i = 0; i < length(config.http_clients || []); i++) {
|
||||
const c = config.http_clients[i];
|
||||
if (c && c.detour === 'main-out')
|
||||
delete c.detour;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
config.route.rule_set = null;
|
||||
if (isEmpty(config.endpoints))
|
||||
config.endpoints = null;
|
||||
|
||||
attachExperimental(config, ctx.routing_mode, ctx.dns_store_dns);
|
||||
attachSchema(config);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/common.uc: shared helpers used by every generator module.
|
||||
*
|
||||
* Every "select a tag from a UCI-style reference" function lives in this
|
||||
* file because the same selector logic (a routing_node name, an
|
||||
* array-of-names, a direct/block/default marker, a ruleset id list) is
|
||||
* reached by the DNS section, the route section, the outbound/endpoint
|
||||
* builder and the ruleset section. Duplicating them per-module would
|
||||
* drift; consolidating them here keeps the convention "one place to
|
||||
* change a tag shape".
|
||||
*
|
||||
* The Loader owns UCI; nothing in here reads UCI directly. The orchestrator
|
||||
* passes `dm` for the routing_node lookups that need to resolve a name into
|
||||
* the underlying node id.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
import { ConfigQuery } from '../config/model.uc';
|
||||
|
||||
/* parse_port() lives in homeproxy.uc. The Adapter layer needs it
|
||||
* too (EndpointFactory builds the WireGuard endpoint) and an adapter must
|
||||
* not import from generator/, so the util moved to the module both layers
|
||||
* already share. Callers import it from '../homeproxy.uc' now. */
|
||||
|
||||
/* Resolve a UCI outbound-style reference (string or array of strings)
|
||||
* into the sing-box tag the generator should emit. The arrays carry
|
||||
* either 'block-out' / 'direct-out' sentinels or routing_node section
|
||||
* names; a single string is the same thing with one entry.
|
||||
*
|
||||
* `dm` is needed for the routing_node -> node id lookup; we deliberately
|
||||
* do not read `dm.routing.nodes` until we know we need it (the common
|
||||
* case is the sentinel). */
|
||||
export function get_outbound(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
if (type(cfg) === 'array') {
|
||||
let outbounds = [];
|
||||
for (let i in cfg)
|
||||
push(outbounds, get_outbound(i, dm));
|
||||
return outbounds;
|
||||
}
|
||||
|
||||
switch (cfg) {
|
||||
case 'block-out':
|
||||
case 'direct-out':
|
||||
return cfg;
|
||||
default:
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
|
||||
if (!rn || isEmpty(rn.node))
|
||||
die(sprintf("%s's node is missing, please check your configuration.", cfg));
|
||||
else if (rn.node === 'urltest')
|
||||
return 'cfg-' + cfg + '-out';
|
||||
else
|
||||
return 'cfg-' + rn.node + '-out';
|
||||
}
|
||||
};
|
||||
|
||||
/* Find a loaded section by name, treating `enabled` as a gate. The Loader
|
||||
* normalises `enabled` on every list section; a section with the field unset
|
||||
* is enabled (boolean sections default to 0 in UCI, but a dns_server/ruleset
|
||||
* without the option is a valid, enabled row).
|
||||
*
|
||||
* Declared before its callers on purpose: ucode resolves function references
|
||||
* at call time from what has already been evaluated, so a `function` statement
|
||||
* placed after get_resolver() fails with "access to undeclared variable". */
|
||||
function find_enabled(items, name) {
|
||||
const item = ConfigQuery.find_by_name(items || [], name);
|
||||
if (!item)
|
||||
return null;
|
||||
if (item.enabled === false || item.enabled === '0')
|
||||
return null;
|
||||
return item;
|
||||
};
|
||||
|
||||
/* Resolve a UCI resolver reference (a dns_server section name or one of
|
||||
* the sentinels) into a sing-box resolver tag.
|
||||
*
|
||||
* `dm` is required for the same reason get_outbound() needs it: a reference to
|
||||
* a section that was deleted or disabled produces `cfg-<name>-dns`, which
|
||||
* nothing defines - sing-box then rejects the whole configuration with
|
||||
* "dns server not found", the reload silently keeps the previous one, and the
|
||||
* user only sees that their change did not take. Fail with the name instead. */
|
||||
export function get_resolver(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
switch (cfg) {
|
||||
case 'default-dns':
|
||||
case 'system-dns':
|
||||
return cfg;
|
||||
default:
|
||||
if (dm && !find_enabled((dm.dns || {}).servers, cfg))
|
||||
die(sprintf("the DNS server '%s' does not exist or is disabled; check the rule that refers to it.", cfg));
|
||||
return 'cfg-' + cfg + '-dns';
|
||||
}
|
||||
};
|
||||
|
||||
/* Resolve a UCI ruleset reference list (array of ruleset section names)
|
||||
* into the corresponding sing-box rule_set tag list. Same existence gate as
|
||||
* get_resolver(): ruleset.uc skips disabled entries, so a reference to one
|
||||
* would leave a dangling `cfg-<name>-rule` tag behind. */
|
||||
export function get_ruleset(cfg, dm) {
|
||||
if (isEmpty(cfg))
|
||||
return null;
|
||||
|
||||
let rules = [];
|
||||
for (let i in cfg) {
|
||||
if (isEmpty(i))
|
||||
continue;
|
||||
if (dm && !find_enabled((dm.routing || {}).rulesets, i))
|
||||
die(sprintf("the rule-set '%s' does not exist or is disabled; check the rule that refers to it.", i));
|
||||
push(rules, 'cfg-' + i + '-rule');
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
/* Resolve the direct-node destination override that the route builder
|
||||
* needs for a `direct` routing_node target. The override is recorded
|
||||
* earlier by generate_outbound() (see outbound.uc) when a direct node
|
||||
* carries override_address / override_port; this helper just looks it
|
||||
* up. Returns null when the target is not a direct node or no override
|
||||
* is configured. */
|
||||
export function get_direct_override(outbound_selector, dm, direct_overrides) {
|
||||
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
|
||||
return null;
|
||||
|
||||
switch (outbound_selector) {
|
||||
case 'direct-out':
|
||||
case 'block-out':
|
||||
return null;
|
||||
default:
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, outbound_selector);
|
||||
const node = rn && rn.node;
|
||||
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
|
||||
}
|
||||
};
|
||||
|
||||
/* True when `tag` (the sing-box outbound tag) belongs to a direct outbound.
|
||||
* Used by the http_clients builder to drop the detour field on a pure-TUN
|
||||
* setup: sing-box 1.14 rejects detouring to a direct outbound that has
|
||||
* nothing to detour through. */
|
||||
export function isDirectOutboundTag(tag, dm) {
|
||||
if (isEmpty(tag) || tag === 'block-out')
|
||||
return false;
|
||||
if (tag === 'direct-out')
|
||||
return true;
|
||||
|
||||
/* The caller passes the tag the generator emits - get_outbound() builds
|
||||
* `cfg-<node>-out` - not the routing_node section name, so that wrapper has
|
||||
* to be undone before the lookup. Without this the two queries below were
|
||||
* given a name that matches neither table, and the function returned false
|
||||
* for every routing_node: build_http_clients() then kept a `detour` on a
|
||||
* rule-set download in pure TUN mode, where sing-box refuses it ("detour to
|
||||
* an empty direct outbound makes no sense") and rejects the whole
|
||||
* configuration. */
|
||||
const unwrapped = match(tag, /^cfg-(.+)-out$/);
|
||||
const name = unwrapped ? unwrapped[1] : tag;
|
||||
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, name);
|
||||
const node_name = (rn && rn.node) || name;
|
||||
const node = ConfigQuery.node_by_id(dm, node_name);
|
||||
return !!(node && node.type === 'direct');
|
||||
};
|
||||
|
||||
/* Append the sing-box JSON $schema field. Kept here because the value is
|
||||
* fixed and the two generators used to spell it the same way; if it ever
|
||||
* changes, both should pick up the new URL automatically. */
|
||||
export function attachSchema(config) {
|
||||
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
||||
return config;
|
||||
};
|
||||
|
||||
/* Attach the experimental cache_file block when one of the routing modes
|
||||
* that needs it is active. Routing-mode gating stays here because the
|
||||
* block is the same regardless of mode; only the condition differs. */
|
||||
export function attachExperimental(config, routing_mode, dns_store_dns) {
|
||||
if (routing_mode in ['bypass_mainland_china', 'custom']) {
|
||||
config.experimental = {
|
||||
cache_file: {
|
||||
enabled: true,
|
||||
path: '/etc/homeproxy/cache.db',
|
||||
store_dns: (dns_store_dns === '1') ? true : null
|
||||
}
|
||||
};
|
||||
}
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* GenerationContext: the routing-mode/proxy-mode scalars every section
|
||||
* builder consumes, built as a *pure* function of the domain model plus a
|
||||
* small, explicit environment record.
|
||||
*
|
||||
* build_context(dm, env) -> ctx
|
||||
*
|
||||
* env = {
|
||||
* wan_dns, the WAN resolver the CLI resolved via ubus
|
||||
* direct_domain_list, /etc/homeproxy/resources/direct_list.txt
|
||||
* proxy_domain_list, /etc/homeproxy/resources/proxy_list.txt
|
||||
* }
|
||||
*
|
||||
* Why the split exists: this module used to sit inside generator/client.uc
|
||||
* and resolve `env` itself - it called `ubus network.interface status` for
|
||||
* the WAN DNS and `readfile()` for the two resource lists. The generator was
|
||||
* documented as a pure function while depending on live router state, so
|
||||
* "generate the same config twice" was not guaranteed. That is what made
|
||||
* reload's preflight generation and start_service's generation two different
|
||||
* artifacts in principle: the WAN lease can change between them.
|
||||
*
|
||||
* Now the impure part (ubus call, resource read, mode-dependent fallback for
|
||||
* a router with no lease) belongs to the CLI shells - scripts/
|
||||
* generate_client.uc and scripts/generate_server.uc - which are the
|
||||
* process-level boundary and already own the atomic write and
|
||||
* `sing-box check`. Everything under generator/ is a pure function of its
|
||||
* arguments, which tests/arch-guard.sh enforces (guard 27: no ubus/fs import
|
||||
* in generator/).
|
||||
*
|
||||
* `env` is allowed to be partial. A missing wan_dns still yields the same
|
||||
* public fallback the pre-split code used, as a pure function of the routing
|
||||
* mode - not a probe - so a caller that has no ubus keeps producing a valid
|
||||
* config instead of failing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt } from '../homeproxy.uc';
|
||||
|
||||
/* Build the routing-mode-independent ctx the section builders consume.
|
||||
* Mode-dependent defaults are filled in here so each builder can stay a
|
||||
* straight consumer of its fields without recomputing them. */
|
||||
export function build_context(dm, env) {
|
||||
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||
const proxy_mode = dm.general.proxy_mode || 'redirect_tproxy';
|
||||
|
||||
/* Mode-shared scalars */
|
||||
const ipv6_support = dm.general.ipv6_support || '0';
|
||||
const log_level = dm.general.log_level || 'warn';
|
||||
const ntp_server = dm.infra.ntp_server || 'time.apple.com';
|
||||
const dns_port = dm.infra.dns_port || '5333';
|
||||
const mixed_port = dm.infra.mixed_port || '5330';
|
||||
const udp_timeout = (routing_mode === 'custom')
|
||||
? (dm.routing.settings.udp_timeout)
|
||||
: dm.infra.udp_timeout;
|
||||
|
||||
/* self_mark is intentionally null for proxy_mode 'tun' only:
|
||||
* the DNS server detour (default-dns / system-dns / china-dns) is
|
||||
* gated on it, and the old generator kept the same code shape -
|
||||
* any non-empty string is truthy, so the test fixture's
|
||||
* self_mark='100' would leak a 'detour: direct-out' onto every
|
||||
* DNS server when proxy_mode is tun. removeBlankAttrs() then
|
||||
* strips the null detour, so the generated JSON stays identical
|
||||
* to the pre-refactor output. */
|
||||
const self_mark = match(proxy_mode, /redirect/)
|
||||
? (dm.infra.self_mark || '100') : null;
|
||||
|
||||
/* proxy-mode-derived ports / TUN params */
|
||||
let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6,
|
||||
tun_mtu, tcpip_stack, endpoint_independent_nat;
|
||||
if (match(proxy_mode, /redirect/))
|
||||
redirect_port = dm.infra.redirect_port || '5331';
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if ((dm.general.main_udp_node || 'nil') !== 'nil' || routing_mode === 'custom')
|
||||
tproxy_port = dm.infra.tproxy_port || '5332';
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
tun_name = dm.infra.tun_name || 'singtun0';
|
||||
tun_addr4 = dm.infra.tun_addr4 || '172.19.0.1/30';
|
||||
tun_addr6 = dm.infra.tun_addr6 || 'fdfe:dcba:9876::1/126';
|
||||
tun_mtu = dm.infra.tun_mtu || '9000';
|
||||
tcpip_stack = 'system';
|
||||
if (routing_mode === 'custom') {
|
||||
tcpip_stack = dm.routing.settings.tcpip_stack || 'system';
|
||||
endpoint_independent_nat = dm.routing.settings.endpoint_independent_nat;
|
||||
}
|
||||
}
|
||||
|
||||
/* The WAN resolver is an input, not a lookup. See the module header for
|
||||
* the fallback's provenance - it is the same mode-dependent public
|
||||
* resolver the pre-split generator chose when ubus had nothing to say. */
|
||||
const wan_dns = !isEmpty(env.wan_dns)
|
||||
? env.wan_dns
|
||||
: ((routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5');
|
||||
|
||||
/* dns/routing fields used by both sections */
|
||||
const dns_optimistic_cache = (dm.dns.settings || {}).optimistic_cache || '0';
|
||||
const dns_optimistic_timeout = (dm.dns.settings || {}).optimistic_timeout;
|
||||
const dns_query_timeout = (dm.dns.settings || {}).dns_timeout;
|
||||
const dns_store_dns = (dm.dns.settings || {}).cache_file_store_dns || '0';
|
||||
|
||||
const tun_dns_mode_raw = dm.general.tun_dns_mode,
|
||||
tun_dns_address = dm.general.tun_dns_address,
|
||||
udp_mapping_raw = dm.general.udp_mapping,
|
||||
udp_filtering_raw = dm.general.udp_filtering,
|
||||
udp_nat_max = strToInt(dm.general.udp_nat_max);
|
||||
|
||||
const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
|
||||
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
|
||||
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
|
||||
|
||||
/* Routing-mode-dependent defaults */
|
||||
const ctx = {
|
||||
routing_mode, proxy_mode, ipv6_support, log_level,
|
||||
self_mark, ntp_server, dns_port, mixed_port,
|
||||
redirect_port, tproxy_port,
|
||||
tun_name, tun_addr4, tun_addr6, tun_mtu,
|
||||
tcpip_stack, endpoint_independent_nat,
|
||||
udp_timeout, udp_mapping, udp_filtering, udp_nat_max,
|
||||
tun_dns_mode, tun_dns_address,
|
||||
wan_dns, dns_optimistic_cache, dns_optimistic_timeout,
|
||||
dns_query_timeout, dns_store_dns,
|
||||
default_interface: (dm.access_control.control || {}).bind_interface,
|
||||
|
||||
/* udp/tcp shaping knobs the endpoint builder reads */
|
||||
endpoint_options: {
|
||||
udp_mapping, udp_filtering, udp_nat_max
|
||||
},
|
||||
|
||||
/* Routing-mode-specific scalars; the dns/route modules consult
|
||||
* only the fields their branch needs.
|
||||
*
|
||||
* The main-node reference is mode-dependent, and custom routing has
|
||||
* its own outbound/rule tables (routing.default_outbound plus the
|
||||
* routing_node / routing_rule / ruleset sections). A residual
|
||||
* config.main_node must therefore not switch the generator back to
|
||||
* the preset path: LuCI hides that field in custom mode but does not
|
||||
* clear it (`rmempty = false`), so a router that was configured in a
|
||||
* preset mode and then switched to custom keeps the old value in
|
||||
* UCI - and the pre-refactor code read it only when the mode was not
|
||||
* custom. Reading it here anyway silently dropped every custom
|
||||
* routing rule and made route.final 'main-out'. */
|
||||
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
|
||||
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
|
||||
/* Custom-only scalars. The gate is not cosmetic: route.uc picks its
|
||||
* branch with `isEmpty(default_outbound)` and outbound.uc does the
|
||||
* same, while the preset branch keys off main_node. A residual
|
||||
* default_outbound from an earlier custom configuration therefore
|
||||
* switched a preset-mode router onto the custom branch with the
|
||||
* preset DNS builder bailing out (it keys off main_node) - the DNS
|
||||
* block then ended up with only default-dns/system-dns and no
|
||||
* main-dns/china-dns, the route block with leftover custom rules -
|
||||
* and the rule-set tags those rules reference are only built in
|
||||
* custom mode, so `sing-box check` rejected the whole generation.
|
||||
* Preset modes read custom-only fields nowhere; gate them all. */
|
||||
default_outbound: (routing_mode === 'custom') ? (dm.routing.settings || {}).default_outbound : null,
|
||||
default_outbound_dns: (routing_mode === 'custom')
|
||||
? ((dm.routing.settings || {}).default_outbound_dns || 'default-dns') : null,
|
||||
domain_strategy: (routing_mode === 'custom') ? (dm.routing.settings || {}).domain_strategy : null,
|
||||
find_neighbor: (routing_mode === 'custom') ? (dm.routing.settings || {}).find_neighbor : null,
|
||||
|
||||
/* Main-line defaults, mirroring what the pre-refactor
|
||||
* generate_client.uc did inline: an empty UCI value becomes 'wan'
|
||||
* for the main DNS server, and 'wan' - the value the UI writes for
|
||||
* "WAN DNS (read from interface)" - is replaced by the resolver the
|
||||
* CLI read off the WAN interface (`wan_dns`, which itself falls back
|
||||
* to a public resolver when ubus has no answer). Leaving the
|
||||
* literal 'wan' in place makes sing-box resolve a host by that name.
|
||||
* The China DNS server falls back to the Aliyun public resolver
|
||||
* instead of leaking into the default-dns pipeline. */
|
||||
dns_server: (isEmpty(dm.general.dns_server) || dm.general.dns_server === 'wan')
|
||||
? wan_dns : dm.general.dns_server,
|
||||
china_dns_server: (dm.general.china_dns_server && dm.general.china_dns_server !== 'wan')
|
||||
? dm.general.china_dns_server : '223.5.5.5',
|
||||
/* dns_default_strategy: in proxy mode, ipv4_only is the safe
|
||||
* default whenever ipv6_support is off; in custom mode the
|
||||
* UCI default_strategy field is the source of truth (no
|
||||
* implicit fallback - missing UCI => strategy: null =>
|
||||
* removeBlankAttrs strips it). */
|
||||
dns_default_strategy: (routing_mode === 'custom')
|
||||
? (dm.dns.settings || {}).default_strategy
|
||||
: ((ipv6_support !== '1') ? 'ipv4_only' : null),
|
||||
cn_ip_fallback: dm.general.cn_ip_fallback,
|
||||
main_urltest_nodes: dm.general.main_urltest_nodes || [],
|
||||
main_urltest_interval: dm.general.main_urltest_interval,
|
||||
main_urltest_tolerance: dm.general.main_urltest_tolerance,
|
||||
main_udp_urltest_nodes: dm.general.main_udp_urltest_nodes || [],
|
||||
main_udp_urltest_interval: dm.general.main_udp_urltest_interval,
|
||||
main_udp_urltest_tolerance: dm.general.main_udp_urltest_tolerance,
|
||||
|
||||
/* Custom-mode DNS settings */
|
||||
dns_default_server: (dm.dns.settings || {}).default_server,
|
||||
dns_disable_cache: (dm.dns.settings || {}).disable_cache,
|
||||
dns_disable_cache_expire: (dm.dns.settings || {}).disable_cache_expire,
|
||||
dns_client_subnet: (dm.dns.settings || {}).client_subnet,
|
||||
};
|
||||
|
||||
/* Inline static rule-sets (direct-domain, proxy-domain) only fire
|
||||
* when the routing mode has a non-empty domain list. Both lists are
|
||||
* inputs now: the CLI reads the two files under
|
||||
* /etc/homeproxy/resources/ and passes them in. Custom mode ignores
|
||||
* them entirely, exactly as the pre-split read did. */
|
||||
ctx.direct_domain_list = [];
|
||||
ctx.proxy_domain_list = [];
|
||||
if (routing_mode !== 'custom') {
|
||||
ctx.direct_domain_list = env.direct_domain_list || [];
|
||||
ctx.proxy_domain_list = env.proxy_domain_list || [];
|
||||
}
|
||||
|
||||
/* `dedicated_udp_node` means main_udp_node was set to a different
|
||||
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
|
||||
* qualify). Kept on ctx so both build_outbounds and build_route
|
||||
* see the same value. */
|
||||
ctx.dedicated_udp_node = !isEmpty(ctx.main_udp_node) && !(ctx.main_udp_node in ['same', ctx.main_node]);
|
||||
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/dns.uc: build the sing-box `dns` block.
|
||||
*
|
||||
* Two routing-mode families exist:
|
||||
*
|
||||
* 1. bypass_mainland_china / proxy_mainland_china / global /
|
||||
* gfwlist - the proxy path. The orchestrator passes main_node
|
||||
* (possibly 'urltest'), main_udp_node, china_dns_server,
|
||||
* direct_domain_list and proxy_domain_list in ctx; the legacy
|
||||
* NAPTR trick (qtype 35) and the CN-IP fallback (cn_ip_fallback)
|
||||
* are part of this path.
|
||||
*
|
||||
* 2. custom - the user-defined rules path. The orchestrator passes
|
||||
* default_outbound, default_outbound_dns, the dns settings
|
||||
* (default_strategy, default_server, disable_cache, ...) and the
|
||||
* dns_server / dns_rule section lists under dm.dns.
|
||||
*
|
||||
* The dns_server / dns_rule iteration is the part that is mode-agnostic
|
||||
* and lives in this module rather than the orchestrator because every
|
||||
* field comes straight from UCI and the legacy 1.14 evaluate/match_response
|
||||
* rewrite is the per-field payload.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy.uc';
|
||||
|
||||
|
||||
import { get_outbound, get_resolver, get_ruleset } from './common.uc';
|
||||
|
||||
/* Build a DNS server spec from a UCI address string. sing-box wants the
|
||||
* server name, the protocol kind, an optional path, and an integer port;
|
||||
* the UCI representation is a single string, optionally without a
|
||||
* scheme, which is why a default_protocol is supplied. */
|
||||
function parse_dnsserver(server_addr, default_protocol) {
|
||||
if (isEmpty(server_addr))
|
||||
return null;
|
||||
|
||||
if (!match(server_addr, /:\/\//))
|
||||
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
|
||||
|
||||
/* parseURL() returns null for an address it cannot make sense of (a bad
|
||||
* host, an unknown scheme). The old code dereferenced the result straight
|
||||
* away, so one malformed UCI value took the whole generator down with
|
||||
* "left-hand side expression is null" instead of naming the dns_server. */
|
||||
server_addr = parseURL(server_addr);
|
||||
if (!server_addr)
|
||||
return null;
|
||||
|
||||
return {
|
||||
type: server_addr.protocol,
|
||||
server: server_addr.hostname,
|
||||
server_port: strToInt(server_addr.port),
|
||||
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse the DNS rule query_type list UCI emits. A single value comes as
|
||||
* a string, a list as an array of strings; both forms are turned into an
|
||||
* array of strings or integers (mixing the two is intentional - the type
|
||||
* can be a name or a numeric code in UCI). */
|
||||
function parse_dnsquery(strquery) {
|
||||
if (type(strquery) !== 'array' || isEmpty(strquery))
|
||||
return null;
|
||||
|
||||
let querys = [];
|
||||
for (let i in strquery)
|
||||
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
|
||||
|
||||
return querys;
|
||||
}
|
||||
|
||||
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
|
||||
|
||||
function append_proxy_dns(config, dm, ctx) {
|
||||
if (isEmpty(ctx.main_node))
|
||||
return;
|
||||
|
||||
/* Main DNS */
|
||||
push(config.dns.servers, {
|
||||
tag: 'main-dns',
|
||||
domain_resolver: {
|
||||
server: 'default-dns',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
|
||||
},
|
||||
detour: 'main-out',
|
||||
...parse_dnsserver(ctx.dns_server, 'tcp')
|
||||
});
|
||||
config.dns.final = 'main-dns';
|
||||
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'direct-domain',
|
||||
action: 'route',
|
||||
server: (ctx.routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
|
||||
});
|
||||
|
||||
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
|
||||
push(config.dns.rules, {
|
||||
query_type: [64, 65],
|
||||
action: 'reject'
|
||||
});
|
||||
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
push(config.dns.servers, {
|
||||
tag: 'china-dns',
|
||||
domain_resolver: {
|
||||
server: 'default-dns',
|
||||
strategy: 'prefer_ipv6'
|
||||
},
|
||||
detour: ctx.self_mark ? 'direct-out' : null,
|
||||
...parse_dnsserver(ctx.china_dns_server)
|
||||
});
|
||||
|
||||
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
|
||||
default-dns directly: china-dns (223.5.5.5) has intermittent
|
||||
multi-second first responses for NAPTR, causing 'context deadline
|
||||
exceeded' (e.g. sipgz12.hbq.r.10086.cn) */
|
||||
push(config.dns.rules, {
|
||||
query_type: [35],
|
||||
domain_suffix: ['r.10086.cn', '10086.cn', 'pub.3gppnetwork.org'],
|
||||
action: 'route',
|
||||
server: 'default-dns'
|
||||
});
|
||||
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'proxy-domain',
|
||||
action: 'route',
|
||||
server: 'main-dns'
|
||||
});
|
||||
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'geosite-cn',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
|
||||
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
||||
if (ctx.cn_ip_fallback === '1') {
|
||||
push(config.dns.rules, {
|
||||
action: 'evaluate',
|
||||
server: 'main-dns',
|
||||
tag: 'cn-fallback'
|
||||
});
|
||||
push(config.dns.rules, {
|
||||
match_response: 'cn-fallback',
|
||||
rule_set: 'geoip-cn',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
|
||||
|
||||
function append_custom_dns(config, dm, ctx) {
|
||||
/* DNS servers */
|
||||
for (let cfg in dm.dns.servers) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
let outbound = get_outbound(cfg.outbound, dm);
|
||||
if (outbound === 'direct-out' && isEmpty(ctx.self_mark))
|
||||
outbound = null;
|
||||
|
||||
push(config.dns.servers, {
|
||||
tag: 'cfg-' + cfg.name + '-dns',
|
||||
type: cfg.type,
|
||||
server: cfg.server,
|
||||
server_port: strToInt(cfg.server_port),
|
||||
path: cfg.path,
|
||||
headers: cfg.headers,
|
||||
tls: cfg.tls_sni ? {
|
||||
enabled: true,
|
||||
server_name: cfg.tls_sni
|
||||
} : null,
|
||||
domain_resolver: (cfg.address_resolver || cfg.address_strategy) ? {
|
||||
server: get_resolver(cfg.address_resolver || ctx.dns_default_server, dm),
|
||||
strategy: cfg.address_strategy
|
||||
} : null,
|
||||
detour: outbound
|
||||
});
|
||||
}
|
||||
|
||||
/* DNS rules */
|
||||
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
|
||||
evaluate action; deprecated strategy/accept_empty fields are dropped. */
|
||||
const builtin_dns_rules = [];
|
||||
for (let cfg in dm.dns.rules) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
const rule = {
|
||||
ip_version: strToInt(cfg.ip_version),
|
||||
query_type: parse_dnsquery(cfg.query_type),
|
||||
network: cfg.network,
|
||||
protocol: cfg.protocol,
|
||||
domain: cfg.domain,
|
||||
domain_suffix: cfg.domain_suffix,
|
||||
domain_keyword: cfg.domain_keyword,
|
||||
domain_regex: cfg.domain_regex,
|
||||
port: parse_port(cfg.port),
|
||||
port_range: cfg.port_range,
|
||||
source_ip_cidr: cfg.source_ip_cidr,
|
||||
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
||||
source_port: parse_port(cfg.source_port),
|
||||
source_port_range: cfg.source_port_range,
|
||||
process_name: cfg.process_name,
|
||||
process_path: cfg.process_path,
|
||||
process_path_regex: cfg.process_path_regex,
|
||||
user: cfg.user,
|
||||
rule_set: get_ruleset(cfg.rule_set, dm),
|
||||
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
||||
invert: strToBool(cfg.invert),
|
||||
race: strToBool(cfg.race),
|
||||
speculative: strToBool(cfg.speculative),
|
||||
action: cfg.action,
|
||||
server: get_resolver(cfg.server, dm),
|
||||
disable_cache: strToBool(cfg.dns_disable_cache),
|
||||
disable_optimistic_cache: strToBool(cfg.disable_optimistic_cache),
|
||||
rewrite_ttl: strToInt(cfg.rewrite_ttl),
|
||||
timeout: strToTime(cfg.dns_timeout),
|
||||
client_subnet: cfg.client_subnet,
|
||||
remove_client_subnet: strToBool(cfg.remove_client_subnet),
|
||||
method: cfg.reject_method,
|
||||
no_drop: strToBool(cfg.reject_no_drop),
|
||||
rcode: cfg.predefined_rcode,
|
||||
answer: cfg.predefined_answer,
|
||||
ns: cfg.predefined_ns,
|
||||
extra: cfg.predefined_extra
|
||||
};
|
||||
|
||||
if (cfg.action === 'evaluate')
|
||||
rule.tag = cfg.evaluate_tag || null;
|
||||
|
||||
if (cfg.match_response && cfg.match_response !== '0')
|
||||
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
|
||||
|
||||
rule.query_client_subnet = cfg.query_client_subnet;
|
||||
rule.query_dnssec = strToBool(cfg.query_dnssec);
|
||||
rule.source_mac_address = cfg.source_mac_address;
|
||||
rule.source_hostname = cfg.source_hostname;
|
||||
|
||||
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
|
||||
/* Every action needs the evaluate prefix to see the response, not just
|
||||
* `route`: with the old `&& cfg.action === 'route'` an address-filtered
|
||||
* reject rule lost the filter entirely and became an unconditional
|
||||
* reject - a DNS black hole for every query. */
|
||||
if (legacy_filter && !rule.match_response) {
|
||||
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
|
||||
paradigm. Carry the original query-matching fields onto the evaluate
|
||||
prefix rule so only queries that would have hit this rule get
|
||||
pre-resolved; an unconditional evaluate would resolve every query. */
|
||||
const eval_tag = '_hp_eval_' + cfg.name;
|
||||
const eval_rule = {
|
||||
action: 'evaluate',
|
||||
server: get_resolver(cfg.server, dm),
|
||||
tag: eval_tag
|
||||
};
|
||||
const eval_match_fields = [
|
||||
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
|
||||
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
|
||||
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
|
||||
'source_port', 'source_port_range', 'process_name', 'process_path',
|
||||
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
|
||||
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
|
||||
'source_hostname'
|
||||
];
|
||||
for (let f in eval_match_fields)
|
||||
eval_rule[f] = rule[f];
|
||||
push(builtin_dns_rules, eval_rule);
|
||||
rule.match_response = eval_tag;
|
||||
}
|
||||
|
||||
/* Response-match fields are only valid together with match_response in
|
||||
* 1.14: sing-box rejects the rule with "Response Match Fields (...)
|
||||
* require match_response to be enabled". ip_cidr / ip_is_private were
|
||||
* already gated here; the response_* quartet was emitted
|
||||
* unconditionally next door. */
|
||||
if (rule.match_response) {
|
||||
rule.ip_cidr = cfg.ip_cidr;
|
||||
rule.ip_is_private = strToBool(cfg.ip_is_private);
|
||||
rule.response_rcode = cfg.response_rcode;
|
||||
rule.response_answer = cfg.response_answer;
|
||||
rule.response_ns = cfg.response_ns;
|
||||
rule.response_extra = cfg.response_extra;
|
||||
}
|
||||
|
||||
push(builtin_dns_rules, rule);
|
||||
}
|
||||
config.dns.rules = builtin_dns_rules;
|
||||
|
||||
config.dns.final = get_resolver(ctx.dns_default_server, dm);
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* The always-on DNS block. Every routing mode (proxy and custom) gets
|
||||
* default-dns (UDP via the WAN resolver) and system-dns (local, used as
|
||||
* a fallback). The routing-mode-specific builders push additional
|
||||
* servers/rules on top of this list. Keeping the common prefix here is
|
||||
* why the proxy-mode and custom-mode paths stay almost mirror images:
|
||||
* both branches see config.dns already populated. */
|
||||
function initDns(config, ctx) {
|
||||
/* sing-box refuses the combination outright:
|
||||
* FATAL initialize DNS router: `optimistic` is conflict with `disable_cache`
|
||||
* (reproduced against 1.14.1 on a real config). Both are opt-in switches
|
||||
* in the UI, so turning both on used to produce a configuration that
|
||||
* cannot start - the reload fails and rolls back, and the only symptom the
|
||||
* user sees is "my setting did not take". optimistic wins; the disable_*
|
||||
* switches are dropped rather than emitted. */
|
||||
const optimistic = (ctx.dns_optimistic_cache === '1');
|
||||
|
||||
config.dns = {
|
||||
servers: [
|
||||
{
|
||||
tag: 'default-dns',
|
||||
type: 'udp',
|
||||
server: ctx.wan_dns,
|
||||
detour: ctx.self_mark ? 'direct-out' : null
|
||||
},
|
||||
{
|
||||
tag: 'system-dns',
|
||||
type: 'local',
|
||||
detour: ctx.self_mark ? 'direct-out' : null
|
||||
}
|
||||
],
|
||||
rules: [],
|
||||
strategy: ctx.dns_default_strategy,
|
||||
disable_cache: optimistic ? null : strToBool(ctx.dns_disable_cache),
|
||||
disable_expire: optimistic ? null : strToBool(ctx.dns_disable_cache_expire),
|
||||
client_subnet: ctx.dns_client_subnet,
|
||||
optimistic: optimistic ? {
|
||||
enabled: true,
|
||||
/* strToTime(), not the raw UCI string: sing-box parses this
|
||||
* field as a duration and rejects a bare number outright
|
||||
* ("time: missing unit in duration \"3600\""), so the whole
|
||||
* generation would fail - and a failed generation keeps the
|
||||
* previous configuration running, which the user sees as
|
||||
* "my setting did not take". The UI documents the field as
|
||||
* "Examples: 3d, 1h" but does not enforce a unit, so both
|
||||
* forms have to work: strToTime() turns "3600" into "3600s"
|
||||
* and leaves "3d" alone. dns_query_timeout below already
|
||||
* does this. */
|
||||
timeout: !isEmpty(ctx.dns_optimistic_timeout) ? strToTime(ctx.dns_optimistic_timeout) : '3d'
|
||||
} : null,
|
||||
timeout: !isEmpty(ctx.dns_query_timeout) ? strToTime(ctx.dns_query_timeout) : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Build the sing-box `dns` block. Mutates `config` and returns it for
|
||||
* chaining. Routing mode is determined by ctx.routing_mode: any non-
|
||||
* 'custom' mode follows the proxy path (the proxy-mode split inside the
|
||||
* proxy path is just which rules to emit). */
|
||||
export function build_dns(config, dm, ctx) {
|
||||
initDns(config, ctx);
|
||||
|
||||
if (ctx.routing_mode === 'custom')
|
||||
append_custom_dns(config, dm, ctx);
|
||||
else
|
||||
append_proxy_dns(config, dm, ctx);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/inbound.uc: build the sing-box `inbounds` block.
|
||||
*
|
||||
* The client always needs the dns-in and mixed-in inbounds, plus
|
||||
* whichever of redirect-in / tproxy-in / tun-in matches proxy_mode.
|
||||
* udp_* fields on tproxy-in and tun-in come from the same UCI settings
|
||||
* (a single source of truth in init.d, but the orchestrator extracts
|
||||
* them and re-shapes them for sing-box here).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool } from '../homeproxy.uc';
|
||||
|
||||
/* Append the always-on inbounds. dns-in is the local listener the DNS chain
|
||||
* hands queries to - runtime/dns.sh points dnsmasq at 127.0.0.1#<dns_port>,
|
||||
* so nothing outside this host has any reason to reach it. It used to
|
||||
* listen on '::', every interface, and a `direct` inbound forwards without
|
||||
* consulting the route rules: any client that connected to the DNS port got
|
||||
* its traffic dialled straight out instead of routed. A router log showed
|
||||
* exactly that - connections to a blocked address timing out through the
|
||||
* built-in `direct` outbound while every rule pointed at main-out.
|
||||
* Loopback-only removes the entry point; mixed-in stays on '::' because that
|
||||
* one is the SOCKS/HTTP listener clients are meant to reach. Their order
|
||||
* matters because dns-in has to come first when sing-box matches inbounds in
|
||||
* declaration order. */
|
||||
function pushBaseInbounds(inbounds, ctx) {
|
||||
push(inbounds, {
|
||||
type: 'direct',
|
||||
tag: 'dns-in',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: int(ctx.dns_port)
|
||||
});
|
||||
|
||||
push(inbounds, {
|
||||
type: 'mixed',
|
||||
tag: 'mixed-in',
|
||||
listen: '::',
|
||||
listen_port: int(ctx.mixed_port),
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
set_system_proxy: false
|
||||
});
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box `inbounds` block. The caller supplies an empty
|
||||
* array on `config.inbounds` so this can be a pure builder. */
|
||||
export function build_inbounds(config, ctx) {
|
||||
const inbounds = config.inbounds || [];
|
||||
|
||||
pushBaseInbounds(inbounds, ctx);
|
||||
|
||||
if (match(ctx.proxy_mode, /redirect/))
|
||||
push(inbounds, {
|
||||
type: 'redirect',
|
||||
tag: 'redirect-in',
|
||||
|
||||
listen: '::',
|
||||
listen_port: int(ctx.redirect_port)
|
||||
});
|
||||
|
||||
/* tproxy-in is gated on the port existing, not only on the proxy mode:
|
||||
* context.uc only assigns tproxy_port when a dedicated UDP node is
|
||||
* configured (or in custom mode), because that is exactly when
|
||||
* firewall_post.ut emits the tproxy chain. Emitting the inbound anyway
|
||||
* bound a UDP socket to listen_port 0 - a random port that no nft rule
|
||||
* ever points at - so the two layers now share one condition. */
|
||||
if (match(ctx.proxy_mode, /tproxy/) && !isEmpty(ctx.tproxy_port))
|
||||
push(inbounds, {
|
||||
type: 'tproxy',
|
||||
tag: 'tproxy-in',
|
||||
|
||||
listen: '::',
|
||||
listen_port: int(ctx.tproxy_port),
|
||||
network: 'udp',
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max,
|
||||
});
|
||||
|
||||
if (match(ctx.proxy_mode, /tun/))
|
||||
push(inbounds, {
|
||||
type: 'tun',
|
||||
tag: 'tun-in',
|
||||
|
||||
interface_name: ctx.tun_name,
|
||||
address: (ctx.ipv6_support === '1') ? [ctx.tun_addr4, ctx.tun_addr6] : [ctx.tun_addr4],
|
||||
mtu: strToInt(ctx.tun_mtu),
|
||||
auto_route: false,
|
||||
endpoint_independent_nat: strToBool(ctx.endpoint_independent_nat),
|
||||
udp_timeout: strToTime(ctx.udp_timeout),
|
||||
dns_mode: !isEmpty(ctx.tun_dns_mode) ? ctx.tun_dns_mode : null,
|
||||
dns_address: !isEmpty(ctx.tun_dns_address) ? ctx.tun_dns_address : null,
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max,
|
||||
stack: ctx.tcpip_stack,
|
||||
});
|
||||
|
||||
config.inbounds = inbounds;
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/outbound.uc: build the sing-box `outbounds` and `endpoints`
|
||||
* blocks, plus the urltest pruning the route
|
||||
* builder depends on.
|
||||
*
|
||||
* Two builders live here:
|
||||
*
|
||||
* generate_endpoint(node, ctx) -> sing-box endpoint object (WireGuard only
|
||||
* at the moment; the tag convention is the same as for outbounds).
|
||||
*
|
||||
* generate_outbound(node, mark, direct_overrides) -> sing-box outbound
|
||||
* object. Side effect: a direct node carrying override_address /
|
||||
* override_port is recorded into the caller-supplied `direct_overrides`
|
||||
* map so the route builder can later emit the route-options action.
|
||||
* The Adapter (config/adapter.uc) already handles every protocol's
|
||||
* payload; this module is just the orchestration and the override side
|
||||
* effect.
|
||||
*
|
||||
* build_outbounds(dm, ctx, direct_overrides) -> { outbounds, endpoints }
|
||||
* Implements the routing-mode split (proxy vs custom), the urltest
|
||||
* pruning via keep_candidate / buildable_candidates, and the
|
||||
* routing_node -> outbound / endpoint resolution.
|
||||
*
|
||||
* The `direct_overrides` parameter is a mutable map the orchestrator owns
|
||||
* and threads through the generators. Making it explicit avoids the
|
||||
* "module-level side effect" the old generate_client.uc had, which was
|
||||
* the only thing tying outbound building to route building.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToBool, strToTime } from '../homeproxy.uc';
|
||||
|
||||
import { ConfigQuery } from '../config/model.uc';
|
||||
import { OutboundFactory, EndpointFactory } from '../config/adapter.uc';
|
||||
|
||||
import { get_outbound, get_resolver } from './common.uc';
|
||||
|
||||
/* --- single-endpoint / single-outbound builders ------------------------ */
|
||||
|
||||
/* WireGuard goes through the same Node as every other protocol; the
|
||||
* fields come from node.protocol_options (Loader's wireguard row) and
|
||||
* the cross-protocol options from node.common.
|
||||
*
|
||||
* This used to read the flat UCI keys (node.wireguard_*, and
|
||||
* node.tcp_fast_open / node.tcp_multi_path / node.udp_fragment) off a
|
||||
* Node. Those keys only existed on the flat section dict the
|
||||
* pre-refactor call sites passed; once A2/A3 made every call site pass
|
||||
* a Node they were simply null, so every WireGuard main / UDP /
|
||||
* urltest node emitted an endpoint with no private key and a peer with
|
||||
* no public key. tests/fixtures/generators/wireguard.uci guards it.
|
||||
*
|
||||
* The endpoint *shaping* lives in EndpointFactory in
|
||||
* config/adapter.uc, so the generator no longer holds protocol business
|
||||
* logic for WireGuard. This stays as the call sites' entry point, which
|
||||
* keeps the module's endpoint vocabulary without making every caller
|
||||
* import the adapter directly. */
|
||||
export function generate_endpoint(node, ctx) {
|
||||
return EndpointFactory.create(node, ctx);
|
||||
};
|
||||
|
||||
/* Build one outbound from a Node via the Adapter layer. A direct node
|
||||
* with override_address / override_port also gets recorded into the
|
||||
* caller's `direct_overrides` map so the route builder can emit the
|
||||
* route-options action; this is the only side effect of the function
|
||||
* beyond returning the outbound object. */
|
||||
export function generate_outbound(node, mark, direct_overrides) {
|
||||
/* Stage A3+A5: this used to be a 110-line ternary soup that built the
|
||||
* sing-box outbound by hand for every protocol. It now takes a
|
||||
* Node (looked up via ConfigQuery.node_by_id) and hands it straight
|
||||
* to OutboundFactory.create() (the Adapter layer).
|
||||
*
|
||||
* The direct-node override table is the one side effect that survives;
|
||||
* the Adapter layer has no place to record it, and the route builder
|
||||
* reads it later. */
|
||||
if (type(node) !== 'object' || isEmpty(node))
|
||||
return null;
|
||||
|
||||
if (node.type === 'direct' && (!isEmpty(node.protocol_options.override_address) || !isEmpty(node.protocol_options.override_port)))
|
||||
direct_overrides[node.id] = {
|
||||
override_address: node.protocol_options.override_address,
|
||||
override_port: strToInt(node.protocol_options.override_port)
|
||||
};
|
||||
|
||||
return OutboundFactory.create(node, mark);
|
||||
};
|
||||
|
||||
/* Keep only the candidate ids the Adapter can actually build.
|
||||
*
|
||||
* urltest member lists are the one place a broken node must not be fatal:
|
||||
* a single bad node in a 200-node subscription used to make the Adapter
|
||||
* die(), which aborted the whole generator, so the service could not start
|
||||
* at all. The node stays in UCI (the UI shows it and the next
|
||||
* subscription update can repair or drop it); it is only left out of the
|
||||
* urltest group, and the reason is logged.
|
||||
*
|
||||
* Nodes that routing rules or DNS actually resolve through are NOT pruned:
|
||||
* a rule pointing at a missing tag is invalid sing-box config, so those
|
||||
* stay fatal on purpose. */
|
||||
function keep_candidate(dm, id) {
|
||||
const node = ConfigQuery.node_by_id(dm, id);
|
||||
|
||||
if (!node) {
|
||||
warn(sprintf("homeproxy: urltest candidate '%s' no longer exists, skipping.", id));
|
||||
return false;
|
||||
}
|
||||
|
||||
const problems = OutboundFactory.problems(node);
|
||||
if (length(problems)) {
|
||||
warn(sprintf("homeproxy: skipping urltest candidate '%s': %s.", id, join(', ', problems)));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Map over a candidate id list, dropping the unbuildable ones. */
|
||||
function buildable_candidates(dm, ids) {
|
||||
return filter(ids || [], (id) => keep_candidate(dm, id));
|
||||
}
|
||||
|
||||
/* --- main-outbound building (proxy mode) ------------------------------- */
|
||||
|
||||
/* Decide whether a candidate node should be emitted as a sing-box endpoint
|
||||
* (WireGuard) or as an outbound (everything else). Returns the populated
|
||||
* object so the caller can retag it. */
|
||||
function emitNode(node, mark, tag, direct_overrides, ctx) {
|
||||
if (node && node.type === 'wireguard') {
|
||||
const endpoint = generate_endpoint(node, ctx);
|
||||
endpoint.tag = tag;
|
||||
return { kind: 'endpoint', object: endpoint };
|
||||
}
|
||||
|
||||
const outbound = generate_outbound(node, mark, direct_overrides);
|
||||
|
||||
/* generate_outbound() returns null for an empty node, and a reference can
|
||||
* point at a section that no longer exists: main_node and main_udp_node
|
||||
* are stored by name, and deleting a node in the UI does not rewrite
|
||||
* them. Setting `.tag` on null raised "left-hand side expression is
|
||||
* null", which says nothing about which reference is stale. */
|
||||
if (outbound == null)
|
||||
die(sprintf("cannot build %s: the node it refers to does not exist (it may have been deleted, or the reference is stale).\n", tag));
|
||||
|
||||
outbound.tag = tag;
|
||||
return { kind: 'outbound', object: outbound };
|
||||
}
|
||||
|
||||
function buildMainOutbounds(dm, config, ctx, direct_overrides) {
|
||||
const outbounds = config.outbounds;
|
||||
const endpoints = config.endpoints;
|
||||
const mark = ctx.self_mark;
|
||||
let urltest_nodes = [];
|
||||
|
||||
if (ctx.main_node === 'urltest') {
|
||||
const main_urltest_nodes = buildable_candidates(dm, ctx.main_urltest_nodes);
|
||||
const main_urltest_interval = ctx.main_urltest_interval;
|
||||
const main_urltest_tolerance = ctx.main_urltest_tolerance;
|
||||
|
||||
/* An empty group means every candidate was unbuildable: there is
|
||||
* nothing left to route through, so fail with the reason instead
|
||||
* of emitting an urltest outbound with no members. */
|
||||
if (isEmpty(main_urltest_nodes))
|
||||
die("no buildable node in the main urltest group, please check your configuration.\n");
|
||||
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'main-out',
|
||||
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
interval: strToTime(main_urltest_interval),
|
||||
tolerance: strToInt(main_urltest_tolerance),
|
||||
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
|
||||
});
|
||||
urltest_nodes = main_urltest_nodes;
|
||||
} else {
|
||||
const main_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_node);
|
||||
const emitted = emitNode(main_node_cfg, mark, 'main-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
|
||||
if (ctx.main_udp_node === 'urltest') {
|
||||
const main_udp_urltest_nodes = buildable_candidates(dm, ctx.main_udp_urltest_nodes);
|
||||
const main_udp_urltest_interval = ctx.main_udp_urltest_interval;
|
||||
const main_udp_urltest_tolerance = ctx.main_udp_urltest_tolerance;
|
||||
|
||||
if (isEmpty(main_udp_urltest_nodes))
|
||||
die("no buildable node in the main UDP urltest group, please check your configuration.\n");
|
||||
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'main-udp-out',
|
||||
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
interval: strToTime(main_udp_urltest_interval),
|
||||
tolerance: strToInt(main_udp_urltest_tolerance),
|
||||
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
|
||||
});
|
||||
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
||||
} else if (ctx.dedicated_udp_node) {
|
||||
const main_udp_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_udp_node);
|
||||
const emitted = emitNode(main_udp_node_cfg, mark, 'main-udp-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
|
||||
for (let i in urltest_nodes) {
|
||||
const urltest_node = ConfigQuery.node_by_id(dm, i);
|
||||
if (!urltest_node)
|
||||
continue;
|
||||
const emitted = emitNode(urltest_node, mark, 'cfg-' + i + '-out', direct_overrides, ctx);
|
||||
if (emitted.kind === 'endpoint')
|
||||
push(endpoints, emitted.object);
|
||||
else
|
||||
push(outbounds, emitted.object);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- custom-mode outbound building (routing_node iteration) ------------- */
|
||||
|
||||
function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
|
||||
const outbounds = config.outbounds;
|
||||
const endpoints = config.endpoints;
|
||||
const mark = ctx.self_mark;
|
||||
let urltest_nodes = [],
|
||||
routing_nodes = [];
|
||||
|
||||
for (let cfg in dm.routing.nodes) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
if (cfg.node === 'urltest') {
|
||||
const cfg_urltest_nodes = buildable_candidates(dm, cfg.urltest_nodes);
|
||||
push(outbounds, {
|
||||
type: 'urltest',
|
||||
tag: 'cfg-' + cfg.name + '-out',
|
||||
outbounds: map(cfg_urltest_nodes, (k) => `cfg-${k}-out`),
|
||||
url: cfg.urltest_url,
|
||||
interval: strToTime(cfg.urltest_interval),
|
||||
tolerance: strToInt(cfg.urltest_tolerance),
|
||||
idle_timeout: strToTime(cfg.urltest_idle_timeout),
|
||||
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
|
||||
});
|
||||
urltest_nodes = [...urltest_nodes, ...filter(cfg_urltest_nodes, (l) => !~index(urltest_nodes, l))];
|
||||
} else {
|
||||
const outbound = ConfigQuery.node_by_id(dm, cfg.node);
|
||||
|
||||
/* Same stale-reference case as emitNode(), but here the reference
|
||||
* comes from a routing_node. `|| {}` made it look non-null and the
|
||||
* failure surfaced as a null dereference two lines later. */
|
||||
if (isEmpty(outbound))
|
||||
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
|
||||
|
||||
if (outbound.type === 'wireguard') {
|
||||
/* WireGuard goes through generate_endpoint() which tags
|
||||
* the endpoint as cfg-<node_id>-out (same convention as
|
||||
* generate_outbound()). The routing_node section name is
|
||||
* used by routing rules via get_outbound() but not as the
|
||||
* tag itself; both paths agree on cfg-<node_id>-out. */
|
||||
const endpoint = generate_endpoint(outbound, ctx);
|
||||
endpoint.bind_interface = cfg.bind_interface;
|
||||
endpoint.detour = get_outbound(cfg.outbound, dm);
|
||||
if (cfg.domain_resolver)
|
||||
endpoint.domain_resolver = {
|
||||
server: get_resolver(cfg.domain_resolver, dm),
|
||||
strategy: cfg.domain_strategy
|
||||
};
|
||||
push(endpoints, endpoint);
|
||||
} else {
|
||||
const ob = generate_outbound(outbound, mark, direct_overrides);
|
||||
|
||||
if (ob == null)
|
||||
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
|
||||
|
||||
ob.bind_interface = cfg.bind_interface;
|
||||
ob.detour = get_outbound(cfg.outbound, dm);
|
||||
if (cfg.domain_resolver)
|
||||
ob.domain_resolver = {
|
||||
server: get_resolver(cfg.domain_resolver, dm),
|
||||
strategy: cfg.domain_strategy
|
||||
};
|
||||
push(outbounds, ob);
|
||||
}
|
||||
push(routing_nodes, cfg.node);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i in filter (urltest_nodes, (l) => !~index(routing_nodes, l))) {
|
||||
const urltest_node = ConfigQuery.node_by_id(dm, i) || {};
|
||||
if (urltest_node.type === 'wireguard')
|
||||
push(endpoints, generate_endpoint(urltest_node, ctx));
|
||||
else
|
||||
push(outbounds, generate_outbound(urltest_node, mark, direct_overrides));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box `outbounds` and `endpoints` blocks. Mutates
|
||||
* config.endpoints and config.outbounds; the caller initialises them
|
||||
* with the always-on default outbounds (direct-out + block-out) and
|
||||
* passes the mutable accumulator in `ctx.acc` so the same arrays can
|
||||
* be threaded through this module, the ruleset module and the route
|
||||
* module without copying.
|
||||
*
|
||||
* `direct_overrides` is the same mutable map the route module will read
|
||||
* later; populate it here so the override information is available
|
||||
* when the route builder runs. */
|
||||
export function build_outbounds(config, dm, ctx, direct_overrides) {
|
||||
if (isEmpty(ctx.main_node) && isEmpty(ctx.default_outbound))
|
||||
return config;
|
||||
|
||||
if (!isEmpty(ctx.main_node))
|
||||
buildMainOutbounds(dm, config, ctx, direct_overrides);
|
||||
else if (!isEmpty(ctx.default_outbound))
|
||||
buildCustomOutbounds(dm, config, ctx, direct_overrides);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/route.uc: build the sing-box `route` block.
|
||||
*
|
||||
* The route block has the same routing-mode split as the DNS block:
|
||||
*
|
||||
* proxy mode - dns-in hijack, sniff, the bypass_mainland_china
|
||||
* resolve + geoip-cn split, dedicated UDP route,
|
||||
* main_override route-options, main-out final,
|
||||
* plus the static inline + remote rule_sets
|
||||
* (direct-domain / proxy-domain / geoip-cn /
|
||||
* geosite-cn).
|
||||
* custom mode - the user-defined routing_node -> outbound graph,
|
||||
* routing_rule iteration with the 1.14 action-specific
|
||||
* fields, find_neighbor, the optional resolve prefix,
|
||||
* final_outbound resolution, final_override
|
||||
* route-options, and the rule_set user-defined loop
|
||||
* (the loop itself is in ruleset.uc).
|
||||
*
|
||||
* The orchestrator owns config.route.rule_set: route.uc may push static
|
||||
* entries (proxy mode), ruleset.uc may push user entries (custom mode),
|
||||
* and build_http_clients() runs at the end to normalise the legacy
|
||||
* download_detour into 1.14's http_clients / http_client pairing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port } from '../homeproxy.uc';
|
||||
|
||||
|
||||
import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
|
||||
|
||||
/* --- shared initial block (every routing mode) ------------------------- */
|
||||
|
||||
/* Initialise config.route with the always-on rules and the defaults
|
||||
* that every mode shares (dns-in hijack, sniff, optional auto_detect).
|
||||
* The caller must have created an empty config.route; we mutate it. */
|
||||
function initRoute(config, ctx) {
|
||||
config.route = {
|
||||
rules: [
|
||||
{
|
||||
inbound: 'dns-in',
|
||||
action: 'hijack-dns'
|
||||
},
|
||||
{
|
||||
action: 'sniff',
|
||||
timeout: '300ms'
|
||||
}
|
||||
],
|
||||
rule_set: [],
|
||||
auto_detect_interface: isEmpty(ctx.default_interface) ? true : null,
|
||||
default_interface: ctx.default_interface
|
||||
};
|
||||
}
|
||||
|
||||
/* --- proxy mode route rules + rule_set --------------------------------- */
|
||||
|
||||
function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
/* Resolve outbound server domains through the WAN default resolver.
|
||||
Do not use china-dns here: china-dns is for resolving mainland China
|
||||
destinations, not the proxy node itself. Coupling node bootstrap to
|
||||
china_dns_server can break dialing when that resolver is polluted or
|
||||
unsuitable for the node domain. */
|
||||
config.route.default_domain_resolver = {
|
||||
server: 'default-dns',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
};
|
||||
|
||||
/* Direct list */
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.route.rules, {
|
||||
rule_set: 'direct-domain',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
|
||||
/* Bypass CN traffic: resolve the destination first, then route by IP.
|
||||
sing-box does not match an IP-based rule set (geoip-cn) against a domain
|
||||
destination unless it is resolved first, so add an explicit resolve
|
||||
action; geoip-cn then sends China IPs to direct and everything else falls
|
||||
through to main-out (proxy). This avoids relying on the geosite-* domain
|
||||
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
|
||||
beacons) as "cn" and send them direct to time out. Keep the direct-domain
|
||||
fast-path above for known direct domains. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
});
|
||||
push(config.route.rules, {
|
||||
rule_set: 'geoip-cn',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
}
|
||||
|
||||
/* Main UDP out */
|
||||
if (ctx.dedicated_udp_node) {
|
||||
const udp_override = direct_overrides[ctx.main_udp_node] || null;
|
||||
push(config.route.rules, {
|
||||
network: 'udp',
|
||||
action: 'route',
|
||||
outbound: 'main-udp-out',
|
||||
override_address: udp_override ? udp_override.override_address : null,
|
||||
override_port: udp_override ? udp_override.override_port : null
|
||||
});
|
||||
}
|
||||
|
||||
/* Direct-node destination override, emitted as route-options action
|
||||
(direct outbound options removed since sing-box 1.13) */
|
||||
const main_override = direct_overrides[ctx.main_node] || null;
|
||||
if (main_override)
|
||||
push(config.route.rules, {
|
||||
action: 'route-options',
|
||||
override_address: main_override.override_address,
|
||||
override_port: main_override.override_port
|
||||
});
|
||||
|
||||
config.route.final = 'main-out';
|
||||
|
||||
/* --- proxy-mode rule_set block ----------------------------------- */
|
||||
|
||||
/* Direct list */
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.route.rule_set, {
|
||||
type: 'inline',
|
||||
tag: 'direct-domain',
|
||||
rules: [
|
||||
{
|
||||
domain_keyword: ctx.direct_domain_list,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* Proxy list */
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.route.rule_set, {
|
||||
type: 'inline',
|
||||
tag: 'proxy-domain',
|
||||
rules: [
|
||||
{
|
||||
domain_keyword: ctx.proxy_domain_list,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/*
|
||||
* Fetched straight from the upstream SagerNet repositories and
|
||||
* downloaded through the selected node. A direct fetch depends on
|
||||
* the CDN staying reachable from mainland China, where DNS pollution
|
||||
* makes it fail intermittently; the files total ~250 KB per
|
||||
* day, so proxying the download costs almost nothing.
|
||||
*
|
||||
* Only geosite-cn and geoip-cn are emitted: they are the two the
|
||||
* DNS and route blocks actually reference. A third geosite-noncn
|
||||
* used to be declared here as well, but no rule ever referenced it -
|
||||
* sing-box creates (and keeps updating, and holds in memory) every
|
||||
* declared rule-set regardless of use, so it was a daily ~100 KB
|
||||
* download through the proxy for nothing.
|
||||
*/
|
||||
push(config.route.rule_set, {
|
||||
type: 'remote',
|
||||
tag: 'geoip-cn',
|
||||
format: 'binary',
|
||||
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
|
||||
update_interval: '24h',
|
||||
download_detour: 'main-out'
|
||||
});
|
||||
push(config.route.rule_set, {
|
||||
type: 'remote',
|
||||
tag: 'geosite-cn',
|
||||
format: 'binary',
|
||||
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
|
||||
update_interval: '24h',
|
||||
download_detour: 'main-out'
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
config.route.rule_set = null;
|
||||
}
|
||||
|
||||
/* --- custom-mode route rules ------------------------------------------- */
|
||||
|
||||
function build_route_custom(config, dm, ctx, direct_overrides) {
|
||||
config.route.default_domain_resolver = {
|
||||
server: get_resolver(ctx.default_outbound_dns, dm)
|
||||
};
|
||||
|
||||
if (ctx.find_neighbor === '1')
|
||||
config.route.find_neighbor = true;
|
||||
|
||||
if (ctx.domain_strategy)
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: ctx.domain_strategy
|
||||
});
|
||||
|
||||
for (let cfg in dm.routing.rules) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
const rule_outbound = get_outbound(cfg.outbound, dm);
|
||||
const rule_direct_override = get_direct_override(cfg.outbound, dm, direct_overrides);
|
||||
let rule_override_address = cfg.override_address,
|
||||
rule_override_port = strToInt(cfg.override_port);
|
||||
if (isEmpty(rule_override_address) && isEmpty(rule_override_port) && rule_direct_override) {
|
||||
rule_override_address = rule_direct_override.override_address;
|
||||
rule_override_port = rule_direct_override.override_port;
|
||||
}
|
||||
|
||||
/* sing-box routing rule: emit match fields plus action-specific fields.
|
||||
client is the sniffed client type (only set with protocol quic/ssh);
|
||||
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
|
||||
reject adds method/no_drop. Emitted conditionally so a given action
|
||||
never carries fields sing-box rejects for it. */
|
||||
const rule = {
|
||||
ip_version: strToInt(cfg.ip_version),
|
||||
protocol: cfg.protocol,
|
||||
network: cfg.network,
|
||||
client: cfg.client,
|
||||
domain: cfg.domain,
|
||||
domain_suffix: cfg.domain_suffix,
|
||||
domain_keyword: cfg.domain_keyword,
|
||||
domain_regex: cfg.domain_regex,
|
||||
source_ip_cidr: cfg.source_ip_cidr,
|
||||
source_ip_is_private: strToBool(cfg.source_ip_is_private),
|
||||
ip_cidr: cfg.ip_cidr,
|
||||
ip_is_private: strToBool(cfg.ip_is_private),
|
||||
source_port: parse_port(cfg.source_port),
|
||||
source_port_range: cfg.source_port_range,
|
||||
port: parse_port(cfg.port),
|
||||
port_range: cfg.port_range,
|
||||
process_name: cfg.process_name,
|
||||
process_path: cfg.process_path,
|
||||
process_path_regex: cfg.process_path_regex,
|
||||
user: cfg.user,
|
||||
rule_set: get_ruleset(cfg.rule_set, dm),
|
||||
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
||||
invert: strToBool(cfg.invert),
|
||||
action: cfg.action,
|
||||
source_mac_address: cfg.source_mac_address,
|
||||
source_hostname: cfg.source_hostname
|
||||
};
|
||||
|
||||
/* Route-option fields belong to the actions that dial out (`route` and
|
||||
* `route-options`). They used to be assigned unconditionally, which
|
||||
* stayed invisible as long as the UI kept them hidden per action - but
|
||||
* changing a rule's action leaves the previous values in UCI, and
|
||||
* sing-box then refuses the whole configuration with "unknown field" on
|
||||
* the first one the new action does not take. */
|
||||
if (cfg.action !== 'resolve' && cfg.action !== 'reject') {
|
||||
rule.outbound = rule_outbound;
|
||||
rule.override_address = rule_override_address;
|
||||
rule.override_port = rule_override_port;
|
||||
rule.udp_disable_domain_unmapping = strToBool(cfg.udp_disable_domain_unmapping);
|
||||
rule.udp_connect = strToBool(cfg.udp_connect);
|
||||
rule.udp_timeout = strToTime(cfg.udp_timeout);
|
||||
rule.tls_fragment = strToBool(cfg.tls_fragment);
|
||||
rule.tls_fragment_fallback_delay = strToTime(cfg.tls_fragment_fallback_delay);
|
||||
rule.tls_record_fragment = strToBool(cfg.tls_record_fragment);
|
||||
rule.tls_spoof = cfg.tls_spoof || null;
|
||||
rule.tls_spoof_method = cfg.tls_spoof_method || null;
|
||||
}
|
||||
|
||||
if (cfg.action === 'resolve') {
|
||||
rule.server = get_resolver(cfg.resolve_server, dm);
|
||||
rule.strategy = cfg.resolve_strategy;
|
||||
rule.disable_cache = strToBool(cfg.resolve_disable_cache);
|
||||
rule.disable_optimistic_cache = strToBool(cfg.resolve_disable_optimistic_cache);
|
||||
rule.rewrite_ttl = strToInt(cfg.resolve_rewrite_ttl);
|
||||
rule.timeout = strToTime(cfg.resolve_timeout);
|
||||
rule.client_subnet = cfg.resolve_client_subnet;
|
||||
}
|
||||
if (cfg.action === 'reject') {
|
||||
rule.method = cfg.reject_method;
|
||||
rule.no_drop = strToBool(cfg.reject_no_drop);
|
||||
}
|
||||
push(config.route.rules, rule);
|
||||
}
|
||||
|
||||
/* Direct-node destination override, emitted as route-options action
|
||||
(direct outbound options removed since sing-box 1.13) */
|
||||
const final_override = get_direct_override(ctx.default_outbound, dm, direct_overrides);
|
||||
if (final_override)
|
||||
push(config.route.rules, {
|
||||
action: 'route-options',
|
||||
override_address: final_override.override_address,
|
||||
override_port: final_override.override_port
|
||||
});
|
||||
|
||||
config.route.final = get_outbound(ctx.default_outbound, dm);
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
export function build_route(config, dm, ctx, direct_overrides) {
|
||||
initRoute(config, ctx);
|
||||
|
||||
if (!isEmpty(ctx.main_node))
|
||||
build_route_proxy(config, dm, ctx, direct_overrides);
|
||||
else if (!isEmpty(ctx.default_outbound))
|
||||
build_route_custom(config, dm, ctx, direct_overrides);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/ruleset.uc: user-defined rule_set entries + the sing-box
|
||||
* 1.14 http_clients normalisation.
|
||||
*
|
||||
* Two distinct pieces share this module because they both iterate over
|
||||
* `config.route.rule_set` after every other module has had a chance to
|
||||
* append:
|
||||
*
|
||||
* build_user_rulesets(rule_set_array, dm, ctx)
|
||||
* The custom-mode `dm.routing.rulesets` loop, which builds the
|
||||
* user-defined rule_set entries (cfg-<name>-rule tag) and pre-1.14
|
||||
* download_detour. The legacy field is stripped later, in the same
|
||||
* module, by build_http_clients(); leaving it here would make
|
||||
* sing-box 1.14 reject the config.
|
||||
*
|
||||
* build_http_clients(rule_set_array, dm, ctx)
|
||||
* sing-box 1.14 dropped download_detour from local/inline rule-sets
|
||||
* and moved the remote rule-set fetch into top-level http_clients.
|
||||
* This walks every entry, strips download_detour (sing-box 1.14
|
||||
* rejects it on local/inline), and emits a http_clients array whose
|
||||
* `detour` is dropped for pure-TUN setups (direct outbound with no
|
||||
* self_mark; sing-box rejects that detour).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, validateHomeProxyPath } from '../homeproxy.uc';
|
||||
|
||||
import { get_outbound, isDirectOutboundTag } from './common.uc';
|
||||
|
||||
/* --- user-defined rule_set entries (custom mode only) ----------------- */
|
||||
|
||||
/* Append user-defined rule_set entries to `rule_set_array`. Each cfg
|
||||
* produces one entry; `extra_tags` adds additional rule_set tags that
|
||||
* share the same fetch source (sing-box 1.14 multi-tag). */
|
||||
export function build_user_rulesets(rule_set_array, dm, ctx) {
|
||||
for (let cfg in dm.routing.rulesets) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
const extra_tags = cfg.extra_tags || [];
|
||||
let rs_tag = 'cfg-' + cfg.name + '-rule';
|
||||
if (length(extra_tags) && cfg.type !== 'inline') {
|
||||
rs_tag = [rs_tag];
|
||||
for (let t in extra_tags)
|
||||
push(rs_tag, 'cfg-' + t + '-rule');
|
||||
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
|
||||
(remote: url and initial_path, local: path) */
|
||||
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
|
||||
if (!match(fetch_ref, /\{tag\}/))
|
||||
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder.", cfg.name, cfg.type));
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
|
||||
warn(sprintf("homeproxy: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
|
||||
}
|
||||
|
||||
const ruleset = {
|
||||
type: cfg.type,
|
||||
tag: rs_tag,
|
||||
format: cfg.format,
|
||||
/* Local rule-set path is read by sing-box as root. The
|
||||
* whitelist is enforced here because the LuCI form's
|
||||
* datatype='file' is only UX - UCI can be set from anywhere
|
||||
* on the LAN, and an arbitrary /etc/passwd would leak the
|
||||
* file to anyone who could write UCI. Drop the field rather
|
||||
* than die(): a missing path on a local ruleset becomes
|
||||
* sing-box invalid (and known-good stays), same effect as a
|
||||
* hard fail. */
|
||||
path: (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
|
||||
? null
|
||||
: cfg.path,
|
||||
url: cfg.url,
|
||||
update_interval: cfg.update_interval
|
||||
};
|
||||
/* download_detour is a pre-1.14 option that only makes sense for
|
||||
remote rule-sets; emitting it for local/inline ones makes sing-box
|
||||
* 1.14 reject the whole config. It is translated into http_clients
|
||||
right below. */
|
||||
if (cfg.type === 'remote')
|
||||
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
|
||||
/* initial_path is read off the local disk as root; same
|
||||
* whitelist as the local ruleset path. */
|
||||
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
|
||||
push(rule_set_array, ruleset);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- http_clients normalisation (every routing mode) ------------------ */
|
||||
|
||||
/* Strip the legacy download_detour from every entry (sing-box 1.14
|
||||
* rejects it on local/inline), and for remote rule-sets translate it
|
||||
* into the http_client tag pointing at the corresponding http_clients
|
||||
* entry. Returns the http_clients array (caller attaches it to
|
||||
* config.http_clients) and mutates the rule_set entries in place. */
|
||||
export function build_http_clients(rule_set_array, dm, ctx) {
|
||||
const http_clients = [];
|
||||
const http_seen = {};
|
||||
for (let rs in (rule_set_array || [])) {
|
||||
/* Strip the legacy field from every entry first, including the
|
||||
local/inline ones: sing-box 1.14 rejects it everywhere except on
|
||||
remote rule-sets, where it is replaced by http_client below. */
|
||||
let detour = rs.download_detour;
|
||||
delete rs.download_detour;
|
||||
|
||||
if (rs.type !== 'remote')
|
||||
continue;
|
||||
|
||||
if (isEmpty(detour))
|
||||
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
|
||||
|
||||
const tag = 'hp-' + detour;
|
||||
rs.http_client = tag;
|
||||
if (!http_seen[detour]) {
|
||||
http_seen[detour] = true;
|
||||
/* sing-box 1.14 rejects detouring to an empty direct outbound
|
||||
(pure TUN mode has no self_mark on direct-out). Omitting detour
|
||||
uses the same system direct dialer, so behavior is unchanged. */
|
||||
const client = { tag: tag };
|
||||
if (!(isEmpty(ctx.self_mark) && isDirectOutboundTag(detour, dm)))
|
||||
client.detour = detour;
|
||||
push(http_clients, client);
|
||||
}
|
||||
}
|
||||
return http_clients;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/server.uc: server-side orchestrator.
|
||||
*
|
||||
* This module no longer shapes
|
||||
* any protocol. It used to read the flat UCI section directly
|
||||
* (`cfg.snell_version`, `cfg.shadowsocks_encrypt_method`,
|
||||
* `cfg.hysteria_obfs_min_packet_size`, ...) and spell out every
|
||||
* per-protocol sing-box field inline, which meant the server path had
|
||||
* no domain model and adding a protocol touched the generator.
|
||||
*
|
||||
* Now:
|
||||
*
|
||||
* UCI -> Loader.load_inbound() -> Inbound (domain) -> InboundFactory
|
||||
* -> sing-box inbound JSON
|
||||
*
|
||||
* The protocol knowledge lives in config/model.uc (the canonical <->
|
||||
* UCI tables) and config/adapter.uc (InboundFactory's sing-box field
|
||||
* tables). What is left here is the orchestration: gate on `enabled`,
|
||||
* build the log block, and decide that "no inbound" means "server
|
||||
* disabled" (the CLI shell turns the null into exit 1).
|
||||
*
|
||||
* generate_server(dm) -> config object. Pure: no UCI, no file I/O.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { InboundFactory } from '../config/adapter.uc';
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* Build the sing-box server config object. Returns null when no
|
||||
* enabled inbound exists (the caller treats that as "service disabled",
|
||||
* which the runtime interprets as "do not start the server instance"). */
|
||||
export function generate_server(dm) {
|
||||
const log_level = ((dm.server || {}).settings || {}).log_level || 'warn';
|
||||
|
||||
const config = {
|
||||
log: {
|
||||
disabled: false,
|
||||
level: log_level,
|
||||
output: '/var/run/homeproxy/sing-box-s.log',
|
||||
timestamp: true
|
||||
},
|
||||
inbounds: []
|
||||
};
|
||||
|
||||
for (let inbound in ((dm.server || {}).inbounds || [])) {
|
||||
if (!inbound.enabled)
|
||||
continue;
|
||||
|
||||
/* create() dies on a broken section, which is the previous
|
||||
* behaviour too: the old code would emit a sing-box config
|
||||
* that `sing-box check` then rejected, and the runtime's
|
||||
* candidate/known-good path caught it. Failing here instead
|
||||
* names the offending section in the log. */
|
||||
push(config.inbounds, InboundFactory.create(inbound));
|
||||
}
|
||||
|
||||
if (length(config.inbounds) === 0)
|
||||
return null;
|
||||
|
||||
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,794 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
|
||||
import { urldecode_params } from 'luci.http';
|
||||
|
||||
/* Global variables start */
|
||||
export const HP_DIR = '/etc/homeproxy';
|
||||
export const RUN_DIR = '/var/run/homeproxy';
|
||||
/* Where the UCI configuration lives. This is /etc/config - NOT HP_DIR/config.
|
||||
* The package ships its config as the conffile /etc/config/homeproxy, and
|
||||
* ucode's `cursor(dir)` treats `dir` as the *confdir*, so
|
||||
* cursor(HP_DIR + '/config') would read /etc/homeproxy/config/homeproxy, a
|
||||
* path that exists nowhere. uci.load() then returns null, every uci.get()
|
||||
* returns null, and the Loader silently yields pure defaults: no main-out,
|
||||
* no route/direct final, none of the user's ports or nodes.
|
||||
* Verified on a device: cursor('/etc/homeproxy/config').get(...) is empty
|
||||
* while cursor().get(...) returns the configured value.
|
||||
* The test suite stages a rewritten copy of this constant instead of the
|
||||
* old `__LOADER_DIR__` source sed. */
|
||||
export const UCICONFIG_DIR = '/etc/config';
|
||||
/* Largest subscription body we will fetch, in bytes. See wGETVerbose(). */
|
||||
export const HP_FETCH_CAP = 5 * 1024 * 1024;
|
||||
/* Global variables end */
|
||||
|
||||
/* Utilities start */
|
||||
/* Kanged from luci-app-commands */
|
||||
export function shellQuote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
};
|
||||
|
||||
export function isBinary(str) {
|
||||
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
|
||||
if (byte <= 8 || (byte >= 14 && byte <= 31))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Whitelist absolute paths the generators are allowed to put into
|
||||
* sing-box config. sing-box runs as root and reads the file itself; an
|
||||
* arbitrary UCI value of e.g. /etc/passwd would leak the file to anyone
|
||||
* who could write to the UCI tree. The LuCI UI already gates the input
|
||||
* via validateCertificatePath() in homeproxy.js, but UCI can be set from
|
||||
* any node on the LAN (subscription updates, scripted edits) and the
|
||||
* UI check is only UX - we have to enforce on the backend.
|
||||
*
|
||||
* /etc/homeproxy/... - project-managed certs / ruleset paths / etc.
|
||||
* /tmp/homeproxy_* - upload staging files (cert_upload_* writes)
|
||||
*
|
||||
* Anything else (relative paths, /etc/passwd, /var/run/...) returns false
|
||||
* and the caller is expected to die() / warn() / strip the value. */
|
||||
export function validateHomeProxyPath(p) {
|
||||
if (!p || type(p) !== 'string')
|
||||
return false;
|
||||
|
||||
/* Reject traversal *before* the prefix checks below: a plain prefix
|
||||
* comparison accepts '/etc/homeproxy/../../etc/shadow', and sing-box reads
|
||||
* these paths as root. */
|
||||
if (match(p, /(^|\/)\.\.(\/|$)/))
|
||||
return false;
|
||||
|
||||
/* Reject anything that does not start with '/' - relative paths in
|
||||
* sing-box resolve against the process CWD, which is /tmp at boot
|
||||
* but is not a position we want any UCI value to land in. */
|
||||
if (substr(p, 0, 1) !== '/')
|
||||
return false;
|
||||
|
||||
if (substr(p, 0, length('/etc/homeproxy/')) === '/etc/homeproxy/')
|
||||
return true;
|
||||
|
||||
if (substr(p, 0, length('/tmp/homeproxy_')) === '/tmp/homeproxy_')
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Certificates the sing-box instances are allowed to read.
|
||||
*
|
||||
* TLS certificate_path / key_path are not rule-set paths: a server's
|
||||
* certificate normally lives under /etc/ssl/ or under the ACME state in
|
||||
* /etc/acme/, and the two sing-box jails mount exactly those directories
|
||||
* (see runtime/service.sh's procd_add_jail_mount calls). validateHomeProxyPath()
|
||||
* only knows /etc/homeproxy/ and /tmp/homeproxy_, so a certificate the user
|
||||
* legitimately picked from /etc/ssl/ was accepted by the LuCI validator and
|
||||
* then silently dropped here - certificate_path became null and the TLS
|
||||
* listener failed with no error pointing at the path.
|
||||
*
|
||||
* The list is mirrored by HP_CERT_PATH_ROOTS in
|
||||
* htdocs/luci-static/resources/homeproxy.js; guard 29 in tests/arch-guard.sh
|
||||
* keeps the two in step.
|
||||
*/
|
||||
export const CERT_PATH_ROOTS = ['/etc/homeproxy/certs/', '/etc/acme/', '/etc/ssl/'];
|
||||
|
||||
/* validateCertificatePath(p) - the TLS certificate/key path gate.
|
||||
*
|
||||
* Same shape as validateHomeProxyPath(): reject traversal and relative paths,
|
||||
* then require one of CERT_PATH_ROOTS. Kept separate rather than folded into
|
||||
* validateHomeProxyPath() so a rule-set path cannot be pointed at /etc/ssl/
|
||||
* and a certificate cannot be pointed at /tmp/homeproxy_*. */
|
||||
export function validateCertificatePath(p) {
|
||||
if (!p || type(p) !== 'string')
|
||||
return false;
|
||||
|
||||
if (match(p, /(^|\/)\.\.(\/|$)/))
|
||||
return false;
|
||||
|
||||
if (substr(p, 0, 1) !== '/')
|
||||
return false;
|
||||
|
||||
for (let root in CERT_PATH_ROOTS)
|
||||
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/* Read at most `limit` bytes from a file, or '' when it does not exist.
|
||||
* The cap is deliberate: a command's output is not trustworthy input. */
|
||||
function read_capped(path, limit) {
|
||||
const f = open(path);
|
||||
|
||||
if (!f)
|
||||
return '';
|
||||
|
||||
const data = f.read(limit) ?? '';
|
||||
f.close();
|
||||
return data;
|
||||
};
|
||||
|
||||
/* Remove the scratch files and the directory created for one run. Best
|
||||
* effort: a command the shell could not even parse leaves no files behind,
|
||||
* and a failed cleanup must never mask the command's own result. */
|
||||
function cleanup_exec_dir(dir, outpath, errpath) {
|
||||
try {
|
||||
if (access(outpath))
|
||||
unlink(outpath);
|
||||
if (access(errpath))
|
||||
unlink(errpath);
|
||||
rmdir(dir);
|
||||
} catch (e) {
|
||||
/* nothing useful to do - the results are already captured */
|
||||
}
|
||||
};
|
||||
|
||||
export function executeCommand(...args) {
|
||||
const command = join(' ', args);
|
||||
const dir = mkdtemp();
|
||||
const outpath = dir + '/stdout';
|
||||
const errpath = dir + '/stderr';
|
||||
|
||||
let exitcode = null, stdout = '', stderr = '';
|
||||
|
||||
try {
|
||||
/* Redirect to real paths, not to the descriptors of two mkstemp()
|
||||
* files. The old form appended `>&N 2>&N`, which only works when
|
||||
* the child shell can see those descriptors; /bin/sh reports
|
||||
* "Bad file descriptor" (bash) / "Bad fd number" (dash) when it
|
||||
* cannot, so the command was never executed and every caller got
|
||||
* an empty result with exit status 2. That is what happened in
|
||||
* CI, where /bin/sh is dash. Redirecting to paths is plain POSIX
|
||||
* and behaves identically under busybox ash, dash and bash. */
|
||||
exitcode = system(sprintf('%s >%s 2>%s', command, outpath, errpath));
|
||||
|
||||
/* The reader has to be able to return one byte more than
|
||||
* HP_FETCH_CAP, or wGETVerbose()'s "response exceeds the limit"
|
||||
* branch below is unreachable: the reader used to stop at 512 KiB,
|
||||
* so every subscription between 512 KiB and 5 MiB was handed to the
|
||||
* parser as a truncated body with error: null - the size check
|
||||
* could never fire and the caller had no way to tell. stderr keeps
|
||||
* the smaller cap: it is a diagnostic message, not a payload. The
|
||||
* stderr_truncated flag on the result tells the caller the cap
|
||||
* actually fired, so a "reload exited with status N" line is not
|
||||
* the only signal that something went wrong upstream. */
|
||||
stdout = read_capped(outpath, HP_FETCH_CAP + 1);
|
||||
stderr = read_capped(errpath, 1024 * 512);
|
||||
} catch (e) {
|
||||
/* Never leave the scratch directory behind on a failing run.
|
||||
* ucode has no `finally` clause, so the exception is re-raised
|
||||
* by hand. */
|
||||
cleanup_exec_dir(dir, outpath, errpath);
|
||||
|
||||
die(e);
|
||||
}
|
||||
|
||||
const binary = isBinary(stdout);
|
||||
|
||||
/* Did stderr actually overflow the 512 KiB cap? The temp file is
|
||||
* unlinked right after this, so size is read while it still exists.
|
||||
* lstat() returns null on missing files, so the .size fallback is
|
||||
* defensive against an exception-driven early cleanup. */
|
||||
const stderr_size = (lstat(errpath) || {}).size || 0;
|
||||
const stderr_truncated = stderr_size > 1024 * 512;
|
||||
|
||||
cleanup_exec_dir(dir, outpath, errpath);
|
||||
|
||||
return {
|
||||
command,
|
||||
stdout: binary ? null : stdout,
|
||||
stderr,
|
||||
stderr_truncated,
|
||||
exitcode,
|
||||
binary
|
||||
};
|
||||
};
|
||||
|
||||
export function getTime(epoch) {
|
||||
const local_time = localtime(epoch);
|
||||
return replace(replace(sprintf(
|
||||
'%d-%2d-%2d@%2d:%2d:%2d',
|
||||
local_time.year,
|
||||
local_time.mon,
|
||||
local_time.mday,
|
||||
local_time.hour,
|
||||
local_time.min,
|
||||
local_time.sec
|
||||
), ' ', '0'), '@', ' ');
|
||||
|
||||
};
|
||||
|
||||
/* Redact the credential-bearing parts of a URL before logging it. Any
|
||||
* subscription URL we ship into /var/run/homeproxy/homeproxy.log is
|
||||
* readable by anyone who can read /var/run/homeproxy - including UCI
|
||||
* defaults that ship on the device and anyone with shell on the LAN.
|
||||
* (Confirmed on the device: the log is 0644.)
|
||||
*
|
||||
* Only the scheme, host and port are kept - enough to answer "which
|
||||
* provider/endpoint failed?". The userinfo, the path and the query string
|
||||
* all carry subscription tokens in practice: many providers hand out
|
||||
* `https://host:port/<user>/<token>` with no query string at all, which is
|
||||
* exactly what the device this was found on was configured with, and the
|
||||
* path used to survive the redaction. The original URL is still passed to
|
||||
* wGETVerbose.
|
||||
*
|
||||
* https://user:token@host.example.com/path/sub?q=abc&token=secret
|
||||
* -> https://***@host.example.com/***?*** */
|
||||
export function redactUrl(url) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return '';
|
||||
|
||||
let u = url;
|
||||
|
||||
/* userinfo: scheme://user:pass@host -> scheme://***@host */
|
||||
const at = index(u, '@');
|
||||
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||
if (scheme && at !== -1 && at > length(scheme[0]))
|
||||
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
|
||||
|
||||
/* path and query: keep the authority, mark the rest. Splitting them
|
||||
* rather than masking from the first '/' keeps the structural markers
|
||||
* (`/***?***`) so a reader can still tell a path from a query, and it is
|
||||
* why the authority end is the *earlier* of the two separators. */
|
||||
const scheme_end = scheme ? length(scheme[0]) : 0;
|
||||
const rest = substr(u, scheme_end);
|
||||
const path_at = index(rest, '/');
|
||||
const query_at = index(rest, '?');
|
||||
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
|
||||
|
||||
let authority_end = length(rest);
|
||||
if (has_path)
|
||||
authority_end = path_at;
|
||||
else if (query_at !== -1)
|
||||
authority_end = query_at;
|
||||
|
||||
let tail = '';
|
||||
if (has_path)
|
||||
tail += '/***';
|
||||
if (query_at !== -1)
|
||||
tail += '?***';
|
||||
|
||||
u = substr(u, 0, scheme_end + authority_end) + tail;
|
||||
|
||||
return u;
|
||||
};
|
||||
|
||||
/* Scan a free-form error string and redact every URL in it. GNU wget's
|
||||
* stderr writes the requested URL back into the message:
|
||||
*
|
||||
* https://host/path?q=token=secret: Bad port '80080'.
|
||||
*
|
||||
* The trailing `:` separator is greedy-matched here, which is harmless -
|
||||
* redactUrl leaves it as-is. Doing this here, in wGETVerbose, means the
|
||||
* returned `error` is safe no matter where the caller ships it - the
|
||||
* fetcher logs it, the orchestrator returns it, anything that prints it
|
||||
* afterwards has already lost the token. Centralising the redaction at
|
||||
* the source is the point: every call site used to have to
|
||||
* remember to redact, and the one that forgot was the original bug.
|
||||
*
|
||||
* A non-string input is returned unchanged so this is safe to apply to
|
||||
* the trimmed wget stderr even when it is empty.
|
||||
*
|
||||
* Both definitions sit above wGETVerbose on purpose: ucode does not hoist
|
||||
* `export function`, so a call compiled before the declaration is bound
|
||||
* fails at runtime with "access to undeclared variable". */
|
||||
export function redactReason(reason) {
|
||||
if (!reason || type(reason) !== 'string')
|
||||
return reason;
|
||||
|
||||
return replace(reason, /https?:\/\/\S+/g, (url) => redactUrl(url));
|
||||
};
|
||||
|
||||
/*
|
||||
* Fetch a URL and report both the body and, on failure, the reason. The
|
||||
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
|
||||
* caller can tell a DNS failure from a timeout or a TLS handshake error.
|
||||
*/
|
||||
export function wGETVerbose(url, ua) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return { content: null, error: 'invalid URL' };
|
||||
|
||||
if (!ua)
|
||||
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||||
|
||||
/* -nv (not -q) so wget still reports *why* a fetch failed on stderr.
|
||||
*
|
||||
* The size cap is enforced by piping through `head -c`, NOT with wget's
|
||||
* --max-filesize: that option does not exist in busybox wget (the target's
|
||||
* /usr/bin/wget), which exits 2 with "unrecognized option" before making a
|
||||
* single request - so every subscription fetch failed. GNU wget has no
|
||||
* such option either. `head` closing the pipe stops wget early, which
|
||||
* bounds the download; the cap is therefore CAP+1 bytes rather than
|
||||
* exactly CAP, and one byte past the limit means "too large".
|
||||
*
|
||||
* 5 MiB covers a 10 000-node subscription with ~3 KB per node plus the
|
||||
* base64 inflation. Anything larger is almost certainly an attack or a
|
||||
* misconfiguration.
|
||||
*
|
||||
* The pipeline does cost the exit status: `system()` returns head's, which
|
||||
* is always 0. A wget failure therefore arrives as an empty body plus
|
||||
* wget's own message on stderr, and that is reported below. */
|
||||
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
|
||||
* and in `a | b >out 2>err` those redirections bind to b only - wget's
|
||||
* stderr would go to the caller's terminal and the failure message would
|
||||
* be lost. Grouping the pipeline makes both stream to the capture files. */
|
||||
const output = executeCommand(`{ /usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
|
||||
let reason = trim(output.stderr || '');
|
||||
reason = reason ? replace(reason, /\s+/g, ' ') : '';
|
||||
/* Review H3: an HTTP-level wget failure echoes the full URL - query
|
||||
* string and subscription token included - as in
|
||||
* `https://host/path?token=secret: 404 Not Found`. (A pure connection
|
||||
* failure prints only `failed: Connection refused.` and leaks nothing,
|
||||
* but the 404/403 case is enough.) Redact at the source so every caller
|
||||
* of wGETVerbose gets a safe `error` whether or not it remembers to call
|
||||
* redactUrl itself. */
|
||||
if (reason)
|
||||
reason = redactReason(reason);
|
||||
|
||||
if (length(output.stdout || '') > HP_FETCH_CAP)
|
||||
return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` };
|
||||
|
||||
if (output.exitcode !== 0) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
return { content: null, error: `wget exited with status ${output.exitcode}: ${reason || 'no error output'}` };
|
||||
}
|
||||
|
||||
/* head() masks wget's status, so a failed fetch shows up here instead. */
|
||||
if (!length(trim(output.stdout)) && reason) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
return { content: null, error: `wget failed: ${reason}` };
|
||||
}
|
||||
|
||||
return { content: trim(output.stdout), error: null };
|
||||
};
|
||||
|
||||
/* Utilities end */
|
||||
|
||||
/* String helper start */
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
|
||||
export function strToBool(str) {
|
||||
return str === '1' ? true : (str === '0' ? false : null);
|
||||
};
|
||||
|
||||
export function strToInt(str) {
|
||||
/* Zero reads back as "not set" on purpose: several sing-box fields mean
|
||||
* "omit me" at zero - a vmess user's alterId, for instance - and the
|
||||
* generators depend on removeBlankAttrs() dropping the null this produces.
|
||||
* A review flagged it as "a legitimate 0 becomes null"; the golden outbound
|
||||
* snapshot and the vmess alterId assertion both say otherwise. */
|
||||
return !isEmpty(str) ? (int(str) || null) : null;
|
||||
};
|
||||
|
||||
export function strToTime(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
/* Preserve values that already carry a time unit (e.g. "30s", "1m") */
|
||||
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
|
||||
};
|
||||
|
||||
/* Turn a UCI list of port strings into the int array sing-box wants
|
||||
* (e.g. the WireGuard `reserved` list, a routing rule's `port` /
|
||||
* `source_port`). Returns null for anything that is not a non-empty
|
||||
* array, so a caller can let removeBlankAttrs() drop the field.
|
||||
*
|
||||
* Lives here rather than in generator/common.uc because the Adapter
|
||||
* layer needs it too (EndpointFactory builds the WireGuard endpoint)
|
||||
* and an adapter must not import from generator/. */
|
||||
export function parse_port(strport) {
|
||||
if (type(strport) !== 'array' || isEmpty(strport))
|
||||
return null;
|
||||
|
||||
let ports = [];
|
||||
for (let i in strport) {
|
||||
/* Only a bare decimal is a port: int() truncates, so '80-90' used to
|
||||
* become 80 and '443/tcp' become 443, silently widening the rule
|
||||
* instead of dropping the malformed entry. */
|
||||
if (match(i, /^[0-9]+$/) && int(i) >= 1 && int(i) <= 65535)
|
||||
push(ports, int(i));
|
||||
}
|
||||
|
||||
return ports;
|
||||
};
|
||||
|
||||
export function removeBlankAttrs(res) {
|
||||
let content;
|
||||
|
||||
if (type(res) === 'object') {
|
||||
content = {};
|
||||
map(keys(res), (k) => {
|
||||
if (type(res[k]) in ['array', 'object'])
|
||||
content[k] = removeBlankAttrs(res[k]);
|
||||
else if (res[k] !== null && res[k] !== '')
|
||||
content[k] = res[k];
|
||||
});
|
||||
} else if (type(res) === 'array') {
|
||||
content = [];
|
||||
map(res, (k, i) => {
|
||||
if (type(k) in ['array', 'object'])
|
||||
push(content, removeBlankAttrs(k));
|
||||
else if (k !== null && k !== '')
|
||||
push(content, k);
|
||||
});
|
||||
} else
|
||||
return res;
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export function validation(datatype, data) {
|
||||
if (!datatype || !data)
|
||||
return null;
|
||||
|
||||
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
|
||||
return (ret === 0);
|
||||
};
|
||||
|
||||
/* Validate IP/CIDR format to prevent nftables template injection from resource files */
|
||||
export function isValidCIDR(addr, family) {
|
||||
if (isEmpty(addr))
|
||||
return false;
|
||||
|
||||
/* Strip leading/trailing whitespace */
|
||||
addr = trim(addr);
|
||||
if (!addr)
|
||||
return false;
|
||||
|
||||
/* Split address and optional prefix */
|
||||
const parts = split(addr, '/');
|
||||
const ip = parts[0];
|
||||
const prefix = parts[1];
|
||||
|
||||
/* Validate IP part */
|
||||
if (family === 4) {
|
||||
if (!match(ip, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/))
|
||||
return false;
|
||||
/* Validate octet ranges */
|
||||
const octets = split(ip, '.');
|
||||
for (let o in octets)
|
||||
if (int(o) > 255)
|
||||
return false;
|
||||
/* Validate prefix if present */
|
||||
if (prefix && (int(prefix) < 0 || int(prefix) > 32))
|
||||
return false;
|
||||
} else if (family === 6) {
|
||||
/* Only hex digits and colons */
|
||||
if (!match(ip, /^[0-9a-fA-F:]+$/))
|
||||
return false;
|
||||
|
||||
/* At most one "::" */
|
||||
if (match(ip, /::.*::/))
|
||||
return false;
|
||||
|
||||
/* Validate group structure */
|
||||
const dcolon = index(ip, '::');
|
||||
if (dcolon === -1) {
|
||||
/* Uncompressed form: exactly 8 groups of 1-4 hex digits */
|
||||
const groups = split(ip, ':');
|
||||
if (length(groups) !== 8)
|
||||
return false;
|
||||
for (let g in groups)
|
||||
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
|
||||
return false;
|
||||
} else {
|
||||
/* Compressed form: "::" expands to at least one zero group */
|
||||
let count = 0;
|
||||
for (let part in [substr(ip, 0, dcolon), substr(ip, dcolon + 2)]) {
|
||||
if (part === '')
|
||||
continue;
|
||||
for (let g in split(part, ':')) {
|
||||
if (!match(g, /^[0-9a-fA-F]{1,4}$/))
|
||||
return false;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 7)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prefix && (int(prefix) < 0 || int(prefix) > 128))
|
||||
return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
/* String helper end */
|
||||
|
||||
/* String parser start */
|
||||
export function decodeBase64Str(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
str = trim(str);
|
||||
str = replace(str, /_/g, '/');
|
||||
str = replace(str, /-/g, '+');
|
||||
|
||||
const padding = length(str) % 4;
|
||||
if (padding)
|
||||
str = str + substr('====', padding);
|
||||
|
||||
return b64dec(str);
|
||||
};
|
||||
|
||||
export function parseURL(url) {
|
||||
if (type(url) !== 'string')
|
||||
return null;
|
||||
|
||||
const services = {
|
||||
http: '80',
|
||||
https: '443'
|
||||
};
|
||||
|
||||
const objurl = {};
|
||||
|
||||
objurl.href = url;
|
||||
|
||||
url = replace(url, /#(.+)$/, (_, val) => {
|
||||
objurl.hash = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
|
||||
objurl.protocol = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /\?(.+)/, (_, val) => {
|
||||
objurl.search = val;
|
||||
objurl.searchParams = urldecode_params(val);
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
|
||||
val = replace(val, /^([^@]+)@/, (_, val) => {
|
||||
objurl.userinfo = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
val = replace(val, /:(\d+)$/, (_, val) => {
|
||||
objurl.port = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (validation('ip4addr', val) ||
|
||||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
|
||||
validation('hostname', val))
|
||||
objurl.hostname = val;
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
objurl.pathname = url || '/';
|
||||
|
||||
if (!objurl.protocol || !objurl.hostname)
|
||||
return null;
|
||||
|
||||
if (objurl.userinfo) {
|
||||
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
|
||||
objurl.password = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
|
||||
objurl.username = objurl.userinfo;
|
||||
delete objurl.userinfo;
|
||||
} else {
|
||||
delete objurl.userinfo;
|
||||
delete objurl.password;
|
||||
}
|
||||
};
|
||||
|
||||
if (!objurl.port)
|
||||
objurl.port = services[objurl.protocol];
|
||||
|
||||
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
|
||||
objurl.origin = `${objurl.protocol}://${objurl.host}`;
|
||||
|
||||
return objurl;
|
||||
};
|
||||
/* String parser end */
|
||||
|
||||
/* Config generator helper start */
|
||||
/*
|
||||
* Shared sing-box TLS object builder for the client outbound and the server
|
||||
* inbound. The property order below is significant: the generated JSON keeps
|
||||
* the insertion order of this object after removeBlankAttrs() drops the blank
|
||||
* attributes, and both generators used to emit the fields in exactly this
|
||||
* order. Keep client-only fields (insecure/handshake_timeout/utls) and
|
||||
* server-only fields (key_path/certificate_provider) at their current
|
||||
* positions when editing.
|
||||
*
|
||||
* P1-B: the first arg is now a Node.tls-shape structured sub-object
|
||||
* (the same shape the Loader produces) rather than a flat UCI section.
|
||||
* The Adapter passes node.tls; the server generator builds a
|
||||
* structured view of its UCI inbound before calling. Server-only
|
||||
* fields (key material, ACME, server-side reality handshake) still
|
||||
* live on the flat UCI section - they are not part of the Node model
|
||||
* because no client outbound needs them - so the server passes them
|
||||
* through `server_extras` instead of expecting the structured tls
|
||||
* to carry them.
|
||||
*/
|
||||
export function buildTLSObject(tls, is_server, server_extras) {
|
||||
if ((tls && tls.enabled) !== '1')
|
||||
return null;
|
||||
|
||||
/* When is_server the caller hands a flat UCI section's server-only
|
||||
* tail. We accept either an object or null; null means "no server
|
||||
* extras" which only matters when the caller still wants a TLS
|
||||
* object built for an inbound that does not actually serve TLS
|
||||
* itself (the enabled check above returns null first in that case). */
|
||||
const extras = is_server ? (server_extras || {}) : {};
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
server_name: tls.server_name,
|
||||
insecure: is_server ? null : strToBool(tls.insecure),
|
||||
alpn: tls.alpn,
|
||||
min_version: tls.min_version,
|
||||
max_version: tls.max_version,
|
||||
handshake_timeout: is_server ? null : strToTime(tls.handshake_timeout),
|
||||
cipher_suites: tls.cipher_suites,
|
||||
certificate_path: tls.cert_path && validateCertificatePath(tls.cert_path) ? tls.cert_path : null,
|
||||
key_path: (is_server && extras.tls_key_path && validateCertificatePath(extras.tls_key_path)) ? extras.tls_key_path : null,
|
||||
certificate_provider: (is_server && extras.tls_acme === '1') ? {
|
||||
type: 'acme',
|
||||
domain: (type(extras.tls_acme_domain) === 'array') ? extras.tls_acme_domain
|
||||
: (isEmpty(extras.tls_acme_domain) ? [] : [extras.tls_acme_domain]),
|
||||
data_directory: HP_DIR + '/certs',
|
||||
default_server_name: extras.tls_acme_dsn,
|
||||
email: extras.tls_acme_email,
|
||||
provider: extras.tls_acme_provider,
|
||||
account_key: extras.tls_acme_account_key,
|
||||
key_type: extras.tls_acme_key_type,
|
||||
profile: extras.tls_acme_profile,
|
||||
disable_http_challenge: strToBool(extras.tls_acme_dhc),
|
||||
disable_tls_alpn_challenge: strToBool(extras.tls_acme_dtac),
|
||||
alternative_http_port: strToInt(extras.tls_acme_ahp),
|
||||
alternative_tls_port: strToInt(extras.tls_acme_atp),
|
||||
external_account: (extras.tls_acme_external_account === '1') ? {
|
||||
key_id: extras.tls_acme_ea_keyid,
|
||||
mac_key: extras.tls_acme_ea_mackey
|
||||
} : null,
|
||||
dns01_challenge: (extras.tls_dns01_challenge === '1') ? {
|
||||
provider: extras.tls_dns01_provider,
|
||||
access_key_id: extras.tls_dns01_ali_akid,
|
||||
access_key_secret: extras.tls_dns01_ali_aksec,
|
||||
region_id: extras.tls_dns01_ali_rid,
|
||||
api_token: extras.tls_dns01_cf_api_token
|
||||
} : null
|
||||
} : null,
|
||||
ech: is_server ? (extras.tls_ech_key ? {
|
||||
enabled: true,
|
||||
key: split(extras.tls_ech_key, '\n')
|
||||
/* config: split(extras.tls_ech_config, '\n') */
|
||||
} : null) : ((tls.ech && tls.ech.enabled === '1') ? {
|
||||
enabled: true,
|
||||
config: tls.ech.config,
|
||||
/* Same gate as certificate_path: this is a file sing-box opens as
|
||||
* root, and it used to reach the config unvalidated. */
|
||||
config_path: tls.ech.config_path && validateCertificatePath(tls.ech.config_path) ? tls.ech.config_path : null
|
||||
} : null),
|
||||
utls: (is_server || isEmpty(tls.utls && tls.utls.fingerprint)) ? null : {
|
||||
enabled: true,
|
||||
fingerprint: tls.utls.fingerprint
|
||||
},
|
||||
reality: ((tls.reality && tls.reality.enabled) !== '1') ? null : (is_server ? {
|
||||
enabled: true,
|
||||
private_key: extras.tls_reality_private_key,
|
||||
short_id: tls.reality.short_id,
|
||||
max_time_difference: strToTime(extras.tls_reality_max_time_difference),
|
||||
handshake: {
|
||||
server: extras.tls_reality_server_addr,
|
||||
server_port: strToInt(extras.tls_reality_server_port)
|
||||
}
|
||||
} : {
|
||||
enabled: true,
|
||||
public_key: tls.reality.public_key,
|
||||
short_id: tls.reality.short_id
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* Shared sing-box transport object builder; the client transport additionally
|
||||
supports the gRPC keepalive hint, the server one does not.
|
||||
P1-B: the first arg is now a Node.transport-shape structured sub-object. */
|
||||
export function buildTransportObject(transport, is_server) {
|
||||
if (isEmpty(transport && transport.type))
|
||||
return null;
|
||||
|
||||
return {
|
||||
type: transport.type,
|
||||
host: transport.host,
|
||||
path: transport.path,
|
||||
headers: transport.headers,
|
||||
method: transport.method,
|
||||
max_early_data: strToInt(transport.max_early_data),
|
||||
early_data_header_name: transport.early_data_header_name,
|
||||
service_name: transport.service_name,
|
||||
idle_timeout: strToTime(transport.idle_timeout),
|
||||
ping_timeout: strToTime(transport.ping_timeout),
|
||||
permit_without_stream: is_server ? null : strToBool(transport.permit_without_stream)
|
||||
};
|
||||
};
|
||||
/* Config generator helper end */
|
||||
|
||||
/* PEM validation start */
|
||||
/*
|
||||
* Check that `content` is a PEM certificate (is_private_key = false) or an
|
||||
* RSA/EC private key (is_private_key = true): matching BEGIN/END boundaries
|
||||
* with a base64 body in between. Kanged from luci-proto-openconnect; used by
|
||||
* the rpcd certificate upload and available for future certificate features.
|
||||
*/
|
||||
/* Shared by the certificate, private-key and ECH-config validators: only the
|
||||
* markers differ, the body rule does not (base64 lines - or a single 64
|
||||
* character line - between a BEGIN and an END marker). */
|
||||
function validatePEM(content, beg, end) {
|
||||
if (isEmpty(content))
|
||||
return false;
|
||||
|
||||
const lines = split(trim(content), /[\r\n]/);
|
||||
let start = false, i;
|
||||
|
||||
for (i = 0; i < length(lines); i++) {
|
||||
if (match(lines[i], beg))
|
||||
start = true;
|
||||
else if (start && !b64dec(lines[i]) && length(lines[i]) !== 64)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!start || i < length(lines) - 1 || !match(lines[i], end))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function isValidPEM(content, is_private_key) {
|
||||
const beg = is_private_key ? /^-----BEGIN (RSA|EC) PRIVATE KEY-----$/ : /^-----BEGIN CERTIFICATE-----$/,
|
||||
end = is_private_key ? /^-----END (RSA|EC) PRIVATE KEY-----$/ : /^-----END CERTIFICATE-----$/;
|
||||
|
||||
return validatePEM(content, beg, end);
|
||||
};
|
||||
|
||||
/* A client ECH config list is neither a certificate nor a private key - it is
|
||||
* wrapped in its own markers ("-----BEGIN ECH CONFIGS-----"), so isValidPEM()
|
||||
* rejects it. The node form's "Upload ECH config" button has always sent
|
||||
* certificate_write('client_ech_conf'); the backend had no case for that name,
|
||||
* and would have rejected the body here even with one. */
|
||||
export function isValidECHConfig(content) {
|
||||
return validatePEM(content,
|
||||
/^-----BEGIN ECH CONFIGS-----$/,
|
||||
/^-----END ECH CONFIGS-----$/);
|
||||
};
|
||||
/* PEM validation end */
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, parseURL, shellQuote, validation } from './homeproxy.uc';
|
||||
|
||||
const uci = cursor();
|
||||
|
||||
const uciconfig = 'homeproxy';
|
||||
uci.load(uciconfig);
|
||||
|
||||
const uciinfra = 'infra',
|
||||
ucimigration = 'migration',
|
||||
ucimain = 'config',
|
||||
ucinode = 'node',
|
||||
ucidns = 'dns',
|
||||
ucidnsserver = 'dns_server',
|
||||
ucidnsrule = 'dns_rule',
|
||||
ucirouting = 'routing',
|
||||
uciroutingnode = 'routing_node',
|
||||
uciroutingrule = 'routing_rule',
|
||||
uciserver = 'server';
|
||||
|
||||
/* chinadns-ng has been removed */
|
||||
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
|
||||
uci.delete(uciconfig, uciinfra, 'china_dns_port');
|
||||
|
||||
/* chinadns server now only accepts single server */
|
||||
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
|
||||
if (type(china_dns_server) === 'array') {
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
|
||||
} else {
|
||||
if (china_dns_server === 'wan_114')
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
|
||||
else if (match(china_dns_server, /,/))
|
||||
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
|
||||
}
|
||||
|
||||
/* github_token option has been moved to config section */
|
||||
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
|
||||
if (github_token) {
|
||||
uci.set(uciconfig, ucimain, 'github_token', github_token);
|
||||
uci.delete(uciconfig, uciinfra, 'github_token')
|
||||
}
|
||||
|
||||
/* ntp_server was introduced */
|
||||
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
|
||||
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
|
||||
|
||||
/* tun_gso was deprecated in sb 1.11 */
|
||||
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
|
||||
uci.delete(uciconfig, uciinfra, 'tun_gso');
|
||||
|
||||
/* create migration section */
|
||||
if (!uci.get(uciconfig, ucimigration))
|
||||
uci.set(uciconfig, ucimigration, uciconfig);
|
||||
|
||||
/* delete old crontab command.
|
||||
*
|
||||
* Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host
|
||||
* with BSD sed it fails with "invalid command code". The old form sent stderr
|
||||
* to /dev/null as well, so the failure was invisible and the stale entry stayed
|
||||
* behind - which is exactly what the shell twin (hp_crontab_drop in
|
||||
* runtime/service.sh) documents and avoids. Editing through a temporary file
|
||||
* behaves the same on busybox, GNU and BSD sed, and lets the failure be seen. */
|
||||
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
|
||||
if (!migration_crontab) {
|
||||
const crontab = '/etc/crontabs/root';
|
||||
const tmp = crontab + '.hp-new';
|
||||
|
||||
if (system(sprintf('sed "/update_crond.sh/d" %s > %s', shellQuote(crontab), shellQuote(tmp))) === 0
|
||||
&& system(sprintf('mv -f %s %s', shellQuote(tmp), shellQuote(crontab))) === 0) {
|
||||
uci.set(uciconfig, ucimigration, 'crontab', '1');
|
||||
}
|
||||
else {
|
||||
/* Leave a trace instead of a silent no-op; the marker is not set, so
|
||||
* the next run tries again. */
|
||||
system(sprintf('rm -f %s', shellQuote(tmp)));
|
||||
warn('migrate_config: could not drop the legacy update_crond.sh crontab entry\n');
|
||||
}
|
||||
}
|
||||
|
||||
/* log_level was introduced */
|
||||
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
|
||||
uci.set(uciconfig, ucimain, 'log_level', 'warn');
|
||||
|
||||
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
|
||||
uci.set(uciconfig, uciserver, 'log_level', 'warn');
|
||||
|
||||
/* empty value defaults to all ports now */
|
||||
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
|
||||
uci.delete(uciconfig, ucimain, 'routing_port');
|
||||
|
||||
/* experimental section was removed */
|
||||
if (uci.get(uciconfig, 'experimental'))
|
||||
uci.delete(uciconfig, 'experimental');
|
||||
|
||||
/* block-dns was removed from built-in dns servers */
|
||||
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
|
||||
if (default_dns_server === 'block-dns') {
|
||||
/* append a rule at last to block all DNS queries */
|
||||
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'mode', 'default');
|
||||
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
|
||||
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
|
||||
}
|
||||
else if (default_dns_server === 'local-dns')
|
||||
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
|
||||
|
||||
const dns_server_migration = {};
|
||||
/* DNS servers options */
|
||||
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
|
||||
/* legacy format was deprecated in sb 1.12 */
|
||||
if (cfg.address) {
|
||||
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
|
||||
/* parseURL() gives null for an address it cannot parse; the old code
|
||||
* dereferenced it immediately and aborted the migration. */
|
||||
if (!addr)
|
||||
return;
|
||||
/* RCode was moved into DNS rules */
|
||||
if (addr.protocol === 'rcode') {
|
||||
dns_server_migration[cfg['.name']] = { action: 'predefined' };
|
||||
switch (addr.hostname) {
|
||||
case 'success':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NOERROR';
|
||||
break;
|
||||
case 'format_error':
|
||||
dns_server_migration[cfg['.name']].rcode = 'FORMERR';
|
||||
break;
|
||||
case 'server_failure':
|
||||
dns_server_migration[cfg['.name']].rcode = 'SERVFAIL';
|
||||
break;
|
||||
case 'name_error':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NXDOMAIN';
|
||||
break;
|
||||
case 'not_implemented':
|
||||
dns_server_migration[cfg['.name']].rcode = 'NOTIMP';
|
||||
break;
|
||||
case 'refused':
|
||||
default:
|
||||
dns_server_migration[cfg['.name']].rcode = 'REFUSED';
|
||||
break;
|
||||
}
|
||||
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
return;
|
||||
}
|
||||
uci.set(uciconfig, cfg['.name'], 'type', addr.protocol);
|
||||
uci.set(uciconfig, cfg['.name'], 'server', addr.hostname);
|
||||
uci.set(uciconfig, cfg['.name'], 'server_port', addr.port);
|
||||
uci.set(uciconfig, cfg['.name'], 'path', (addr.pathname !== '/') ? addr.pathname : null);
|
||||
uci.delete(uciconfig, cfg['.name'], 'address');
|
||||
}
|
||||
|
||||
if (cfg.strategy) {
|
||||
if (cfg['.name'] === default_dns_server)
|
||||
uci.set(uciconfig, ucidns, 'default_strategy', cfg.strategy);
|
||||
dns_server_migration[cfg['.name']] = { strategy: cfg.strategy };
|
||||
uci.delete(uciconfig, cfg['.name'], 'strategy');
|
||||
}
|
||||
|
||||
if (cfg.client_subnet) {
|
||||
if (cfg['.name'] === default_dns_server)
|
||||
uci.set(uciconfig, ucidns, 'client_subnet', cfg.client_subnet);
|
||||
|
||||
if (isEmpty(dns_server_migration[cfg['.name']]))
|
||||
dns_server_migration[cfg['.name']] = {};
|
||||
dns_server_migration[cfg['.name']].client_subnet = cfg.client_subnet;
|
||||
uci.delete(uciconfig, cfg['.name'], 'client_subnet');
|
||||
}
|
||||
});
|
||||
|
||||
/* DNS rules options */
|
||||
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
|
||||
/* outbound was removed in sb 1.12 */
|
||||
if (cfg.outbound) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
if (!cfg.enabled)
|
||||
return;
|
||||
|
||||
/* uci.foreach hands a single-valued option over as a string, and map()
|
||||
* returns null for anything that is not an array - so a rule with one
|
||||
* outbound was deleted on the line above and then never migrated. */
|
||||
const outbounds = (type(cfg.outbound) === 'array') ? cfg.outbound : [ cfg.outbound ];
|
||||
map(outbounds, (outbound) => {
|
||||
switch (outbound) {
|
||||
case 'direct-out':
|
||||
case 'block-out':
|
||||
break;
|
||||
case 'any-out':
|
||||
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
|
||||
break;
|
||||
default:
|
||||
uci.set(uciconfig, outbound, 'domain_resolver', cfg.server);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
|
||||
if (cfg.rule_set_ipcidr_match_source === '1')
|
||||
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
|
||||
|
||||
/* block-dns was moved into action in sb 1.11 */
|
||||
if (cfg.server === 'block-dns') {
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
|
||||
uci.delete(uciconfig, cfg['.name'], 'server');
|
||||
} else if (!cfg.action) {
|
||||
/* add missing 'action' field */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'route');
|
||||
}
|
||||
|
||||
/* strategy and client_subnet were moved into dns rules */
|
||||
if (dns_server_migration[cfg.server]) {
|
||||
if (dns_server_migration[cfg.server].strategy)
|
||||
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
|
||||
|
||||
if (dns_server_migration[cfg.server].client_subnet)
|
||||
uci.set(uciconfig, cfg['.name'], 'client_subnet', dns_server_migration[cfg.server].client_subnet);
|
||||
|
||||
if (dns_server_migration[cfg.server].rcode) {
|
||||
/* The key the generator and the form both read is
|
||||
* `predefined_rcode`; writing `rcode` left the migrated rule with
|
||||
* no rcode at all, so a NXDOMAIN / REFUSED predefined answer
|
||||
* silently became the default. */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'predefined');
|
||||
uci.set(uciconfig, cfg['.name'], 'predefined_rcode', dns_server_migration[cfg.server].rcode);
|
||||
uci.delete(uciconfig, cfg['.name'], 'server');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* nodes options */
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
|
||||
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
|
||||
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
|
||||
|
||||
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
|
||||
if (!isEmpty(cfg.tls_ech_enable_pqss))
|
||||
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
|
||||
|
||||
/* wireguard_gso was deprecated in sb 1.11 */
|
||||
if (!isEmpty(cfg.wireguard_gso))
|
||||
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
|
||||
|
||||
/* hysteria_revc_window was a typo, renamed to hysteria_recv_window */
|
||||
if (!isEmpty(cfg.hysteria_revc_window))
|
||||
uci.rename(uciconfig, cfg['.name'], 'hysteria_revc_window', 'hysteria_recv_window');
|
||||
});
|
||||
|
||||
/* sing-box 1.14: remove deprecated DNS semantics */
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'independent_cache')))
|
||||
uci.delete(uciconfig, ucidns, 'independent_cache');
|
||||
|
||||
const legacy_store_rdrc = uci.get(uciconfig, ucidns, 'cache_file_store_rdrc');
|
||||
if (!isEmpty(legacy_store_rdrc)) {
|
||||
uci.set(uciconfig, ucidns, 'cache_file_store_dns', legacy_store_rdrc);
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_store_rdrc');
|
||||
}
|
||||
if (!isEmpty(uci.get(uciconfig, ucidns, 'cache_file_rdrc_timeout')))
|
||||
uci.delete(uciconfig, ucidns, 'cache_file_rdrc_timeout');
|
||||
|
||||
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
|
||||
if (!isEmpty(cfg.domain_strategy))
|
||||
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
|
||||
if (!isEmpty(cfg.rule_set_ip_cidr_accept_empty))
|
||||
uci.delete(uciconfig, cfg['.name'], 'rule_set_ip_cidr_accept_empty');
|
||||
});
|
||||
|
||||
/* routing rules options */
|
||||
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
|
||||
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
|
||||
if (cfg.rule_set_ipcidr_match_source === '1')
|
||||
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
|
||||
|
||||
/* block-out was moved into action in sb 1.11 */
|
||||
if (cfg.outbound === 'block-out') {
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
|
||||
uci.delete(uciconfig, cfg['.name'], 'outbound');
|
||||
} else if (!cfg.action) {
|
||||
/* add missing 'action' field */
|
||||
uci.set(uciconfig, cfg['.name'], 'action', 'route');
|
||||
}
|
||||
});
|
||||
|
||||
/* server options */
|
||||
/* auto_firewall was moved into server options */
|
||||
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
|
||||
if (!isEmpty(auto_firewall))
|
||||
uci.delete(uciconfig, uciserver, 'auto_firewall');
|
||||
|
||||
uci.foreach(uciconfig, uciserver, (cfg) => {
|
||||
/* auto_firewall was moved into server options */
|
||||
if (auto_firewall === '1')
|
||||
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
|
||||
|
||||
/* sniff_override was deprecated in sb 1.11 */
|
||||
if (!isEmpty(cfg.sniff_override))
|
||||
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
|
||||
|
||||
/* domain_strategy is now pointless without sniff override */
|
||||
if (!isEmpty(cfg.domain_strategy))
|
||||
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
|
||||
});
|
||||
|
||||
if (!isEmpty(uci.changes(uciconfig)))
|
||||
uci.commit(uciconfig);
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* The inverse of
|
||||
* parser/normalize.uc. Where normalize() turns the parser's flat
|
||||
* UCI-key output into the canonical Node shape the Adapter reads,
|
||||
* flatten() turns a canonical Node back into the flat UCI-key dict
|
||||
* UCI needs.
|
||||
*
|
||||
* The Repository's apply_nodes() is the only production caller: it is
|
||||
* the one boundary where a canonical Node becomes UCI keys, which is
|
||||
* what lets the orchestrator, the filter and the policy step work on
|
||||
* canonical Nodes alone.
|
||||
*
|
||||
* Same single-source-of-truth discipline: every canonical -> UCI
|
||||
* mapping comes from parser/mapping.uc's PROTOCOL_TO_UCI. Adding a
|
||||
* protocol option is a one-place change in mapping.uc, and both
|
||||
* directions pick it up.
|
||||
*
|
||||
* The output is the same shape the parser emits plus whatever
|
||||
* metadata fields the orchestrator (or Repository) added
|
||||
* (label, grouphash, isExisting). flatten() does not invent or
|
||||
* strip them: it only does the canonical-sub-object -> flat-key
|
||||
* expansion, then layers identity on top, then copies metadata
|
||||
* through unchanged.
|
||||
*
|
||||
* `null` / `undefined` canonical values are written as null too -
|
||||
* UCI serialises null as an empty option, which the Loader reads
|
||||
* back as null, so the round-trip is preserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { PROTOCOL_TO_UCI } from './mapping.uc';
|
||||
|
||||
/* Per-protocol option fields: canonical -> UCI keys. Mirrors the
|
||||
* loader's load_protocol_options() / normalize.uc's
|
||||
* build_protocol_options(). */
|
||||
function flatten_protocol_options(protocol, node) {
|
||||
const map = PROTOCOL_TO_UCI[protocol] || {};
|
||||
const opts = node.protocol_options || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[uci] = opts[canonical];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* TLS fields: shared across protocols, sourced from the canonical
|
||||
* `tls` sub-object. Mirrors normalize.uc's build_tls(). */
|
||||
function flatten_tls(node) {
|
||||
const tls = node.tls || {};
|
||||
const ech = tls.ech || {};
|
||||
const utls = tls.utls || {};
|
||||
const reality = tls.reality || {};
|
||||
|
||||
return {
|
||||
tls: tls.enabled,
|
||||
tls_sni: tls.server_name,
|
||||
tls_insecure: tls.insecure,
|
||||
tls_alpn: tls.alpn,
|
||||
tls_min_version: tls.min_version,
|
||||
tls_max_version: tls.max_version,
|
||||
tls_handshake_timeout: tls.handshake_timeout,
|
||||
tls_cipher_suites: tls.cipher_suites,
|
||||
tls_cert_path: tls.cert_path,
|
||||
tls_ech: ech.enabled,
|
||||
tls_ech_config: ech.config,
|
||||
tls_ech_config_path: ech.config_path,
|
||||
tls_utls: utls.fingerprint,
|
||||
tls_reality: reality.enabled,
|
||||
tls_reality_public_key: reality.public_key,
|
||||
tls_reality_short_id: reality.short_id
|
||||
};
|
||||
}
|
||||
|
||||
/* Transport: share-link parsers spread transport fields across
|
||||
* multiple UCI keys (http_host, ws_host, ws_path, httpupgrade_host,
|
||||
* http_path, http_method, websocket_early_data(_header),
|
||||
* grpc_servicename, http_idle_timeout, http_ping_timeout,
|
||||
* grpc_permit_without_stream). Mirrors normalize.uc's
|
||||
* build_transport(). */
|
||||
function flatten_transport(node) {
|
||||
const t = (node.transport || {}).type;
|
||||
const out = { transport: t || null };
|
||||
|
||||
if (!t)
|
||||
return out;
|
||||
|
||||
/* The host / path UCI option names depend on the transport: a ws
|
||||
* transport stores them as ws_path / ws_host, an httpupgrade as
|
||||
* http_path / httpupgrade_host, plain http as http_host / http_path.
|
||||
* normalize() collapses both path spellings into transport.path, so
|
||||
* flatten() has to pick the right one back or a re-written node
|
||||
* would sprout an http_path the parser never produced (and drop the
|
||||
* ws_path the loader's `http_path || ws_path` fallback had read). */
|
||||
if (t === 'ws') {
|
||||
out.ws_host = node.transport.headers?.Host;
|
||||
out.ws_path = node.transport.path;
|
||||
}
|
||||
|
||||
if (t === 'httpupgrade') {
|
||||
out.httpupgrade_host = node.transport.host;
|
||||
out.http_path = node.transport.path;
|
||||
}
|
||||
|
||||
if (t === 'http') {
|
||||
out.http_host = node.transport.host;
|
||||
out.http_path = node.transport.path;
|
||||
out.http_method = node.transport.method;
|
||||
}
|
||||
|
||||
if (t === 'grpc')
|
||||
out.grpc_servicename = node.transport.service_name;
|
||||
|
||||
out.http_idle_timeout = node.transport.idle_timeout;
|
||||
out.http_ping_timeout = node.transport.ping_timeout;
|
||||
out.grpc_permit_without_stream = node.transport.permit_without_stream;
|
||||
out.websocket_early_data_header = node.transport.early_data_header_name;
|
||||
out.websocket_early_data = node.transport.max_early_data;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Common: every outbound protocol carries these. */
|
||||
function flatten_common(node) {
|
||||
const c = node.common || {};
|
||||
return {
|
||||
proxy_protocol: c.proxy_protocol,
|
||||
tcp_fast_open: c.tcp_fast_open,
|
||||
tcp_multi_path: c.tcp_multi_path,
|
||||
udp_fragment: c.udp_fragment
|
||||
};
|
||||
}
|
||||
|
||||
/* Multiplex: shares a brutal nested sub-shape with normalize.uc. */
|
||||
function flatten_multiplex(node) {
|
||||
const m = node.multiplex || {};
|
||||
const brutal = m.brutal || {};
|
||||
|
||||
return {
|
||||
multiplex: m.enabled,
|
||||
multiplex_protocol: m.protocol,
|
||||
multiplex_max_connections: m.max_connections,
|
||||
multiplex_min_streams: m.min_streams,
|
||||
multiplex_max_streams: m.max_streams,
|
||||
multiplex_padding: m.padding,
|
||||
multiplex_brutal: brutal.enabled,
|
||||
multiplex_brutal_up: brutal.up_mbps,
|
||||
multiplex_brutal_down: brutal.down_mbps
|
||||
};
|
||||
}
|
||||
|
||||
/* Credentials: same CREDENTIALS table as normalize.uc, traversed
|
||||
* in reverse: canonical credential name -> UCI option. */
|
||||
const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
function flatten_credentials(protocol, node) {
|
||||
const map = CREDENTIALS[protocol] || {};
|
||||
const creds = node.credentials || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[uci] = creds[canonical];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The full pipeline, returning a flat UCI-key dict. Metadata that
|
||||
* the orchestrator / Repository attaches to the canonical Node
|
||||
* (label, grouphash) is not a canonical sub-object, so we copy
|
||||
* those fields through explicitly rather than walk `keys(node)`
|
||||
* and pull whatever happens to be there.
|
||||
*
|
||||
* `isExisting` is deliberately NOT copied: it is a within-run
|
||||
* marker Repository sets after a successful write, used to skip
|
||||
* re-adding in the same loop. It is never written to / read from
|
||||
* UCI. Identity (id / type / address / port) and `name` (which
|
||||
* becomes the UCI `label` option) are the Adapter's identity
|
||||
* half; everything else is the metadata tail. */
|
||||
function copy_meta(out, node) {
|
||||
if ('label' in node)
|
||||
out.label = node.label;
|
||||
if ('grouphash' in node)
|
||||
out.grouphash = node.grouphash;
|
||||
}
|
||||
|
||||
/* Top-level: take a canonical Node, return a flat UCI-key dict
|
||||
* suitable for uci.set() per key. Returns null when the input is
|
||||
* null / empty, mirroring normalize()'s `null -> null` contract
|
||||
* so callers can compose `flatten(normalize(parse_uri(uri)))`. */
|
||||
export function flatten(node) {
|
||||
if (!node)
|
||||
return null;
|
||||
|
||||
const protocol = node.type;
|
||||
const out = {
|
||||
type: protocol,
|
||||
address: node.address,
|
||||
port: node.port,
|
||||
label: node.name
|
||||
};
|
||||
|
||||
/* Identity sub-fields: drop empties so a freshly-saved
|
||||
* canonical Node (which always carries the full shape, even
|
||||
* when `null`) does not suddenly litter /etc/config with
|
||||
* `multiplex_max_connections ''` entries. The Loader treats
|
||||
* null and absent the same, so this is observably equivalent. */
|
||||
const sub = {
|
||||
...flatten_common(node),
|
||||
...flatten_credentials(protocol, node),
|
||||
...flatten_tls(node),
|
||||
...flatten_transport(node),
|
||||
...flatten_multiplex(node),
|
||||
...flatten_protocol_options(protocol, node)
|
||||
};
|
||||
|
||||
/* ucode has no `undefined`: an absent key reads as null, so a
|
||||
* single null check covers both. */
|
||||
for (let k, v in sub)
|
||||
if (v !== null)
|
||||
out[k] = v;
|
||||
|
||||
copy_meta(out, node);
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Single source of truth for the canonical-name <-> UCI-option-name
|
||||
* mapping per protocol. The same mapping used to live in three places:
|
||||
*
|
||||
* - parse_uri.uc (wrote UCI-flat keys directly: `tls`, `tls_sni`,
|
||||
* `vless_flow`, `shadowsocks_encrypt_method`, ...)
|
||||
* - config/loader.uc PROTOCOL_OPTIONS (canonical -> UCI)
|
||||
* - config/adapter.uc OPTION_FIELDS (canonical -> adapter code)
|
||||
*
|
||||
* The Loader's PROTOCOL_OPTIONS is derived from the table in this file,
|
||||
* and both directions of the pipeline read it: normalize.uc renames UCI
|
||||
* keys to canonical names, flatten.uc renames them back on the way to
|
||||
* UCI. Adding a protocol option is one edit here, not one per layer.
|
||||
*
|
||||
* `keys()` direction: every row's left side is the canonical name the
|
||||
* Loader hands the Adapter; the right side is the UCI option name the
|
||||
* Loader reads and the Repository writes. Keep them sorted by
|
||||
* canonical name so a diff that adds a row is obvious.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/* `canonical -> uci` for per-protocol options. Anything not listed here
|
||||
* is not part of the domain contract for that protocol (the form might
|
||||
* still surface it, the Adapter ignores it). */
|
||||
export const PROTOCOL_TO_UCI = {
|
||||
vless: {
|
||||
flow: 'vless_flow',
|
||||
packet_encoding: 'packet_encoding'
|
||||
/* udp_over_tcp used to be here. It is a shadowsocks/socks
|
||||
* option: sing-box 1.14 rejects it on a vless outbound, so
|
||||
* mapping it here turned a stale UCI option into an
|
||||
* unusable config. */
|
||||
},
|
||||
snell: {
|
||||
version: 'snell_version',
|
||||
reuse: 'snell_reuse',
|
||||
obfs_mode: 'snell_obfs_mode',
|
||||
obfs_host: 'snell_obfs_host'
|
||||
/* snell_mode is not mapped: sing-box 1.14 has no `mode`
|
||||
* field on a snell outbound (or inbound). It was v6-only
|
||||
* in the form, and v6 is not supported by the target
|
||||
* sing-box. */
|
||||
},
|
||||
shadowsocks: {
|
||||
plugin: 'shadowsocks_plugin',
|
||||
plugin_opts: 'shadowsocks_plugin_opts',
|
||||
udp_over_tcp: 'udp_over_tcp',
|
||||
udp_over_tcp_version: 'udp_over_tcp_version'
|
||||
},
|
||||
anytls: {
|
||||
idle_session_check_interval: 'anytls_idle_session_check_interval',
|
||||
idle_session_timeout: 'anytls_idle_session_timeout',
|
||||
min_idle_session: 'anytls_min_idle_session'
|
||||
},
|
||||
http: {},
|
||||
socks: {
|
||||
version: 'socks_version',
|
||||
udp_over_tcp: 'udp_over_tcp',
|
||||
udp_over_tcp_version: 'udp_over_tcp_version'
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: 'tuic_congestion_control',
|
||||
udp_relay_mode: 'tuic_udp_relay_mode',
|
||||
udp_over_stream: 'tuic_udp_over_stream',
|
||||
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||
heartbeat: 'tuic_heartbeat'
|
||||
},
|
||||
trojan: {},
|
||||
shadowtls: {
|
||||
version: 'shadowtls_version'
|
||||
},
|
||||
hysteria: {
|
||||
protocol: 'hysteria_protocol',
|
||||
auth_type: 'hysteria_auth_type',
|
||||
auth_payload: 'hysteria_auth_payload',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
hopping_port: 'hysteria_hopping_port',
|
||||
hop_interval: 'hysteria_hop_interval'
|
||||
},
|
||||
hysteria2: {
|
||||
obfs_type: 'hysteria_obfs_type',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
hop_interval: 'hysteria_hop_interval',
|
||||
hop_interval_max: 'hysteria_hop_interval_max',
|
||||
hopping_port: 'hysteria_hopping_port',
|
||||
auth_payload: 'hysteria_auth_payload',
|
||||
bbr_profile: 'hysteria_bbr_profile',
|
||||
disable_chrome_parrot: 'hysteria_disable_chrome_parrot'
|
||||
},
|
||||
vmess: {
|
||||
alter_id: 'vmess_alterid',
|
||||
security: 'vmess_encrypt',
|
||||
global_padding: 'vmess_global_padding',
|
||||
auth_payload: 'vmess_auth_payload',
|
||||
/* packet_encoding was missing here, so a vmess node's UCI option
|
||||
* never reached protocol_options and the outbound had none - even
|
||||
* though the node form offers the option for vmess and the
|
||||
* subscription default is applied to it. vless declares it too. */
|
||||
packet_encoding: 'packet_encoding'
|
||||
},
|
||||
ssh: {
|
||||
client_version: 'ssh_client_version',
|
||||
host_key: 'ssh_host_key',
|
||||
host_key_algorithms: 'ssh_host_key_algo'
|
||||
},
|
||||
wireguard: {
|
||||
local_address: 'wireguard_local_address',
|
||||
private_key: 'wireguard_private_key',
|
||||
peer_public_key: 'wireguard_peer_public_key',
|
||||
pre_shared_key: 'wireguard_pre_shared_key',
|
||||
reserved: 'wireguard_reserved',
|
||||
mtu: 'wireguard_mtu',
|
||||
persistent_keepalive_interval: 'wireguard_persistent_keepalive_interval'
|
||||
},
|
||||
direct: {
|
||||
override_address: 'override_address',
|
||||
override_port: 'override_port'
|
||||
}
|
||||
};
|
||||
|
||||
/* Reverse direction: uci -> canonical. Built once at module load so
|
||||
* the parser can look up "what canonical name is `tls_sni` for
|
||||
* <protocol>" without hand-coding the inverse in protocols.uc.
|
||||
*
|
||||
* A protocol with no per-protocol options maps to an empty object,
|
||||
* which is intentional: `for ... in {}` is a no-op and the loader
|
||||
* still has the protocol-shape boundary on the Node. */
|
||||
export const UCI_TO_PROTOCOL = {};
|
||||
for (let proto, row in PROTOCOL_TO_UCI) {
|
||||
UCI_TO_PROTOCOL[proto] = {};
|
||||
for (let canonical, uci in row)
|
||||
UCI_TO_PROTOCOL[proto][uci] = canonical;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* The per-scheme parsers in
|
||||
* parser/protocols.uc produce a FLAT UCI-key dict; that is their output
|
||||
* shape. The Adapter, the Loader, the policy step and the Repository's
|
||||
* write path all think in the canonical Node shape instead: nested tls /
|
||||
* transport / multiplex / credentials / protocol_options sub-objects.
|
||||
*
|
||||
* normalize() is the crossing point: every caller that needs the
|
||||
* canonical Node - the subscription orchestrator, the RPC that imports a
|
||||
* share link, tests - runs the parsed config through it. parser/flatten.uc
|
||||
* is the inverse and is used only where UCI has to be written, which is
|
||||
* the Repository's apply_nodes().
|
||||
*
|
||||
* The mapping is sourced from parser/mapping.uc (canonical -> UCI)
|
||||
* so the Loader, the Adapter and the parser cannot drift apart.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { PROTOCOL_TO_UCI } from './mapping.uc';
|
||||
|
||||
/* Pick every field that PROTOCOL_TO_UCI maps for this protocol off
|
||||
* the flat config and rename it from UCI to its canonical name. The
|
||||
* result is the protocol_options sub-object. Fields the parser wrote
|
||||
* that are not mapped (e.g. a stale `vless_udp_over_tcp` left over
|
||||
* from a shadowsocks -> vless type switch) are dropped here, which is
|
||||
* the same "stale UCI option becomes a config error" failure the
|
||||
* Loader has been guarding against via PROTOCOL_OPTIONS. */
|
||||
function build_protocol_options(protocol, flat) {
|
||||
const map = PROTOCOL_TO_UCI[protocol] || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map) {
|
||||
if (uci in flat) {
|
||||
const value = flat[uci];
|
||||
/* Empty-string or null means "absent": do not propagate
|
||||
* to the canonical side either. */
|
||||
if (value !== '' && value !== null)
|
||||
out[canonical] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* TLS fields are shared across protocols, not per-protocol, so they
|
||||
* live outside PROTOCOL_TO_UCI. The Loader uses the same shape
|
||||
* (load_tls() in config/loader.uc), so we mirror it. */
|
||||
function build_tls(flat) {
|
||||
const tls = {
|
||||
enabled: flat.tls,
|
||||
server_name: flat.tls_sni,
|
||||
insecure: flat.tls_insecure,
|
||||
alpn: flat.tls_alpn,
|
||||
min_version: flat.tls_min_version,
|
||||
max_version: flat.tls_max_version,
|
||||
handshake_timeout: flat.tls_handshake_timeout,
|
||||
cipher_suites: flat.tls_cipher_suites,
|
||||
cert_path: flat.tls_cert_path,
|
||||
ech: {
|
||||
enabled: flat.tls_ech,
|
||||
config: flat.tls_ech_config,
|
||||
config_path: flat.tls_ech_config_path
|
||||
},
|
||||
utls: {
|
||||
fingerprint: flat.tls_utls
|
||||
},
|
||||
reality: {
|
||||
enabled: flat.tls_reality,
|
||||
public_key: flat.tls_reality_public_key,
|
||||
short_id: flat.tls_reality_short_id
|
||||
}
|
||||
};
|
||||
|
||||
return tls;
|
||||
}
|
||||
|
||||
/* Transport fields are also shared; mirror the shape the Loader's
|
||||
* load_transport() produces. Returns `{ type: null }` when the parser
|
||||
* did not write a transport. */
|
||||
function build_transport(flat) {
|
||||
const t = flat.transport;
|
||||
if (t == null || t === '')
|
||||
return { type: null };
|
||||
|
||||
return {
|
||||
type: t,
|
||||
host: flat.http_host || flat.httpupgrade_host,
|
||||
path: flat.http_path || flat.ws_path,
|
||||
headers: flat.ws_host ? { Host: flat.ws_host } : null,
|
||||
method: flat.http_method,
|
||||
max_early_data: flat.websocket_early_data,
|
||||
early_data_header_name: flat.websocket_early_data_header,
|
||||
service_name: flat.grpc_servicename,
|
||||
idle_timeout: flat.http_idle_timeout,
|
||||
ping_timeout: flat.http_ping_timeout,
|
||||
permit_without_stream: flat.grpc_permit_without_stream
|
||||
};
|
||||
}
|
||||
|
||||
/* Common fields that every outbound protocol carries. Mirrors the
|
||||
* Loader's load_common(). */
|
||||
function build_common(flat) {
|
||||
return {
|
||||
proxy_protocol: flat.proxy_protocol,
|
||||
tcp_fast_open: flat.tcp_fast_open,
|
||||
tcp_multi_path: flat.tcp_multi_path,
|
||||
udp_fragment: flat.udp_fragment
|
||||
};
|
||||
}
|
||||
|
||||
/* Multiplex fields - mirrors the Loader's load_multiplex(). */
|
||||
function build_multiplex(flat) {
|
||||
return {
|
||||
enabled: flat.multiplex,
|
||||
protocol: flat.multiplex_protocol,
|
||||
max_connections: flat.multiplex_max_connections,
|
||||
min_streams: flat.multiplex_min_streams,
|
||||
max_streams: flat.multiplex_max_streams,
|
||||
padding: flat.multiplex_padding,
|
||||
brutal: {
|
||||
enabled: flat.multiplex_brutal,
|
||||
up_mbps: flat.multiplex_brutal_up,
|
||||
down_mbps: flat.multiplex_brutal_down
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Credentials: pull every UCI key the protocol's CREDENTIALS row lists
|
||||
* (canonical -> UCI) and put them under the canonical name. Empty /
|
||||
* null values stay null so the Adapter's null-strip pass keeps the
|
||||
* field out of the emitted sing-box JSON. */
|
||||
const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
function build_credentials(protocol, flat) {
|
||||
const map = CREDENTIALS[protocol] || {};
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci in map)
|
||||
out[canonical] = flat[uci];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Top-level: produce a Node-shaped object from the parser's flat
|
||||
* output. Returns null when the input is null / empty so callers can
|
||||
* do `normalize(parse_uri(uri))` and propagate parse failures
|
||||
* unchanged. */
|
||||
export function normalize(flat) {
|
||||
if (!flat)
|
||||
return null;
|
||||
|
||||
const protocol = flat.type;
|
||||
|
||||
return {
|
||||
/* identity */
|
||||
id: null,
|
||||
name: flat.label,
|
||||
type: protocol,
|
||||
|
||||
/* endpoint */
|
||||
address: flat.address,
|
||||
port: flat.port,
|
||||
|
||||
/* sub-objects */
|
||||
common: build_common(flat),
|
||||
credentials: build_credentials(protocol, flat),
|
||||
tls: build_tls(flat),
|
||||
transport: build_transport(flat),
|
||||
multiplex: build_multiplex(flat),
|
||||
protocol_options: build_protocol_options(protocol, flat)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Every protocol's parse_<scheme>_uri()
|
||||
* used to live in the top-level parse_uri.uc. They are now grouped
|
||||
* here so the dispatch (parser/uri.uc), the validation
|
||||
* (parser/validator.uc), and the canonical normalisation
|
||||
* (parser/normalize.uc) can each be edited in isolation.
|
||||
*
|
||||
* Each parser returns a FLAT UCI-key dict - the key names UCI uses,
|
||||
* produced through the mapping table (parser/mapping.uc) so a protocol
|
||||
* option has one definition. That is the parser's output shape, not the
|
||||
* pipeline's: normalize() turns it into a canonical Node, the policy step
|
||||
* and the Repository work on that Node, and the Repository flattens it
|
||||
* back to UCI keys on the way out. Nothing downstream of normalize() sees
|
||||
* these flat dicts.
|
||||
*
|
||||
* `features` is the sing-box feature map (with_quic / with_utls / ...),
|
||||
* `log` a logging callback, both injected so the parsers stay free of
|
||||
* ubus / uci.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { urldecode, urlencode } from 'luci.http';
|
||||
|
||||
import { decodeBase64Str, isEmpty, parseURL } from '../homeproxy.uc';
|
||||
|
||||
/* https://shadowsocks.org/guide/sip008.html */
|
||||
export function parse_sip008_uri(uri) {
|
||||
return {
|
||||
label: uri.remarks,
|
||||
type: 'shadowsocks',
|
||||
address: uri.server,
|
||||
port: uri.server_port,
|
||||
shadowsocks_encrypt_method: uri.method,
|
||||
password: uri.password,
|
||||
shadowsocks_plugin: uri.plugin,
|
||||
shadowsocks_plugin_opts: uri.plugin_opts
|
||||
};
|
||||
};
|
||||
|
||||
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
|
||||
export function parse_anytls_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'anytls',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: urldecode(url.username),
|
||||
tls: '1',
|
||||
tls_sni: params.sni,
|
||||
tls_insecure: (params.insecure === '1') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_http_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'http',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
username: url.username ? urldecode(url.username) : null,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
tls: (uri[0] === 'https') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_hysteria_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'hysteria',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
hysteria_protocol: params.protocol || 'udp',
|
||||
hysteria_auth_type: params.auth ? 'string' : null,
|
||||
hysteria_auth_payload: params.auth,
|
||||
hysteria_obfs_password: params.obfsParam,
|
||||
hysteria_down_mbps: params.downmbps,
|
||||
hysteria_up_mbps: params.upmbps,
|
||||
tls: '1',
|
||||
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
|
||||
tls_sni: params.peer,
|
||||
tls_alpn: params.alpn
|
||||
};
|
||||
};
|
||||
|
||||
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
|
||||
export function parse_hysteria2_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'hysteria2',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: url.username ? (
|
||||
urldecode(url.username + (url.password ? (':' + url.password) : ''))
|
||||
) : null,
|
||||
hysteria_obfs_type: params.obfs,
|
||||
hysteria_obfs_password: params['obfs-password'],
|
||||
tls: '1',
|
||||
tls_insecure: (params.insecure === '1') ? '1' : '0',
|
||||
tls_sni: params.sni
|
||||
};
|
||||
};
|
||||
|
||||
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
|
||||
export function parse_snell_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'snell',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: url.username ? urldecode(url.username)
|
||||
: (params.psk ? urldecode(params.psk) : null),
|
||||
snell_version: params.version || '4',
|
||||
snell_userkey: params.userkey,
|
||||
snell_obfs_mode: (params.obfs === 'http') ? 'http' : null,
|
||||
snell_obfs_host: params['obfs-host'],
|
||||
snell_reuse: (params.reuse === '1') ? '1' : '0'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_socks_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'socks',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
username: url.username ? urldecode(url.username) : null,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
socks_version: (match(uri[0], /4/)) ? '4' : '5'
|
||||
};
|
||||
};
|
||||
|
||||
export function parse_ss_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
/* "Lovely" Shadowrocket format */
|
||||
const ss_suri = split(uri[1], '#');
|
||||
let ss_slabel = '';
|
||||
if (length(ss_suri) <= 2) {
|
||||
if (length(ss_suri) === 2)
|
||||
ss_slabel = '#' + urlencode(ss_suri[1]);
|
||||
if (decodeBase64Str(ss_suri[0]))
|
||||
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
|
||||
}
|
||||
|
||||
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
|
||||
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
|
||||
|
||||
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
|
||||
let ss_userinfo = {};
|
||||
if (url.username && url.password)
|
||||
/* User info encoded with URIComponent */
|
||||
ss_userinfo = [url.username, urldecode(url.password)];
|
||||
else if (url.username) {
|
||||
/* User info encoded with base64. decodeBase64Str() returns null for
|
||||
* anything that is not valid base64 (a '.' in the userinfo is
|
||||
* enough), and ucode's split(null, ...) is null, so indexing it
|
||||
* raised a ReferenceError. parse_uri() does not catch that: the RPC
|
||||
* import path happens to, update_subscriptions.uc does not, so one
|
||||
* malformed link aborted an entire subscription update. A link with
|
||||
* no usable userinfo is simply not a node we can build. */
|
||||
const decoded = decodeBase64Str(urldecode(url.username));
|
||||
|
||||
ss_userinfo = decoded ? split(decoded, ':', 2) : {};
|
||||
}
|
||||
|
||||
let ss_plugin, ss_plugin_opts;
|
||||
if (url.search && url.searchParams.plugin) {
|
||||
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
|
||||
ss_plugin = ss_plugin_info[0];
|
||||
if (ss_plugin === 'simple-obfs')
|
||||
/* Fix non-standard plugin name */
|
||||
ss_plugin = 'obfs-local';
|
||||
ss_plugin_opts = ss_plugin_info[1];
|
||||
}
|
||||
|
||||
if (isEmpty(ss_userinfo[0]))
|
||||
return null;
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'shadowsocks',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
shadowsocks_encrypt_method: ss_userinfo[0],
|
||||
password: ss_userinfo[1],
|
||||
shadowsocks_plugin: ss_plugin,
|
||||
shadowsocks_plugin_opts: ss_plugin_opts
|
||||
};
|
||||
};
|
||||
|
||||
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
|
||||
export function parse_trojan_uri(uri) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
const config = {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'trojan',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
password: urldecode(url.username),
|
||||
transport: (params.type !== 'tcp') ? params.type : null,
|
||||
tls: '1',
|
||||
tls_sni: params.sni
|
||||
};
|
||||
switch (params.type) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = params.serviceName;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = params.host ? urldecode(params.host) : null;
|
||||
config.ws_path = params.path ? urldecode(params.path) : null;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* https://github.com/daeuniverse/dae/discussions/182 */
|
||||
export function parse_tuic_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
if (!features.with_quic) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'tuic',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
uuid: url.username,
|
||||
password: url.password ? urldecode(url.password) : null,
|
||||
tuic_congestion_control: params.congestion_control,
|
||||
tuic_udp_relay_mode: params.udp_relay_mode,
|
||||
tls: '1',
|
||||
tls_sni: params.sni,
|
||||
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
||||
};
|
||||
};
|
||||
|
||||
/* https://github.com/XTLS/Xray-core/discussions/716 */
|
||||
export function parse_vless_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
const url = parseURL('http://' + uri[1]) || {};
|
||||
const params = url.searchParams || {};
|
||||
|
||||
/* Unsupported protocol */
|
||||
if (params.type === 'kcp') {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
return null;
|
||||
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
label: url.hash ? urldecode(url.hash) : null,
|
||||
type: 'vless',
|
||||
address: url.hostname,
|
||||
port: url.port,
|
||||
uuid: url.username,
|
||||
transport: (params.type !== 'tcp') ? params.type : null,
|
||||
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
|
||||
tls_sni: params.sni,
|
||||
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
|
||||
tls_reality: (params.security === 'reality') ? '1' : '0',
|
||||
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
|
||||
tls_reality_short_id: params.sid,
|
||||
tls_utls: features.with_utls ? params.fp : null,
|
||||
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
|
||||
};
|
||||
switch (params.type) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = params.serviceName;
|
||||
break;
|
||||
case 'http':
|
||||
case 'tcp':
|
||||
if (params.type === 'http' || params.headerType === 'http') {
|
||||
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
|
||||
config.http_path = params.path ? urldecode(params.path) : null;
|
||||
}
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
|
||||
config.http_path = params.path ? urldecode(params.path) : null;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = params.host ? urldecode(params.host) : null;
|
||||
config.ws_path = params.path ? urldecode(params.path) : null;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
|
||||
export function parse_vmess_uri(uri, features, log) {
|
||||
uri = split(trim(uri), '://');
|
||||
|
||||
/* "Lovely" shadowrocket format. `uri` is the split() result, so the old
|
||||
* match(uri, /&/) could never fire - match() takes a string. */
|
||||
if (length(uri) > 1 && match(uri[1], /&/)) {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = json(decodeBase64Str(uri[1])) || {};
|
||||
} catch(e) {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.v != '2') {
|
||||
log(sprintf('Skipping unsupported %s format.', uri[0]));
|
||||
return null;
|
||||
/* Unsupported protocol */
|
||||
} else if (payload.net === 'kcp') {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
return null;
|
||||
} else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) {
|
||||
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
if (!features.with_quic)
|
||||
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
|
||||
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
|
||||
* else if (payload.aid && int(payload.aid) !== 0) {
|
||||
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
|
||||
* return null;
|
||||
* }
|
||||
*/
|
||||
|
||||
const config = {
|
||||
label: payload.ps ? urldecode(payload.ps) : null,
|
||||
type: 'vmess',
|
||||
address: payload.add,
|
||||
port: payload.port,
|
||||
uuid: payload.id,
|
||||
vmess_alterid: payload.aid,
|
||||
vmess_encrypt: payload.scy || 'auto',
|
||||
vmess_global_padding: '1',
|
||||
transport: (payload.net !== 'tcp') ? payload.net : null,
|
||||
tls: (payload.tls === 'tls') ? '1' : '0',
|
||||
tls_sni: payload.sni || payload.host,
|
||||
tls_alpn: payload.alpn ? split(payload.alpn, ',') : null,
|
||||
tls_utls: features.with_utls ? payload.fp : null
|
||||
};
|
||||
switch (payload.net) {
|
||||
case 'grpc':
|
||||
config.grpc_servicename = payload.path;
|
||||
break;
|
||||
case 'h2':
|
||||
case 'tcp':
|
||||
if (payload.net === 'h2' || payload.type === 'http') {
|
||||
config.transport = 'http';
|
||||
config.http_host = payload.host ? split(payload.host, ',') : null;
|
||||
config.http_path = payload.path;
|
||||
}
|
||||
break;
|
||||
case 'httpupgrade':
|
||||
config.httpupgrade_host = payload.host;
|
||||
config.http_path = payload.path;
|
||||
break;
|
||||
case 'ws':
|
||||
config.ws_host = payload.host;
|
||||
config.ws_path = payload.path;
|
||||
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
|
||||
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
|
||||
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
|
||||
config.ws_path = split(config.ws_path, '?ed=')[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* parse_uri() is a thin dispatcher.
|
||||
* The 11 per-scheme parsers moved to parser/protocols.uc; the
|
||||
* categorised post-parse checks moved to parser/validator.uc; the
|
||||
* canonical-shape conversion lives in parser/normalize.uc; and the
|
||||
* canonical <-> UCI mapping table lives in parser/mapping.uc.
|
||||
*
|
||||
* This file is the only one parse_uri.uc's old callers should reach
|
||||
* for. The output shape is unchanged (flat UCI-key dict) so the
|
||||
* Repository's contract is preserved; the next PR can flip the
|
||||
* parser to canonical Node output and teach the Repository to
|
||||
* flatten, with parser/mapping.uc as the single source of truth.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, validation } from '../homeproxy.uc';
|
||||
|
||||
import {
|
||||
parse_sip008_uri, parse_anytls_uri, parse_http_uri,
|
||||
parse_hysteria_uri, parse_hysteria2_uri, parse_snell_uri,
|
||||
parse_socks_uri, parse_ss_uri, parse_trojan_uri,
|
||||
parse_tuic_uri, parse_vless_uri, parse_vmess_uri
|
||||
} from './protocols.uc';
|
||||
|
||||
import { validate } from './validator.uc';
|
||||
|
||||
/* Default label: ip6addr uses brackets for readability, otherwise
|
||||
* `address:port`. Exported because every scheme benefits and because a
|
||||
* caller that mutates a parsed config's address (subscription
|
||||
* apply_policy does not, but a future one could) can re-derive it.
|
||||
*
|
||||
* Declared above parse_uri on purpose: ucode does not hoist
|
||||
* `export function`, so the call below would otherwise fail at runtime
|
||||
* with "access to undeclared variable". */
|
||||
export function derive_label(config) {
|
||||
if (config.label)
|
||||
return config.label;
|
||||
if (!config.address || !config.port)
|
||||
return null;
|
||||
return (validation('ip6addr', config.address) ?
|
||||
`[${config.address}]` : config.address) + ':' + config.port;
|
||||
};
|
||||
|
||||
export function parse_uri(uri, features, log) {
|
||||
if (!features) features = {};
|
||||
if (!log) log = function() {};
|
||||
|
||||
let config;
|
||||
|
||||
if (type(uri) === 'object') {
|
||||
if (uri.nodetype === 'sip008')
|
||||
config = parse_sip008_uri(uri);
|
||||
} else if (type(uri) === 'string') {
|
||||
switch (split(trim(uri), '://')[0]) {
|
||||
case 'anytls':
|
||||
config = parse_anytls_uri(uri, features, log);
|
||||
break;
|
||||
case 'http':
|
||||
case 'https':
|
||||
config = parse_http_uri(uri, features, log);
|
||||
break;
|
||||
case 'hysteria':
|
||||
config = parse_hysteria_uri(uri, features, log);
|
||||
break;
|
||||
case 'hysteria2':
|
||||
case 'hy2':
|
||||
config = parse_hysteria2_uri(uri, features, log);
|
||||
break;
|
||||
case 'snell':
|
||||
config = parse_snell_uri(uri, features, log);
|
||||
break;
|
||||
case 'socks':
|
||||
case 'socks4':
|
||||
case 'socks4a':
|
||||
case 'socks5':
|
||||
case 'socks5h':
|
||||
config = parse_socks_uri(uri, features, log);
|
||||
break;
|
||||
case 'ss':
|
||||
config = parse_ss_uri(uri, features, log);
|
||||
break;
|
||||
case 'trojan':
|
||||
config = parse_trojan_uri(uri, features, log);
|
||||
break;
|
||||
case 'tuic':
|
||||
config = parse_tuic_uri(uri, features, log);
|
||||
break;
|
||||
case 'vless':
|
||||
config = parse_vless_uri(uri, features, log);
|
||||
break;
|
||||
case 'vmess':
|
||||
config = parse_vmess_uri(uri, features, log);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(config))
|
||||
return null;
|
||||
|
||||
/* Strip the brackets UCI still allows around IPv6 literals - they
|
||||
* survive into a sing-box `server` field verbatim otherwise. */
|
||||
if (config.address)
|
||||
config.address = replace(config.address, /\[|\]/g, '');
|
||||
|
||||
/* validate() returns null (and logs) when the host or port is not
|
||||
* usable, so the result has to be tested rather than merged with
|
||||
* `|| config` - that fallback would hand the invalid config back. */
|
||||
const checked = validate(config, log);
|
||||
if (!checked)
|
||||
return null;
|
||||
|
||||
if (!checked.label)
|
||||
checked.label = derive_label(checked);
|
||||
|
||||
return checked;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* parse_uri() used to do its host/port
|
||||
* check inline at the end of the dispatcher, just before returning the
|
||||
* config. Per the guidance doc (section 八, Parse -> Normalize ->
|
||||
* Validate three-layer separation), the post-parse checks belong in
|
||||
* their own module so:
|
||||
*
|
||||
* - failures are categorised (host / port / credential / tls /
|
||||
* scheme), so the orchestrator's log line can name the failure
|
||||
* instead of dumping the whole config;
|
||||
* - new rules (REQUIRED_CREDENTIALS, TLS sanity) can land without
|
||||
* touching the dispatcher or the per-scheme parsers;
|
||||
* - tests can exercise the validator without going through any
|
||||
* scheme-specific parser.
|
||||
*
|
||||
* Today only the original two checks (host / port) are implemented,
|
||||
* so the categorisation is mostly plumbing. Adding REQUIRED_CREDENTIALS
|
||||
* in a follow-up is now a one-place change.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { validation } from '../homeproxy.uc';
|
||||
|
||||
/* Each entry carries a stable `kind` (so callers / tests can branch on
|
||||
* it) and a `message` for the log line. Adding a new rule is:
|
||||
*
|
||||
* { kind: 'missing_credential', field: 'uuid',
|
||||
* message: sprintf('%s requires %s', type, field) }
|
||||
*/
|
||||
export function validate(config, log) {
|
||||
let errors = [];
|
||||
|
||||
if (!validation('host', config.address)) {
|
||||
errors = [...errors, {
|
||||
kind: 'invalid_host',
|
||||
message: sprintf('Skipping invalid %s node: %s.',
|
||||
config.type, config.label || 'NULL')
|
||||
}];
|
||||
}
|
||||
|
||||
if (!validation('port', config.port)) {
|
||||
errors = [...errors, {
|
||||
kind: 'invalid_port',
|
||||
message: sprintf('Skipping invalid %s node: %s.',
|
||||
config.type, config.label || 'NULL')
|
||||
}];
|
||||
}
|
||||
|
||||
if (length(errors)) {
|
||||
/* Stay log-compatible with the old code path: one warn line
|
||||
* per failure. The orchestrator already swallows `null`
|
||||
* returns from parse_uri(), so it does not need to learn
|
||||
* about the categorised shape yet. */
|
||||
for (let err in errors)
|
||||
log(err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/* Exposed for tests so they can assert categorisation without going
|
||||
* through the full dispatcher. Returns the (possibly empty) array of
|
||||
* error descriptors that `validate()` would log, but does not log
|
||||
* anything itself. */
|
||||
export function check(config) {
|
||||
let errors = [];
|
||||
|
||||
if (!validation('host', config.address))
|
||||
errors = [...errors, { kind: 'invalid_host', type: config.type, address: config.address }];
|
||||
|
||||
if (!validation('port', config.port))
|
||||
errors = [...errors, { kind: 'invalid_port', type: config.type, port: config.port }];
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Print a file's git blob id: sha1("blob <byte-length>\0" + content).
|
||||
*
|
||||
* That is the identifier GitHub reports for a path in a commit (the `.sha`
|
||||
* field of the contents API), so the resource updater can check that the bytes
|
||||
* it just downloaded are the bytes that commit contains. The download URL is
|
||||
* already pinned to a commit, which is a guarantee about *where* the bytes
|
||||
* come from, not about their content - a mirror, a CDN edge or anything else
|
||||
* between the router and it can answer for that URL with something else.
|
||||
*
|
||||
* It lives in its own file rather than as a few lines inside
|
||||
* update_resources.sh because the shell has no SHA-1: the target's busybox
|
||||
* ships sha256sum but not sha1sum, and the GitHub API only offers SHA-1 blob
|
||||
* ids. ucode-mod-digest is already a package dependency (md5() is used by the
|
||||
* subscription updater), so this adds no new requirement.
|
||||
*
|
||||
* Usage: ucode -S resource_blob_sha.uc <file>
|
||||
* Prints the hex digest and exits 0, or writes a message to stderr and exits 1.
|
||||
*
|
||||
* The failures use warn() + exit(1) rather than die(): on the target's ucode
|
||||
* (2026.01.16) die() exits 254, and the caller's contract is "nothing on stdout
|
||||
* means cannot verify", so a predictable status is worth having for anything
|
||||
* that does look at it.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { sha1 } from 'digest';
|
||||
import { readfile } from 'fs';
|
||||
|
||||
if (!length(ARGV) || ARGV[0] == null) {
|
||||
warn('usage: resource_blob_sha.uc <file>\n');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const data = readfile(ARGV[0]);
|
||||
|
||||
/* readfile() returns null when the file cannot be read and '' for an empty
|
||||
* one. The empty file has a real blob id (e69de29b...), so the two must not be
|
||||
* conflated - and neither may be reported as a digest. */
|
||||
if (type(data) !== 'string') {
|
||||
warn(sprintf('cannot read %s\n', ARGV[0]));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* length() counts bytes, which is what git hashes; chr(0) is the NUL git puts
|
||||
* between the header and the content. */
|
||||
printf('%s\n', sha1(sprintf('blob %d', length(data)) + chr(0) + data));
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Configuration transaction helpers for homeproxy.
|
||||
#
|
||||
# The generated sing-box configuration is the only thing standing between the
|
||||
# router and a working service, so it is handled as a transaction:
|
||||
#
|
||||
# generate the generator writes atomically and only after
|
||||
# `sing-box check` accepted the result, so a failed
|
||||
# generation leaves the previous file in place
|
||||
# -> known-good every accepted file is copied aside, so there is
|
||||
# always something to fall back to
|
||||
# -> ensure-live if generation produced nothing, restore the
|
||||
# known-good copy instead of failing the start
|
||||
# -> rollback a reload that comes up unhealthy puts the known-good
|
||||
# copy back (see reload_service in init.d/homeproxy)
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy. The functions only take explicit paths
|
||||
# and never call procd or ubus, so tests/runtime/test_config_transaction.sh
|
||||
# can exercise them without a router.
|
||||
|
||||
# hp_known_good <live> <known-good>
|
||||
# Record <live> as the new known-good copy. Non-zero when <live> is missing
|
||||
# or empty, in which case the previous known-good copy is left untouched.
|
||||
hp_known_good() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] || return 1
|
||||
|
||||
mkdir -p "$(dirname "$good")" 2>/dev/null
|
||||
cp -f "$live" "$good" 2>/dev/null || return 1
|
||||
# The known-good copy holds the same credentials as the live file, so the
|
||||
# two have to stay in step: otherwise a rollback restores a copy that is
|
||||
# world-readable whenever the source was.
|
||||
chmod 600 "$good" 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_ensure_live <live> <known-good>
|
||||
# 0 <live> is present and usable
|
||||
# 1 <live> was missing and the known-good copy was restored
|
||||
# 2 nothing usable: neither file exists
|
||||
hp_ensure_live() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] && return 0
|
||||
|
||||
if [ -s "$good" ]; then
|
||||
cp -f "$good" "$live" 2>/dev/null && return 1
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
# hp_rollback <live> <known-good>
|
||||
# Replace <live> with the known-good copy. Non-zero when there is nothing to
|
||||
# roll back to, so the caller can distinguish "restored" from "no fallback".
|
||||
hp_rollback() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$good" ] || return 1
|
||||
|
||||
cp -f "$good" "$live" 2>/dev/null || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_same_file <a> <b>
|
||||
# 0 when both files exist and have identical content. Used to skip the
|
||||
# rollback when the failing configuration is already the known-good one.
|
||||
hp_same_file() {
|
||||
[ -s "$1" ] && [ -s "$2" ] || return 1
|
||||
cmp -s "$1" "$2" 2>/dev/null
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# dnsmasq integration for homeproxy.
|
||||
#
|
||||
# homeproxy does not run its own DNS front end: it points dnsmasq at the
|
||||
# sing-box DNS listener and, in the list-based routing modes, feeds it the
|
||||
# per-domain `server=` / `nftset=` mappings. All of that used to live inline
|
||||
# in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, in
|
||||
# hp_dnsmasq_write_snippets, relies on `config_load` having run (see
|
||||
# runtime/service.sh's header for the rationale).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_dnsmasq_resolve_dir
|
||||
# Locate the directory dnsmasq reads the homeproxy snippets from and store it
|
||||
# in the global DNSMASQ_DIR.
|
||||
#
|
||||
# The UCI section name of the dnsmasq config is part of
|
||||
# /tmp/etc/dnsmasq.conf.<name>, but `uci get` cannot return a section name
|
||||
# (there is no `__name__`), so it is derived from the first `uci show` line.
|
||||
# Either step can fail on an unusual setup; fall back to the generic
|
||||
# directory and say so in the log instead of silently writing dnsmasq
|
||||
# snippets nobody reads.
|
||||
#
|
||||
# Called ONCE, at source time. stop_service deliberately reuses the value
|
||||
# instead of recomputing it: the directory must be the one the snippets were
|
||||
# written to, and a second resolution could log the warning twice or pick a
|
||||
# different directory after a dnsmasq reconfiguration.
|
||||
hp_dnsmasq_resolve_dir() {
|
||||
local dnsmasq_uci_config dnsmasq_conf_dir
|
||||
|
||||
dnsmasq_uci_config="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config#*.}"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config%%=*}"
|
||||
|
||||
DNSMASQ_DIR=""
|
||||
if [ -n "$dnsmasq_uci_config" ] && [ -f "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config" ]; then
|
||||
dnsmasq_conf_dir="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config")"
|
||||
[ -n "$dnsmasq_conf_dir" ] && DNSMASQ_DIR="$dnsmasq_conf_dir/dnsmasq-homeproxy.d"
|
||||
fi
|
||||
|
||||
if [ -z "$DNSMASQ_DIR" ]; then
|
||||
log "WARNING: unable to derive the dnsmasq conf-dir (section '${dnsmasq_uci_config:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy.d."
|
||||
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_has_nftset
|
||||
# 0 when the running dnsmasq understands `nftset=`. The base dnsmasq variant
|
||||
# is built without HAVE_NFTSET, and it does not ignore the directive: it aborts
|
||||
# with "recompile with HAVE_NFTSET defined", and because the snippet is read
|
||||
# through conf-dir that abort takes dnsmasq itself down - the LAN loses DNS and
|
||||
# DHCP, not just the domain routing. Probe once and degrade instead.
|
||||
hp_dnsmasq_has_nftset() {
|
||||
if [ -z "${HP_DNSMASQ_NFTSET:-}" ]; then
|
||||
# Look it up through PATH so the off-target harness can stand in a
|
||||
# dnsmasq of either flavour; fall back to the absolute path for an
|
||||
# init environment with a bare PATH.
|
||||
local bin
|
||||
bin="$(command -v dnsmasq 2>"/dev/null" || echo /usr/sbin/dnsmasq)"
|
||||
|
||||
if "$bin" --version 2>"/dev/null" | grep -qw nftset; then
|
||||
HP_DNSMASQ_NFTSET=1
|
||||
else
|
||||
HP_DNSMASQ_NFTSET=0
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "$HP_DNSMASQ_NFTSET" = "1" ]
|
||||
}
|
||||
|
||||
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
|
||||
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
|
||||
# restarts nothing: the caller decides whether the result differs from what is
|
||||
# already installed.
|
||||
#
|
||||
# The input list gates are `-s` rather than the original unconditional sed:
|
||||
# when the resource file is missing the old code produced an empty snippet
|
||||
# (or a stale one never got removed), which made "is this the same as last
|
||||
# time" undecidable. An empty list now means "no snippet for this mode",
|
||||
# which is also what a fresh install means.
|
||||
hp_dnsmasq_render_snippets() {
|
||||
local stage="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local dns_port="$4"
|
||||
local ipv6="$5"
|
||||
local gfw_nftset_v6="" wan_nftset_v6=""
|
||||
local nftset_ok=1
|
||||
|
||||
# dns_port comes from `infra`, a section with no UI and no validator, and
|
||||
# it is interpolated both into the snippet text and into sed's replacement
|
||||
# below. A value carrying a newline reaches sed as `\n` and becomes a real
|
||||
# dnsmasq directive (the generator accepts it too: int() is strtoll-based,
|
||||
# so '5333\nlog-queries' is simply 5333 there), and a value with a `/`
|
||||
# makes sed fail - while `> "$stage/x.conf"` has already truncated the
|
||||
# file, so an empty snippet used to be installed and then cached by the
|
||||
# "unchanged" comparison. Keep the numeric prefix, refuse the rest.
|
||||
dns_port="$(printf '%s' "$dns_port" | sed -n 's/^\([0-9]\{1,\}\).*/\1/p')"
|
||||
case "$dns_port" in
|
||||
''|*[!0-9]*)
|
||||
log "Warning: infra.dns_port is not a port (${4:-}); skipping the dnsmasq snippets."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$dns_port" -lt 1 ] || [ "$dns_port" -gt 65535 ]; then
|
||||
log "Warning: infra.dns_port=$dns_port is out of range; skipping the dnsmasq snippets."
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_dnsmasq_has_nftset || nftset_ok=0
|
||||
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom"|"global")
|
||||
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
|
||||
no-poll
|
||||
no-resolv
|
||||
server=127.0.0.1#$dns_port
|
||||
EOF
|
||||
;;
|
||||
"gfwlist")
|
||||
if [ -s "$hp_dir/resources/gfw_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6"
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; gfwlist routing degrades to plain server= entries (no address sets)."
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"proxy_mainland_china")
|
||||
if [ -s "$hp_dir/resources/china_list.txt" ]; then
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6"
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; the proxy list degrades to plain server= entries."
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
|
||||
# 0 when the installed snippet set is not exactly the staged one, 1 when it is
|
||||
# identical. Compares file names as well as contents, so a snippet that the
|
||||
# current routing mode no longer produces (but a previous one did) counts as a
|
||||
# change and gets cleaned up.
|
||||
hp_dnsmasq_dir_differs() {
|
||||
local stage="$1"
|
||||
local live="$2"
|
||||
local staged installed f
|
||||
|
||||
[ -d "$live" ] || return 0
|
||||
|
||||
staged="$(ls -1 "$stage" 2>"/dev/null" | sort)"
|
||||
installed="$(ls -1 "$live" 2>"/dev/null" | sort)"
|
||||
[ "$staged" = "$installed" ] || return 0
|
||||
|
||||
for f in $staged; do
|
||||
cmp -s "$stage/$f" "$live/$f" || return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
|
||||
# Write the include file plus the mode-specific snippet, then restart
|
||||
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
|
||||
#
|
||||
# The snippets are rendered into a staging directory first and compared with
|
||||
# what is installed. dnsmasq restart drops every client's DNS cache, and the
|
||||
# list files it feeds on change once a day at most while the routing mode and
|
||||
# the port stay the same - so the previous unconditional rewrite-and-restart
|
||||
# flushed the LAN's DNS cache on every reload for no reason.
|
||||
hp_dnsmasq_write_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local ipv6_support dns_port
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
|
||||
local stage changed=0
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get dns_port "infra" "dns_port" "5333"
|
||||
|
||||
stage="$(mktemp -d "${TMPDIR:-/tmp}/hp-dnsmasq.XXXXXX")" || {
|
||||
log "Warning: failed to create a staging directory for the dnsmasq snippets."
|
||||
return 1
|
||||
}
|
||||
|
||||
# A refused render (an unusable dns_port, or sed failing) must leave the
|
||||
# installed snippet set untouched: the staged directory is discarded, so
|
||||
# the caller keeps the snippets that are already in place instead of
|
||||
# installing a truncated one.
|
||||
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$include" ] || [ "$(cat "$include" 2>"/dev/null")" != "conf-dir=$dnsmasq_dir" ]; then
|
||||
changed=1
|
||||
fi
|
||||
if hp_dnsmasq_dir_differs "$stage" "$dnsmasq_dir"; then
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
rm -rf "$stage"
|
||||
log "dnsmasq snippets unchanged, skipping the restart."
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dnsmasq_dir")" 2>"/dev/null" \
|
||||
|| log "Warning: failed to create the dnsmasq conf-dir parent."
|
||||
rm -rf "$dnsmasq_dir"
|
||||
if ! mv "$stage" "$dnsmasq_dir"; then
|
||||
log "Warning: failed to install the dnsmasq snippets into ${dnsmasq_dir}."
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
# printf, not `echo -e`: busybox ash honours -e but a POSIX sh does not,
|
||||
# and on one the file would start with a literal "-e " and dnsmasq would
|
||||
# refuse to parse it. The file is one line of conf-dir= either way.
|
||||
printf 'conf-dir=%s\n' "$dnsmasq_dir" > "$include" \
|
||||
|| log "Warning: failed to write the dnsmasq conf-dir file."
|
||||
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq, DNS-based routing may be stale."
|
||||
}
|
||||
|
||||
# hp_dnsmasq_remove_snippets <dns-dir>
|
||||
# Remove the include file and the snippet directory, then restart dnsmasq.
|
||||
# The snippets are removed rather than restored: nothing ever captures the
|
||||
# pre-existing state, so there is no original to put back.
|
||||
hp_dnsmasq_remove_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy.conf"
|
||||
|
||||
# Nothing to undo when nothing was written - which is the normal case on a
|
||||
# router whose outbound node is 'nil', or one that never started. Removing
|
||||
# paths that do not exist is not an error, and restarting dnsmasq for it is
|
||||
# pure noise (it drops every client's DNS cache for no reason).
|
||||
if [ ! -e "$include" ] && [ ! -d "$dnsmasq_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$include" "$dnsmasq_dir"
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq after removing the homeproxy snippets."
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# fw4 integration for homeproxy.
|
||||
#
|
||||
# homeproxy owns a fixed set of nft chains and sets inside the `fw4` table.
|
||||
# They are created by the rendered template (firewall_post.ut, applied by
|
||||
# `fw4 reload`) and torn down one object at a time on stop. All of that used
|
||||
# to live inline in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, for the
|
||||
# teardown, requires the HP_FW4_CHAINS / HP_FW4_SETS inventory to be in scope
|
||||
# (root/etc/homeproxy/scripts/fw4_names.sh, sourced by init.d).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_restore_upnp_mappings
|
||||
# fw4 reload rewrites the whole ruleset, which drops the miniupnpd mappings,
|
||||
# so ask miniupnpd to reinstall them - but only when it is actually enabled
|
||||
# and running.
|
||||
hp_restore_upnp_mappings() {
|
||||
[ -x /etc/init.d/miniupnpd ] && /etc/init.d/miniupnpd enabled && /etc/init.d/miniupnpd running \
|
||||
&& /etc/init.d/miniupnpd restart >"/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_firewall_apply <hp-dir> <run-dir> <client-enabled>
|
||||
# Run the pre-script, render the post-template for the client side, reload
|
||||
# fw4 and restore the upnp mappings. <client-enabled> is "1" when a client
|
||||
# outbound exists; the post-template is client-only.
|
||||
hp_firewall_apply() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local client_enabled="$3"
|
||||
|
||||
local failed=0
|
||||
|
||||
# Truncate the include fragments first. firewall_pre.uc only writes them
|
||||
# when it has something to put in them, so a start that produces no
|
||||
# forward/input rules would leave the previous run's files in place and fw4
|
||||
# would keep loading them. teardown already truncates for the same reason.
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
|
||||
ucode "$hp_dir/scripts/firewall_pre.uc" 2>"/dev/null" \
|
||||
|| { log "Error: firewall pre-script failed."; failed=1; }
|
||||
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
# Render to a temp file and install it only on success. The old
|
||||
# `> "$run_dir/fw4_post.nft"` truncated the very file fw4 is about to
|
||||
# include, so a failed render left fw4 loading an empty ruleset.
|
||||
if utpl -S "$hp_dir/scripts/firewall_post.ut" > "$run_dir/fw4_post.nft.new" 2>"/dev/null"; then
|
||||
mv -f "$run_dir/fw4_post.nft.new" "$run_dir/fw4_post.nft"
|
||||
else
|
||||
rm -f "$run_dir/fw4_post.nft.new"
|
||||
log "Error: firewall post-script failed."
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
fw4 reload >"/dev/null" 2>&1 \
|
||||
|| { log "Error: fw4 reload failed."; failed=1; }
|
||||
|
||||
# The return value has to describe this function, not whatever
|
||||
# hp_restore_upnp_mappings happened to return last.
|
||||
hp_restore_upnp_mappings
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# hp_firewall_teardown <run-dir> [keep-pre]
|
||||
# Flush and delete every fw4 object homeproxy owns. Deleting one at a time
|
||||
# matters: `nft -f` batches are atomic, so a single object that does not
|
||||
# exist in the current proxy mode would roll back the rest.
|
||||
#
|
||||
# "keep-pre" leaves fw4_forward.nft / fw4_input.nft alone. Those two are the
|
||||
# firewall_pre.uc fragments - the server's inbound accepts and the TUN
|
||||
# forwards - and they are not what black-holes the LAN when the client fails.
|
||||
# A failed client has to release the redirect/tproxy layer without closing
|
||||
# the server's ports, so that path passes keep-pre; stop_service does not.
|
||||
hp_firewall_teardown() {
|
||||
local run_dir="$1"
|
||||
local keep_pre="${2:-}"
|
||||
local fw4_name
|
||||
|
||||
for fw4_name in $HP_FW4_CHAINS; do
|
||||
nft flush chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
for fw4_name in $HP_FW4_SETS; do
|
||||
nft flush set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
|
||||
if [ "$keep_pre" != "keep-pre" ]; then
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
fi
|
||||
: > "$run_dir/fw4_post.nft"
|
||||
|
||||
fw4 reload >"/dev/null" 2>&1 || log "Warning: fw4 reload failed during stop."
|
||||
hp_restore_upnp_mappings
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Runtime health helpers for homeproxy.
|
||||
#
|
||||
# After a reload the service must be able to answer "did the instance
|
||||
# actually come up with the new configuration?". That answer decides whether
|
||||
# the transaction in runtime/config.sh rolls back, so it is kept here instead
|
||||
# of being inlined in init.d/homeproxy.
|
||||
#
|
||||
# A single "is a matching process alive?" probe is NOT a health check, and the
|
||||
# first version of this file learned that the hard way. Measured on the test
|
||||
# device with a configuration whose mixed_port was already taken:
|
||||
#
|
||||
# * procd restarts the crashing instance about once a second, and a STALE
|
||||
# sing-box process from an earlier run kept matching `pgrep -f`, so the
|
||||
# process check returned success for the whole observation window while
|
||||
# the service was dead;
|
||||
# * `ubus service list` was correct throughout: `running` flipped false and
|
||||
# `exit_code` was 1 whenever it was down;
|
||||
# * the configured port WAS listening - owned by the process that had taken
|
||||
# it, not by sing-box. Checking "the port is in the listen table" would
|
||||
# therefore have passed too; the listener has to be attributed to an owner.
|
||||
#
|
||||
# So the gate is: procd says running, procd does not report a failed exit
|
||||
# code, the declared listeners are owned by sing-box, and that holds for
|
||||
# several consecutive samples (a service that binds and then dies must not be
|
||||
# able to pass on a single lucky poll).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy; testable off-target by stubbing pgrep /
|
||||
# ubus / jsonfilter / netstat (see tests/runtime/).
|
||||
|
||||
: "${HP_SERVICE:=homeproxy}"
|
||||
|
||||
# How many consecutive healthy samples hp_wait_service requires, and how many
|
||||
# seconds it may spend trying.
|
||||
#
|
||||
# Measured on the test device: procd waits out `procd_set_param respawn`'s
|
||||
# timeout (5s) before restarting a dead instance, and after a crash-restart
|
||||
# sequence has exhausted its retries the next start takes longer still. A
|
||||
# crash-restart loop produced at most one healthy sample in a row, so 3
|
||||
# separates the two cases while a healthy start only costs ~3s; but the BUDGET
|
||||
# has to cover procd's backoff, otherwise the gate gives up on an instance that
|
||||
# is still coming up (observed with a 15s budget: the rollback restart was
|
||||
# healthy moments after the gate had already declared failure).
|
||||
: "${HP_HEALTH_STABLE:=3}"
|
||||
: "${HP_HEALTH_BUDGET:=30}"
|
||||
|
||||
# The rollback restart runs right after a crash-restart sequence has exhausted
|
||||
# procd's retries, so it gets a longer budget than a normal start.
|
||||
: "${HP_HEALTH_ROLLBACK_BUDGET:=60}"
|
||||
|
||||
# The process name a listening socket must be attributed to.
|
||||
: "${HP_HEALTH_OWNER:=sing-box}"
|
||||
|
||||
# hp_procd_available
|
||||
# 0 when the procd/ubus state can be queried at all.
|
||||
hp_procd_available() {
|
||||
command -v ubus > "/dev/null" 2>&1 && command -v jsonfilter > "/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_procd_running <instance-name>
|
||||
# 0 when procd reports the instance as running AND without a non-zero exit
|
||||
# code. Both halves matter: during a crash-restart loop `running` flickers
|
||||
# and `exit_code` is set, and a `running: true` sample taken between two
|
||||
# failures must not be read as success on its own.
|
||||
hp_procd_running() {
|
||||
local name="$1"
|
||||
local state running exit_code
|
||||
|
||||
hp_procd_available || return 1
|
||||
|
||||
state="$(ubus call service list "{\"name\":\"$HP_SERVICE\"}" 2>"/dev/null" \
|
||||
| jsonfilter -e "@['$HP_SERVICE'].instances['$name']" 2>"/dev/null")"
|
||||
[ -n "$state" ] || return 1
|
||||
|
||||
running="$(printf '%s' "$state" | jsonfilter -e '@.running' 2>"/dev/null")"
|
||||
[ "$running" = "true" ] || return 1
|
||||
|
||||
exit_code="$(printf '%s' "$state" | jsonfilter -e '@.exit_code' 2>"/dev/null")"
|
||||
case "$exit_code" in
|
||||
""|"0") return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_instance_running <instance-name> <config-path>
|
||||
# 0 when the instance is alive.
|
||||
#
|
||||
# procd's view comes first: it is the authority on whether the instance it
|
||||
# supervises is up, and unlike a process scan it cannot be satisfied by a
|
||||
# stale or orphaned process. The process scan is only a fallback for hosts
|
||||
# without ubus, and it is deliberately the weaker evidence.
|
||||
hp_instance_running() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
|
||||
if hp_procd_running "$name"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: accept a matching process only when procd could not be asked
|
||||
# at all. When procd *was* asked and said "not running", that answer
|
||||
# wins - a stale process must not override it.
|
||||
if ! hp_procd_available; then
|
||||
if command -v pgrep > "/dev/null" 2>&1; then
|
||||
pgrep -f "run --config $config" > "/dev/null" 2>&1 && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_listener_owned <owner> <port>...
|
||||
# 0 every port is listening and attributed to <owner>
|
||||
# 1 a port is missing, or is listening but belongs to something else
|
||||
# 2 the check cannot be performed here (no netstat, or no owner column)
|
||||
#
|
||||
# Attribution is the whole point: in the failure this gate exists for, the
|
||||
# port was listening - because the process that had taken it was still
|
||||
# running. "The port is in the listen table" would have reported success for
|
||||
# a service that could not bind it.
|
||||
hp_listener_owned() {
|
||||
local owner="$1"
|
||||
shift
|
||||
local table port
|
||||
|
||||
command -v netstat > "/dev/null" 2>&1 || return 2
|
||||
|
||||
# -p adds the owning pid/name; it needs root, which is how init.d runs.
|
||||
table="$(netstat -ltnup 2>"/dev/null")"
|
||||
[ -n "$table" ] || return 2
|
||||
# Without a "<pid>/<name>" column the attribution cannot be made at all.
|
||||
# The header row says "PID/Program name", so the probe has to require a
|
||||
# digit before the slash - matching a bare "/" would accept the header.
|
||||
case "$table" in
|
||||
*[0-9]/*) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
for port in "$@"; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! printf '%s\n' "$table" | awk -v p="$port" -v o="$owner" '
|
||||
$1 ~ /^(tcp|udp)/ {
|
||||
# Local address is field 4; its last :/. component
|
||||
# is the port. This reads both ":::5330" and
|
||||
# "127.0.0.1.5330".
|
||||
n = split($4, a, /[:.]/)
|
||||
if (a[n] != p)
|
||||
next
|
||||
if ($NF ~ ("/" o "$")) {
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
END { exit(found ? 0 : 1) }
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_config_ports <config-file>
|
||||
# Print the ports a generated configuration declares as listeners.
|
||||
#
|
||||
# The gate must validate the configuration that is ACTUALLY running, not the
|
||||
# one UCI currently describes. After a rollback the live file is the older
|
||||
# known-good copy, and checking it against the candidate's ports made the gate
|
||||
# wait forever for a port the restored configuration never binds - a rollback
|
||||
# that had already brought the service back was reported as failed.
|
||||
hp_config_ports() {
|
||||
grep -o '"listen_port"[[:space:]]*:[[:space:]]*[0-9]\+' "$1" 2>"/dev/null" \
|
||||
| grep -o '[0-9]\+$'
|
||||
}
|
||||
|
||||
# hp_config_port_for_tag <config-file> <tag>
|
||||
# Print the listen_port of the inbound whose "tag" is <tag>, or nothing.
|
||||
#
|
||||
# The functional probes need to know which listener is the DNS inbound and
|
||||
# which is the mixed one, and they have to read that from the same place the
|
||||
# gate does - the running configuration. Using the UCI values instead would
|
||||
# point the probes at the candidate's ports after a rollback restored the older
|
||||
# known-good file: a warning in the log at best, and a rollback of a healthy
|
||||
# service when health_probe_strict is on.
|
||||
#
|
||||
# The generated JSON is pretty-printed with one field per line and `tag`
|
||||
# precedes `listen_port` inside each inbound object (generator/inbound.uc), so
|
||||
# a two-state walk is enough. The sed first splits any compact JSON onto the
|
||||
# same one-field-per-line shape, so the helper does not silently depend on the
|
||||
# generator's formatting: with both keys on one line the greedy tag match would
|
||||
# keep the LAST tag and find no port at all.
|
||||
hp_config_port_for_tag() {
|
||||
sed -e 's/[{},]/\n/g' "$1" 2>"/dev/null" | awk -v tag="$2" '
|
||||
/"tag"[[:space:]]*:/ {
|
||||
t = $0
|
||||
sub(/.*"tag"[[:space:]]*:[[:space:]]*"/, "", t)
|
||||
sub(/".*/, "", t)
|
||||
}
|
||||
/"listen_port"[[:space:]]*:/ && t == tag {
|
||||
p = $0
|
||||
sub(/.*"listen_port"[[:space:]]*:[[:space:]]*/, "", p)
|
||||
sub(/[^0-9].*/, "", p)
|
||||
print p
|
||||
exit
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# hp_service_healthy <instance-name> <config-path> [port...]
|
||||
# One sample of the gate. Ports are optional (the server side has no fixed
|
||||
# listening port worth checking).
|
||||
hp_service_healthy() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
shift 2
|
||||
|
||||
hp_instance_running "$name" "$config" || return 1
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
hp_listener_owned "$HP_HEALTH_OWNER" "$@"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
2)
|
||||
# No netstat owner column here. Say so once instead of
|
||||
# silently pretending the listeners were verified.
|
||||
if [ -z "${HP_HEALTH_LISTENER_WARNED:-}" ]; then
|
||||
HP_HEALTH_LISTENER_WARNED=1
|
||||
export HP_HEALTH_LISTENER_WARNED
|
||||
log "Warning: cannot verify the listeners on this target (netstat -p unavailable); relying on the procd check only."
|
||||
fi
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_wait_service <instance-name> <config-path> [budget] [stable] [port...]
|
||||
# 0 once the instance has been healthy for <stable> consecutive samples,
|
||||
# within <budget> seconds. 1 on timeout.
|
||||
hp_wait_service() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local budget="${3:-$HP_HEALTH_BUDGET}"
|
||||
local stable="${4:-$HP_HEALTH_STABLE}"
|
||||
local good=0 tries=0
|
||||
|
||||
[ "$#" -ge 4 ] && shift 4 || shift "$#"
|
||||
|
||||
while [ "$tries" -lt "$budget" ]; do
|
||||
if hp_service_healthy "$name" "$config" "$@"; then
|
||||
good=$((good + 1))
|
||||
[ "$good" -ge "$stable" ] && return 0
|
||||
else
|
||||
good=0
|
||||
fi
|
||||
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -ge "$budget" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_wait_instance <instance-name> <config-path> [seconds]
|
||||
# Kept for callers and tests that only want the plain liveness poll. New code
|
||||
# should use hp_wait_service: this one cannot tell a crash-restart loop from a
|
||||
# healthy service.
|
||||
hp_wait_instance() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local tries="${3:-10}"
|
||||
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
hp_instance_running "$name" "$config" && return 0
|
||||
sleep 1
|
||||
tries=$((tries - 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- functional probes -----------------------------------------------------
|
||||
#
|
||||
# Everything above answers "is the instance up, and does it own the ports it
|
||||
# declared?". None of it answers "does it actually serve?". A configuration
|
||||
# can pass `sing-box check`, bind every listener, hold them for the whole
|
||||
# observation window - and still not resolve a name, because the DNS router
|
||||
# failed to initialize or a jail mount is missing.
|
||||
#
|
||||
# The probes below add that layer, deliberately as *evidence* first and as a
|
||||
# gate only when asked for:
|
||||
#
|
||||
# * "cannot probe" is reported separately from "the probe failed", so a
|
||||
# target without nslookup/nc does not look like a broken service;
|
||||
# * a failure only fails the gate when health_probe_strict is on. A probe
|
||||
# can fail for reasons that have nothing to do with sing-box - no uplink,
|
||||
# a captive portal, an upstream resolver outage - and rolling a perfectly
|
||||
# good configuration back because of that is worse than the condition
|
||||
# being probed for. A rollback loop is the failure mode to avoid here.
|
||||
#
|
||||
# The client side is the side with a fixed capability worth probing (its DNS
|
||||
# inbound and the mixed inbound). The server side's inbounds are
|
||||
# protocol-specific: a bare TCP connect to a REALITY/trojan port proves
|
||||
# nothing, so it is not probed.
|
||||
: "${HP_PROBE_STRICT:=0}"
|
||||
: "${HP_PROBE_NAME:=example.com}"
|
||||
|
||||
# hp_probe_dns <port> [name]
|
||||
# 0 the DNS inbound answered, 1 it did not, 2 cannot probe here.
|
||||
hp_probe_dns() {
|
||||
local port="$1"
|
||||
local name="${2:-$HP_PROBE_NAME}"
|
||||
|
||||
command -v nslookup > "/dev/null" 2>&1 || return 2
|
||||
[ -n "$port" ] || return 2
|
||||
|
||||
nslookup -port="$port" "$name" 127.0.0.1 > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_probe_tcp <port>
|
||||
# 0 something accepted a connection on loopback:<port>, 1 nothing did,
|
||||
# 2 cannot probe here.
|
||||
hp_probe_tcp() {
|
||||
local port="$1"
|
||||
|
||||
[ -n "$port" ] || return 2
|
||||
command -v nc > "/dev/null" 2>&1 || return 2
|
||||
|
||||
nc 127.0.0.1 "$port" < "/dev/null" > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_run_probes <label> <dns-port> <mixed-port>
|
||||
# Report the probe results for one side. Returns 1 only when a probe failed
|
||||
# AND HP_PROBE_STRICT=1; a probe that could not be performed never fails it.
|
||||
hp_run_probes() {
|
||||
local label="$1"
|
||||
local dns_port="$2"
|
||||
local mixed_port="$3"
|
||||
local failed=0 rc
|
||||
|
||||
hp_probe_dns "$dns_port" "$HP_PROBE_NAME"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: the ${label} DNS inbound (127.0.0.1:${dns_port}) did not answer a query for ${HP_PROBE_NAME}." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} DNS inbound (no nslookup, or no dns-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
hp_probe_tcp "$mixed_port"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: nothing accepted a connection on the ${label} mixed inbound (127.0.0.1:${mixed_port})." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} mixed inbound (no nc, or no mixed-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
if [ "$failed" = "1" ] && [ "${HP_PROBE_STRICT:-0}" = "1" ]; then
|
||||
log "Error: the ${label} functional probes failed and health_probe_strict is on."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Routing/network plumbing for homeproxy.
|
||||
#
|
||||
# The tproxy and TUN paths need ip rules, a routing table and (for TUN) the
|
||||
# device itself before procd starts sing-box, and they need to be torn down
|
||||
# again on stop. All of that used to live inline in /etc/init.d/homeproxy.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, like the
|
||||
# other runtime modules, requires `config_load` to have run (it reads the
|
||||
# infra marks and the TUN device name itself).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_net_wait_wan [seconds]
|
||||
# Block until a default route or an "up" wan interface exists. Bounded: after
|
||||
# the timeout sing-box is started anyway, because a missing default route is
|
||||
# a common transient state at boot and refusing to start would be worse.
|
||||
hp_net_wait_wan() {
|
||||
local limit="${1:-60}"
|
||||
local wan_waited=0
|
||||
|
||||
while ! { ip route show default 2>"/dev/null" | grep -q . \
|
||||
|| ip -6 route show default 2>"/dev/null" | grep -q . \
|
||||
|| ifstatus wan 2>"/dev/null" | grep -q '"up": true'; }; do
|
||||
wan_waited=$((wan_waited + 1))
|
||||
if [ "$wan_waited" -ge "$limit" ]; then
|
||||
log "WARNING: No network route after ${limit}s, starting sing-box anyway."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# hp_net_try <描述> <命令...>
|
||||
# Run a step that may legitimately fail, logging the wrapped message.
|
||||
hp_net_try() {
|
||||
local message="$1"; shift
|
||||
"$@" 2>"/dev/null" || log "Error: ${message}"
|
||||
}
|
||||
|
||||
# hp_net_rule_add <family> <fwmark> <table> <描述>
|
||||
# Install one ip rule idempotently. `ip rule add` has no replace form and the
|
||||
# kernel appends a second identical rule instead of reporting a conflict, so a
|
||||
# repeated start (rc.common's `start` never calls stop_service) used to leave
|
||||
# duplicates behind. The teardown side already drains them with `while del`;
|
||||
# this is the matching install side. family is "" for ip, "-6" for ip -6.
|
||||
hp_net_rule_add() {
|
||||
local family="$1" mark="$2" table="$3" message="$4"
|
||||
|
||||
while ip $family rule del fwmark "$mark" table "$table" 2>"/dev/null"; do :; done
|
||||
hp_net_try "$message" ip $family rule add fwmark "$mark" table "$table"
|
||||
}
|
||||
|
||||
# hp_net_setup <proxy-mode> <routing-mode>
|
||||
# Create the tproxy or TUN routing state for the configured proxy mode.
|
||||
# Requires config_load.
|
||||
#
|
||||
# Every step is idempotent on purpose: `start` on an already-running service
|
||||
# re-runs this function without a preceding teardown. The old code reported
|
||||
# "failed to add the tproxy local route (table 100)" on every such start
|
||||
# (EEXIST from `ip route add`) while silently appending a duplicate ip rule.
|
||||
# Routes use `replace`, rules are drained before they are added, and the TUN
|
||||
# device is only created when it does not already exist.
|
||||
hp_net_setup() {
|
||||
local proxy_mode="$1"
|
||||
local routing_mode="$2"
|
||||
local ipv6_support table_mark outbound_udp_node tproxy_mark tun_name tun_mark
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
|
||||
case "$proxy_mode" in
|
||||
"redirect_tproxy")
|
||||
config_get outbound_udp_node "config" "main_udp_node" "nil"
|
||||
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
|
||||
hp_net_rule_add "" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy ip rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy local route (table ${table_mark})." \
|
||||
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_rule_add "-6" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy IPv6 rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy IPv6 local route (table ${table_mark})." \
|
||||
ip -6 route replace local ::/0 dev lo table "$table_mark"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"redirect_tun"|"tun")
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
|
||||
# Only wait after a real creation: the old unconditional `sleep 1s`
|
||||
# delayed every start (including reloads) even when the device was
|
||||
# already present.
|
||||
if ! ip link show "$tun_name" >"/dev/null" 2>&1; then
|
||||
hp_net_try "failed to create the TUN device ${tun_name}." \
|
||||
ip tuntap add mode tun user root name "$tun_name"
|
||||
sleep 1s
|
||||
fi
|
||||
hp_net_try "failed to bring up the TUN device ${tun_name}." \
|
||||
ip link set "$tun_name" up
|
||||
|
||||
hp_net_try "failed to set the TUN default route (table ${table_mark})." \
|
||||
ip route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN ip rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_try "failed to set the TUN IPv6 default route (table ${table_mark})." \
|
||||
ip -6 route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "-6" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN IPv6 rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_net_teardown
|
||||
# Remove every tproxy/TUN rule and route homeproxy added. Requires
|
||||
# config_load.
|
||||
#
|
||||
# ip rule del removes a single matching rule, so a repeated start (or an
|
||||
# upgrade that starts before the old instance stopped) leaves duplicates
|
||||
# behind. Delete every match instead of only the first one.
|
||||
hp_net_teardown() {
|
||||
local table_mark tproxy_mark tun_mark tun_name
|
||||
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
while ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
|
||||
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
|
||||
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
}
|
||||
|
||||
# hp_net_remove_tun
|
||||
# service_stopped() hook: procd calls it after the instances are gone, so the
|
||||
# TUN device can be removed. Requires config_load.
|
||||
hp_net_remove_tun() {
|
||||
local tun_name
|
||||
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
ip link set "$tun_name" down 2>"/dev/null"
|
||||
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Service-side runtime helpers for homeproxy.
|
||||
#
|
||||
# These are the device-side orchestration pieces that used to be inlined in
|
||||
# /etc/init.d/homeproxy: the sing-box version gate, the auto-update cron
|
||||
# entry, the runtime-file preparation, the procd instance registrations and
|
||||
# the "generate -> known-good" transaction that start_service runs for each
|
||||
# side.
|
||||
#
|
||||
# Layering note: unlike
|
||||
# runtime/config.sh and runtime/health.sh - which are deliberately pure and
|
||||
# only take explicit paths - the extracted modules are device-side
|
||||
# by nature. They call `log`, `config_get` and `procd_*`, so a caller must
|
||||
# have run `config_load` and must be inside a procd init context. That is
|
||||
# exactly why they were moved out of init.d and not turned into pure helpers.
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy.
|
||||
|
||||
# hp_require_singbox
|
||||
# Refuse to start on a sing-box older than 1.14: the generated configuration
|
||||
# uses 1.14-only fields, and an older binary would reject it at run time
|
||||
# instead of failing here. Returns 1 (and logs) when unusable.
|
||||
#
|
||||
# `sing-box` is looked up through PATH, exactly as the previous init script
|
||||
# did - it is deliberately not PROG, so a PATH mismatch between the two is
|
||||
# not silently introduced by this refactor.
|
||||
hp_require_singbox() {
|
||||
local sb_ver sb_major sb_minor
|
||||
|
||||
sb_ver="$(sing-box version -n 2>/dev/null)"
|
||||
if [ -z "$sb_ver" ]; then
|
||||
log "Error: cannot detect sing-box version, abort."
|
||||
return 1
|
||||
fi
|
||||
|
||||
sb_major="${sb_ver%%.*}"
|
||||
sb_minor="${sb_ver#*.}"
|
||||
sb_minor="${sb_minor%%.*}"
|
||||
|
||||
if [ "$sb_major" -lt 1 ] || { [ "$sb_major" -eq 1 ] && [ "$sb_minor" -lt 14 ]; }; then
|
||||
log "Error: sing-box >= 1.14.0 required, found ${sb_ver}."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_crontab_drop <crontab>
|
||||
# Remove the auto-update entry from <crontab>.
|
||||
#
|
||||
# Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with
|
||||
# BSD sed it fails ("invalid command code"), which silently left the stale entry
|
||||
# behind with only a warning in the log. Editing through a temporary file
|
||||
# behaves identically on busybox, GNU and BSD sed, and it is what lets this
|
||||
# module be exercised off-target at all
|
||||
# (tests/runtime/test_runtime_extraction.sh stages and drives it).
|
||||
hp_crontab_drop() {
|
||||
local crontab="$1"
|
||||
local tmp="${crontab}.hp-new"
|
||||
|
||||
sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
|
||||
# mv replaces the *file*, not its contents: the temporary was created by the
|
||||
# shell (0666 & ~umask = 0644) while procd creates /etc/crontabs/root as
|
||||
# 0600, so every stop/start left the root crontab world-readable - verified
|
||||
# on the device (0600 after boot, 0644 after one reload). Only root needs
|
||||
# to read it (crond runs as root), and a root crontab can hold other jobs'
|
||||
# secrets, so set the mode rather than inherit it - which also repairs a
|
||||
# file an older version already loosened.
|
||||
chmod 600 "$crontab" 2>"/dev/null"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_sync_autoupdate_cron <enabled> <hour>
|
||||
# Install (or drop) the subscription auto-update cron entry. <enabled> is a
|
||||
# config_get_bool result; the entry is only touched when it is "1".
|
||||
hp_sync_autoupdate_cron() {
|
||||
local auto_update="$1"
|
||||
local auto_update_time="$2"
|
||||
|
||||
[ "$auto_update" = "1" ] || return 0
|
||||
|
||||
# auto_update_time comes from UCI, which anything on the LAN can write, and
|
||||
# it used to be interpolated into a root crontab line verbatim: a value
|
||||
# like '2 * * * * root echo pwned #' appends a second job. Validate it
|
||||
# before the line exists, and write it with printf rather than `echo -e`
|
||||
# so no backslash sequence is interpreted either.
|
||||
case "$auto_update_time" in
|
||||
''|*[!0-9]*)
|
||||
log "Warning: auto_update_time='$auto_update_time' is not an hour of the day, skipping the cron entry."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$auto_update_time" -gt 23 ]; then
|
||||
log "Warning: auto_update_time=$auto_update_time is out of range (0-23), skipping the cron entry."
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to drop the previous auto-update cron entry."
|
||||
printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \
|
||||
"$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to install the auto-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
# hp_clear_autoupdate_cron
|
||||
# Drop the auto-update cron entry and restart cron. Used by stop_service.
|
||||
hp_clear_autoupdate_cron() {
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to drop the auto-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
||||
hp_prepare_runtime_files() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local client_enabled="$4"
|
||||
local server_enabled="$5"
|
||||
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental). The file has to exist before
|
||||
# the client jail binds it, or sing-box cannot create it in there.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
[ -e "$hp_dir/cache.db" ] || touch "$hp_dir/cache.db" \
|
||||
|| log "Warning: failed to create ${hp_dir}/cache.db."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" = "custom" ]; then
|
||||
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|
||||
|| log "Warning: failed to create ${hp_dir}/ruleset."
|
||||
# A local rule-set is opened by the client, which no longer runs as
|
||||
# root now that the jail is on for this mode too, so the files have to
|
||||
# be readable by the sing-box user. Only the documented directory is
|
||||
# claimed here; a rule-set placed elsewhere under HP_DIR has to be
|
||||
# readable already.
|
||||
chown -R sing-box:sing-box "$hp_dir/ruleset" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/ruleset to sing-box."
|
||||
fi
|
||||
|
||||
[ "$client_enabled" = "1" ] && echo > "$run_dir/sing-box-c.log"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
echo > "$run_dir/sing-box-s.log"
|
||||
if mkdir -p "$hp_dir/certs" 2>"/dev/null"; then
|
||||
# The server runs jailed as the sing-box user and reads its
|
||||
# certificate, key and ECH config from this directory, while an
|
||||
# upload lands as root:0600 (luci.homeproxy). Hand the directory
|
||||
# and its files to that user and keep them 0700/0600: the jailed
|
||||
# server can read them and nobody else can reach the private key.
|
||||
# Without this the choice was "world-readable key" or "jailed
|
||||
# server cannot start".
|
||||
chown sing-box:sing-box "$hp_dir/certs" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/certs to sing-box."
|
||||
chmod 700 "$hp_dir/certs" 2>"/dev/null"
|
||||
for f in "$hp_dir/certs"/*.pem "$hp_dir/certs"/*.crt "$hp_dir/certs"/*.key; do
|
||||
[ -f "$f" ] || continue
|
||||
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${f} to sing-box."
|
||||
chmod 600 "$f" 2>"/dev/null"
|
||||
done
|
||||
else
|
||||
log "Warning: failed to create ${hp_dir}/certs."
|
||||
fi
|
||||
fi
|
||||
|
||||
# chown each path that is actually there.
|
||||
#
|
||||
# The old one-shot chown listed every path unconditionally. A mode whose
|
||||
# start path does not create one of them (custom mode and cache.db, before
|
||||
# that file became shared with it) then failed and logged "failed to
|
||||
# change the ownership of the runtime files" on every single start - a
|
||||
# warning that was always there and never meant anything. A missing path is
|
||||
# not worth reporting; failing to chown a file that exists still is.
|
||||
for f in "$run_dir"/sing-box-*.json "$run_dir"/sing-box-*.log "$hp_dir"/cache.db; do
|
||||
[ -e "$f" ] || continue
|
||||
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
||||
|| log "Warning: failed to change the ownership of ${f} to sing-box."
|
||||
done
|
||||
}
|
||||
|
||||
# hp_procd_client_instance <prog> <hp-dir> <run-dir> <routing-mode> <disable-gso>
|
||||
# Register the sing-box client instance. The ujail gate is client-specific:
|
||||
# a wireguard/tun outbound cannot be jailed (it needs the host network stack),
|
||||
# and custom routing needs HP_DIR inside the jail because a local rule-set may
|
||||
# point at any path validateHomeProxyPath() accepts. The `procd_append_param
|
||||
# command` text below is what runtime/health.sh's pgrep pattern matches -
|
||||
# change both together.
|
||||
hp_procd_client_instance() {
|
||||
local prog="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local routing_mode="$4"
|
||||
local disable_gso="$5"
|
||||
|
||||
procd_open_instance "sing-box-c"
|
||||
|
||||
procd_set_param command "$prog"
|
||||
procd_append_param command run --config "$run_dir/sing-box-c.json"
|
||||
|
||||
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
||||
|
||||
if [ -x "/sbin/ujail" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$run_dir/sing-box-c.json"; then
|
||||
procd_add_jail "sing-box-c" log procfs
|
||||
procd_add_jail_mount "$run_dir/sing-box-c.json"
|
||||
procd_add_jail_mount_rw "$run_dir/sing-box-c.log"
|
||||
# A custom routing table may point a local rule-set anywhere under
|
||||
# HP_DIR, so the whole directory goes in read-only rather than a
|
||||
# guessed subset. procd orders the mounts by path, so this parent is
|
||||
# bound before the read-write cache.db below and cannot shadow it.
|
||||
procd_add_jail_mount "$hp_dir/"
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental), so both need the file
|
||||
# writable inside the jail.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
|
||||
esac
|
||||
procd_add_jail_mount "$hp_dir/certs/"
|
||||
# The certificate path gate accepts /etc/acme as well (a client TLS
|
||||
# certificate managed by acme.sh), so the client jail has to carry it
|
||||
# too - it was missing here while the server jail already had it.
|
||||
procd_add_jail_mount "/etc/acme/"
|
||||
procd_add_jail_mount "/etc/ssl/"
|
||||
# custom routing's find_neighbor resolves LAN hostnames out of the
|
||||
# dnsmasq lease file; its MAC lookups go over netlink and need no file,
|
||||
# but a `source_hostname` rule silently stops matching without this.
|
||||
procd_add_jail_mount "/tmp/dhcp.leases"
|
||||
procd_add_jail_mount "/etc/localtime"
|
||||
procd_add_jail_mount "/etc/TZ"
|
||||
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
fi
|
||||
|
||||
procd_set_param limits core="unlimited"
|
||||
procd_set_param limits nofile="1000000 1000000"
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_procd_server_instance <prog> <hp-dir> <run-dir> <disable-gso>
|
||||
# Register the sing-box server instance. The jail only depends on ujail
|
||||
# being installed, and it needs the certificate/ACME material instead of the
|
||||
# client's cache.db.
|
||||
hp_procd_server_instance() {
|
||||
local prog="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local disable_gso="$4"
|
||||
|
||||
procd_open_instance "sing-box-s"
|
||||
|
||||
procd_set_param command "$prog"
|
||||
procd_append_param command run --config "$run_dir/sing-box-s.json"
|
||||
|
||||
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
||||
|
||||
if [ -x "/sbin/ujail" ]; then
|
||||
procd_add_jail "sing-box-s" log procfs
|
||||
procd_add_jail_mount "$run_dir/sing-box-s.json"
|
||||
procd_add_jail_mount_rw "$run_dir/sing-box-s.log"
|
||||
procd_add_jail_mount_rw "$hp_dir/certs/"
|
||||
procd_add_jail_mount "/etc/acme/"
|
||||
procd_add_jail_mount "/etc/ssl/"
|
||||
procd_add_jail_mount "/etc/localtime"
|
||||
procd_add_jail_mount "/etc/TZ"
|
||||
procd_set_param capabilities "/etc/capabilities/homeproxy.json"
|
||||
procd_set_param no_new_privs 1
|
||||
procd_set_param user sing-box
|
||||
procd_set_param group sing-box
|
||||
fi
|
||||
|
||||
procd_set_param limits core="unlimited"
|
||||
procd_set_param limits nofile="1000000 1000000"
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn
|
||||
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_procd_log_cleaner <hp-dir>
|
||||
# Register the log-rotator instance. It is unconditional: it also has to run
|
||||
# when neither client nor server is enabled.
|
||||
hp_procd_log_cleaner() {
|
||||
local hp_dir="$1"
|
||||
|
||||
procd_open_instance "log-cleaner"
|
||||
procd_set_param command "$hp_dir/scripts/clean_log.sh"
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
# hp_capture_candidate <run-dir> <side>
|
||||
# Stage the configuration the reload preflight just generated and validated.
|
||||
#
|
||||
# The copy is necessary because stop_service() removes $RUN_DIR/sing-box-*.json
|
||||
# on its way to start_service (they are runtime state, not configuration), so
|
||||
# the file the preflight validated is gone by the time the new instance would
|
||||
# use it - which is exactly why the old reload regenerated it. The staged copy
|
||||
# lives in $RUN_DIR/candidate/, which stop_service() does not touch, and it is
|
||||
# what hp_start_generated_config() activates. Non-zero when there is nothing
|
||||
# staged, and the caller aborts the reload rather than continuing without a
|
||||
# validated candidate.
|
||||
hp_capture_candidate() {
|
||||
local run_dir="$1"
|
||||
local side="$2"
|
||||
local live="$run_dir/sing-box-${side}.json"
|
||||
local cand_dir="$run_dir/candidate"
|
||||
local staged="$cand_dir/sing-box-${side}.json"
|
||||
|
||||
[ -s "$live" ] || return 1
|
||||
|
||||
mkdir -p "$cand_dir" 2>/dev/null || return 1
|
||||
cp -f "$live" "$staged" 2>/dev/null || return 1
|
||||
# The staged file carries the same credentials as the live one; the
|
||||
# generator already wrote it 0600 and this copy has to stay that way.
|
||||
chmod 600 "$staged" 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_start_generated_config <side> <hp-dir> <run-dir> <good-dir>
|
||||
# The start-path transaction for one side ("c" or "s"):
|
||||
#
|
||||
# HP_USE_KNOWN_GOOD=1 -> copy the recorded configuration over the live one
|
||||
# instead of regenerating (a rollback: regenerating
|
||||
# would rebuild the very file that just failed)
|
||||
# HP_CANDIDATE_<SIDE>=1 -> the reload preflight generated and `sing-box
|
||||
# check`ed this side; activate the copy
|
||||
# hp_capture_candidate() staged, do not generate again
|
||||
# otherwise -> generate, then make sure a live file exists,
|
||||
# falling back to the known-good copy when it does not
|
||||
#
|
||||
# Returns 1 only when there is nothing to run at all (generation failed and
|
||||
# no known-good copy exists), which is what made start_service bail out.
|
||||
#
|
||||
# It deliberately does NOT touch the known-good copy any more. It used to
|
||||
# record the freshly generated file immediately, which meant that by the time
|
||||
# the health gate ran, the "previous" configuration the rollback would need had
|
||||
# already been overwritten by the candidate - so a failed reload had nothing to
|
||||
# roll back to. Promotion is now a separate step, taken only after the gate
|
||||
# has passed (hp_promote_known_good, called from start_service).
|
||||
#
|
||||
# A3: the candidate flag exists because the generator is no longer guaranteed to
|
||||
# produce the same bytes twice - its env carries the WAN resolver and the two
|
||||
# domain-resource lists, either of which can change between two invocations. A
|
||||
# reload that generated the config, validated it, and then let start_service
|
||||
# generate it *again* would run an artifact that had never been checked:
|
||||
# validate A -> discard A -> generate B -> run B. The flag makes the validated
|
||||
# bytes the ones that run. It is per side, so a config_load disagreement between
|
||||
# reload_service and start_service (a concurrent UCI change) generates the side
|
||||
# the preflight did not cover instead of activating a stale file.
|
||||
hp_start_generated_config() {
|
||||
local side="$1"
|
||||
local hp_dir="$2"
|
||||
local run_dir="$3"
|
||||
local good_dir="$4"
|
||||
local label generator live good candidate staged
|
||||
|
||||
case "$side" in
|
||||
c)
|
||||
label="client"
|
||||
generator="$hp_dir/scripts/generate_client.uc"
|
||||
candidate="${HP_CANDIDATE_CLIENT:-0}"
|
||||
;;
|
||||
s)
|
||||
label="server"
|
||||
generator="$hp_dir/scripts/generate_server.uc"
|
||||
candidate="${HP_CANDIDATE_SERVER:-0}"
|
||||
;;
|
||||
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
||||
esac
|
||||
|
||||
live="$run_dir/sing-box-${side}.json"
|
||||
good="$good_dir/sing-box-${side}.json"
|
||||
|
||||
if [ "${HP_USE_KNOWN_GOOD:-0}" = "1" ] && [ -s "$good" ]; then
|
||||
# Rollback path: start from the recorded configuration instead
|
||||
# of regenerating. Regenerating would rebuild the very config
|
||||
# that just failed to come up, because the generator is a pure
|
||||
# function of UCI.
|
||||
log "Starting with the last known-good ${label} configuration."
|
||||
cp -f "$good" "$live"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$candidate" = "1" ]; then
|
||||
# The reload preflight's artifact is the one that passed `sing-box
|
||||
# check`. stop_service() removed the live copy on the way here, so the
|
||||
# staged copy is what gets activated - the same bytes that were
|
||||
# validated, not a second generation.
|
||||
staged="$run_dir/candidate/sing-box-${side}.json"
|
||||
|
||||
if [ -s "$staged" ] && cp -f "$staged" "$live" 2>/dev/null; then
|
||||
# One shot: the staged copy must not survive to be activated by a
|
||||
# later start that never validated it.
|
||||
rm -f "$staged"
|
||||
log "Activating the ${label} configuration validated by the reload preflight."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Defensive: the flag was set but nothing was staged. Generate rather
|
||||
# than activate an unvalidated or stale file.
|
||||
log "Warning: no validated ${label} candidate is staged; generating the configuration again."
|
||||
fi
|
||||
|
||||
ucode -S "$generator" 2>>"$LOG_PATH"
|
||||
|
||||
# The generator is transactional on its own: it writes a temporary file
|
||||
# and only renames it over the live one once `sing-box check` accepted
|
||||
# it. A failed generation therefore leaves the previous file untouched -
|
||||
# and that is the only `sing-box check` in this path (the runtime used to
|
||||
# run a second one on the same file).
|
||||
hp_ensure_live "$live" "$good"
|
||||
case "$?" in
|
||||
0)
|
||||
;;
|
||||
1)
|
||||
log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;;
|
||||
*)
|
||||
log "Error: failed to generate ${label} configuration."
|
||||
return 1 ;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_promote_known_good <side> <run-dir> <good-dir>
|
||||
# Record the live configuration as the new known-good copy. Called only after
|
||||
# the health gate has proven that the configuration actually runs, so the
|
||||
# rollback target is always something that came up.
|
||||
hp_promote_known_good() {
|
||||
local side="$1"
|
||||
local run_dir="$2"
|
||||
local good_dir="$3"
|
||||
local label
|
||||
|
||||
case "$side" in
|
||||
c) label="client" ;;
|
||||
s) label="server" ;;
|
||||
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
||||
esac
|
||||
|
||||
hp_known_good "$run_dir/sing-box-${side}.json" "$good_dir/sing-box-${side}.json" \
|
||||
|| log "Warning: could not refresh the known-good ${label} configuration."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Pure subscription decoder. The fetcher returns raw bytes
|
||||
* and the orchestrator used to inline the JSON / base64 / SIP008
|
||||
* handling. Pulling it out gives the orchestrator a single
|
||||
* `decode(content, log, url)` call and lets us unit-test each input
|
||||
* shape without involving the network or UCI.
|
||||
*
|
||||
* Three input shapes are recognised, in order:
|
||||
* 1. JSON with a top-level 'servers' array (clash proxy-provider)
|
||||
* 2. JSON array directly (the same data, just unwrapped)
|
||||
* 3. Shadowsocks SIP008 JSON: top-level array of objects with
|
||||
* server+method. Each entry is tagged nodetype='sip008' so the
|
||||
* orchestrator can dispatch them differently downstream.
|
||||
*
|
||||
* On JSON failure, decode the content as base64 and split on '\n'
|
||||
* to get one share-link URI per line (sing-box subscription format).
|
||||
*
|
||||
* On total failure (no JSON, no base64), return an empty list and
|
||||
* let the caller log the empty result.
|
||||
*
|
||||
* `url` is optional and only used to enrich the parse-failed log
|
||||
* line; the decoder itself never makes a network call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, decodeBase64Str, redactUrl } from '../homeproxy.uc';
|
||||
|
||||
export function decode(content, log, url) {
|
||||
if (isEmpty(content))
|
||||
return [];
|
||||
|
||||
let nodes;
|
||||
try {
|
||||
const parsed = json(content);
|
||||
nodes = parsed.servers || parsed;
|
||||
|
||||
/* Shadowsocks SIP008: each entry is a JSON object with
|
||||
* server + method, not a URI string.
|
||||
*
|
||||
* The probe has to check the type first. It used to be a bare
|
||||
* `nodes[0].server`, which THROWS in ucode when nodes[0] is a
|
||||
* string ("left-hand side expression is not an array or
|
||||
* object") - and the throw was swallowed by the JSON
|
||||
* try/catch below, so a perfectly valid `{"servers":["vless://…"]}`
|
||||
* or `["vless://…"]` subscription fell through to the base64
|
||||
* fallback, failed there too and decoded to an empty list. Two
|
||||
* real subscription shapes were silently discarded, and the
|
||||
* decoder test had recorded that as the contract. The same
|
||||
* guard applies inside the loop so a mixed list (SIP008 objects
|
||||
* plus share links) keeps both halves instead of losing
|
||||
* everything on the first string. */
|
||||
if (type(nodes[0]) === 'object' && nodes[0].server && nodes[0].method) {
|
||||
for (let i = 0; i < length(nodes); i++)
|
||||
if (type(nodes[i]) === 'object')
|
||||
nodes[i].nodetype = 'sip008';
|
||||
}
|
||||
} catch (e) {
|
||||
/* The URL carries the subscription token and this line lands in
|
||||
* homeproxy.log, which is world-readable. fetcher.uc and the
|
||||
* orchestrator both redact; this path was missed. */
|
||||
const tag = url ? sprintf('for %s, ', redactUrl(url)) : '';
|
||||
log(sprintf('JSON parse failed %strying base64: %s', tag, e.message));
|
||||
const decoded = decodeBase64Str(content);
|
||||
nodes = decoded ? split(trim(decoded), '\n') : [];
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Thin wrapper around homeproxy's wGETVerbose that logs
|
||||
* the failure and returns a flat { content, error } shape. The
|
||||
* orchestrator used to inline the empty-content check + log
|
||||
* message right next to the call, which made the loop body hard
|
||||
* to read and conflated the fetch's success/failure decision with
|
||||
* the downstream decode step.
|
||||
*
|
||||
* On success: returns { content: <string>, error: null }.
|
||||
* On failure: returns { content: null, error: <string> }, having
|
||||
* already logged the failure with the URL. The caller can keep
|
||||
* the loop flat: 'if (result.content === null) continue;'.
|
||||
*
|
||||
* No caching, no retry, no backoff - the orchestrator decides
|
||||
* those if it ever needs them. The Fetcher is a single HTTP GET
|
||||
* + error log, nothing more.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, redactUrl, wGETVerbose } from '../homeproxy.uc';
|
||||
|
||||
export function fetch(url, user_agent, log) {
|
||||
const result = wGETVerbose(url, user_agent);
|
||||
if (isEmpty(result.content)) {
|
||||
/* Redact the URL before logging: the query string is the
|
||||
* subscription token and homeproxy.log is world-readable on
|
||||
* a default install. The original URL goes back to the caller
|
||||
* via result.error so error propagation still works. */
|
||||
log(sprintf('Failed to fetch resources from %s: %s',
|
||||
redactUrl(url), result.error || 'empty response'));
|
||||
return { content: null, error: result.error || 'empty response' };
|
||||
}
|
||||
return { content: result.content, error: null };
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Pure subscription filter. The orchestrator
|
||||
* (update_subscriptions.uc) used to inline both the keyword
|
||||
* whitelist/blacklist check and the post-parse policy application
|
||||
* (tls.insecure override, protocol_options.packet_encoding for
|
||||
* vless/vmess). They live here so the orchestrator stays focused on
|
||||
* the fetch/cache/repository flow and the pure logic is unit-testable
|
||||
* without UCI access.
|
||||
*
|
||||
* Neither function touches module state: mode / keywords / opts
|
||||
* come in as arguments, and a logger is passed in for the
|
||||
* invalid-regex warning. That makes the module callable from a
|
||||
* plain test stub and means the orchestrator can keep its own
|
||||
* file-writer log() around the call.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Decide whether a node named `name` should be dropped. `mode` is
|
||||
* one of:
|
||||
* 'disabled' - filter is off, return false (keep)
|
||||
* 'blacklist' - drop if any keyword regex matches
|
||||
* 'whitelist' - drop unless any keyword regex matches
|
||||
*
|
||||
* `keywords` is a list of strings; each is compiled as a regexp.
|
||||
* One bad regex is logged and skipped, not treated as fatal - this
|
||||
* matches the pre-B1 behaviour, where a stray `[` would not block
|
||||
* the whole subscription.
|
||||
*
|
||||
* Returns true when the node should be skipped. */
|
||||
export function check(name, mode, keywords, log) {
|
||||
if (isEmpty(name) || mode === 'disabled' || isEmpty(keywords))
|
||||
return false;
|
||||
|
||||
let matched = false;
|
||||
for (let i in keywords) {
|
||||
let patten;
|
||||
try {
|
||||
patten = regexp(i);
|
||||
} catch (e) {
|
||||
log(sprintf('Skipping invalid filter keyword regex: %s.', i));
|
||||
continue;
|
||||
}
|
||||
if (patten && match(name, patten))
|
||||
matched = true;
|
||||
}
|
||||
if (mode === 'whitelist')
|
||||
matched = !matched;
|
||||
|
||||
return matched;
|
||||
};
|
||||
|
||||
/* Apply the two policy tweaks the orchestrator used to do inline. The subject
|
||||
* is a canonical Node - the same shape parser/normalize.uc produces and
|
||||
* parser/flatten.uc turns back into UCI keys - not the parser's flat
|
||||
* UCI-key dict:
|
||||
*
|
||||
* - tls.insecure is set when the node has TLS enabled and the user opted in
|
||||
* via subscription.allow_insecure='1'
|
||||
* - protocol_options.packet_encoding is set on vless/vmess nodes to the
|
||||
* subscription's default
|
||||
*
|
||||
* This used to run on the flat dict, which is why the orchestrator applied
|
||||
* policy on one representation and handed a different one to the Repository.
|
||||
* The canonical Node is now the only business object between the parser and
|
||||
* the Repository, and this was the last step still forcing the flat shape.
|
||||
* The tweaks are a property of the subscription, not of the parsed link, so
|
||||
* they deliberately do not take part in the duplicate fingerprint - which is
|
||||
* computed from the parser output before this runs.
|
||||
*
|
||||
* Mutates and returns the node; the return value is the same object as the
|
||||
* input, so callers can chain. */
|
||||
export function apply_policy(node, opts) {
|
||||
if (!node)
|
||||
return node;
|
||||
|
||||
if (node.tls && node.tls.enabled === '1' && opts.allow_insecure === '1')
|
||||
node.tls.insecure = '1';
|
||||
|
||||
if (node.type in ['vless', 'vmess']) {
|
||||
/* normalize() always builds protocol_options; a hand-made node
|
||||
* (a test fixture, a future caller) may not have it, and the flat
|
||||
* version simply created the key. */
|
||||
if (!node.protocol_options)
|
||||
node.protocol_options = {};
|
||||
|
||||
node.protocol_options.packet_encoding = opts.packet_encoding;
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Every UCI write the
|
||||
* subscription pipeline performs now lives in this module. Three
|
||||
* entry points cover what used to be six uci.set+commit sites in
|
||||
* update_subscriptions.uc plus the existing node add/update/remove
|
||||
* walk:
|
||||
*
|
||||
* apply_nodes - the add / update / remove walk
|
||||
* for the nodes the subscription
|
||||
* fetch returned this run.
|
||||
* apply_main_node_refs - main_node / main_udp_node cleanup:
|
||||
* remove stale urltest members, switch
|
||||
* main_node if its target is gone,
|
||||
* reset both to 'nil' when the
|
||||
* subscription has no nodes.
|
||||
* scrub_stale_urltest_refs - one-shot pass over every UCI
|
||||
* routing_node that purges dead
|
||||
* urltest_nodes entries left behind by
|
||||
* nodes that have since been removed.
|
||||
*
|
||||
* Writes are driven by canonical Node objects (post- parser/normalize).
|
||||
* UCI is flat, so apply_nodes expands each node with parser/flatten.uc -
|
||||
* and that call is the module's only canonical -> UCI boundary: callers
|
||||
* hand this module canonical Nodes, never flat dicts, and every UCI key
|
||||
* name comes from parser/mapping.uc. The orchestrator
|
||||
* (update_subscriptions.uc) never writes UCI directly; it orchestrates
|
||||
* fetch + parse + normalize + filter + policy + reload and owns the one
|
||||
* commit.
|
||||
*
|
||||
* A2 (single commit): this module mutates the caller's cursor and never
|
||||
* commits. It used to commit at nine sites - once at the end of
|
||||
* apply_nodes, seven inside apply_main_node_refs, and once per scrubbed
|
||||
* routing_node - while update_subscriptions.uc's own comments called
|
||||
* that "the repository's single commit". A crash between two of them
|
||||
* left the nodes written but main_node still pointing at a deleted
|
||||
* section, and there was no single point at which the run became
|
||||
* visible. The transaction boundary is now the caller's one commit,
|
||||
* after every mutation is staged.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { md5 } from 'digest';
|
||||
|
||||
import { isEmpty } from '../homeproxy.uc';
|
||||
|
||||
/* Relative, like config/loader.uc's '../parser/mapping.uc': ucode's
|
||||
* resolver only searches top-level module names in the -L tree, so a
|
||||
* bare 'parser/flatten.uc' does not resolve. */
|
||||
import { flatten } from '../parser/flatten.uc';
|
||||
|
||||
/* --- apply_nodes -------------------------------------------------------- */
|
||||
|
||||
/* Add / update / remove the subscription-owned nodes against UCI.
|
||||
*
|
||||
* node_cache - { [groupHash]: { [nameHash]: canonical_node } }
|
||||
* A lookup table keyed by subscription URL group
|
||||
* and then by the per-node name hash. The fetch
|
||||
* loop populates this for de-duplication.
|
||||
* node_result - [[canonical_node, ...]] (one inner list per URL)
|
||||
* The de-duplicated, policy-applied nodes in the
|
||||
* order they should appear in UCI.
|
||||
*
|
||||
* Behaviour parity with the previous code:
|
||||
* - user-created nodes (no grouphash) are never touched
|
||||
* - nodes whose subscription has no cache entry (network
|
||||
* failure for that URL) are left in place; we don't delete
|
||||
* a node just because today's fetch didn't return it
|
||||
* - a node in UCI but not in the cache is deleted
|
||||
* - a node in both has every field of the new config written
|
||||
* (including options the stored section did not have yet),
|
||||
* and the options the new config no longer carries are removed
|
||||
* - new nodes are added under name = md5(groupHash + label), the
|
||||
* same hash the cache uses, so a subsequent re-fetch recognises
|
||||
* them as existing
|
||||
*
|
||||
* Returns the { added, removed } counts. No commit here: the caller's
|
||||
* single commit is the boundary of "this run wrote something". */
|
||||
function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
let added = 0, removed = 0;
|
||||
/* Mark the canonical Node objects the foreach loop above has
|
||||
* already updated in place, so the second loop skips them. We
|
||||
* use object identity (the same Node object is held in node_cache
|
||||
* and in node_result, so `seen[node] === node` works as a set),
|
||||
* not the section name or nameHash: the orchestrator hands the
|
||||
* Repository the same Node instances, and tying the marker to the
|
||||
* object means a stale string key cannot match a renamed node. */
|
||||
const seen = {};
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* User-created nodes do not have a grouphash. The
|
||||
* repository must never touch them, even if the
|
||||
* subscription fetch returns nothing for today. */
|
||||
if (!cfg.grouphash)
|
||||
return null;
|
||||
|
||||
/* No cache entry for this subscription group means
|
||||
* the fetch failed for that URL. Leave the existing
|
||||
* nodes in place so the user does not lose them on
|
||||
* a transient network blip. */
|
||||
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
|
||||
return null;
|
||||
|
||||
const incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
|
||||
if (!incoming) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
removed++;
|
||||
log(sprintf('Removing node: %s.', cfg.label || cfg['name']));
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Canonical Node -> flat UCI keys. flatten() drops
|
||||
* nulls so a freshly-saved canonical Node does not
|
||||
* litter /etc/config with `tls_sni ''` entries for
|
||||
* fields the parser never set; the Loader treats null
|
||||
* and absent the same, so this is observably
|
||||
* equivalent. */
|
||||
const flat = flatten(incoming);
|
||||
|
||||
/* Write every option the new config carries, including
|
||||
* ones the stored section did not have yet. Walking only
|
||||
* the OLD section's keys used to miss new options
|
||||
* (plugin, tls_sni, packet_encoding) the subscription
|
||||
* started sending after the node was first imported. */
|
||||
for (let v in keys(flat))
|
||||
uci.set(uciconfig, cfg['.name'], v, flat[v]);
|
||||
|
||||
/* Then drop the options the new config no longer
|
||||
* carries. `.name` / `.type` / `.index` are section
|
||||
* metadata, not options; deleting them is meaningless
|
||||
* at best. */
|
||||
for (let v in keys(cfg)) {
|
||||
if (substr(v, 0, 1) === '.')
|
||||
continue;
|
||||
if (!(v in flat))
|
||||
uci.delete(uciconfig, cfg['.name'], v);
|
||||
}
|
||||
|
||||
seen[incoming] = true;
|
||||
});
|
||||
|
||||
for (let nodes in node_result)
|
||||
map(nodes, (node) => {
|
||||
if (seen[node])
|
||||
return null;
|
||||
|
||||
/* normalize() exposes the source label as `name`; `label`
|
||||
* is only set when the orchestrator carries it over. Relying
|
||||
* on `label` alone hashed every node of a subscription to the
|
||||
* same section name, because undefined stringifies
|
||||
* identically - four nodes collapsed into one section mixing
|
||||
* several protocols' options. Accept either. */
|
||||
const label = node.label || node.name;
|
||||
const nameHash = md5(node.grouphash + label);
|
||||
|
||||
/* The one boundary where a canonical Node becomes UCI keys.
|
||||
*
|
||||
* UCI is a flat key/value store, so this expansion has to
|
||||
* happen somewhere; keeping it inside the Repository's write
|
||||
* path is what lets every other layer - the orchestrator, the
|
||||
* filter, the policy step - speak canonical only. If a field
|
||||
* must not reach UCI, or its UCI name must change, this call
|
||||
* and parser/mapping.uc are the places to look; no caller
|
||||
* hands this function a flat dict. */
|
||||
const flat = flatten(node);
|
||||
|
||||
uci.set(uciconfig, nameHash, 'node');
|
||||
for (let v in keys(flat))
|
||||
uci.set(uciconfig, nameHash, v, flat[v]);
|
||||
|
||||
added++;
|
||||
log(sprintf('Adding node: %s.', label));
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/* --- apply_main_node_refs ---------------------------------------------- */
|
||||
|
||||
/* Prune the entries of an UCI list that no longer point at a live
|
||||
* node. Used twice below for `main_urltest_nodes` and
|
||||
* `main_udp_urltest_nodes`; the helper writes the cleaned list back
|
||||
* only when it actually changed, and returns it for the caller to
|
||||
* inspect (an empty list is the trigger to switch to first_server). */
|
||||
function maybeScrubUrltestNodes(uci, uciconfig, ucimain, listOption, log) {
|
||||
const old_list = uci.get(uciconfig, ucimain, listOption) || [];
|
||||
const cleaned = filter(old_list, (v) => {
|
||||
if (!uci.get(uciconfig, v)) {
|
||||
log(sprintf('Node %s is gone, removing from urltest list.', v));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (length(cleaned) !== length(old_list))
|
||||
uci.set(uciconfig, ucimain, listOption, cleaned);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/* Switch a main_* field off the dangling reference and onto the first
|
||||
* available node. The two callers below differ only in the UCI option
|
||||
* name (`main_node` vs `main_udp_node`) and the user-facing log line,
|
||||
* so this is what made the pre-refactor code mirror itself.
|
||||
*
|
||||
* The new value is also pushed onto `result.log` so the orchestrator
|
||||
* can replay it (it uses `result.log` to log the same lines verbatim;
|
||||
* the homeproxy.log capture goes through the separate `log` arg). */
|
||||
function switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, field, first_server, result, log) {
|
||||
if (uci.get(uciconfig, field))
|
||||
return field;
|
||||
uci.set(uciconfig, ucimain, field, first_server);
|
||||
const msg = sprintf('Main%s node is gone, switching to the first node.',
|
||||
field === 'main_udp_node' ? ' UDP' : '');
|
||||
log(msg);
|
||||
push(result.log, msg);
|
||||
return first_server;
|
||||
}
|
||||
|
||||
/* Replace the 4 inline uci.set/commit sites (plus the 2-line
|
||||
* 'reset to nil' block) in update_subscriptions.uc. Logic parity:
|
||||
*
|
||||
* - if main_node is set and at least one subscription node exists:
|
||||
* * when main_node === 'urltest', scrub main_urltest_nodes down
|
||||
* to nodes that still exist, then switch main_node to the first
|
||||
* surviving one if the urltest set is empty
|
||||
* * otherwise, switch main_node to first_server if its target is
|
||||
* gone
|
||||
* * same shape for main_udp_node, only when main_udp_node is set
|
||||
* AND not 'same'
|
||||
* - if main_node is set but no subscription nodes exist, reset
|
||||
* both main_node and main_udp_node to 'nil'
|
||||
*
|
||||
* `ctx` carries the inputs the orchestrator used to read from UCI:
|
||||
* { main_node, main_udp_node, has_nodes }
|
||||
*
|
||||
* Mutates only, no commit. The orchestrator commits once after
|
||||
* apply_nodes, this method and scrub_stale_urltest_refs have all
|
||||
* staged their changes, so a run is either fully visible or not at all.
|
||||
*
|
||||
* Returns { main_node: <new>, main_udp_node: <new>, log: [..] } so
|
||||
* the orchestrator can log "Main node is gone, switching to ..." /
|
||||
* "No available node, disable tproxy." exactly as before. */
|
||||
function apply_main_node_refs(uci, uciconfig, ucimain, ucinode, ctx, log) {
|
||||
/* Object destructuring is not part of the dialect the ucode on the
|
||||
* target accepts (ImmortalWrt ucode 2026.01.16 rejects
|
||||
* `const { a } = x` with "Expecting variable name"), so pull the
|
||||
* fields out by name. tests/ucode/test_ucode_grammar.sh guards this. */
|
||||
const main_node = ctx.main_node,
|
||||
main_udp_node = ctx.main_udp_node;
|
||||
const result = { main_node, main_udp_node, log: [] };
|
||||
|
||||
if (isEmpty(main_node))
|
||||
return result;
|
||||
|
||||
const first_server = uci.get_first(uciconfig, ucinode);
|
||||
if (!first_server) {
|
||||
/* No nodes left at all: reset both to 'nil'. */
|
||||
uci.set(uciconfig, ucimain, 'main_node', 'nil');
|
||||
uci.set(uciconfig, ucimain, 'main_udp_node', 'nil');
|
||||
result.main_node = 'nil';
|
||||
result.main_udp_node = 'nil';
|
||||
push(result.log, 'No available node, disable tproxy.');
|
||||
return result;
|
||||
}
|
||||
|
||||
if (main_node === 'urltest') {
|
||||
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_urltest_nodes', log);
|
||||
|
||||
if (!length(cleaned))
|
||||
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', first_server, result, log);
|
||||
} else {
|
||||
result.main_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_node', first_server, result, log);
|
||||
}
|
||||
|
||||
if (!isEmpty(main_udp_node) && main_udp_node !== 'same') {
|
||||
if (main_udp_node === 'urltest') {
|
||||
const cleaned = maybeScrubUrltestNodes(uci, uciconfig, ucimain, 'main_udp_urltest_nodes', log);
|
||||
|
||||
if (!length(cleaned))
|
||||
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', first_server, result, log);
|
||||
} else {
|
||||
result.main_udp_node = switchMainNodeOffDanglingRef(uci, uciconfig, ucimain, 'main_udp_node', first_server, result, log);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* --- scrub_stale_urltest_refs ----------------------------------------- */
|
||||
|
||||
/* Walk every routing_node and prune the urltest_nodes list to drop
|
||||
* entries that no longer point at a live node. Mutates only: the caller
|
||||
* commits once after this returns. Returns { changed } - the number of
|
||||
* routing_nodes it rewrote - which is what the old `commits` counter
|
||||
* measured (it counted its own commits; the previous code committed
|
||||
* inside the foreach loop, once per scrubbed routing_node). */
|
||||
function scrub_stale_urltest_refs(uci, uciconfig, log) {
|
||||
let changed = 0;
|
||||
|
||||
uci.foreach(uciconfig, 'routing_node', (cfg) => {
|
||||
if (cfg.node !== 'urltest' || isEmpty(cfg.urltest_nodes))
|
||||
return null;
|
||||
|
||||
const cleaned_nodes = filter(cfg.urltest_nodes, (v) => uci.get(uciconfig, v));
|
||||
if (length(cleaned_nodes) === length(cfg.urltest_nodes))
|
||||
return null;
|
||||
|
||||
uci.set(uciconfig, cfg['.name'], 'urltest_nodes', cleaned_nodes);
|
||||
changed++;
|
||||
log(sprintf('Routing node %s: removed gone nodes from urltest list.', cfg['.name']));
|
||||
});
|
||||
|
||||
return { changed };
|
||||
}
|
||||
|
||||
export const Repository = {
|
||||
apply_nodes,
|
||||
apply_main_node_refs,
|
||||
scrub_stale_urltest_refs
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2023 ImmortalWrt.org
|
||||
|
||||
SCRIPTS_DIR="/etc/homeproxy/scripts"
|
||||
|
||||
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
|
||||
"$SCRIPTS_DIR"/update_resources.sh "$i"
|
||||
done
|
||||
|
||||
"$SCRIPTS_DIR"/update_subscriptions.uc
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
#
|
||||
# Fetch and install one resource list (china_ip4 / china_ip6 / gfw_list /
|
||||
# china_list). Each list comes from a fixed upstream repository; the current
|
||||
# commit is resolved through the GitHub API, the file is downloaded from the
|
||||
# first reachable mirror at that exact commit, and the result is installed only
|
||||
# after its git blob id has been checked against the one the API reports for
|
||||
# that path and commit (resource_blob_sha.uc). A list that fails the check is
|
||||
# discarded and the installed copy is left in place.
|
||||
#
|
||||
# Driven by the rpcd method `resources_update` and by the auto-update cron
|
||||
# entry; tests/runtime/test_resource_update.sh exercises the verification path
|
||||
# off-target with stubbed wget/jsonfilter/ucode.
|
||||
|
||||
NAME="homeproxy"
|
||||
|
||||
# The blob-digest helper sits next to this script and is invoked through it, so
|
||||
# its location follows however the script was started (rpcd, cron, a shell).
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>"/dev/null" && pwd)"
|
||||
|
||||
RESOURCES_DIR="/etc/$NAME/resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
|
||||
RUN_DIR="/var/run/$NAME"
|
||||
LOG_PATH="$RUN_DIR/$NAME.log"
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
to_upper() {
|
||||
printf '%s' "$1" | tr "[a-z]" "[A-Z]"
|
||||
}
|
||||
|
||||
# Review M7: each entry is tried in order, success stops the loop. Order
|
||||
# matters: fastly.jsdelivr.net is the CDN edge closest to the original
|
||||
# report and used to be the only mirror; gcore and cdn are sibling edges;
|
||||
# raw.githubusercontent.com is the upstream fallback (the file lives in
|
||||
# the same GitHub repo we just queried for the SHA, so it is reachable
|
||||
# whenever the version query was reachable). Putting the CDN edges first
|
||||
# keeps the common case fast; the GitHub fallback catches the case where
|
||||
# the CDN is blocked but GitHub is reachable - which is the shape of the
|
||||
# `api.github.com` rate-limit problem the report calls out.
|
||||
MIRRORS="fastly.jsdelivr.net gcore.jsdelivr.net cdn.jsdelivr.net raw.githubusercontent.com"
|
||||
|
||||
# Pick the first mirror that responds to a HEAD with HTTP 200 in 10s.
|
||||
# Called with: <path-suffix>. Echoes the chosen base URL on stdout, or
|
||||
# fails the script if every mirror timed out.
|
||||
pick_mirror() {
|
||||
local path_suffix="$1"
|
||||
for base in $MIRRORS; do
|
||||
if [ "$base" = "raw.githubusercontent.com" ]; then
|
||||
# GitHub raw serves paths from the repo root, not the
|
||||
# /gh/<repo>@<sha>/<file> shape jsdelivr uses. The caller
|
||||
# passes the suffix already split out, so we re-stitch it.
|
||||
local probe_url="https://$base/$listrepo/$list_sha/$listname"
|
||||
else
|
||||
local probe_url="https://$base/gh/$listrepo@$list_sha/$listname"
|
||||
fi
|
||||
if wget --timeout=10 --spider -q "$probe_url" 2>"/dev/null"; then
|
||||
printf '%s\n' "$base"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_list_update() {
|
||||
local listtype="$1"
|
||||
local listrepo="$2"
|
||||
local listref="$3"
|
||||
local listname="$4"
|
||||
local lock="$RUN_DIR/update_resources-$listtype.lock"
|
||||
local github_token="$(uci -q get homeproxy.config.github_token)"
|
||||
local wget="wget --timeout=10 -q"
|
||||
|
||||
# fd 9, not 200: POSIX only guarantees 0-9, and dash fails the whole
|
||||
# `exec 200>"$lock"` with "exec: 200: not found". busybox ash and bash
|
||||
# accept multi-digit descriptors, so this only ever showed up off-target -
|
||||
# but the shebang says /bin/sh, and the off-target driver runs it there.
|
||||
# `&>` is a bashism for the same reason; `> file 2>&1` is the POSIX form.
|
||||
exec 9>"$lock"
|
||||
if ! flock -n 9 >"/dev/null" 2>&1; then
|
||||
log "[$(to_upper "$listtype")] A task is already running."
|
||||
return 2
|
||||
fi
|
||||
|
||||
# The token travels in argv, which the previous form avoided by writing it
|
||||
# to a 0600 file and passing --header-file - an option neither busybox nor
|
||||
# GNU wget has, so it failed with "unrecognized option" and the version
|
||||
# query never worked at all with a token configured. There is no
|
||||
# file-based header option in either wget, so argv is the only way; on a
|
||||
# single-user router that is the right trade for a feature that otherwise
|
||||
# does not function. `wget` is invoked with the header only when a token
|
||||
# is set, and nothing logs the command line.
|
||||
local github_header=""
|
||||
[ -n "$github_token" ] && github_header="Authorization: Bearer $github_token"
|
||||
|
||||
local list_info="$($wget ${github_header:+--header "$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
|
||||
local wget_exit=$?
|
||||
|
||||
if [ $wget_exit -ne 0 ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
|
||||
return 1
|
||||
fi
|
||||
local list_sha="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].sha")"
|
||||
local list_date="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].commit.committer.date" | cut -d 'T' -f1)"
|
||||
if [ -z "$list_sha" ]; then
|
||||
log "[$(to_upper "$listtype")] Failed to get the latest version, please retry later."
|
||||
return 1
|
||||
fi
|
||||
local list_ver="${list_date:+$list_date }$list_sha"
|
||||
|
||||
local local_list_ver="$(cat "$RESOURCES_DIR/$listtype.ver" 2>"/dev/null" || echo "NOT_FOUND")"
|
||||
local local_list_sha="${local_list_ver##* }"
|
||||
local local_list_disp="${local_list_ver%% *}"
|
||||
if [ "$local_list_sha" = "$list_sha" ]; then
|
||||
[ "$local_list_ver" = "$local_list_sha" ] && [ -n "$list_date" ] && \
|
||||
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
|
||||
log "[$(to_upper "$listtype")] Current version: ${list_ver%% *}."
|
||||
log "[$(to_upper "$listtype")] You're already at the latest version."
|
||||
return 3
|
||||
else
|
||||
log "[$(to_upper "$listtype")] Local version: $local_list_disp, latest version: ${list_ver%% *}."
|
||||
fi
|
||||
|
||||
# Pick a mirror, then download. pick_mirror walks the list and uses
|
||||
# the first reachable one; raw.githubusercontent.com is a separate
|
||||
# path shape so the helper handles it.
|
||||
local mirror="$(pick_mirror)"
|
||||
if [ -z "$mirror" ]; then
|
||||
log "[$(to_upper "$listtype")] All mirrors unreachable (tried: $MIRRORS)."
|
||||
return 1
|
||||
fi
|
||||
local mirror_url
|
||||
if [ "$mirror" = "raw.githubusercontent.com" ]; then
|
||||
mirror_url="https://raw.githubusercontent.com/$listrepo/$list_sha/$listname"
|
||||
else
|
||||
mirror_url="https://$mirror/gh/$listrepo@$list_sha/$listname"
|
||||
fi
|
||||
log "[$(to_upper "$listtype")] Downloading from $mirror."
|
||||
|
||||
if ! $wget -O "$RUN_DIR/$listname" "$mirror_url" || [ ! -s "$RUN_DIR/$listname" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Download failed ($mirror)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Integrity: the URL is pinned to $list_sha, so the mirror is *supposed* to
|
||||
# serve the bytes that commit contains. That is a statement about the URL,
|
||||
# not about the response - a mirror, a CDN edge or anything else in the
|
||||
# path can answer with different content, and the resource lists drive
|
||||
# nft sets and DNS snippets. Compare the file's git blob id against the one
|
||||
# GitHub reports for the same path and commit.
|
||||
#
|
||||
# This fails closed. The version query above already requires
|
||||
# api.github.com, so a router that cannot reach the API could not update
|
||||
# resources before this check either; the alternative - installing bytes
|
||||
# nobody vouched for - is the thing being fixed.
|
||||
local api_blob local_blob
|
||||
api_blob="$($wget ${github_header:+--header "$github_header"} -O- \
|
||||
"https://api.github.com/repos/$listrepo/contents/$listname?ref=$list_sha" \
|
||||
| jsonfilter -qe '@.sha')"
|
||||
if [ -z "$api_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: GitHub reports no blob id for $listname at $list_sha."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local_blob="$(ucode -S "$SCRIPT_DIR/resource_blob_sha.uc" "$RUN_DIR/$listname" 2>"/dev/null")"
|
||||
if [ -z "$local_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: cannot compute the blob id of the downloaded $listname."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$local_blob" != "$api_blob" ]; then
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Refusing to install: $listname from $mirror does not match the content of commit $list_sha (expected $api_blob, got $local_blob)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"; then
|
||||
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
|
||||
# Review M7: persist the time *this router* last succeeded.
|
||||
# $list_date is the upstream commit date and can be months old
|
||||
# even on a successful run, so it is not a stand-in. Stored in
|
||||
# the same directory as the .ver file so resources_get_version
|
||||
# can read it.
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$RESOURCES_DIR/$listtype.updated_at"
|
||||
log "[$(to_upper "$listtype")] Successfully updated via $mirror (blob $local_blob)."
|
||||
else
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
"china_ip4")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
|
||||
;;
|
||||
"china_ip6")
|
||||
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
|
||||
;;
|
||||
"gfw_list")
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
|
||||
;;
|
||||
"china_list")
|
||||
# Not `sed -i`: the bare -i form is a busybox/GNU extension, and the same
|
||||
# script is exercised off-device where a non-busybox sed fails it with
|
||||
# "invalid command code". Edit through a temp file, the way the crontab
|
||||
# helper in runtime/service.sh does.
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
|
||||
sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" && \
|
||||
mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt" || \
|
||||
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023 ImmortalWrt.org
|
||||
*
|
||||
* The orchestrator no
|
||||
* longer touches UCI directly. All UCI writes live in
|
||||
* subscription/repository.uc. The pipeline is:
|
||||
*
|
||||
* Loader.load() -> read subscription + main_node refs
|
||||
* parse_uri() -> flat UCI keys (the parser's output shape)
|
||||
* normalize() -> canonical Node (parser/normalize.uc)
|
||||
* apply_policy() -> policy tweaks on the canonical Node
|
||||
* (tls.insecure, protocol_options.packet_encoding)
|
||||
* Repository.apply_nodes -> writes UCI for subscription nodes
|
||||
* (internally flatten()s the canonical
|
||||
* nodes, so the orchestrator never
|
||||
* touches UCI).
|
||||
* Repository.apply_main_node_refs -> writes UCI for main_node /
|
||||
* main_udp_node / urltest cleanup.
|
||||
* Repository.scrub_stale_urltest_refs -> one-shot pass over UCI
|
||||
* routing_nodes for stale urltest_nodes
|
||||
* entries.
|
||||
*
|
||||
* The canonical Node is the only object the orchestrator hands the
|
||||
* Repository, and the only one policy is applied to. The parser's flat
|
||||
* UCI-key dict survives here for one thing only: the duplicate
|
||||
* fingerprint, which identifies the parsed link and must not move when a
|
||||
* subscription-level policy changes.
|
||||
*
|
||||
* Every UCI write the run performs is therefore in
|
||||
* subscription/repository.uc. The orchestrator's only UCI side
|
||||
* effect is passing its cursor to the Repository methods.
|
||||
*
|
||||
* A2 (single commit): the Repository commits nothing; this script owns the
|
||||
* one uci.commit() the run performs, after every mutation is staged and
|
||||
* before the reload. Read the comment above that commit for what the
|
||||
* multi-commit order used to allow.
|
||||
*
|
||||
* A4 (lock before snapshot): the cursor, the domain model and the recovery
|
||||
* snapshot are all read inside the lock. See load_locked_state().
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { md5 } from 'digest';
|
||||
import { lstat, open, readfile, writefile } from 'fs';
|
||||
import { connect } from 'ubus';
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import {
|
||||
executeCommand, getTime, isEmpty, HP_DIR, RUN_DIR, redactUrl, shellQuote,
|
||||
UCICONFIG_DIR
|
||||
} from './homeproxy.uc';
|
||||
|
||||
import { parse_uri } from './parser/uri.uc';
|
||||
import { normalize } from './parser/normalize.uc';
|
||||
|
||||
import { check as filter_check, apply_policy } from './subscription/filter.uc';
|
||||
import { decode as decode_subscription } from './subscription/decoder.uc';
|
||||
import { fetch as fetch_subscription } from './subscription/fetcher.uc';
|
||||
import { Repository } from './subscription/repository.uc';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
|
||||
/* UCI cursor, configuration and recovery snapshot the run works on.
|
||||
*
|
||||
* A4: none of this is read at module scope any more. The lock used to guard
|
||||
* only the write phase - the subscription list, the domain model and the
|
||||
* backup were all read first - so a run that read while another one held the
|
||||
* lock and then acquired it after that one finished would commit against the
|
||||
* base it had read before, and, on failure, restore a snapshot older than the
|
||||
* run that had just committed. Everything below is now filled by
|
||||
* load_locked_state(), which is called only after acquire_lock() returns true.
|
||||
*
|
||||
* A2: the run performs exactly one uci.commit(). The Repository modules stage
|
||||
* mutations only.
|
||||
*
|
||||
* UCICONFIG_DIR (homeproxy.uc) is the config *directory* Loader.load() takes,
|
||||
* and the sandboxed test points it at its own config instead of /etc/config
|
||||
* (tests/ucode/test_subscription_updater_runs.sh rewrites that line in
|
||||
* homeproxy.uc and fails loudly if the rewrite did not apply). */
|
||||
const CONFIG_FILE = UCICONFIG_DIR + '/homeproxy';
|
||||
const uciconfig = 'homeproxy';
|
||||
|
||||
const ucimain = 'config',
|
||||
ucinode = 'node';
|
||||
|
||||
/* Filled by load_locked_state(). `let`, so the reads could move into the lock
|
||||
* without threading a state object through every use site. */
|
||||
let uci, loaded, sub, routing_mode,
|
||||
allow_insecure, filter_mode, filter_keywords, packet_encoding,
|
||||
subscription_urls, user_agent, main_node, main_udp_node,
|
||||
config_backup = null;
|
||||
|
||||
/* Read everything the run needs, under the lock. Read-only: nothing here
|
||||
* mutates, so a failure cannot leave a partial state behind. */
|
||||
function load_locked_state() {
|
||||
uci = cursor();
|
||||
uci.load(uciconfig);
|
||||
|
||||
/* The orchestrator used to hold its own
|
||||
* uci.cursor() and re-read subscription.* / config.* one field at
|
||||
* a time. Now it goes through Loader.load(), which is read-only
|
||||
* and exposes the canonical sub-objects
|
||||
* (`config.access_control.subscription`). The Repository methods
|
||||
* continue to take the raw cursor for the writes because the
|
||||
* cursor is the UCI writer contract. */
|
||||
loaded = Loader.load(UCICONFIG_DIR);
|
||||
sub = loaded.access_control.subscription;
|
||||
routing_mode = loaded.general.routing_mode;
|
||||
|
||||
allow_insecure = sub.allow_insecure || '0';
|
||||
filter_mode = sub.filter_nodes || 'disabled';
|
||||
/* subscription_urls and filter_keywords are siblings of `subscription`,
|
||||
* not members of it: load_access_control() puts them straight on
|
||||
* access_control (loader.uc:303-304), and test_domain_model_skeleton.uc
|
||||
* asserts that shape. Reading them through `sub.` yielded [] forever, so
|
||||
* the guard below never called main() and the updater exited 0 having done
|
||||
* nothing - the LuCI button and the cron entry were both silent no-ops. */
|
||||
filter_keywords = loaded.access_control.filter_keywords || [];
|
||||
packet_encoding = sub.packet_encoding || 'xudp';
|
||||
subscription_urls = loaded.access_control.subscription_urls || [];
|
||||
user_agent = sub.user_agent;
|
||||
|
||||
main_node = null;
|
||||
main_udp_node = null;
|
||||
if (routing_mode !== 'custom') {
|
||||
main_node = loaded.general.main_node;
|
||||
main_udp_node = loaded.general.main_udp_node;
|
||||
}
|
||||
|
||||
/* The snapshot the failure path restores. It is read here, under the
|
||||
* lock, so it is the state this run actually started from - not whatever
|
||||
* was on disk while the process was still waiting for the lock. */
|
||||
config_backup = readfile(CONFIG_FILE);
|
||||
}
|
||||
|
||||
|
||||
function log(...args) {
|
||||
const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a');
|
||||
logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`);
|
||||
logfile.close();
|
||||
}
|
||||
|
||||
/* One updater at a time.
|
||||
*
|
||||
* The cron entry and the LuCI button can both start this script, and the update
|
||||
* is a sequence of commits (nodes, then main_node refs, then the repository's
|
||||
* single commit). Two runs interleaving them is last-writer-wins, and a failing
|
||||
* run restores ITS snapshot over whatever the other just wrote - so a
|
||||
* concurrent update can lose a whole node set.
|
||||
*
|
||||
* mkdir is the lock: it is atomic on every filesystem this runs on and needs no
|
||||
* flock binding. A lock older than HP_LOCK_STALE is treated as abandoned (a
|
||||
* killed process cannot clean up after itself) and broken, so a crash cannot
|
||||
* block every future update. */
|
||||
const LOCK_DIR = RUN_DIR + '/update_subscriptions.lock';
|
||||
const LOCK_STALE = 600;
|
||||
|
||||
function lock_age() {
|
||||
const st = lstat(LOCK_DIR);
|
||||
|
||||
if (!st)
|
||||
return null;
|
||||
|
||||
/* time() is the epoch; getTime() is this package's *formatter*
|
||||
* (getTime(epoch) -> 'YYYY-MM-DD@HH:MM:SS'), so subtracting it produced
|
||||
* NaN and the stale check never fired. */
|
||||
return max(0, time() - (st.mtime || 0));
|
||||
};
|
||||
|
||||
function acquire_lock() {
|
||||
system(sprintf('mkdir -p %s', shellQuote(RUN_DIR)));
|
||||
|
||||
if (system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0)
|
||||
return true;
|
||||
|
||||
const age = lock_age();
|
||||
|
||||
if (age != null && age > LOCK_STALE) {
|
||||
log(sprintf('Breaking a stale update lock (%s seconds old).', age));
|
||||
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
|
||||
return system(sprintf('mkdir %s 2>/dev/null', shellQuote(LOCK_DIR))) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
function release_lock() {
|
||||
system(sprintf('rmdir %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
};
|
||||
|
||||
|
||||
function main() {
|
||||
const node_cache = {};
|
||||
const node_result = [];
|
||||
|
||||
const ubus = connect();
|
||||
/* ubus is unreachable when no ubusd is running (dev host)
|
||||
* and the feature query is optional: fall back to the same
|
||||
* empty feature set the code below already handles. */
|
||||
const sing_features = (ubus?.call('luci.homeproxy', 'singbox_get_features', {})) || {};
|
||||
if (isEmpty(sing_features))
|
||||
log('Warning: Failed to query sing-box features via ubus, assuming defaults.');
|
||||
|
||||
/* Fetch, decode, parse and filter everything BEFORE touching
|
||||
* the service or UCI. The previous version stopped the proxy
|
||||
* first, so a slow or failing subscription left the router
|
||||
* without a proxy for the whole fetch - the stop -> modify
|
||||
* -> discover-an-error pattern the refactor guide forbids.
|
||||
* Nothing below runs until a full candidate set exists in
|
||||
* memory. */
|
||||
for (let url in subscription_urls) {
|
||||
url = replace(url, /#.*$/, '');
|
||||
const groupHash = md5(url);
|
||||
node_cache[groupHash] = {};
|
||||
|
||||
/* Keep the lock's mtime current for as long as this run is alive.
|
||||
* The stale window (LOCK_STALE) is what lets a later run reclaim a
|
||||
* lock a killed process left behind, but nothing refreshed it while
|
||||
* the fetch was in progress - and the fetch is one wget per URL,
|
||||
* ten seconds each. A run that crosses the threshold while still
|
||||
* working can therefore have its lock broken underneath it, and the
|
||||
* two runs then interleave their read-modify-write exactly like the
|
||||
* comment above acquire_lock() describes. */
|
||||
system(sprintf('touch %s 2>/dev/null', shellQuote(LOCK_DIR)));
|
||||
|
||||
const fetched = fetch_subscription(url, user_agent, log);
|
||||
if (fetched.content === null)
|
||||
continue;
|
||||
|
||||
const nodes = decode_subscription(fetched.content, log, url);
|
||||
|
||||
let count = 0;
|
||||
for (let node in nodes) {
|
||||
let flat;
|
||||
if (!isEmpty(node))
|
||||
flat = parse_uri(node, sing_features, log);
|
||||
if (isEmpty(flat))
|
||||
continue;
|
||||
|
||||
const label = flat.label;
|
||||
flat.label = null;
|
||||
const confHash = md5(sprintf('%J', flat)),
|
||||
nameHash = md5(groupHash + label);
|
||||
flat.label = label;
|
||||
|
||||
if (filter_check(flat.label, filter_mode, filter_keywords, log))
|
||||
log(sprintf('Skipping blacklist node: %s.', flat.label));
|
||||
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
|
||||
log(sprintf('Skipping duplicate node: %s.', flat.label));
|
||||
else {
|
||||
/* normalize() first: the canonical Node is the only
|
||||
* business object this orchestrator hands the Repository.
|
||||
* The policy tweaks used to be applied to `flat` before
|
||||
* this, which meant the pipeline was flat -> flat(tweaked)
|
||||
* -> canonical, and only the Repository's internal
|
||||
* flatten() brought it back. Now it is
|
||||
* parse -> normalize -> apply_policy -> Repository.
|
||||
*
|
||||
* `flat` stays in scope for the fingerprint above only.
|
||||
* That fingerprint is taken from the parser's output on
|
||||
* purpose: it identifies the parsed link, and the policy
|
||||
* tweaks belong to the subscription rather than to the
|
||||
* node. */
|
||||
const node_canonical = normalize(flat);
|
||||
node_canonical.grouphash = groupHash;
|
||||
|
||||
/* normalize() exposes the source label as `name`; the
|
||||
* Repository identifies a node by
|
||||
* md5(grouphash + label), so without this every node in
|
||||
* one subscription hashes to the SAME section name and
|
||||
* they overwrite each other - a 4-node subscription
|
||||
* collapsed into a single section carrying one protocol's
|
||||
* `type` with several protocols' options mixed in.
|
||||
* The cache is keyed by the same hash, so this also makes
|
||||
* an unchanged node match on the next run instead of
|
||||
* being deleted and re-added every time. */
|
||||
node_canonical.label = flat.label;
|
||||
|
||||
apply_policy(node_canonical, { allow_insecure, packet_encoding });
|
||||
|
||||
push(node_result, [ node_canonical ]);
|
||||
node_cache[groupHash][confHash] = node_canonical;
|
||||
node_cache[groupHash][nameHash] = node_canonical;
|
||||
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0)
|
||||
log(sprintf('No valid node found in %s.', redactUrl(url)));
|
||||
else
|
||||
log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), redactUrl(url)));
|
||||
}
|
||||
|
||||
if (isEmpty(node_result)) {
|
||||
/* Nothing was touched yet (the fetch phase never writes),
|
||||
* so the running service and the stored configuration
|
||||
* stay as they are. */
|
||||
log('Failed to update subscriptions: no valid node found.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/* The add / update / remove walk and the
|
||||
* final commit now live in subscription/repository.uc. The
|
||||
* orchestrator just hands it the canonical Node cache +
|
||||
* result built during the fetch phase and uses the
|
||||
* { added, removed } counts for the end-of-run log. */
|
||||
const repository_result = Repository.apply_nodes(
|
||||
uci, uciconfig, ucinode, node_cache, node_result, log
|
||||
);
|
||||
const added = repository_result.added,
|
||||
removed = repository_result.removed;
|
||||
|
||||
/* The 6 inline uci.set/commit sites
|
||||
* (main_urltest_nodes cleanup, main_node switch on missing
|
||||
* target, main_udp_urltest_nodes cleanup, main_udp_node
|
||||
* switch, reset-to-'nil', routing_node urltest scrub) moved
|
||||
* into subscription/repository.uc. The orchestrator now
|
||||
* drives three Repository methods and replays the log lines
|
||||
* the Repository attached to its result. */
|
||||
if (!isEmpty(main_node)) {
|
||||
const main_refs = Repository.apply_main_node_refs(
|
||||
uci, uciconfig, ucimain, ucinode,
|
||||
{ main_node, main_udp_node, has_nodes: added > 0 },
|
||||
log
|
||||
);
|
||||
for (let line in main_refs.log)
|
||||
log(line);
|
||||
}
|
||||
|
||||
Repository.scrub_stale_urltest_refs(uci, uciconfig, log);
|
||||
|
||||
/* A2: the run's single transaction boundary.
|
||||
*
|
||||
* apply_nodes(), apply_main_node_refs() and scrub_stale_urltest_refs()
|
||||
* stage every mutation on this one cursor and commit nothing. Before
|
||||
* this, each of them committed for itself - eight sites inside the
|
||||
* repository - so a crash (or a kill) between two of them could leave
|
||||
* the subscription's nodes written while main_node still pointed at a
|
||||
* section that had just been deleted. The commit below is the only
|
||||
* point at which the run becomes visible, and the reload only ever sees
|
||||
* a fully applied state.
|
||||
*
|
||||
* commit() returns true on success, so a failure is detected here rather
|
||||
* than by generating a configuration from a half-written file. */
|
||||
if (uci.commit(uciconfig) !== true) {
|
||||
log('FAILED to commit the new configuration; the previous configuration is kept.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Reload once, after the whole candidate set is committed
|
||||
* and stale references are scrubbed. The old code stopped
|
||||
* the service before fetching and then did stop+start; the
|
||||
* reload path now validates the new configuration and rolls
|
||||
* back if an instance fails to come up, so the service is
|
||||
* only ever restarted onto a config that passed `sing-box
|
||||
* check`. */
|
||||
log('Reloading service...');
|
||||
|
||||
/* Run the init script directly. The previous code called
|
||||
* `init_action('homeproxy', 'reload')` imported from luci.sys, but
|
||||
* neither openwrt/luci nor immortalwrt/luci exports an `init_action`
|
||||
* from that module (it has process_list / conntrack_list /
|
||||
* init_list / init_index / init_enabled), so the import failed to
|
||||
* resolve and this script could not load on a router at all.
|
||||
*
|
||||
* executeCommand() is the package's own runner (homeproxy.uc); unlike
|
||||
* a bare system() call it returns the exit status and the captured
|
||||
* stderr, so a failed reload is recorded instead of disappearing. */
|
||||
const reload = executeCommand('/etc/init.d/homeproxy', 'reload');
|
||||
if (reload.exitcode !== 0)
|
||||
log(sprintf('Warning: reload exited with status %d: %s',
|
||||
reload.exitcode, trim(reload.stderr || '')));
|
||||
else if (reload.stderr_truncated)
|
||||
/* Non-zero exit + stderr_truncated is unambiguous; the
|
||||
* log line above already carries the message. The truncated
|
||||
* case is the subtle one: a clean exit can still hide a
|
||||
* sing-box check warning that was truncated out of view. */
|
||||
log('Warning: reload stderr was truncated (>512 KiB); the captured output is incomplete.');
|
||||
|
||||
log(sprintf('%s nodes added, %s removed.', added, removed));
|
||||
log('Successfully updated subscriptions.');
|
||||
}
|
||||
|
||||
/* A4: the lock comes first, and every read happens inside it.
|
||||
*
|
||||
* This block used to test the subscription list before taking the lock, so
|
||||
* the read of the configuration happened outside the critical section. The
|
||||
* order is now: acquire -> read -> mutate -> commit -> reload -> release.
|
||||
* The pre-lock "nothing configured" shortcut is preserved in meaning (no
|
||||
* fetch, no write, no log) but it is decided after the lock is held; taking
|
||||
* and releasing an uncontended mkdir lock costs nothing measurable next to
|
||||
* the fetch that would follow. */
|
||||
if (!acquire_lock()) {
|
||||
log('Another subscription update is already running; skipping this one.');
|
||||
}
|
||||
else {
|
||||
/* ucode has no `finally`, so the release is written out on both paths by
|
||||
* the explicit call after the try/catch. */
|
||||
try {
|
||||
load_locked_state();
|
||||
|
||||
if (isEmpty(subscription_urls)) {
|
||||
/* Nothing configured: no fetch, no write, no log line - the same
|
||||
* silent no-op the pre-lock check performed. */
|
||||
}
|
||||
else {
|
||||
call(main);
|
||||
}
|
||||
} catch(e) {
|
||||
log('[FATAL ERROR] An error occurred during updating subscriptions:');
|
||||
log(sprintf('%s: %s', e.type, e.message));
|
||||
log(e.stacktrace[0].context);
|
||||
|
||||
if (config_backup != null) {
|
||||
/* Write the snapshot beside the config and rename it into place.
|
||||
* A bare writefile() truncates first, so a crash or a full disk
|
||||
* part-way through the restore leaves /etc/config/homeproxy
|
||||
* partial or empty - losing the very configuration this is
|
||||
* meant to protect. rename() is atomic within a directory. */
|
||||
const restore_tmp = CONFIG_FILE + '.hp-restore';
|
||||
|
||||
if (writefile(restore_tmp, config_backup) != null
|
||||
&& system(sprintf('mv -f %s %s', shellQuote(restore_tmp), shellQuote(CONFIG_FILE))) === 0) {
|
||||
log('Restored the previous configuration; the running service was not stopped.');
|
||||
}
|
||||
else {
|
||||
system(sprintf('rm -f %s', shellQuote(restore_tmp)));
|
||||
log('FAILED to restore the previous configuration - the file on disk may be incomplete.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release_lock();
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||
#
|
||||
# This file is the lifecycle orchestrator only. Everything
|
||||
# that is not "read the configuration, decide, and drive procd" now lives in
|
||||
# /etc/homeproxy/scripts/runtime/:
|
||||
#
|
||||
# config.sh generated-configuration transaction (known-good, restore,
|
||||
# rollback) - pure, takes explicit paths
|
||||
# health.sh the health gate: procd state + listener ownership, sustained
|
||||
# over several samples
|
||||
# service.sh sing-box version gate, auto-update cron, runtime-file
|
||||
# preparation, procd instance registration, the
|
||||
# generate->known-good transaction
|
||||
# dns.sh dnsmasq snippet directory, snippet writing, teardown
|
||||
# firewall.sh fw4 pre/post scripts, reload, owned-object teardown, upnp
|
||||
# net.sh tproxy / TUN ip rules and routes, teardown
|
||||
#
|
||||
# What deliberately stays here: USE_PROCD and the start/stop/reload/
|
||||
# service_triggers skeletons, the configuration reads that choose between
|
||||
# client, server and neither, and the ordering that makes a reload safe
|
||||
# (generate first, tear down later, prove it came up, roll back otherwise).
|
||||
#
|
||||
# The transaction, in order:
|
||||
#
|
||||
# start_service generate -> register with procd -> PROVE IT RUNS ->
|
||||
# record known-good
|
||||
# reload_service generate (abort before teardown if invalid) -> stop ->
|
||||
# start -> if start could not prove the new configuration,
|
||||
# roll back to the known-good copy and restart
|
||||
#
|
||||
# A3: on the reload path the generation happens exactly once. reload_service
|
||||
# generates each enabled side and hands start_service the per-side
|
||||
# HP_CANDIDATE_* flags; hp_start_generated_config() then activates that exact
|
||||
# file instead of generating a second one. The generator's env (WAN resolver,
|
||||
# domain-resource lists) can change between two invocations, so "validate A,
|
||||
# discard A, generate B, run B" is no longer possible.
|
||||
#
|
||||
# "Prove it runs" is the health gate in runtime/health.sh. It used to be a
|
||||
# single "is a matching process alive?" probe, which was satisfied by a stale
|
||||
# process and by procd's own respawn loop while the service was dead - and
|
||||
# because the generated file was recorded as known-good *before* that probe
|
||||
# ran, the rollback had nothing left to restore.
|
||||
#
|
||||
# What the rollback does NOT cover: the dnsmasq snippets, the ip rules/routes
|
||||
# and the nft rules are rebuilt from the *current* UCI on every start, so a
|
||||
# reload that fails because of a UCI-level change (a port that is now taken, a
|
||||
# proxy-mode switch) restores the sing-box configuration and leaves the network
|
||||
# layer describing the new settings. Rolling that back as well would mean
|
||||
# snapshotting UCI at promotion time; that is deliberately not attempted here,
|
||||
# which is why this paragraph exists rather than a note in a plan document.
|
||||
|
||||
USE_PROCD=1
|
||||
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
CONF="homeproxy"
|
||||
PROG="/usr/bin/sing-box"
|
||||
|
||||
HP_DIR="/etc/homeproxy"
|
||||
RUN_DIR="/var/run/homeproxy"
|
||||
LOG_PATH="$RUN_DIR/homeproxy.log"
|
||||
|
||||
# Last configuration that was proven to run. start_service records it only
|
||||
# after the health gate passed, and reload_service restores it when a new
|
||||
# configuration fails to come up.
|
||||
GOOD_DIR="$RUN_DIR/known-good"
|
||||
|
||||
|
||||
# fw4 chain/set inventory; single source of truth shared with firewall_post.ut
|
||||
[ -f "$HP_DIR/scripts/fw4_names.sh" ] && . "$HP_DIR/scripts/fw4_names.sh"
|
||||
|
||||
# Configuration transaction (known-good copy, restore, rollback) and the
|
||||
# health gate that decides whether a configuration may be recorded or must be
|
||||
# rolled back.
|
||||
[ -f "$HP_DIR/scripts/runtime/config.sh" ] && . "$HP_DIR/scripts/runtime/config.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/health.sh" ] && . "$HP_DIR/scripts/runtime/health.sh"
|
||||
|
||||
# Device-side orchestration extracted from the init script.
|
||||
[ -f "$HP_DIR/scripts/runtime/service.sh" ] && . "$HP_DIR/scripts/runtime/service.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/dns.sh" ] && . "$HP_DIR/scripts/runtime/dns.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/firewall.sh" ] && . "$HP_DIR/scripts/runtime/firewall.sh"
|
||||
[ -f "$HP_DIR/scripts/runtime/net.sh" ] && . "$HP_DIR/scripts/runtime/net.sh"
|
||||
|
||||
log() {
|
||||
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
# hp_client_outbound_node <routing-mode> <dest-var>
|
||||
# Read the client's "main node" reference into <dest-var>.
|
||||
#
|
||||
# The reference is mode-dependent: the built-in routing modes keep it on
|
||||
# config.main_node, while custom routing keeps it on routing.default_outbound.
|
||||
# start_service, service_started and reload_service all need this value to
|
||||
# decide whether a client instance exists at all, and each of them used to
|
||||
# spell the branch out itself.
|
||||
hp_client_outbound_node() {
|
||||
if [ "$1" != "custom" ]; then
|
||||
config_get "$2" "config" "main_node" "nil"
|
||||
else
|
||||
config_get "$2" "routing" "default_outbound" "nil"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the dnsmasq snippet directory once, at load time. stop_service
|
||||
# reuses the stored value rather than recomputing it, so a dnsmasq
|
||||
# reconfiguration between start and stop cannot make it delete the wrong
|
||||
# directory (see runtime/dns.sh).
|
||||
hp_dnsmasq_resolve_dir
|
||||
|
||||
# hp_intercept_installed
|
||||
# True when a previous start actually installed the intercept layer: the fw4
|
||||
# post fragment holds the redirect/tproxy chains, the dnsmasq include file
|
||||
# points at the snippets. Both live in tmpfs, so after a reboot there is
|
||||
# nothing stale to release.
|
||||
hp_intercept_installed() {
|
||||
[ -s "$RUN_DIR/fw4_post.nft" ] || [ -e "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" ]
|
||||
}
|
||||
|
||||
# hp_release_intercept
|
||||
# Take the intercept layer back down, without touching the service's own
|
||||
# instances (procd keeps supervising them).
|
||||
#
|
||||
# A start that cannot prove the client came up must not leave the redirect
|
||||
# rules and the DNS snippets in place: they point the LAN's TCP and DNS at
|
||||
# listeners that are not there, so the routers' clients lose both instead of
|
||||
# merely not being proxied. The reload path gets that for free through its
|
||||
# rollback; the start path (boot, a manual start, LuCI's Start button) has no
|
||||
# rollback at all, which is the asymmetry this closes.
|
||||
#
|
||||
# The server's inbound accepts (fw4_forward/fw4_input) are left in place: a
|
||||
# failed *client* does not make the server unreachable, and the two sides are
|
||||
# independent instances.
|
||||
#
|
||||
# The release is recorded in $RUN_DIR/intercept-released so that service_started
|
||||
# can put the layer back once the instance does come up. Without that marker the
|
||||
# release is one-way: procd keeps respawning the instance, the health check can
|
||||
# start passing on a later attempt, and nothing reinstalls the rules - the router
|
||||
# then reports the service as running while the LAN is not proxied at all.
|
||||
hp_release_intercept() {
|
||||
log "Reverting the intercept layer; traffic will go out unproxied until the client is up."
|
||||
hp_net_teardown
|
||||
hp_firewall_teardown "$RUN_DIR" keep-pre
|
||||
hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
|
||||
printf '%s\n' "1" > "$RUN_DIR/intercept-released"
|
||||
}
|
||||
|
||||
# hp_rearm_intercept <proxy-mode> <routing-mode>
|
||||
# Reinstall the layer a failed start released, once the client is proven up.
|
||||
# It only runs when that failure actually happened - on a healthy start there is
|
||||
# nothing to redo - which is what makes the release in hp_release_intercept()
|
||||
# reversible instead of a silent, permanent downgrade to direct routing.
|
||||
hp_rearm_intercept() {
|
||||
[ -f "$RUN_DIR/intercept-released" ] || return 0
|
||||
rm -f "$RUN_DIR/intercept-released"
|
||||
log "Client is up again; reinstalling the intercept layer."
|
||||
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2"
|
||||
hp_net_setup "$1" "$2"
|
||||
hp_firewall_apply "$HP_DIR" "$RUN_DIR" "1" \
|
||||
|| log "Error: the firewall rules could not be reinstalled; the proxy may not be enforced."
|
||||
}
|
||||
|
||||
# hp_release_stale_intercept
|
||||
# The same, but only when a previous start left something installed. Without
|
||||
# the check every start of an unconfigured router would pay a full fw4 reload
|
||||
# and a dnsmasq restart to undo rules that were never installed.
|
||||
hp_release_stale_intercept() {
|
||||
hp_intercept_installed || return 0
|
||||
hp_release_intercept
|
||||
}
|
||||
|
||||
start_service() {
|
||||
config_load "$CONF"
|
||||
|
||||
# Every refusal below happens *before* this run installs anything, so a
|
||||
# stale intercept layer is released rather than re-applied. Without it,
|
||||
# a router whose node was removed (or whose sing-box just failed its
|
||||
# version gate) keeps redirecting the LAN into a listener that no instance
|
||||
# is serving any more.
|
||||
if ! hp_require_singbox; then
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local routing_mode proxy_mode outbound_node server_enabled
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
|
||||
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
|
||||
log "Neither client nor server is configured. Service not started."
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local client_enabled=0
|
||||
[ "$outbound_node" = "nil" ] || client_enabled=1
|
||||
|
||||
# Client switched off (main_node cleared, or custom routing without a
|
||||
# default outbound): the redirect rules and the DNS snippets from the
|
||||
# previous start have to go, or the LAN keeps pointing at listeners no
|
||||
# instance is serving. This matters more since a reload keeps the DNS
|
||||
# layer across its stop (see stop_service): without it, a reload that
|
||||
# disables the client would leave the snippets installed forever.
|
||||
# The server's accepts (fw4_forward/fw4_input) are left alone.
|
||||
if [ "$client_enabled" != "1" ]; then
|
||||
hp_release_stale_intercept
|
||||
fi
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
# Kernel 6.6.x has a TUN/GSO bug that needs GSO turned off for the tun
|
||||
# inbound; other releases either carry the fix or never had the problem.
|
||||
local disable_gso=0
|
||||
uname -r | grep -Eq "^6\.6" && disable_gso=1
|
||||
|
||||
# The health gate reads the ports the running configuration declares from
|
||||
# sing-box-c.json (service_started -> hp_config_ports), not from UCI: after
|
||||
# a rollback the live file is the older known-good copy and may bind
|
||||
# different ports. Reading mixed_port/dns_port here as well only produced
|
||||
# two unused locals.
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
hp_net_wait_wan 60
|
||||
|
||||
if ! hp_start_generated_config c "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
|
||||
hp_release_stale_intercept
|
||||
return 1
|
||||
fi
|
||||
|
||||
local auto_update auto_update_time
|
||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
||||
config_get auto_update_time "subscription" "auto_update_time" "2"
|
||||
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
|
||||
|
||||
# The DNS snippets are the other half of the intercept layer: without
|
||||
# them the LAN resolves through the ISP while the nft rules still send
|
||||
# its DNS to sing-box's dns-in, which the redirect chain does not cover.
|
||||
# The writer already reports failures (render or install) instead of
|
||||
# leaving an empty snippet behind; record it the same way a firewall
|
||||
# failure is recorded, so the configuration is not promoted to
|
||||
# known-good and the log names the real problem.
|
||||
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then
|
||||
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
|
||||
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
|
||||
fi
|
||||
hp_net_setup "$proxy_mode" "$routing_mode"
|
||||
|
||||
# R-08: gfwlist mode routes by the nft set that dnsmasq populates
|
||||
# (`nftset=`), and the post-template gates every proxy chain on
|
||||
# `ip daddr != @homeproxy_gfw_list_v4`. On a dnsmasq without nftset the
|
||||
# set is never filled, so that gate is true for every packet and the
|
||||
# whole mode silently degrades to direct routing. Record the capability
|
||||
# where the template can read it (it renders before this point on the
|
||||
# next start, so the file has to exist by then) and let the template
|
||||
# omit the gate instead: traffic then reaches sing-box, which routes it
|
||||
# by geoip exactly like the nftset gate would have.
|
||||
hp_dnsmasq_has_nftset && printf '%s\n' "1" > "$HP_DIR/resources/.dnsmasq-nftset" \
|
||||
|| rm -f "$HP_DIR/resources/.dnsmasq-nftset"
|
||||
|
||||
hp_procd_client_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$routing_mode" "$disable_gso"
|
||||
fi
|
||||
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
if ! hp_start_generated_config s "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
|
||||
# Only the server side failed. hp_release_stale_intercept() tears
|
||||
# down the *client* layer (net rules + redirect chains + DNS
|
||||
# snippets), so calling it here took a healthy, already-verified
|
||||
# client off the air because a server certificate or port was bad -
|
||||
# the opposite of the "the two sides are independent instances"
|
||||
# rule this file states above. service_started()'s
|
||||
# failed="sing-box-s" path handles the server side and already
|
||||
# leaves the client layer alone.
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_procd_server_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$disable_gso"
|
||||
fi
|
||||
|
||||
hp_procd_log_cleaner "$HP_DIR"
|
||||
|
||||
hp_prepare_runtime_files "$HP_DIR" "$RUN_DIR" "$routing_mode" "$client_enabled" "$server_enabled"
|
||||
|
||||
# A firewall that did not apply means traffic is not actually being
|
||||
# intercepted. sing-box still runs (the service stays up), but the
|
||||
# configuration must not become the rollback target - service_started()
|
||||
# reads this marker before promoting.
|
||||
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "$client_enabled"; then
|
||||
log "Error: the firewall rules were not applied completely; the proxy may not be enforced."
|
||||
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
|
||||
fi
|
||||
|
||||
# Nothing is verified here on purpose. With USE_PROCD=1 the
|
||||
# procd_open_instance/procd_close_instance calls above only *register* the
|
||||
# instances; procd starts them when rc.common closes the service, which
|
||||
# happens after this function returns. Gating here would sample a service
|
||||
# that has not been started yet and always time out - the health gate
|
||||
# therefore lives in service_started(), which rc.common calls at the point
|
||||
# where the instances are actually up.
|
||||
return 0
|
||||
}
|
||||
|
||||
# service_started() is rc.common's post-start hook:
|
||||
#
|
||||
# start() { rc_procd start_service "$@"; service_started; }
|
||||
#
|
||||
# so this runs after procd_close_service. It is where a configuration earns
|
||||
# the right to be called good.
|
||||
service_started() {
|
||||
config_load "$CONF"
|
||||
|
||||
local routing_mode outbound_node server_enabled proxy_mode
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
|
||||
local client_enabled=0
|
||||
[ "$outbound_node" = "nil" ] || client_enabled=1
|
||||
|
||||
[ "$client_enabled" = "1" ] || [ "$server_enabled" = "1" ] || return 0
|
||||
|
||||
# The listeners to verify are the ones the running configuration declares,
|
||||
# read from the file rather than from UCI: after a rollback the live file
|
||||
# is the older known-good copy, so UCI describes ports this configuration
|
||||
# does not use.
|
||||
#
|
||||
# The gate checks that sing-box OWNS them: a port that is listening but
|
||||
# belongs to another process is exactly the failure it has to catch.
|
||||
#
|
||||
# `sing-box check` cannot see a port that is already taken or a
|
||||
# certificate it cannot read, and a single process probe cannot tell a
|
||||
# healthy instance from procd restarting a dying one.
|
||||
local failed="" client_ports server_ports
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
client_ports="$(hp_config_ports "$RUN_DIR/sing-box-c.json")"
|
||||
hp_wait_service "sing-box-c" "$RUN_DIR/sing-box-c.json" \
|
||||
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $client_ports \
|
||||
|| failed="sing-box-c"
|
||||
|
||||
# The probes read their ports from the running configuration, not from
|
||||
# UCI: after a rollback the live file is the older known-good copy and
|
||||
# may bind different ports, and probing the candidate's ports would
|
||||
# warn about a healthy service (or, with health_probe_strict on, roll
|
||||
# it back again).
|
||||
if [ -z "$failed" ]; then
|
||||
local probe_dns_port probe_mixed_port probe_strict probe_name
|
||||
probe_dns_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'dns-in')"
|
||||
probe_mixed_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'mixed-in')"
|
||||
config_get_bool probe_strict "config" "health_probe_strict" "0"
|
||||
config_get probe_name "config" "health_probe_name" "example.com"
|
||||
|
||||
HP_PROBE_STRICT="$probe_strict"
|
||||
HP_PROBE_NAME="$probe_name"
|
||||
export HP_PROBE_STRICT HP_PROBE_NAME
|
||||
|
||||
hp_run_probes "client" "$probe_dns_port" "$probe_mixed_port" \
|
||||
|| failed="sing-box-c"
|
||||
fi
|
||||
fi
|
||||
if [ "$server_enabled" = "1" ] && [ -z "$failed" ]; then
|
||||
server_ports="$(hp_config_ports "$RUN_DIR/sing-box-s.json")"
|
||||
hp_wait_service "sing-box-s" "$RUN_DIR/sing-box-s.json" \
|
||||
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $server_ports \
|
||||
|| failed="sing-box-s"
|
||||
fi
|
||||
|
||||
if [ -n "$failed" ]; then
|
||||
# start() returns procd's status, not start_service's, so the failure
|
||||
# is recorded where reload_service cannot miss it. A file is used
|
||||
# rather than a shell variable because service_started and
|
||||
# reload_service are not the same shell invocation.
|
||||
printf '%s\n' "$failed" > "$RUN_DIR/failed-side"
|
||||
log "Error: $failed did not come up with the new configuration."
|
||||
# The intercept rules were installed by start_service before this
|
||||
# gate ran, and on the *start* path nothing else takes them back
|
||||
# down: the LAN would keep sending TCP to the redirect port and DNS
|
||||
# to the dnsmasq snippets while no instance is listening. Only a
|
||||
# failed client black-holes the LAN - the server's accepts are
|
||||
# inbound and a failed *server* leaves the client working - so this
|
||||
# is scoped to the client side. On the reload path the rollback
|
||||
# below re-applies everything on its way to the old configuration.
|
||||
[ "$failed" = "sing-box-c" ] && hp_release_intercept
|
||||
# The rollback is driven by reload_service, which can stop and
|
||||
# restart the service.
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -f "$RUN_DIR/failed-side"
|
||||
|
||||
# A previous start may have released the intercept layer because the client
|
||||
# did not come up (hp_release_intercept). procd respawns the instance, and
|
||||
# now that it is proven healthy the rules have to come back - otherwise the
|
||||
# service runs and reports "running" while the LAN is not proxied.
|
||||
[ "$client_enabled" = "1" ] && hp_rearm_intercept "$proxy_mode" "$routing_mode"
|
||||
|
||||
# Proven good: this is what a later failed reload rolls back to. A
|
||||
# configuration whose intercept layer is incomplete is not "good" - traffic
|
||||
# is not being intercepted the way the configuration says - so it is not
|
||||
# recorded, and the previous known-good survives as the rollback target.
|
||||
# Two independent markers, because a successful hp_firewall_apply() must not
|
||||
# be able to mask a failed snippet install (or the other way round).
|
||||
if [ -s "$RUN_DIR/failed-firewall" ] || [ -s "$RUN_DIR/failed-dns" ]; then
|
||||
[ -s "$RUN_DIR/failed-firewall" ] \
|
||||
&& log "Warning: the firewall rules did not apply; keeping the previous known-good configuration."
|
||||
[ -s "$RUN_DIR/failed-dns" ] \
|
||||
&& log "Warning: the DNS snippets were not installed; keeping the previous known-good configuration."
|
||||
rm -f "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
|
||||
else
|
||||
[ "$client_enabled" = "1" ] && hp_promote_known_good c "$RUN_DIR" "$GOOD_DIR"
|
||||
[ "$server_enabled" = "1" ] && hp_promote_known_good s "$RUN_DIR" "$GOOD_DIR"
|
||||
fi
|
||||
|
||||
log "sing-box $(sing-box version -n 2>/dev/null || echo "unknown") started."
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
hp_clear_autoupdate_cron
|
||||
|
||||
config_load "$CONF"
|
||||
|
||||
# P1-8: on a reload the network layer is left in place too. The ip rules and
|
||||
# the routing table are the same before and after a configuration change
|
||||
# (they derive from the infra marks, not from the nodes), hp_net_setup() is
|
||||
# idempotent since r20, and tearing them down here only opened a window in
|
||||
# which the tproxy rules were gone. A real stop still removes them.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_net_teardown
|
||||
hp_firewall_teardown "$RUN_DIR"
|
||||
# A reload is a restart, not a shutdown, and it keeps the DNS layer: the
|
||||
# snippets are re-rendered by start_service moments later, and their writer
|
||||
# compares content before restarting dnsmasq. Taking them down here would
|
||||
# leak the LAN's queries to the ISP resolvers for the whole stop/start
|
||||
# window and restart dnsmasq twice (flushing every client's cache) for a
|
||||
# configuration that did not change. The cost is that DNS points at
|
||||
# sing-box's dns-in while the instances are down - a few seconds of failed
|
||||
# lookups instead of a few seconds of plaintext queries. A real stop (no
|
||||
# flag) removes them, and a reload that disables the client releases them
|
||||
# through hp_release_stale_intercept() in start_service.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
|
||||
|
||||
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
|
||||
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log" \
|
||||
"$RUN_DIR/failed-side" "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
|
||||
# A released marker only means something within one start; a stop clears it
|
||||
# so the next start does not reinstall a layer it is about to install anyway.
|
||||
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || rm -f "$RUN_DIR/intercept-released"
|
||||
|
||||
log "Service stopped."
|
||||
}
|
||||
|
||||
service_stopped() {
|
||||
config_load "$CONF"
|
||||
|
||||
hp_net_remove_tun
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
log "Reloading service..."
|
||||
|
||||
config_load "$CONF"
|
||||
local routing_mode
|
||||
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
|
||||
|
||||
local outbound_node
|
||||
hp_client_outbound_node "$routing_mode" outbound_node
|
||||
|
||||
# Generate first, tear down later. If the new configuration is invalid
|
||||
# the generator leaves the running one in place, so the reload is
|
||||
# aborted *before* stop_service() - the old configuration keeps running
|
||||
# instead of the router ending up with no service at all.
|
||||
#
|
||||
# A3: whichever sides are generated here are the candidates start_service
|
||||
# must activate - it must not generate them a second time. Two invocations
|
||||
# of the generator are not guaranteed to write the same bytes (its env
|
||||
# carries the WAN resolver and the domain-resource lists), so regenerating
|
||||
# in start_service would run a file that was never `sing-box check`ed:
|
||||
# validate A -> discard A -> generate B -> run B.
|
||||
#
|
||||
# The flags are per side on purpose: reload_service and start_service each
|
||||
# do their own config_load, and if the two disagree (a concurrent UCI
|
||||
# change) the side this preflight did not generate must still be generated
|
||||
# rather than activated from a stale file.
|
||||
HP_CANDIDATE_CLIENT=0
|
||||
HP_CANDIDATE_SERVER=0
|
||||
|
||||
if [ "$outbound_node" != "nil" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
HP_CANDIDATE_CLIENT=1
|
||||
fi
|
||||
|
||||
local server_enabled
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
HP_CANDIDATE_SERVER=1
|
||||
fi
|
||||
|
||||
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
|
||||
# file that was just validated would not survive to start_service. Stage a
|
||||
# copy of each validated side first; the stop that follows cannot reach it,
|
||||
# and start_service activates those exact bytes. A staging failure aborts
|
||||
# the reload while the running configuration is still untouched.
|
||||
if [ "$HP_CANDIDATE_CLIENT" = "1" ]; then
|
||||
if ! hp_capture_candidate "$RUN_DIR" c; then
|
||||
log "Error: could not stage the validated client configuration; reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
if [ "$HP_CANDIDATE_SERVER" = "1" ]; then
|
||||
if ! hp_capture_candidate "$RUN_DIR" s; then
|
||||
log "Error: could not stage the validated server configuration; reload aborted; the running configuration is kept."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# `start` runs start_service in this same shell, but export them anyway so
|
||||
# the contract does not depend on that: HP_USE_KNOWN_GOOD is exported the
|
||||
# same way in the rollback path below.
|
||||
export HP_CANDIDATE_CLIENT HP_CANDIDATE_SERVER
|
||||
|
||||
# Both configurations passed `sing-box check`; now it is safe to restart.
|
||||
# start_service owns the rest of the transaction: it proves the instances
|
||||
# came up and only then records the new known-good, so the previous one
|
||||
# survives until that point - which is what makes the rollback below
|
||||
# possible.
|
||||
#
|
||||
# The flag tells stop_service that this stop belongs to a reload, so the
|
||||
# DNS layer is left in place for start_service to re-render (it compares
|
||||
# content and only restarts dnsmasq when something actually changed). It
|
||||
# stays set for the rollback path below, which is also a reload.
|
||||
HP_RELOAD_IN_PROGRESS=1
|
||||
export HP_RELOAD_IN_PROGRESS
|
||||
stop
|
||||
start
|
||||
|
||||
# start_service proved the candidate does not run (it left the marker).
|
||||
# The known-good copy still holds the previous configuration, because a
|
||||
# candidate is no longer recorded before it has proven itself.
|
||||
local failed=""
|
||||
[ -s "$RUN_DIR/failed-side" ] && failed="$(cat "$RUN_DIR/failed-side")"
|
||||
|
||||
# reload_service and start_service each do their own config_load. If the
|
||||
# two disagree - a concurrent reload, or a read that fell back to the UCI
|
||||
# defaults - the marker can be missing while nothing was started at all.
|
||||
# The filesystem is the tie-breaker: a side this reload believed was
|
||||
# enabled must have produced a live configuration.
|
||||
if [ -z "$failed" ] && [ "$outbound_node" != "nil" ] && [ ! -s "$RUN_DIR/sing-box-c.json" ]; then
|
||||
failed="sing-box-c"
|
||||
fi
|
||||
if [ -z "$failed" ] && [ "$server_enabled" = "1" ] && [ ! -s "$RUN_DIR/sing-box-s.json" ]; then
|
||||
failed="sing-box-s"
|
||||
fi
|
||||
|
||||
if [ -z "$failed" ]; then
|
||||
log "Reload completed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Error: $failed did not come up with the new configuration, rolling back."
|
||||
|
||||
local good="$GOOD_DIR/$failed.json"
|
||||
local live="$RUN_DIR/$failed.json"
|
||||
local rolled_back=0
|
||||
|
||||
if hp_same_file "$live" "$good"; then
|
||||
log "Error: the failing configuration is already the known-good one; nothing to roll back to."
|
||||
elif hp_rollback "$live" "$good"; then
|
||||
rolled_back=1
|
||||
else
|
||||
log "Error: no known-good configuration to roll back to."
|
||||
fi
|
||||
|
||||
if [ "$rolled_back" = "0" ]; then
|
||||
log "Error: rollback was not possible; the service may be down. Check $LOG_PATH."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Start from the recorded file rather than regenerating: the generator
|
||||
# is a pure function of UCI, so it would rebuild the config that just
|
||||
# failed.
|
||||
HP_USE_KNOWN_GOOD=1
|
||||
# The failed candidate has just exhausted procd's respawn retries, so the
|
||||
# restart below can take noticeably longer than a normal start.
|
||||
HP_HEALTH_BUDGET="$HP_HEALTH_ROLLBACK_BUDGET"
|
||||
export HP_USE_KNOWN_GOOD HP_HEALTH_BUDGET
|
||||
|
||||
stop
|
||||
start
|
||||
|
||||
if [ ! -s "$RUN_DIR/failed-side" ]; then
|
||||
log "Rollback completed; the previous configuration is running."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Error: rollback did not restore the service; manual intervention required."
|
||||
return 1
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "$CONF"
|
||||
# `wan` is the logical name netifd and the firewall use on every target
|
||||
# (a PPPoE uplink is `wan` at the logical layer, `pppoe-wan` only at the
|
||||
# device layer), so this does not need a per-device lookup.
|
||||
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ -f "/www/luci-static/resources/icons/loading.gif" ] && \
|
||||
sed -i "s,/loading.svg,/loading.gif,g" "/www/luci-static/resources/view/homeproxy/status.js"
|
||||
|
||||
uci -q batch <<-EOF >"/dev/null"
|
||||
delete firewall.homeproxy_pre
|
||||
|
||||
delete firewall.homeproxy_forward
|
||||
set firewall.homeproxy_forward=include
|
||||
set firewall.homeproxy_forward.type=nftables
|
||||
set firewall.homeproxy_forward.path="/var/run/homeproxy/fw4_forward.nft"
|
||||
set firewall.homeproxy_forward.position="chain-pre"
|
||||
set firewall.homeproxy_forward.chain="forward"
|
||||
|
||||
delete firewall.homeproxy_input
|
||||
set firewall.homeproxy_input=include
|
||||
set firewall.homeproxy_input.type=nftables
|
||||
set firewall.homeproxy_input.path="/var/run/homeproxy/fw4_input.nft"
|
||||
set firewall.homeproxy_input.position="chain-pre"
|
||||
set firewall.homeproxy_input.chain="input"
|
||||
|
||||
delete firewall.homeproxy_post
|
||||
set firewall.homeproxy_post=include
|
||||
set firewall.homeproxy_post.type=nftables
|
||||
set firewall.homeproxy_post.path="/var/run/homeproxy/fw4_post.nft"
|
||||
set firewall.homeproxy_post.position="table-post"
|
||||
commit firewall
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
ucode "/etc/homeproxy/scripts/migrate_config.uc"
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"admin/services/homeproxy": {
|
||||
"title": "HomeProxy",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-homeproxy" ],
|
||||
"uci": { "homeproxy": true }
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/client": {
|
||||
"title": "Client Settings",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/client"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/node": {
|
||||
"title": "Node Settings",
|
||||
"order": 15,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/node"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/server": {
|
||||
"title": "Server Settings",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/server"
|
||||
}
|
||||
},
|
||||
"admin/services/homeproxy/status": {
|
||||
"title": "Service Status",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "homeproxy/status"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"luci-app-homeproxy": {
|
||||
"description": "Grant access to homeproxy configuration",
|
||||
"read": {
|
||||
"file": {
|
||||
"/var/run/homeproxy/homeproxy.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-c.log": [ "read" ],
|
||||
"/var/run/homeproxy/sing-box-s.log": [ "read" ]
|
||||
},
|
||||
"ubus": {
|
||||
"service": [ "list" ],
|
||||
"luci.homeproxy": [
|
||||
"acllist_read",
|
||||
"connection_check",
|
||||
"node_parse",
|
||||
"resources_get_version",
|
||||
"singbox_get_features",
|
||||
"update_subscriptions_status"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
},
|
||||
"write": {
|
||||
"file": {
|
||||
"/tmp/homeproxy_cert_server_publickey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_server_privatekey.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ca.tmp": [ "write" ],
|
||||
"/tmp/homeproxy_cert_client_ech_conf.tmp": [ "write" ]
|
||||
},
|
||||
"ubus": {
|
||||
"luci.homeproxy": [
|
||||
"acllist_write",
|
||||
"certificate_write",
|
||||
"log_clean",
|
||||
"resources_update",
|
||||
"singbox_generator",
|
||||
"update_subscriptions"
|
||||
]
|
||||
},
|
||||
"uci": [ "homeproxy" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2023-2024 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { access, error, lstat, popen, readfile, writefile } from 'fs';
|
||||
|
||||
import { isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy/scripts/homeproxy.uc';
|
||||
import { parse_uri } from '/etc/homeproxy/scripts/parser/uri.uc';
|
||||
|
||||
/* Kanged from ucode/luci - replaced by the imported shellQuote() so every shell
|
||||
* argument in this file goes through one helper. */
|
||||
function hasKernelModule(kmod) {
|
||||
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
|
||||
}
|
||||
|
||||
const HP_DIR = '/etc/homeproxy';
|
||||
const RUN_DIR = '/var/run/homeproxy';
|
||||
const LOG_PATH = `${RUN_DIR}/homeproxy.log`;
|
||||
|
||||
/* Queried by singbox_get_features and by node_parse: the share-link parser
|
||||
* skips QUIC protocols and uTLS fingerprints the running sing-box cannot
|
||||
* serve, so it needs the same feature set the UI displays. */
|
||||
function collectFeatures() {
|
||||
let features = {};
|
||||
|
||||
const fd = popen('/usr/bin/sing-box version');
|
||||
if (fd) {
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (match(trim(line), /^sing-box version (.*)/))
|
||||
features.version = match(trim(line), /^sing-box version (.*)/)[1];
|
||||
|
||||
let tags = match(trim(line), /^Tags: (.*)/);
|
||||
if (tags)
|
||||
for (let i in split(tags[1], ','))
|
||||
features[i] = true;
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
features.hp_has_ip_full = access('/usr/libexec/ip-full');
|
||||
features.hp_has_tcp_brutal = hasKernelModule('brutal.ko');
|
||||
features.hp_has_tproxy = hasKernelModule('nft_tproxy.ko') || access('/etc/modules.d/nft-tproxy');
|
||||
features.hp_has_tun = hasKernelModule('tun.ko') || access('/etc/modules.d/30-tun');
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
acllist_read: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { content: null, error: 'illegal type' };
|
||||
|
||||
const filecontent = readfile(`${HP_DIR}/resources/${req.args?.type}.txt`);
|
||||
return { content: filecontent };
|
||||
}
|
||||
},
|
||||
acllist_write: {
|
||||
args: { type: 'type', content: 'content' },
|
||||
call: function(req) {
|
||||
if (index(['direct_list', 'proxy_list'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const file = `${HP_DIR}/resources/${req.args?.type}.txt`;
|
||||
let content = req.args?.content;
|
||||
|
||||
/* Sanitize content */
|
||||
if (content) {
|
||||
content = trim(content);
|
||||
content = replace(content, /\r\n?/g, '\n');
|
||||
if (!match(content, /\n$/))
|
||||
content += '\n';
|
||||
|
||||
/* Domains only: reject lines that could break the downstream
|
||||
dnsmasq/nftset injection (whitespace / shell or directive chars).
|
||||
Review L1: the rejection used to say "invalid character in
|
||||
domain list" with no line number, so a 200-line list with one
|
||||
bad entry forced the user to bisect by hand. Report line 1
|
||||
for the first bad row so the user can jump straight to it. */
|
||||
const lines = split(content, /\n/);
|
||||
for (let i = 0; i < length(lines); i++) {
|
||||
const line = lines[i];
|
||||
if (!trim(line))
|
||||
continue;
|
||||
if (match(line, /[ \t\/\\;<>="'&#|]/))
|
||||
return { result: false, error: `line ${i + 1}: invalid character in domain list (only domains, no spaces/comments)` };
|
||||
}
|
||||
}
|
||||
|
||||
/* writefile() with a null body writes nothing useful; an empty
|
||||
* body is what clearing the field means. */
|
||||
if (content == null)
|
||||
content = '';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/resources`);
|
||||
writefile(file, content);
|
||||
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
certificate_write: {
|
||||
args: { filename: 'filename' },
|
||||
call: function(req) {
|
||||
/* `kind` is 'cert', 'key' or 'ech'. Each certificate button
|
||||
* has its own PEM shape; an ECH config list in particular is
|
||||
* neither a certificate nor a key, so reusing isValidPEM()
|
||||
* for it would reject every valid upload with a misleading
|
||||
* "does not look like a correct PEM file". */
|
||||
const writeCertificate = (filename, kind) => {
|
||||
/* Per-button staging path. The previous code used a
|
||||
* single /tmp/homeproxy_certificate.tmp shared by every
|
||||
* certificate button, which was a race: the second upload
|
||||
* landed on top of the first while the backend was still
|
||||
* reading it, and a slow upload could overwrite a faster
|
||||
* one. The frontend (homeproxy.js uploadCertificate)
|
||||
* derives the same path from the UCI option name, and
|
||||
* the ACL write list enumerates all four. */
|
||||
const tmpcert = `/tmp/homeproxy_cert_${filename}.tmp`;
|
||||
const filestat = lstat(tmpcert);
|
||||
|
||||
if (!filestat || filestat.type !== 'file' || filestat.size <= 0) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'empty certificate file' };
|
||||
}
|
||||
|
||||
let filecontent = readfile(tmpcert);
|
||||
if (isBinary(filecontent)) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'illegal file type: binary' };
|
||||
}
|
||||
|
||||
/* Validate the PEM structure before storing it */
|
||||
const wellformed = kind === 'ech'
|
||||
? isValidECHConfig(filecontent)
|
||||
: isValidPEM(filecontent, kind === 'key');
|
||||
|
||||
if (!wellformed) {
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
return { result: false, error: 'this does not look like a correct PEM file' };
|
||||
}
|
||||
|
||||
/* Sanitize certificate */
|
||||
filecontent = trim(filecontent);
|
||||
filecontent = replace(filecontent, /\r\n?/g, '\n');
|
||||
if (!match(filecontent, /\n$/))
|
||||
filecontent += '\n';
|
||||
|
||||
system(`mkdir -p ${shellQuote(HP_DIR)}/certs`);
|
||||
writefile(`${HP_DIR}/certs/${filename}.pem`, filecontent);
|
||||
/* writefile() is fopen(path, "w"): 0666 & ~umask, i.e. 0644 at
|
||||
* the usual 022 - world-readable, on a file that can be a TLS
|
||||
* private key. Start it at 0600; the start path
|
||||
* (runtime/service.sh, hp_prepare_runtime_files) hands the
|
||||
* directory and its files to the sing-box user so the jailed
|
||||
* server can read them, with the mode kept at 0600. */
|
||||
system(`chmod 600 ${shellQuote(HP_DIR)}/certs/${shellQuote(filename + '.pem')}`);
|
||||
system(`rm -f ${shellQuote(tmpcert)}`);
|
||||
|
||||
return { result: true };
|
||||
};
|
||||
|
||||
const filename = req.args?.filename;
|
||||
switch (filename) {
|
||||
case 'client_ca':
|
||||
case 'server_publickey':
|
||||
return writeCertificate(filename, 'cert');
|
||||
break;
|
||||
case 'server_privatekey':
|
||||
return writeCertificate(filename, 'key');
|
||||
break;
|
||||
/* The node form's fourth upload button. It was missing here
|
||||
* from the initial commit, so the button always failed with
|
||||
* "illegal cerificate filename" while the ACL granted the
|
||||
* two write paths - the frontend assumed the backend case
|
||||
* existed and nothing tested that it did. */
|
||||
case 'client_ech_conf':
|
||||
return writeCertificate(filename, 'ech');
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal cerificate filename' };
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
connection_check: {
|
||||
args: { site: 'site' },
|
||||
call: function(req) {
|
||||
let url;
|
||||
switch(req.args?.site) {
|
||||
case 'baidu':
|
||||
url = 'https://www.baidu.com';
|
||||
break;
|
||||
case 'google':
|
||||
url = 'https://www.google.com';
|
||||
break;
|
||||
default:
|
||||
return { result: false, error: 'illegal site' };
|
||||
break;
|
||||
}
|
||||
|
||||
return { result: (system(`/usr/bin/wget --spider -qT3 ${shellQuote(url)} 2>"/dev/null"`, 3100) === 0) };
|
||||
}
|
||||
},
|
||||
|
||||
log_clean: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['homeproxy', 'sing-box-c', 'sing-box-s'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const filestat = lstat(`${RUN_DIR}/${req.args?.type}.log`);
|
||||
if (filestat)
|
||||
writefile(`${RUN_DIR}/${req.args?.type}.log`, '');
|
||||
return { result: true };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_generator: {
|
||||
args: { type: 'type', params: 'params' },
|
||||
call: function(req) {
|
||||
if (index(['ech-keypair', 'uuid', 'reality-keypair', 'vapid-keypair', 'wg-keypair'], req.args?.type) === -1)
|
||||
return { result: false, error: 'illegal type' };
|
||||
|
||||
const type = req.args?.type;
|
||||
let result = {};
|
||||
|
||||
const fd = popen(`/usr/bin/sing-box generate ${shellQuote(type)} ${shellQuote(req.args?.params || '')}`);
|
||||
if (fd) {
|
||||
let ech_cfg_set = false;
|
||||
let ech_key_set = false;
|
||||
|
||||
for (let line = fd.read('line'); length(line); line = fd.read('line')) {
|
||||
if (type === 'uuid')
|
||||
result.uuid = trim(line);
|
||||
else if (index(['reality-keypair', 'vapid-keypair', 'wg-keypair'], type) !== -1) {
|
||||
let priv = match(trim(line), /PrivateKey: (.*)/);
|
||||
if (priv)
|
||||
result.private_key = priv[1];
|
||||
let pub = match(trim(line), /PublicKey: (.*)/);
|
||||
if (pub)
|
||||
result.public_key = pub[1];
|
||||
} else if (type === 'ech-keypair') {
|
||||
if (trim(line) === '-----BEGIN ECH CONFIGS-----')
|
||||
ech_cfg_set = true;
|
||||
else if (trim(line) === '-----BEGIN ECH KEYS-----')
|
||||
ech_key_set = true;
|
||||
|
||||
if (ech_cfg_set)
|
||||
result.ech_cfg = result.ech_cfg ? result.ech_cfg + '\n' + trim(line) : trim(line) ;
|
||||
if (ech_key_set)
|
||||
result.ech_key = result.ech_key ? result.ech_key + '\n' + trim(line) : trim(line) ;
|
||||
|
||||
if (trim(line) === '-----END ECH CONFIGS-----')
|
||||
ech_cfg_set = false;
|
||||
else if (trim(line) === '-----END ECH KEYS-----')
|
||||
ech_key_set = false;
|
||||
}
|
||||
}
|
||||
|
||||
fd.close();
|
||||
}
|
||||
|
||||
return { result };
|
||||
}
|
||||
},
|
||||
|
||||
singbox_get_features: {
|
||||
call: function() {
|
||||
return collectFeatures();
|
||||
}
|
||||
},
|
||||
|
||||
/* Parse one share link with the same parser the subscription pipeline
|
||||
* uses (parse_uri.uc). This exists so the browser does not need a second
|
||||
* implementation: the LuCI form used to carry a 388-line JavaScript copy
|
||||
* of the parser that had already drifted from the backend (the vmess
|
||||
* branch lost vmess_global_padding, anytls defaulted a missing port to
|
||||
* 80) and that wrote nodes into UCI without the /sbin/validate_data
|
||||
* host/port checks parse_uri() applies. */
|
||||
node_parse: {
|
||||
args: { uri: 'string' },
|
||||
call: function(req) {
|
||||
const uri = req.args?.uri;
|
||||
|
||||
if (type(uri) !== 'string' || !length(uri) || length(uri) > 4096)
|
||||
return { config: null, error: 'illegal share link' };
|
||||
|
||||
let config;
|
||||
try {
|
||||
/* parse_uri() reports skipped nodes through the logger;
|
||||
* the caller only needs the verdict. */
|
||||
config = parse_uri(trim(uri), collectFeatures(), function() {});
|
||||
} catch (e) {
|
||||
return { config: null, error: 'parse failed: ' + e.message };
|
||||
}
|
||||
|
||||
if (!config)
|
||||
return { config: null, error: 'unsupported or invalid share link' };
|
||||
|
||||
return { config: config };
|
||||
}
|
||||
},
|
||||
|
||||
resources_get_version: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
/* whitelist resource types to prevent path traversal */
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { error: 'illegal type', version: null, updated_at: null };
|
||||
let version = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.ver`));
|
||||
/* Review M7: the version file holds the upstream commit date
|
||||
* and SHA; .updated_at is when *this router* last succeeded.
|
||||
* The two are not the same - a successful run on a stale
|
||||
* router still records the same upstream date, so the UI
|
||||
* shows both. */
|
||||
/* The shipped .ver files hold a single timestamp (the format the
|
||||
* resource updater used before it started recording the upstream
|
||||
* commit); a runtime update writes "<date> <sha>". Show the date
|
||||
* part when there is one, and the value as it stands otherwise.
|
||||
*
|
||||
* No non-capturing groups: ucode's regex engine rejects `(?:...)`
|
||||
* with "Repetition not preceded by valid expression" when the
|
||||
* literal is evaluated, so it compiles fine and then takes this
|
||||
* method down at call time. */
|
||||
const fields = version ? match(version, /^(\S+) \S+$/) : null;
|
||||
version = fields ? fields[1] : version;
|
||||
/* Only a missing .ver means "not found"; that is the case the
|
||||
* UI paints red. */
|
||||
if (!version)
|
||||
return { version: null, updated_at: null, error: 'no version file' };
|
||||
/* .updated_at is written by update_resources.sh after a
|
||||
* successful run, so a device that has never updated anything
|
||||
* legitimately has none. Returning error() here reported the
|
||||
* errno left behind by *that* missing file, which made every
|
||||
* fresh install show "not found" in red next to a perfectly
|
||||
* good version string. */
|
||||
const updated_at = trim(readfile(`${HP_DIR}/resources/${req.args?.type}.updated_at`));
|
||||
return { version: version, updated_at: updated_at || null };
|
||||
}
|
||||
},
|
||||
resources_update: {
|
||||
args: { type: 'type' },
|
||||
call: function(req) {
|
||||
if (index(['china_ip4', 'china_ip6', 'gfw_list', 'china_list'], req.args?.type) === -1)
|
||||
return { status: 255, error: 'illegal type' };
|
||||
if (req.args?.type) {
|
||||
const exit_code = system(`${shellQuote(HP_DIR)}/scripts/update_resources.sh ${shellQuote(req.args?.type)}`);
|
||||
return { status: exit_code };
|
||||
} else
|
||||
return { status: 255, error: 'illegal type' };
|
||||
}
|
||||
},
|
||||
update_subscriptions: {
|
||||
/* Run the subscription updater once and report how it went.
|
||||
*
|
||||
* The LuCI button used fs.exec_direct() on
|
||||
* /etc/homeproxy/scripts/update_subscriptions.uc, which is the only
|
||||
* reason the package ACL had to grant `file exec` on a root-owned
|
||||
* entry point: the browser session was authorised to execute that
|
||||
* path. Through one named ubus method the session is granted the
|
||||
* action instead, and the ACL lists no executable path at all
|
||||
* (arch-guard 30).
|
||||
*
|
||||
* The previous synchronous shape returned the orchestrator's exit
|
||||
* status and captured stderr, but the full pipeline (wget fetch
|
||||
* through a CN -> overseas link, UCI commit, /etc/init.d/homeproxy
|
||||
* reload, the 30 s sing-box health gate) regularly took 10-15 s,
|
||||
* which exceeded the browser XHR timeout on every browser we tried.
|
||||
* The UI button then surfaced "XHR request timed out" while the
|
||||
* backend happily completed the work; the user was left with a
|
||||
* stale page and no obvious way to know whether anything happened.
|
||||
*
|
||||
* The fix: spawn the script detached via "sh -c '... &'", return
|
||||
* immediately, and let the frontend poll update_subscriptions_status
|
||||
* until the orchestrator's lock file is gone (created in
|
||||
* acquire_lock(), removed on every exit path). The lock is also
|
||||
* what serialises two simultaneous updates, so refusing a second
|
||||
* call while the lock is present is consistent with the script's
|
||||
* own behaviour. The script's stdio is redirected so it cannot
|
||||
* block on a closed pipe when the rpcd process exits. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
if (lstat(lock))
|
||||
return { result: false, error: 'Subscription update already running', running: true };
|
||||
|
||||
/* Trailing '&' is what makes this return immediately: /bin/sh
|
||||
* backgrounds the script before the shell exits, and system()
|
||||
* sees only the (sub-)shell's exit. Redirections (<dev/null,
|
||||
* >dev/null, 2>&1) are the other half: the orphaned script
|
||||
* must not hold open any fd inherited from rpcd, or it
|
||||
* keeps rpcd's stdio alive across the call. */
|
||||
const script = shellQuote(`${HP_DIR}/scripts/update_subscriptions.uc`);
|
||||
system(`sh -c ${shellQuote(`${script} </dev/null >/dev/null 2>&1 &`)}`);
|
||||
|
||||
return { result: true, async: true };
|
||||
}
|
||||
},
|
||||
|
||||
update_subscriptions_status: {
|
||||
/* Status companion to update_subscriptions. Reports whether the
|
||||
* updater is still running (lock file present) and the last ~12
|
||||
* lines of the homeproxy log so the page can show what the run
|
||||
* said without re-reading everything. The log is world-readable
|
||||
* and is rotated by runtime/service.sh -> log-cleaner.sh.
|
||||
*
|
||||
* `readfile()` returns null (not throws) on a missing file in this
|
||||
* ucode build, and the downstream split/slice/join then propagate
|
||||
* that null straight through. Guard with a truthy check on the
|
||||
* raw text so log_tail stays a string even when the log has never
|
||||
* been written - the test_rpc_methods shape contract pins it as
|
||||
* `string`, and the UI otherwise shows "null" in the toast. */
|
||||
call: function() {
|
||||
const lock = `${RUN_DIR}/update_subscriptions.lock`;
|
||||
const running = !!lstat(lock);
|
||||
|
||||
let tail = '';
|
||||
try {
|
||||
const text = readfile(`${LOG_PATH}`);
|
||||
if (text) {
|
||||
const lines = split(text, '\n');
|
||||
const start = length(lines) > 12 ? length(lines) - 12 : 0;
|
||||
tail = join('\n', slice(lines, start)) || '';
|
||||
}
|
||||
} catch (e) {
|
||||
tail = '';
|
||||
}
|
||||
|
||||
return { running, log_tail: tail };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { 'luci.homeproxy': methods };
|
||||
Reference in new issue
Block a user