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

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

No files matched your search

@@ -0,0 +1,244 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The inverse of
* parser/normalize.uc. Where normalize() turns the parser's flat
* UCI-key output into the canonical Node shape the Adapter reads,
* flatten() turns a canonical Node back into the flat UCI-key dict
* UCI needs.
*
* The Repository's apply_nodes() is the only production caller: it is
* the one boundary where a canonical Node becomes UCI keys, which is
* what lets the orchestrator, the filter and the policy step work on
* canonical Nodes alone.
*
* Same single-source-of-truth discipline: every canonical -> UCI
* mapping comes from parser/mapping.uc's PROTOCOL_TO_UCI. Adding a
* protocol option is a one-place change in mapping.uc, and both
* directions pick it up.
*
* The output is the same shape the parser emits plus whatever
* metadata fields the orchestrator (or Repository) added
* (label, grouphash, isExisting). flatten() does not invent or
* strip them: it only does the canonical-sub-object -> flat-key
* expansion, then layers identity on top, then copies metadata
* through unchanged.
*
* `null` / `undefined` canonical values are written as null too -
* UCI serialises null as an empty option, which the Loader reads
* back as null, so the round-trip is preserved.
*/
'use strict';
import { PROTOCOL_TO_UCI } from './mapping.uc';
/* Per-protocol option fields: canonical -> UCI keys. Mirrors the
* loader's load_protocol_options() / normalize.uc's
* build_protocol_options(). */
function flatten_protocol_options(protocol, node) {
const map = PROTOCOL_TO_UCI[protocol] || {};
const opts = node.protocol_options || {};
const out = {};
for (let canonical, uci in map)
out[uci] = opts[canonical];
return out;
}
/* TLS fields: shared across protocols, sourced from the canonical
* `tls` sub-object. Mirrors normalize.uc's build_tls(). */
function flatten_tls(node) {
const tls = node.tls || {};
const ech = tls.ech || {};
const utls = tls.utls || {};
const reality = tls.reality || {};
return {
tls: tls.enabled,
tls_sni: tls.server_name,
tls_insecure: tls.insecure,
tls_alpn: tls.alpn,
tls_min_version: tls.min_version,
tls_max_version: tls.max_version,
tls_handshake_timeout: tls.handshake_timeout,
tls_cipher_suites: tls.cipher_suites,
tls_cert_path: tls.cert_path,
tls_ech: ech.enabled,
tls_ech_config: ech.config,
tls_ech_config_path: ech.config_path,
tls_utls: utls.fingerprint,
tls_reality: reality.enabled,
tls_reality_public_key: reality.public_key,
tls_reality_short_id: reality.short_id
};
}
/* Transport: share-link parsers spread transport fields across
* multiple UCI keys (http_host, ws_host, ws_path, httpupgrade_host,
* http_path, http_method, websocket_early_data(_header),
* grpc_servicename, http_idle_timeout, http_ping_timeout,
* grpc_permit_without_stream). Mirrors normalize.uc's
* build_transport(). */
function flatten_transport(node) {
const t = (node.transport || {}).type;
const out = { transport: t || null };
if (!t)
return out;
/* The host / path UCI option names depend on the transport: a ws
* transport stores them as ws_path / ws_host, an httpupgrade as
* http_path / httpupgrade_host, plain http as http_host / http_path.
* normalize() collapses both path spellings into transport.path, so
* flatten() has to pick the right one back or a re-written node
* would sprout an http_path the parser never produced (and drop the
* ws_path the loader's `http_path || ws_path` fallback had read). */
if (t === 'ws') {
out.ws_host = node.transport.headers?.Host;
out.ws_path = node.transport.path;
}
if (t === 'httpupgrade') {
out.httpupgrade_host = node.transport.host;
out.http_path = node.transport.path;
}
if (t === 'http') {
out.http_host = node.transport.host;
out.http_path = node.transport.path;
out.http_method = node.transport.method;
}
if (t === 'grpc')
out.grpc_servicename = node.transport.service_name;
out.http_idle_timeout = node.transport.idle_timeout;
out.http_ping_timeout = node.transport.ping_timeout;
out.grpc_permit_without_stream = node.transport.permit_without_stream;
out.websocket_early_data_header = node.transport.early_data_header_name;
out.websocket_early_data = node.transport.max_early_data;
return out;
}
/* Common: every outbound protocol carries these. */
function flatten_common(node) {
const c = node.common || {};
return {
proxy_protocol: c.proxy_protocol,
tcp_fast_open: c.tcp_fast_open,
tcp_multi_path: c.tcp_multi_path,
udp_fragment: c.udp_fragment
};
}
/* Multiplex: shares a brutal nested sub-shape with normalize.uc. */
function flatten_multiplex(node) {
const m = node.multiplex || {};
const brutal = m.brutal || {};
return {
multiplex: m.enabled,
multiplex_protocol: m.protocol,
multiplex_max_connections: m.max_connections,
multiplex_min_streams: m.min_streams,
multiplex_max_streams: m.max_streams,
multiplex_padding: m.padding,
multiplex_brutal: brutal.enabled,
multiplex_brutal_up: brutal.up_mbps,
multiplex_brutal_down: brutal.down_mbps
};
}
/* Credentials: same CREDENTIALS table as normalize.uc, traversed
* in reverse: canonical credential name -> UCI option. */
const CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
socks: { username: 'username', password: 'password' },
http: { username: 'username', password: 'password' },
snell: { psk: 'password', userkey: 'snell_userkey' },
ssh: { user: 'username', password: 'password',
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
anytls: { password: 'password' },
shadowtls: { password: 'password' },
direct: {}
};
function flatten_credentials(protocol, node) {
const map = CREDENTIALS[protocol] || {};
const creds = node.credentials || {};
const out = {};
for (let canonical, uci in map)
out[uci] = creds[canonical];
return out;
}
/* The full pipeline, returning a flat UCI-key dict. Metadata that
* the orchestrator / Repository attaches to the canonical Node
* (label, grouphash) is not a canonical sub-object, so we copy
* those fields through explicitly rather than walk `keys(node)`
* and pull whatever happens to be there.
*
* `isExisting` is deliberately NOT copied: it is a within-run
* marker Repository sets after a successful write, used to skip
* re-adding in the same loop. It is never written to / read from
* UCI. Identity (id / type / address / port) and `name` (which
* becomes the UCI `label` option) are the Adapter's identity
* half; everything else is the metadata tail. */
function copy_meta(out, node) {
if ('label' in node)
out.label = node.label;
if ('grouphash' in node)
out.grouphash = node.grouphash;
}
/* Top-level: take a canonical Node, return a flat UCI-key dict
* suitable for uci.set() per key. Returns null when the input is
* null / empty, mirroring normalize()'s `null -> null` contract
* so callers can compose `flatten(normalize(parse_uri(uri)))`. */
export function flatten(node) {
if (!node)
return null;
const protocol = node.type;
const out = {
type: protocol,
address: node.address,
port: node.port,
label: node.name
};
/* Identity sub-fields: drop empties so a freshly-saved
* canonical Node (which always carries the full shape, even
* when `null`) does not suddenly litter /etc/config with
* `multiplex_max_connections ''` entries. The Loader treats
* null and absent the same, so this is observably equivalent. */
const sub = {
...flatten_common(node),
...flatten_credentials(protocol, node),
...flatten_tls(node),
...flatten_transport(node),
...flatten_multiplex(node),
...flatten_protocol_options(protocol, node)
};
/* ucode has no `undefined`: an absent key reads as null, so a
* single null check covers both. */
for (let k, v in sub)
if (v !== null)
out[k] = v;
copy_meta(out, node);
return out;
};
@@ -0,0 +1,140 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Single source of truth for the canonical-name <-> UCI-option-name
* mapping per protocol. The same mapping used to live in three places:
*
* - parse_uri.uc (wrote UCI-flat keys directly: `tls`, `tls_sni`,
* `vless_flow`, `shadowsocks_encrypt_method`, ...)
* - config/loader.uc PROTOCOL_OPTIONS (canonical -> UCI)
* - config/adapter.uc OPTION_FIELDS (canonical -> adapter code)
*
* The Loader's PROTOCOL_OPTIONS is derived from the table in this file,
* and both directions of the pipeline read it: normalize.uc renames UCI
* keys to canonical names, flatten.uc renames them back on the way to
* UCI. Adding a protocol option is one edit here, not one per layer.
*
* `keys()` direction: every row's left side is the canonical name the
* Loader hands the Adapter; the right side is the UCI option name the
* Loader reads and the Repository writes. Keep them sorted by
* canonical name so a diff that adds a row is obvious.
*/
'use strict';
/* `canonical -> uci` for per-protocol options. Anything not listed here
* is not part of the domain contract for that protocol (the form might
* still surface it, the Adapter ignores it). */
export const PROTOCOL_TO_UCI = {
vless: {
flow: 'vless_flow',
packet_encoding: 'packet_encoding'
/* udp_over_tcp used to be here. It is a shadowsocks/socks
* option: sing-box 1.14 rejects it on a vless outbound, so
* mapping it here turned a stale UCI option into an
* unusable config. */
},
snell: {
version: 'snell_version',
reuse: 'snell_reuse',
obfs_mode: 'snell_obfs_mode',
obfs_host: 'snell_obfs_host'
/* snell_mode is not mapped: sing-box 1.14 has no `mode`
* field on a snell outbound (or inbound). It was v6-only
* in the form, and v6 is not supported by the target
* sing-box. */
},
shadowsocks: {
plugin: 'shadowsocks_plugin',
plugin_opts: 'shadowsocks_plugin_opts',
udp_over_tcp: 'udp_over_tcp',
udp_over_tcp_version: 'udp_over_tcp_version'
},
anytls: {
idle_session_check_interval: 'anytls_idle_session_check_interval',
idle_session_timeout: 'anytls_idle_session_timeout',
min_idle_session: 'anytls_min_idle_session'
},
http: {},
socks: {
version: 'socks_version',
udp_over_tcp: 'udp_over_tcp',
udp_over_tcp_version: 'udp_over_tcp_version'
},
tuic: {
congestion_control: 'tuic_congestion_control',
udp_relay_mode: 'tuic_udp_relay_mode',
udp_over_stream: 'tuic_udp_over_stream',
zero_rtt_handshake: 'tuic_enable_zero_rtt',
heartbeat: 'tuic_heartbeat'
},
trojan: {},
shadowtls: {
version: 'shadowtls_version'
},
hysteria: {
protocol: 'hysteria_protocol',
auth_type: 'hysteria_auth_type',
auth_payload: 'hysteria_auth_payload',
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
obfs_password: 'hysteria_obfs_password',
hopping_port: 'hysteria_hopping_port',
hop_interval: 'hysteria_hop_interval'
},
hysteria2: {
obfs_type: 'hysteria_obfs_type',
obfs_password: 'hysteria_obfs_password',
up_mbps: 'hysteria_up_mbps',
down_mbps: 'hysteria_down_mbps',
hop_interval: 'hysteria_hop_interval',
hop_interval_max: 'hysteria_hop_interval_max',
hopping_port: 'hysteria_hopping_port',
auth_payload: 'hysteria_auth_payload',
bbr_profile: 'hysteria_bbr_profile',
disable_chrome_parrot: 'hysteria_disable_chrome_parrot'
},
vmess: {
alter_id: 'vmess_alterid',
security: 'vmess_encrypt',
global_padding: 'vmess_global_padding',
auth_payload: 'vmess_auth_payload',
/* packet_encoding was missing here, so a vmess node's UCI option
* never reached protocol_options and the outbound had none - even
* though the node form offers the option for vmess and the
* subscription default is applied to it. vless declares it too. */
packet_encoding: 'packet_encoding'
},
ssh: {
client_version: 'ssh_client_version',
host_key: 'ssh_host_key',
host_key_algorithms: 'ssh_host_key_algo'
},
wireguard: {
local_address: 'wireguard_local_address',
private_key: 'wireguard_private_key',
peer_public_key: 'wireguard_peer_public_key',
pre_shared_key: 'wireguard_pre_shared_key',
reserved: 'wireguard_reserved',
mtu: 'wireguard_mtu',
persistent_keepalive_interval: 'wireguard_persistent_keepalive_interval'
},
direct: {
override_address: 'override_address',
override_port: 'override_port'
}
};
/* Reverse direction: uci -> canonical. Built once at module load so
* the parser can look up "what canonical name is `tls_sni` for
* <protocol>" without hand-coding the inverse in protocols.uc.
*
* A protocol with no per-protocol options maps to an empty object,
* which is intentional: `for ... in {}` is a no-op and the loader
* still has the protocol-shape boundary on the Node. */
export const UCI_TO_PROTOCOL = {};
for (let proto, row in PROTOCOL_TO_UCI) {
UCI_TO_PROTOCOL[proto] = {};
for (let canonical, uci in row)
UCI_TO_PROTOCOL[proto][uci] = canonical;
}
@@ -0,0 +1,191 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* The per-scheme parsers in
* parser/protocols.uc produce a FLAT UCI-key dict; that is their output
* shape. The Adapter, the Loader, the policy step and the Repository's
* write path all think in the canonical Node shape instead: nested tls /
* transport / multiplex / credentials / protocol_options sub-objects.
*
* normalize() is the crossing point: every caller that needs the
* canonical Node - the subscription orchestrator, the RPC that imports a
* share link, tests - runs the parsed config through it. parser/flatten.uc
* is the inverse and is used only where UCI has to be written, which is
* the Repository's apply_nodes().
*
* The mapping is sourced from parser/mapping.uc (canonical -> UCI)
* so the Loader, the Adapter and the parser cannot drift apart.
*/
'use strict';
import { PROTOCOL_TO_UCI } from './mapping.uc';
/* Pick every field that PROTOCOL_TO_UCI maps for this protocol off
* the flat config and rename it from UCI to its canonical name. The
* result is the protocol_options sub-object. Fields the parser wrote
* that are not mapped (e.g. a stale `vless_udp_over_tcp` left over
* from a shadowsocks -> vless type switch) are dropped here, which is
* the same "stale UCI option becomes a config error" failure the
* Loader has been guarding against via PROTOCOL_OPTIONS. */
function build_protocol_options(protocol, flat) {
const map = PROTOCOL_TO_UCI[protocol] || {};
const out = {};
for (let canonical, uci in map) {
if (uci in flat) {
const value = flat[uci];
/* Empty-string or null means "absent": do not propagate
* to the canonical side either. */
if (value !== '' && value !== null)
out[canonical] = value;
}
}
return out;
}
/* TLS fields are shared across protocols, not per-protocol, so they
* live outside PROTOCOL_TO_UCI. The Loader uses the same shape
* (load_tls() in config/loader.uc), so we mirror it. */
function build_tls(flat) {
const tls = {
enabled: flat.tls,
server_name: flat.tls_sni,
insecure: flat.tls_insecure,
alpn: flat.tls_alpn,
min_version: flat.tls_min_version,
max_version: flat.tls_max_version,
handshake_timeout: flat.tls_handshake_timeout,
cipher_suites: flat.tls_cipher_suites,
cert_path: flat.tls_cert_path,
ech: {
enabled: flat.tls_ech,
config: flat.tls_ech_config,
config_path: flat.tls_ech_config_path
},
utls: {
fingerprint: flat.tls_utls
},
reality: {
enabled: flat.tls_reality,
public_key: flat.tls_reality_public_key,
short_id: flat.tls_reality_short_id
}
};
return tls;
}
/* Transport fields are also shared; mirror the shape the Loader's
* load_transport() produces. Returns `{ type: null }` when the parser
* did not write a transport. */
function build_transport(flat) {
const t = flat.transport;
if (t == null || t === '')
return { type: null };
return {
type: t,
host: flat.http_host || flat.httpupgrade_host,
path: flat.http_path || flat.ws_path,
headers: flat.ws_host ? { Host: flat.ws_host } : null,
method: flat.http_method,
max_early_data: flat.websocket_early_data,
early_data_header_name: flat.websocket_early_data_header,
service_name: flat.grpc_servicename,
idle_timeout: flat.http_idle_timeout,
ping_timeout: flat.http_ping_timeout,
permit_without_stream: flat.grpc_permit_without_stream
};
}
/* Common fields that every outbound protocol carries. Mirrors the
* Loader's load_common(). */
function build_common(flat) {
return {
proxy_protocol: flat.proxy_protocol,
tcp_fast_open: flat.tcp_fast_open,
tcp_multi_path: flat.tcp_multi_path,
udp_fragment: flat.udp_fragment
};
}
/* Multiplex fields - mirrors the Loader's load_multiplex(). */
function build_multiplex(flat) {
return {
enabled: flat.multiplex,
protocol: flat.multiplex_protocol,
max_connections: flat.multiplex_max_connections,
min_streams: flat.multiplex_min_streams,
max_streams: flat.multiplex_max_streams,
padding: flat.multiplex_padding,
brutal: {
enabled: flat.multiplex_brutal,
up_mbps: flat.multiplex_brutal_up,
down_mbps: flat.multiplex_brutal_down
}
};
}
/* Credentials: pull every UCI key the protocol's CREDENTIALS row lists
* (canonical -> UCI) and put them under the canonical name. Empty /
* null values stay null so the Adapter's null-strip pass keeps the
* field out of the emitted sing-box JSON. */
const CREDENTIALS = {
vless: { uuid: 'uuid' },
vmess: { uuid: 'uuid' },
trojan: { password: 'password' },
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
hysteria2: { password: 'password' },
tuic: { uuid: 'uuid', password: 'password' },
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
socks: { username: 'username', password: 'password' },
http: { username: 'username', password: 'password' },
snell: { psk: 'password', userkey: 'snell_userkey' },
ssh: { user: 'username', password: 'password',
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
anytls: { password: 'password' },
shadowtls: { password: 'password' },
direct: {}
};
function build_credentials(protocol, flat) {
const map = CREDENTIALS[protocol] || {};
const out = {};
for (let canonical, uci in map)
out[canonical] = flat[uci];
return out;
}
/* Top-level: produce a Node-shaped object from the parser's flat
* output. Returns null when the input is null / empty so callers can
* do `normalize(parse_uri(uri))` and propagate parse failures
* unchanged. */
export function normalize(flat) {
if (!flat)
return null;
const protocol = flat.type;
return {
/* identity */
id: null,
name: flat.label,
type: protocol,
/* endpoint */
address: flat.address,
port: flat.port,
/* sub-objects */
common: build_common(flat),
credentials: build_credentials(protocol, flat),
tls: build_tls(flat),
transport: build_transport(flat),
multiplex: build_multiplex(flat),
protocol_options: build_protocol_options(protocol, flat)
};
};
@@ -0,0 +1,450 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Every protocol's parse_<scheme>_uri()
* used to live in the top-level parse_uri.uc. They are now grouped
* here so the dispatch (parser/uri.uc), the validation
* (parser/validator.uc), and the canonical normalisation
* (parser/normalize.uc) can each be edited in isolation.
*
* Each parser returns a FLAT UCI-key dict - the key names UCI uses,
* produced through the mapping table (parser/mapping.uc) so a protocol
* option has one definition. That is the parser's output shape, not the
* pipeline's: normalize() turns it into a canonical Node, the policy step
* and the Repository work on that Node, and the Repository flattens it
* back to UCI keys on the way out. Nothing downstream of normalize() sees
* these flat dicts.
*
* `features` is the sing-box feature map (with_quic / with_utls / ...),
* `log` a logging callback, both injected so the parsers stay free of
* ubus / uci.
*/
'use strict';
import { urldecode, urlencode } from 'luci.http';
import { decodeBase64Str, isEmpty, parseURL } from '../homeproxy-pro.uc';
/* https://shadowsocks.org/guide/sip008.html */
export function parse_sip008_uri(uri) {
return {
label: uri.remarks,
type: 'shadowsocks',
address: uri.server,
port: uri.server_port,
shadowsocks_encrypt_method: uri.method,
password: uri.password,
shadowsocks_plugin: uri.plugin,
shadowsocks_plugin_opts: uri.plugin_opts
};
};
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
export function parse_anytls_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'anytls',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
tls: '1',
tls_sni: params.sni,
tls_insecure: (params.insecure === '1') ? '1' : '0'
};
};
export function parse_http_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'http',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'https') ? '1' : '0'
};
};
export function parse_hysteria_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria',
address: url.hostname,
port: url.port,
hysteria_protocol: params.protocol || 'udp',
hysteria_auth_type: params.auth ? 'string' : null,
hysteria_auth_payload: params.auth,
hysteria_obfs_password: params.obfsParam,
hysteria_down_mbps: params.downmbps,
hysteria_up_mbps: params.upmbps,
tls: '1',
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
tls_sni: params.peer,
tls_alpn: params.alpn
};
};
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
export function parse_hysteria2_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria2',
address: url.hostname,
port: url.port,
password: url.username ? (
urldecode(url.username + (url.password ? (':' + url.password) : ''))
) : null,
hysteria_obfs_type: params.obfs,
hysteria_obfs_password: params['obfs-password'],
tls: '1',
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
};
/* Surge Snell share link: snell://host:port?psk=..&obfs=http&obfs-host=..#name */
export function parse_snell_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'snell',
address: url.hostname,
port: url.port,
password: url.username ? urldecode(url.username)
: (params.psk ? urldecode(params.psk) : null),
snell_version: params.version || '4',
snell_userkey: params.userkey,
snell_obfs_mode: (params.obfs === 'http') ? 'http' : null,
snell_obfs_host: params['obfs-host'],
snell_reuse: (params.reuse === '1') ? '1' : '0'
};
};
export function parse_socks_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'socks',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
socks_version: (match(uri[0], /4/)) ? '4' : '5'
};
};
export function parse_ss_uri(uri) {
uri = split(trim(uri), '://');
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
const url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(url.password)];
else if (url.username) {
/* User info encoded with base64. decodeBase64Str() returns null for
* anything that is not valid base64 (a '.' in the userinfo is
* enough), and ucode's split(null, ...) is null, so indexing it
* raised a ReferenceError. parse_uri() does not catch that: the RPC
* import path happens to, update_subscriptions.uc does not, so one
* malformed link aborted an entire subscription update. A link with
* no usable userinfo is simply not a node we can build. */
const decoded = decodeBase64Str(urldecode(url.username));
ss_userinfo = decoded ? split(decoded, ':', 2) : {};
}
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
if (isEmpty(ss_userinfo[0]))
return null;
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: ss_userinfo[0],
password: ss_userinfo[1],
shadowsocks_plugin: ss_plugin,
shadowsocks_plugin_opts: ss_plugin_opts
};
};
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
export function parse_trojan_uri(uri) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'trojan',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
transport: (params.type !== 'tcp') ? params.type : null,
tls: '1',
tls_sni: params.sni
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/daeuniverse/dae/discussions/182 */
export function parse_tuic_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
if (!features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
return {
label: url.hash ? urldecode(url.hash) : null,
type: 'tuic',
address: url.hostname,
port: url.port,
uuid: url.username,
password: url.password ? urldecode(url.password) : null,
tuic_congestion_control: params.congestion_control,
tuic_udp_relay_mode: params.udp_relay_mode,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
};
};
/* https://github.com/XTLS/Xray-core/discussions/716 */
export function parse_vless_uri(uri, features, log) {
uri = split(trim(uri), '://');
const url = parseURL('http://' + uri[1]) || {};
const params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
const config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'vless',
address: url.hostname,
port: url.port,
uuid: url.username,
transport: (params.type !== 'tcp') ? params.type : null,
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: features.with_utls ? params.fp : null,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
};
switch (params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
export function parse_vmess_uri(uri, features, log) {
uri = split(trim(uri), '://');
/* "Lovely" shadowrocket format. `uri` is the split() result, so the old
* match(uri, /&/) could never fire - match() takes a string. */
if (length(uri) > 1 && match(uri[1], /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
let payload;
try {
payload = json(decodeBase64Str(uri[1])) || {};
} catch(e) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
if (payload.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (payload.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
return null;
} else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
if (!features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (payload.aid && int(payload.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add));
* return null;
* }
*/
const config = {
label: payload.ps ? urldecode(payload.ps) : null,
type: 'vmess',
address: payload.add,
port: payload.port,
uuid: payload.id,
vmess_alterid: payload.aid,
vmess_encrypt: payload.scy || 'auto',
vmess_global_padding: '1',
transport: (payload.net !== 'tcp') ? payload.net : null,
tls: (payload.tls === 'tls') ? '1' : '0',
tls_sni: payload.sni || payload.host,
tls_alpn: payload.alpn ? split(payload.alpn, ',') : null,
tls_utls: features.with_utls ? payload.fp : null
};
switch (payload.net) {
case 'grpc':
config.grpc_servicename = payload.path;
break;
case 'h2':
case 'tcp':
if (payload.net === 'h2' || payload.type === 'http') {
config.transport = 'http';
config.http_host = payload.host ? split(payload.host, ',') : null;
config.http_path = payload.path;
}
break;
case 'httpupgrade':
config.httpupgrade_host = payload.host;
config.http_path = payload.path;
break;
case 'ws':
config.ws_host = payload.host;
config.ws_path = payload.path;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
return config;
};
@@ -0,0 +1,119 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* parse_uri() is a thin dispatcher.
* The 11 per-scheme parsers moved to parser/protocols.uc; the
* categorised post-parse checks moved to parser/validator.uc; the
* canonical-shape conversion lives in parser/normalize.uc; and the
* canonical <-> UCI mapping table lives in parser/mapping.uc.
*
* This file is the only one parse_uri.uc's old callers should reach
* for. The output shape is unchanged (flat UCI-key dict) so the
* Repository's contract is preserved; the next PR can flip the
* parser to canonical Node output and teach the Repository to
* flatten, with parser/mapping.uc as the single source of truth.
*/
'use strict';
import { isEmpty, validation } from '../homeproxy-pro.uc';
import {
parse_sip008_uri, parse_anytls_uri, parse_http_uri,
parse_hysteria_uri, parse_hysteria2_uri, parse_snell_uri,
parse_socks_uri, parse_ss_uri, parse_trojan_uri,
parse_tuic_uri, parse_vless_uri, parse_vmess_uri
} from './protocols.uc';
import { validate } from './validator.uc';
/* Default label: ip6addr uses brackets for readability, otherwise
* `address:port`. Exported because every scheme benefits and because a
* caller that mutates a parsed config's address (subscription
* apply_policy does not, but a future one could) can re-derive it.
*
* Declared above parse_uri on purpose: ucode does not hoist
* `export function`, so the call below would otherwise fail at runtime
* with "access to undeclared variable". */
export function derive_label(config) {
if (config.label)
return config.label;
if (!config.address || !config.port)
return null;
return (validation('ip6addr', config.address) ?
`[${config.address}]` : config.address) + ':' + config.port;
};
export function parse_uri(uri, features, log) {
if (!features) features = {};
if (!log) log = function() {};
let config;
if (type(uri) === 'object') {
if (uri.nodetype === 'sip008')
config = parse_sip008_uri(uri);
} else if (type(uri) === 'string') {
switch (split(trim(uri), '://')[0]) {
case 'anytls':
config = parse_anytls_uri(uri, features, log);
break;
case 'http':
case 'https':
config = parse_http_uri(uri, features, log);
break;
case 'hysteria':
config = parse_hysteria_uri(uri, features, log);
break;
case 'hysteria2':
case 'hy2':
config = parse_hysteria2_uri(uri, features, log);
break;
case 'snell':
config = parse_snell_uri(uri, features, log);
break;
case 'socks':
case 'socks4':
case 'socks4a':
case 'socks5':
case 'socks5h':
config = parse_socks_uri(uri, features, log);
break;
case 'ss':
config = parse_ss_uri(uri, features, log);
break;
case 'trojan':
config = parse_trojan_uri(uri, features, log);
break;
case 'tuic':
config = parse_tuic_uri(uri, features, log);
break;
case 'vless':
config = parse_vless_uri(uri, features, log);
break;
case 'vmess':
config = parse_vmess_uri(uri, features, log);
break;
}
}
if (isEmpty(config))
return null;
/* Strip the brackets UCI still allows around IPv6 literals - they
* survive into a sing-box `server` field verbatim otherwise. */
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
/* validate() returns null (and logs) when the host or port is not
* usable, so the result has to be tested rather than merged with
* `|| config` - that fallback would hand the invalid config back. */
const checked = validate(config, log);
if (!checked)
return null;
if (!checked.label)
checked.label = derive_label(checked);
return checked;
};
@@ -0,0 +1,79 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* parse_uri() used to do its host/port
* check inline at the end of the dispatcher, just before returning the
* config. Per the guidance doc (section 八, Parse -> Normalize ->
* Validate three-layer separation), the post-parse checks belong in
* their own module so:
*
* - failures are categorised (host / port / credential / tls /
* scheme), so the orchestrator's log line can name the failure
* instead of dumping the whole config;
* - new rules (REQUIRED_CREDENTIALS, TLS sanity) can land without
* touching the dispatcher or the per-scheme parsers;
* - tests can exercise the validator without going through any
* scheme-specific parser.
*
* Today only the original two checks (host / port) are implemented,
* so the categorisation is mostly plumbing. Adding REQUIRED_CREDENTIALS
* in a follow-up is now a one-place change.
*/
'use strict';
import { validation } from '../homeproxy-pro.uc';
/* Each entry carries a stable `kind` (so callers / tests can branch on
* it) and a `message` for the log line. Adding a new rule is:
*
* { kind: 'missing_credential', field: 'uuid',
* message: sprintf('%s requires %s', type, field) }
*/
export function validate(config, log) {
let errors = [];
if (!validation('host', config.address)) {
errors = [...errors, {
kind: 'invalid_host',
message: sprintf('Skipping invalid %s node: %s.',
config.type, config.label || 'NULL')
}];
}
if (!validation('port', config.port)) {
errors = [...errors, {
kind: 'invalid_port',
message: sprintf('Skipping invalid %s node: %s.',
config.type, config.label || 'NULL')
}];
}
if (length(errors)) {
/* Stay log-compatible with the old code path: one warn line
* per failure. The orchestrator already swallows `null`
* returns from parse_uri(), so it does not need to learn
* about the categorised shape yet. */
for (let err in errors)
log(err.message);
return null;
}
return config;
};
/* Exposed for tests so they can assert categorisation without going
* through the full dispatcher. Returns the (possibly empty) array of
* error descriptors that `validate()` would log, but does not log
* anything itself. */
export function check(config) {
let errors = [];
if (!validation('host', config.address))
errors = [...errors, { kind: 'invalid_host', type: config.type, address: config.address }];
if (!validation('port', config.port))
errors = [...errors, { kind: 'invalid_port', type: config.type, port: config.port }];
return errors;
};