mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
1 parent
a4d6e3e4b9
commit
da77daf88e
191 files changed
+8077
-6247
No files matched your search
@@ -0,0 +1,765 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* Node -> Protocol Adapter -> SingBox Outbound
|
||||
*
|
||||
* This replaces the single 110-line `generate_outbound()` whose protocol
|
||||
* branches were expressed as ~60 ternaries inside one object literal. Three
|
||||
* properties are deliberate:
|
||||
*
|
||||
* kept the shared TLS/transport/multiplex code is still the existing
|
||||
* production builders - the adapter only supplies them a
|
||||
* Node-shaped view instead of a flat UCI dict.
|
||||
* pinned the emitted JSON is frozen by tests/snapshots/generator/
|
||||
* outbounds.json, so a field change for any protocol is a
|
||||
* reviewable diff (test_golden_outbounds.sh).
|
||||
* dropped a per-protocol Adapter class hierarchy. The protocols differ by
|
||||
* which *fields* they carry, not by behaviour, so the honest
|
||||
* extension point is the field tables below, not eleven classes.
|
||||
*
|
||||
* Adding a protocol means adding three entries: CREDENTIALS (model.uc),
|
||||
* PROTOCOL_OPTIONS (loader.uc) and OPTION_FIELDS here - and a node in
|
||||
* tests/fixtures/generators/outbounds.uci, which
|
||||
* tests/ucode/test_protocol_inventory.sh enforces.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
isEmpty, strToBool, strToInt, strToTime, removeBlankAttrs,
|
||||
buildTLSObject, buildTransportObject, parse_port
|
||||
} from '../homeproxy-pro.uc';
|
||||
|
||||
import { Node, Inbound, INBOUND_TLS_SERVER } from './model.uc';
|
||||
|
||||
/* Per-protocol required credential fields. Node.validate() covers the
|
||||
* cross-protocol rules (type/address/port/TLS); this table is the
|
||||
* per-protocol half - "vless needs uuid, vmess needs uuid, trojan needs
|
||||
* password, snell needs psk, ..." - and it is the Adapter's job because
|
||||
* the requirement is shaped by what the sing-box outbound for each
|
||||
* protocol will actually accept, not by anything the model knows about
|
||||
* UCI. The keys are the canonical credential names the Loader uses
|
||||
* (see CREDENTIALS in model.uc), so the check is data-driven and adding
|
||||
* a protocol is a one-line change. */
|
||||
export const REQUIRED_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
ssh: ['user'],
|
||||
/* These three have no hard required credential - the protocol can
|
||||
* run anonymously (http / socks) or the auth is delivered out of
|
||||
* band (hysteria, direct). Node.validate()'s address/port check
|
||||
* is enough. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
socks: [],
|
||||
direct: []
|
||||
};
|
||||
|
||||
/* Run a Node through the per-protocol required-field check. Returns
|
||||
* the (possibly empty) list of human-readable problems.
|
||||
*
|
||||
* The same ucode quirk that bit Node.validate() bites here too: push()
|
||||
* returns the value pushed, not the new array, so we build the result
|
||||
* via spread instead of the obvious `problems = push(problems, X)`. */
|
||||
function protocol_problems(node) {
|
||||
const required = REQUIRED_CREDENTIALS[node.type] || [];
|
||||
const creds = node.credentials || {};
|
||||
let problems = [];
|
||||
|
||||
for (let field in required) {
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${node.type} requires ${field}`];
|
||||
}
|
||||
|
||||
/* WireGuard is the one protocol whose material does not live in
|
||||
* `credentials`: the endpoint builder reads node.protocol_options.
|
||||
* Checking it here means a broken WireGuard node is caught by the
|
||||
* same "is this node buildable?" path as every other protocol, which
|
||||
* is what lets a urltest list prune it instead of failing the whole
|
||||
* configuration. */
|
||||
if (node.type === 'wireguard') {
|
||||
const opts = node.protocol_options || {};
|
||||
for (let field in ['local_address', 'private_key', 'peer_public_key'])
|
||||
if (!opts[field])
|
||||
problems = [...problems, `wireguard requires ${field}`];
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* --- field transforms --------------------------------------------------- */
|
||||
|
||||
/* A table value is either a function of the domain object (a Node for the
|
||||
* outbound tables, an Inbound for the server tables), a literal, or a
|
||||
* string naming a transform (in which case the value is null - the spec
|
||||
* is present so the key still gets emitted, but the value will be
|
||||
* stripped by removeBlankAttrs()). */
|
||||
const TRANSFORMS = {
|
||||
raw: (value) => value,
|
||||
bool: (value) => strToBool(value),
|
||||
int: (value) => strToInt(value),
|
||||
time: (value) => strToTime(value)
|
||||
};
|
||||
|
||||
/* Shared by the outbound and the inbound field tables: the spec is a
|
||||
* plain function in every table except the outbound one, which carries a
|
||||
* few TRANSFORMS markers for keys that exist but are runtime-owned. */
|
||||
function resolve(spec, subject) {
|
||||
if (type(spec) === 'function')
|
||||
return spec(subject);
|
||||
|
||||
if (type(spec) === 'string' && spec in TRANSFORMS)
|
||||
return null;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* --- field tables ------------------------------------------------------- */
|
||||
|
||||
/* Fields every protocol emits. Each is null when absent, and nulls are
|
||||
* stripped once, at the end, by removeBlankAttrs(). Cross-protocol common
|
||||
* fields live in node.common (Loader's load_common); protocol-specific
|
||||
* canonical names live in node.protocol_options. The legacy
|
||||
* `node.raw` opaque bag was dropped - the Adapter reads only the explicit
|
||||
* sub-objects, so no escape hatch exists.
|
||||
*
|
||||
* This table is applied AFTER the literal in build_outbound(), so a key
|
||||
* listed here wins over whatever that literal set. A field the runtime owns
|
||||
* (routing_mark, from the `mark` argument) therefore must NOT be listed
|
||||
* here, not even as `null`: the loop would overwrite the value with null and
|
||||
* removeBlankAttrs() would drop the field entirely. routing_mark is what
|
||||
* exempts sing-box's own proxy connections from the nft redirect chain, so
|
||||
* losing it loops the traffic back into the redirect inbound. Guard 32 in
|
||||
* tests/arch-guard.sh keeps the literal's keys and this table disjoint. */
|
||||
const COMMON_FIELDS = {
|
||||
server: (node) => node.address,
|
||||
server_port: (node) => strToInt(node.port),
|
||||
proxy_protocol: (node) => strToInt(node.common.proxy_protocol),
|
||||
tcp_fast_open: (node) => strToBool(node.common.tcp_fast_open),
|
||||
tcp_multi_path: (node) => strToBool(node.common.tcp_multi_path),
|
||||
udp_fragment: (node) => strToBool(node.common.udp_fragment),
|
||||
packet_encoding: (node) => node.protocol_options.packet_encoding || null
|
||||
};
|
||||
|
||||
/* The node form stores an SSH private key in a DynamicList, i.e. one UCI
|
||||
* list entry per line of the OpenSSH/PEM blob (a UCI option cannot hold
|
||||
* newlines). sing-box wants the whole key as one string, so re-join the
|
||||
* lines. A single-entry value is passed through unchanged.
|
||||
*
|
||||
* Declared before CLAIM_FIELDS on purpose: ucode resolves module-level names
|
||||
* lexically, so a callee has to appear above its caller in the file. */
|
||||
function ssh_private_key(value) {
|
||||
if (type(value) === 'array')
|
||||
return length(value) ? join('\n', value) : null;
|
||||
|
||||
return value || null;
|
||||
}
|
||||
|
||||
/* Credential fields: one canonical set, emitted under the name each protocol
|
||||
* wants. This is where the old builder repeated itself. */
|
||||
function CLAIM_FIELDS(node) {
|
||||
switch (node.type) {
|
||||
case 'snell':
|
||||
return {
|
||||
psk: node.credentials.psk,
|
||||
userkey: node.credentials.userkey,
|
||||
reuse: strToBool(node.protocol_options.reuse)
|
||||
};
|
||||
case 'ssh':
|
||||
return {
|
||||
user: node.credentials.user,
|
||||
password: node.credentials.password,
|
||||
private_key: ssh_private_key(node.credentials.private_key),
|
||||
private_key_passphrase: node.credentials.private_key_passphrase
|
||||
};
|
||||
case 'hysteria':
|
||||
return {
|
||||
auth: (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null
|
||||
};
|
||||
default:
|
||||
return {
|
||||
username: node.credentials.username,
|
||||
uuid: node.credentials.uuid,
|
||||
password: node.credentials.password,
|
||||
method: node.credentials.method
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-protocol option fields, keyed by the same canonical names the model
|
||||
* exposes. Anything not modelled here is still in node.protocol_options
|
||||
* (which is itself populated from the protocol's PROTOCOL_OPTIONS row in
|
||||
* the Loader). */
|
||||
export const OPTION_FIELDS = {
|
||||
vless: {
|
||||
flow: (node) => node.protocol_options.flow
|
||||
/* No udp_over_tcp here: sing-box 1.14 rejects that field on a vless
|
||||
* outbound ("json: unknown field"). It used to be emitted, so a
|
||||
* node whose type was switched from shadowsocks to vless kept the
|
||||
* stale UCI option and produced a config sing-box refused outright.
|
||||
* The node form never offered the option for vless anyway. */
|
||||
},
|
||||
/* `mode` (snell v6 traffic shaping) is deliberately absent: sing-box
|
||||
* 1.14 rejects it as an outbound field, and the node form only offered
|
||||
* it for snell_version 6, which this sing-box does not support either. */
|
||||
snell: {
|
||||
version: (node) => strToInt(node.protocol_options.version) || 4,
|
||||
obfs_mode: (node) => node.protocol_options.obfs_mode || null,
|
||||
obfs_host: (node) => node.protocol_options.obfs_host || null
|
||||
},
|
||||
/* A4.1: shadowsocks. SIP002 plugin support is emitted only when both
|
||||
* plugin and plugin_opts are present, matching the generator's ternary
|
||||
* that emits `plugin: ...` unconditionally and lets removeBlankAttrs
|
||||
* drop empties. `udp_over_tcp` mirrors the vless shape. */
|
||||
shadowsocks: {
|
||||
plugin: (node) => node.protocol_options.plugin || null,
|
||||
plugin_opts: (node) => node.protocol_options.plugin_opts || null,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.2: anytls idle session tuning (sing-box 1.14 lets the client probe
|
||||
* upstream sessions and proactively close stuck ones). */
|
||||
anytls: {
|
||||
idle_session_check_interval: (node) => strToTime(node.protocol_options.idle_session_check_interval),
|
||||
idle_session_timeout: (node) => strToTime(node.protocol_options.idle_session_timeout),
|
||||
min_idle_session: (node) => strToInt(node.protocol_options.min_idle_session)
|
||||
},
|
||||
/* A4.3: http - no protocol-specific options; transport / tls / multiplex
|
||||
* are shared. */
|
||||
http: {},
|
||||
/* A4.4: socks. The node form offers UDP-over-TCP for socks and sing-box
|
||||
* accepts it on the socks outbound, but the adapter never emitted it, so
|
||||
* the setting was silently ignored. */
|
||||
socks: {
|
||||
version: (node) => node.protocol_options.version,
|
||||
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||
enabled: true,
|
||||
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||
} : null
|
||||
},
|
||||
/* A4.5: tuic */
|
||||
tuic: {
|
||||
congestion_control: (node) => node.protocol_options.congestion_control,
|
||||
udp_relay_mode: (node) => node.protocol_options.udp_relay_mode,
|
||||
udp_over_stream: (node) => strToBool(node.protocol_options.udp_over_stream),
|
||||
zero_rtt_handshake: (node) => strToBool(node.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (node) => strToTime(node.protocol_options.heartbeat)
|
||||
},
|
||||
/* A4.6: trojan - no protocol-specific options. */
|
||||
trojan: {},
|
||||
/* A4.7: shadowtls */
|
||||
shadowtls: {
|
||||
version: (node) => strToInt(node.protocol_options.version)
|
||||
},
|
||||
/* A4.8: hysteria (v1). The generator emits two fields: `auth` when the
|
||||
* payload is base64, `auth_str` when it is a string. Node.validate()
|
||||
* only catches the obvious case; the conditional keeps the table-driven
|
||||
* shape.
|
||||
*
|
||||
* obfs is the plain obfuscation password string here. The node form
|
||||
* only offers the password for v1 (the type selector is hysteria2-only)
|
||||
* and sing-box rejects the {type, password} object on a hysteria
|
||||
* outbound - that shape belongs to hysteria2. Emitting the object made
|
||||
* any obfuscated hysteria node produce a config sing-box refused. */
|
||||
hysteria: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
obfs: (node) => node.protocol_options.obfs_password || null
|
||||
},
|
||||
/* A4.9: hysteria2 - extends hysteria with hop_interval_max /
|
||||
* server_ports / bbr_profile / disable_chrome_parrot. The obfs
|
||||
* shape is the same. Note sing-box 1.14 uses `server_ports`
|
||||
* (port hopping list), not `hopping_port` - the latter is the
|
||||
* UCI option name, so the rename happens here. */
|
||||
hysteria2: {
|
||||
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||
hop_interval_max: (node) => strToTime(node.protocol_options.hop_interval_max),
|
||||
server_ports: (node) => node.protocol_options.hopping_port,
|
||||
obfs: (node) => node.protocol_options.obfs_type ? {
|
||||
type: node.protocol_options.obfs_type,
|
||||
password: node.protocol_options.obfs_password
|
||||
} : null,
|
||||
bbr_profile: (node) => node.protocol_options.bbr_profile || null,
|
||||
disable_chrome_parrot: (node) => (node.protocol_options.disable_chrome_parrot === '1') ? true : null
|
||||
},
|
||||
/* A4.10: vmess */
|
||||
vmess: {
|
||||
alter_id: (node) => strToInt(node.protocol_options.alter_id),
|
||||
security: (node) => node.protocol_options.security,
|
||||
global_padding: (node) => strToBool(node.protocol_options.global_padding)
|
||||
},
|
||||
/* SSH: host-key pinning and the client banner, matching sing-box's ssh
|
||||
* outbound field names. */
|
||||
ssh: {
|
||||
client_version: (node) => node.protocol_options.client_version || null,
|
||||
host_key: (node) => node.protocol_options.host_key || null,
|
||||
host_key_algorithms: (node) => node.protocol_options.host_key_algorithms || null
|
||||
}
|
||||
};
|
||||
|
||||
/* --- shared builders ---------------------------------------------------- */
|
||||
|
||||
function build_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
protocol: mux.protocol,
|
||||
max_connections: strToInt(mux.max_connections),
|
||||
min_streams: strToInt(mux.min_streams),
|
||||
max_streams: strToInt(mux.max_streams),
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* --- adapter ------------------------------------------------------------ */
|
||||
|
||||
/* Both validation layers as one list. Standalone (not a method) because the
|
||||
* OutboundFactory literal below cannot reference itself during its own
|
||||
* initialisation: ucode resolves the binding lexically and throws
|
||||
* "Can't access lexical declaration before initialization". */
|
||||
function outbound_problems(node) {
|
||||
return [
|
||||
...Node.validate(node),
|
||||
...protocol_problems(node)
|
||||
];
|
||||
}
|
||||
|
||||
/* Node -> sing-box outbound object. Pure: no UCI, no module state, no file
|
||||
* access; `mark` is passed in instead of read from a global.
|
||||
*
|
||||
* Standalone for the same reason outbound_problems() is: the literal below
|
||||
* cannot name OutboundFactory in any nested scope, not even inside an arrow
|
||||
* body, without tripping ucode's "Can't access lexical declaration before
|
||||
* initialization" check. Validation is done by the callers. */
|
||||
function build_outbound(node, mark) {
|
||||
const outbound = {
|
||||
type: node.type,
|
||||
tag: Node.tag(node),
|
||||
routing_mark: strToInt(mark)
|
||||
};
|
||||
|
||||
for (let field, spec in COMMON_FIELDS)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
const claims = CLAIM_FIELDS(node);
|
||||
for (let field, value in claims)
|
||||
outbound[field] = value;
|
||||
|
||||
const options = OPTION_FIELDS[node.type] || {};
|
||||
for (let field, spec in options)
|
||||
outbound[field] = resolve(spec, node);
|
||||
|
||||
outbound.multiplex = build_multiplex(node.multiplex);
|
||||
outbound.tls = buildTLSObject(node.tls, false);
|
||||
outbound.transport = buildTransportObject(node.transport, false);
|
||||
|
||||
if (node.type === 'direct')
|
||||
outbound.proxy_protocol = strToInt(node.common.proxy_protocol);
|
||||
|
||||
/* removeBlankAttrs() is what the generator applies before writing, and
|
||||
it drops every null the field tables produced. Cleaning here means
|
||||
this function returns the final artifact, so a golden-snapshot test
|
||||
can compare it against what sing-box would actually be handed. */
|
||||
return removeBlankAttrs(outbound);
|
||||
}
|
||||
|
||||
export const OutboundFactory = {
|
||||
/* Validation split out of create() so a caller can decide whether a
|
||||
* broken node is fatal. Two-layer: Node.validate() covers
|
||||
* cross-protocol rules, protocol_problems() the per-protocol ones. */
|
||||
problems: outbound_problems,
|
||||
|
||||
/* True when create() would succeed. Used to prune optional node lists
|
||||
* (urltest candidates) so one misconfigured node cannot abort the
|
||||
* entire configuration. */
|
||||
buildable: (node) => length(outbound_problems(node)) === 0,
|
||||
|
||||
/* Node -> { outbound, problems }. Same result as create() but never
|
||||
* dies, for nodes the user only *may* route through. */
|
||||
tryCreate: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
return { outbound: null, problems: problems };
|
||||
|
||||
return { outbound: build_outbound(node, mark), problems: [] };
|
||||
},
|
||||
|
||||
/* Node -> sing-box outbound object, or die() when the node is not
|
||||
* buildable. Use problems()/buildable()/tryCreate() for nodes whose
|
||||
* breakage must not be fatal (urltest candidates). */
|
||||
create: (node, mark) => {
|
||||
const problems = outbound_problems(node);
|
||||
|
||||
if (length(problems))
|
||||
die(`node '${node.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_outbound(node, mark);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- WireGuard endpoint ------------------------------------------------- */
|
||||
|
||||
/* WireGuard is emitted as a
|
||||
* sing-box *endpoint*, not an outbound, so it needs its own builder.
|
||||
* That builder used to live in generator/outbound.uc, which meant the
|
||||
* generator still held protocol business logic for one protocol. It
|
||||
* now lives here, next to the outbound factory, and the generator only
|
||||
* decides *when* an endpoint is needed (routing-mode split, urltest
|
||||
* resolution).
|
||||
*
|
||||
* `ctx` carries the process-wide UDP tuning the runtime owns
|
||||
* (udp_mapping / udp_filtering / udp_nat_max) - same shape
|
||||
* build_outbounds() already passes around, so callers do not change. */
|
||||
function build_endpoint(node, ctx) {
|
||||
const opts = node.protocol_options || {};
|
||||
const common = node.common || {};
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
tag: 'cfg-' + node.id + '-out',
|
||||
address: opts.local_address,
|
||||
mtu: strToInt(opts.mtu),
|
||||
private_key: opts.private_key,
|
||||
peers: (node.type === 'wireguard') ? [
|
||||
{
|
||||
address: node.address,
|
||||
port: strToInt(node.port),
|
||||
allowed_ips: [
|
||||
'0.0.0.0/0',
|
||||
'::/0'
|
||||
],
|
||||
persistent_keepalive_interval: strToInt(opts.persistent_keepalive_interval),
|
||||
public_key: opts.peer_public_key,
|
||||
pre_shared_key: opts.pre_shared_key,
|
||||
reserved: parse_port(opts.reserved),
|
||||
}
|
||||
] : null,
|
||||
system: (node.type === 'wireguard') ? false : null,
|
||||
tcp_fast_open: strToBool(common.tcp_fast_open),
|
||||
tcp_multi_path: strToBool(common.tcp_multi_path),
|
||||
udp_fragment: strToBool(common.udp_fragment),
|
||||
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||
udp_nat_max: ctx.udp_nat_max
|
||||
};
|
||||
}
|
||||
|
||||
export const EndpointFactory = {
|
||||
/* Node -> sing-box endpoint object, or null when there is nothing to
|
||||
* build. Unlike OutboundFactory.create() this never dies: an endpoint
|
||||
* is only ever reached for a node the routing layer already selected,
|
||||
* and the caller treats null as "skip this endpoint". */
|
||||
create: (node, ctx) => {
|
||||
if (type(node) !== 'object' || isEmpty(node))
|
||||
return null;
|
||||
|
||||
return build_endpoint(node, ctx);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- server inbound ----------------------------------------------------- */
|
||||
|
||||
/* The server half of the Adapter. Same data-table approach as
|
||||
* the outbound side: the protocol differences are *fields*, not
|
||||
* behaviour, so they are expressed as tables rather than a branch per
|
||||
* protocol. generator/server.uc used to spell every one of these out
|
||||
* inline while reading flat UCI keys off the section dict.
|
||||
*
|
||||
* Adding a protocol means adding two rows: INBOUND_CREDENTIALS and
|
||||
* INBOUND_OPTIONS in model.uc, and an INBOUND_OPTION_FIELDS row here. */
|
||||
|
||||
/* Listener-level fields every inbound emits. Each value is a function of
|
||||
* the Inbound, or null (which removeBlankAttrs() strips). */
|
||||
const INBOUND_COMMON_FIELDS = {
|
||||
listen: (inbound) => inbound.address || '::',
|
||||
listen_port: (inbound) => strToInt(inbound.port),
|
||||
bind_interface: (inbound) => inbound.common.bind_interface,
|
||||
reuse_addr: (inbound) => strToBool(inbound.common.reuse_addr),
|
||||
tcp_fast_open: (inbound) => strToBool(inbound.common.tcp_fast_open),
|
||||
tcp_multi_path: (inbound) => strToBool(inbound.common.tcp_multi_path),
|
||||
udp_fragment: (inbound) => strToBool(inbound.common.udp_fragment),
|
||||
udp_timeout: (inbound) => strToTime(inbound.common.udp_timeout),
|
||||
network: (inbound) => inbound.common.network
|
||||
};
|
||||
|
||||
/* Listener fields a protocol must NOT be given even when the UCI section
|
||||
* happens to carry the option. sing-box 1.14's snell inbound takes no
|
||||
* udp_fragment / udp_timeout / network; the previous generator had a
|
||||
* dedicated build_snell_inbound() that omitted them, and this table
|
||||
* keeps that behaviour instead of relying on the user not setting them. */
|
||||
const INBOUND_COMMON_OMIT = {
|
||||
snell: ['udp_fragment', 'udp_timeout', 'network']
|
||||
};
|
||||
|
||||
/* Protocols whose sing-box inbound has no users[] block. snell carries a
|
||||
* single top-level psk, shadowsocks a top-level password + method; giving
|
||||
* either of them a users[] entry makes sing-box reject the section
|
||||
* ("snell: bad user key" - the users entry is interpreted as an
|
||||
* additional user key). */
|
||||
const INBOUND_NO_USERS = ['shadowsocks', 'snell'];
|
||||
|
||||
/* Credential fields the inbound emits, keyed by the sing-box field
|
||||
* name. Most protocols put them in the users[] block (below); snell
|
||||
* wants a single top-level `psk`, and shadowsocks / shadowtls a
|
||||
* top-level password (plus method for shadowsocks), so those are
|
||||
* handled here. */
|
||||
function INBOUND_CLAIM_FIELDS(inbound) {
|
||||
switch (inbound.type) {
|
||||
case 'snell':
|
||||
return { psk: inbound.credentials.psk };
|
||||
case 'shadowsocks':
|
||||
return {
|
||||
method: inbound.credentials.method,
|
||||
password: inbound.credentials.password
|
||||
};
|
||||
case 'shadowtls':
|
||||
return { password: inbound.credentials.password };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/* The users[] entry. sing-box wants one user per inbound; every
|
||||
* protocol not listed in INBOUND_NO_USERS accepts the block. */
|
||||
function build_inbound_user(inbound) {
|
||||
const creds = inbound.credentials || {};
|
||||
const opts = inbound.protocol_options || {};
|
||||
|
||||
return {
|
||||
name: !(inbound.type in ['http', 'mixed', 'naive', 'socks']) ?
|
||||
'cfg-' + inbound.id + '-server' : null,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
|
||||
/* Hysteria (1) authentication: exactly one of the two
|
||||
* spellings, selected by the auth type. */
|
||||
auth: (opts.auth_type === 'base64') ? creds.auth_base64 : null,
|
||||
auth_str: (opts.auth_type === 'string') ? creds.auth_str : null,
|
||||
|
||||
uuid: creds.uuid,
|
||||
|
||||
/* VLESS / VMess. Both are per-user fields: sing-box has no
|
||||
* top-level `flow` on a vless inbound nor `alterId` on a vmess
|
||||
* one, which is why INBOUND_OPTION_FIELDS has no row for these
|
||||
* two protocols. */
|
||||
flow: opts.flow,
|
||||
alterId: strToInt(opts.alter_id)
|
||||
};
|
||||
}
|
||||
|
||||
/* Per-protocol inbound fields, keyed by the sing-box field name. Only
|
||||
* the protocols whose sing-box inbound carries something beyond the
|
||||
* shared listener/credential/TLS/transport set need a row. */
|
||||
const INBOUND_OPTION_FIELDS = {
|
||||
anytls: {
|
||||
padding_scheme: (inbound) => inbound.protocol_options.padding_scheme
|
||||
},
|
||||
/* Hysteria (1) and Hysteria2 share three fields and differ on
|
||||
* obfs: v1 carries the obfuscation as a plain password string,
|
||||
* v2 as a {type, password, min_packet_size, max_packet_size}
|
||||
* object. Emitting v1's obfs as a string is what the generator
|
||||
* always did - the {type, ...} shape on a v1 inbound is a
|
||||
* sing-box rejection. */
|
||||
hysteria: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_password,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||
obfs: (inbound) => inbound.protocol_options.obfs_type ? {
|
||||
type: inbound.protocol_options.obfs_type,
|
||||
password: inbound.protocol_options.obfs_password,
|
||||
min_packet_size: strToInt(inbound.protocol_options.obfs_min_packet_size),
|
||||
max_packet_size: strToInt(inbound.protocol_options.obfs_max_packet_size)
|
||||
/* hysteria2 takes an obfs *object* here. Falling back to the bare
|
||||
* password string (which is what hysteria v1 wants) makes sing-box
|
||||
* reject the inbound with "cannot unmarshal string into Go struct
|
||||
* field Hysteria2InboundOptions.obfs" - so no type means no obfs. */
|
||||
} : null,
|
||||
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||
},
|
||||
snell: {
|
||||
version: (inbound) => strToInt(inbound.protocol_options.version) || 5,
|
||||
obfs_mode: (inbound) => inbound.protocol_options.obfs_mode
|
||||
/* no `mode`: sing-box 1.14 rejects it on a snell inbound; it
|
||||
* was a v6-only option and v6 is not supported. */
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: (inbound) => inbound.protocol_options.congestion_control,
|
||||
auth_timeout: (inbound) => strToTime(inbound.protocol_options.auth_timeout),
|
||||
zero_rtt_handshake: (inbound) => strToBool(inbound.protocol_options.zero_rtt_handshake),
|
||||
heartbeat: (inbound) => strToTime(inbound.protocol_options.heartbeat)
|
||||
}
|
||||
/* No row for vless / vmess: both protocols' only per-protocol knobs
|
||||
* (flow, alterId) are per-user fields, emitted by
|
||||
* build_inbound_user() above. sing-box has no top-level `flow` on a
|
||||
* vless inbound nor `alterId` on a vmess one, so adding a row here
|
||||
* would emit a field sing-box rejects. The Loader still loads them
|
||||
* into inbound.protocol_options (INBOUND_OPTIONS in model.uc), which
|
||||
* is where build_inbound_user() reads them from.
|
||||
*
|
||||
* No row for trojan / shadowtls either: everything they carry is
|
||||
* shared (credentials / TLS / transport). */
|
||||
};
|
||||
|
||||
/* The server-only TLS tail, re-keyed from the Inbound's canonical
|
||||
* names into the UCI option names buildTLSObject() reads. That shared
|
||||
* builder is exercised directly by tests/ucode/test_tls_transport.uc
|
||||
* and is used by both the client and the server path, so its signature
|
||||
* stays as it is; this is the single place the flat names survive. */
|
||||
function build_tls_server_extras(tls_server) {
|
||||
const extras = {};
|
||||
|
||||
for (let canonical, uci in INBOUND_TLS_SERVER)
|
||||
extras[uci] = tls_server[canonical];
|
||||
|
||||
return extras;
|
||||
}
|
||||
|
||||
/* Build the inbound `multiplex` block. The server side has no
|
||||
* max_connections / min_streams / max_streams (those are client
|
||||
* dialling knobs), so this is deliberately a smaller shape than the
|
||||
* outbound factory's build_multiplex(). */
|
||||
function build_inbound_multiplex(mux) {
|
||||
if (!mux || mux.enabled !== '1')
|
||||
return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
padding: strToBool(mux.padding),
|
||||
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||
enabled: true,
|
||||
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Inbound -> sing-box inbound object. Pure: no UCI, no file access. */
|
||||
function build_inbound(inbound) {
|
||||
const out = {
|
||||
type: inbound.type,
|
||||
tag: Inbound.tag(inbound)
|
||||
};
|
||||
|
||||
const omit = INBOUND_COMMON_OMIT[inbound.type] || [];
|
||||
|
||||
for (let field, spec in INBOUND_COMMON_FIELDS) {
|
||||
if (field in omit)
|
||||
continue;
|
||||
out[field] = resolve(spec, inbound);
|
||||
}
|
||||
|
||||
const claims = INBOUND_CLAIM_FIELDS(inbound);
|
||||
for (let field, value in claims)
|
||||
out[field] = value;
|
||||
|
||||
const options = INBOUND_OPTION_FIELDS[inbound.type] || {};
|
||||
for (let field, spec in options)
|
||||
out[field] = resolve(spec, inbound);
|
||||
|
||||
out.users = (inbound.type in INBOUND_NO_USERS) ? null : [ build_inbound_user(inbound) ];
|
||||
|
||||
out.multiplex = build_inbound_multiplex(inbound.multiplex);
|
||||
out.tls = buildTLSObject(inbound.tls, true, build_tls_server_extras(inbound.tls_server || {}));
|
||||
out.transport = buildTransportObject(inbound.transport, true);
|
||||
|
||||
/* Same contract as build_outbound(): return the final artifact so a
|
||||
* golden snapshot can compare it against what sing-box is handed. */
|
||||
return removeBlankAttrs(out);
|
||||
}
|
||||
|
||||
/* Per-protocol requirements, mirroring OutboundFactory's
|
||||
* REQUIRED_CREDENTIALS: what the sing-box inbound for this protocol
|
||||
* needs in order to be usable. */
|
||||
const REQUIRED_INBOUND_CREDENTIALS = {
|
||||
vless: ['uuid'],
|
||||
vmess: ['uuid'],
|
||||
trojan: ['password'],
|
||||
hysteria2: ['password'],
|
||||
tuic: ['uuid', 'password'],
|
||||
shadowsocks: ['password'],
|
||||
snell: ['psk'],
|
||||
anytls: ['password'],
|
||||
shadowtls: ['password'],
|
||||
/* These run without a credential: http / mixed / socks / naive can
|
||||
* be anonymous, and hysteria (1) may carry its auth out of band. */
|
||||
hysteria: [],
|
||||
http: [],
|
||||
mixed: [],
|
||||
naive: [],
|
||||
socks: []
|
||||
};
|
||||
|
||||
function inbound_problems(inbound) {
|
||||
let problems = [...Inbound.validate(inbound)];
|
||||
|
||||
const required = REQUIRED_INBOUND_CREDENTIALS[inbound.type] || [];
|
||||
const creds = inbound.credentials || {};
|
||||
|
||||
for (let field in required)
|
||||
if (!creds[field])
|
||||
problems = [...problems, `${inbound.type} requires ${field}`];
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export const InboundFactory = {
|
||||
problems: inbound_problems,
|
||||
|
||||
buildable: (inbound) => length(inbound_problems(inbound)) === 0,
|
||||
|
||||
/* Inbound -> { inbound, problems }. Never dies, so a single broken
|
||||
* server section can be reported by the caller instead of taking
|
||||
* the whole server instance down. */
|
||||
tryCreate: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
return { inbound: null, problems: problems };
|
||||
|
||||
return { inbound: build_inbound(inbound), problems: [] };
|
||||
},
|
||||
|
||||
/* Inbound -> sing-box inbound object, or die(). */
|
||||
create: (inbound) => {
|
||||
const problems = inbound_problems(inbound);
|
||||
|
||||
if (length(problems))
|
||||
die(`inbound '${inbound.id}': ${join(', ', problems)}\n`);
|
||||
|
||||
return build_inbound(inbound);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig
|
||||
*
|
||||
* This file originated as the demo loader and was promoted into the
|
||||
* production tree; the demo copy is gone, so this is the only one.
|
||||
*
|
||||
* What this layer does:
|
||||
* - owns the only `uci.cursor()` in the client configuration path
|
||||
* - reads general + infra + nodes + dns + routing + access_control + server
|
||||
* - never emits sing-box JSON
|
||||
* - never mutates/commits UCI
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import {
|
||||
Config, Node, CREDENTIALS, Inbound,
|
||||
INBOUND_CREDENTIALS, INBOUND_OPTIONS, INBOUND_COMMON, INBOUND_TLS_SERVER
|
||||
} from './model.uc';
|
||||
|
||||
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from '../parser/mapping.uc';
|
||||
|
||||
const UCICONFIG = 'homeproxy-pro';
|
||||
|
||||
const SECTION = {
|
||||
main: 'config',
|
||||
infra: 'infra',
|
||||
node: 'node',
|
||||
dns: 'dns',
|
||||
dns_server: 'dns_server',
|
||||
dns_rule: 'dns_rule',
|
||||
routing: 'routing',
|
||||
routing_node: 'routing_node',
|
||||
routing_rule: 'routing_rule',
|
||||
ruleset: 'ruleset',
|
||||
control: 'control',
|
||||
subscription: 'subscription',
|
||||
server: 'server'
|
||||
};
|
||||
|
||||
/* Booleans are UCI '0'/'1'; keep the conversion in one place. */
|
||||
function bool(value) {
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
function opt(uci, section, name) {
|
||||
/* UCI returns null for an absent option; the old code papered over that
|
||||
* with `|| ''` at every call site, losing the distinction between "not
|
||||
* set" and "set to empty". The model keeps null instead. */
|
||||
return uci.get(UCICONFIG, SECTION[section], name);
|
||||
}
|
||||
|
||||
function node_opt(uci, section_id, name) {
|
||||
return uci.get(UCICONFIG, section_id, name);
|
||||
}
|
||||
|
||||
/* --- sub-object extraction --------------------------------------------- */
|
||||
|
||||
/* These three functions are the whole reason a Node is hierarchical: the
|
||||
* shared TLS/transport/multiplex builders (homeproxy-pro.uc) take a flat,
|
||||
* prefixed dict today, so every caller has to hand them the right subset. A
|
||||
* sub-object makes the boundary explicit and testable.
|
||||
*
|
||||
* load_tls and load_transport are exported so the server generator can
|
||||
* shape its UCI inbound sections the same way; only the multiplex shape is
|
||||
* Loader-internal because no caller outside the client node path needs it. */
|
||||
|
||||
export function load_tls(get) {
|
||||
return {
|
||||
enabled: get('tls'),
|
||||
server_name: get('tls_sni'),
|
||||
insecure: get('tls_insecure'),
|
||||
alpn: get('tls_alpn'),
|
||||
min_version: get('tls_min_version'),
|
||||
max_version: get('tls_max_version'),
|
||||
handshake_timeout: get('tls_handshake_timeout'),
|
||||
cipher_suites: get('tls_cipher_suites'),
|
||||
cert_path: get('tls_cert_path'),
|
||||
ech: {
|
||||
enabled: get('tls_ech'),
|
||||
config: get('tls_ech_config'),
|
||||
config_path: get('tls_ech_config_path')
|
||||
},
|
||||
utls: {
|
||||
fingerprint: get('tls_utls')
|
||||
},
|
||||
reality: {
|
||||
enabled: get('tls_reality'),
|
||||
public_key: get('tls_reality_public_key'),
|
||||
short_id: get('tls_reality_short_id')
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function load_transport(get) {
|
||||
const transport = get('transport');
|
||||
|
||||
if (transport == null || transport === '')
|
||||
return { type: null };
|
||||
|
||||
return {
|
||||
type: transport,
|
||||
host: get('http_host') || get('httpupgrade_host'),
|
||||
path: get('http_path') || get('ws_path'),
|
||||
headers: get('ws_host') ? { Host: get('ws_host') } : null,
|
||||
method: get('http_method'),
|
||||
max_early_data: get('websocket_early_data'),
|
||||
early_data_header_name: get('websocket_early_data_header'),
|
||||
service_name: get('grpc_servicename'),
|
||||
idle_timeout: get('http_idle_timeout'),
|
||||
ping_timeout: get('http_ping_timeout'),
|
||||
permit_without_stream: get('grpc_permit_without_stream')
|
||||
};
|
||||
};
|
||||
|
||||
function load_multiplex(get) {
|
||||
return {
|
||||
enabled: get('multiplex'),
|
||||
protocol: get('multiplex_protocol'),
|
||||
max_connections: get('multiplex_max_connections'),
|
||||
min_streams: get('multiplex_min_streams'),
|
||||
max_streams: get('multiplex_max_streams'),
|
||||
padding: get('multiplex_padding'),
|
||||
brutal: {
|
||||
enabled: get('multiplex_brutal'),
|
||||
up_mbps: get('multiplex_brutal_up'),
|
||||
down_mbps: get('multiplex_brutal_down')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Cross-protocol common fields (every outbound protocol carries them, not
|
||||
* just one). Lives as its own sub-object instead of being repeated in
|
||||
* every PROTOCOL_OPTIONS row, and the Adapter reads from `node.common`. */
|
||||
function load_common(get) {
|
||||
return {
|
||||
proxy_protocol: get('proxy_protocol'),
|
||||
tcp_fast_open: get('tcp_fast_open'),
|
||||
tcp_multi_path: get('tcp_multi_path'),
|
||||
udp_fragment: get('udp_fragment')
|
||||
};
|
||||
}
|
||||
|
||||
/* --- protocol options --------------------------------------------------- */
|
||||
|
||||
/* The canonical <-> UCI mapping table lives in scripts/parser/mapping.uc and
|
||||
* is imported as PROTOCOL_OPTIONS above. The Loader no longer maintains its
|
||||
* own copy, so the parser, the Loader, the parser's normalize(), and the
|
||||
* Repository all read from the same source. Anything not listed in
|
||||
* parser/mapping.uc for a given protocol is not part of the domain
|
||||
* contract; a stale UCI option is now dropped at the parser side too.
|
||||
*/
|
||||
|
||||
function load_protocol_options(get, type) {
|
||||
const mapping = PROTOCOL_OPTIONS[type] || {};
|
||||
const options = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
options[canonical] = get(uci_name);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/* Pull the protocol's credential fields out under canonical names, using the
|
||||
* single CREDENTIALS table from the model. This is the loader half of the
|
||||
* mapping that the old code spread across ~20 ternaries in generate_outbound().
|
||||
* Declared before Loader: ucode resolves module-level names at call time but
|
||||
* not before they are declared. */
|
||||
function load_credentials(get, type) {
|
||||
const mapping = CREDENTIALS[type] || {};
|
||||
const credentials = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
credentials[canonical] = get(uci_name);
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/* --- domain sub-objects ------------------------------------------------ */
|
||||
|
||||
/* Settings: only the keys that exist in UCI are kept (null is dropped), so
|
||||
* the consumer can tell "not set" from "set to empty". A3 (Generator split)
|
||||
* will own the actual key list - this loader just preserves what UCI has. */
|
||||
function load_settings(uci, section, keys) {
|
||||
const settings = {};
|
||||
|
||||
for (let key in keys) {
|
||||
const v = uci.get(UCICONFIG, section, key);
|
||||
if (v != null)
|
||||
settings[key] = v;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/* The raw UCI section dict that
|
||||
* uci.foreach() yields carries internal fields prefixed with a dot
|
||||
* (`.name`, `.index`, `.type`); downstream generators had to read those
|
||||
* directly, and every consumer had to remember `cfg.enabled !== '1'`
|
||||
* to gate a section. Normalise once here so consumers see a flat
|
||||
* domain object: the dot-prefixed pseudo-fields are dropped, the UCI
|
||||
* section name is exposed as `name`, and `enabled` (when present) is
|
||||
* coerced to a real boolean.
|
||||
*
|
||||
* Fields with non-prefixed names that happen to collide with the UCI
|
||||
* metadata (e.g. an option literally called `name` or `index`) would
|
||||
* be shadowed; in this codebase that does not happen, and the Adapter keeps
|
||||
* it that way. */
|
||||
function normalize_section(cfg) {
|
||||
const item = {};
|
||||
|
||||
for (let k, v in cfg) {
|
||||
/* substr(), not k[0]: the target ucode (2026.01.16) rejects
|
||||
* string indexing with "left-hand side expression is not an
|
||||
* array or object". subscription/repository.uc already uses
|
||||
* the substr() form for the same check. */
|
||||
if (substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
item[k] = v;
|
||||
}
|
||||
|
||||
if ('enabled' in item)
|
||||
item.enabled = (item.enabled === '1');
|
||||
|
||||
item.name = cfg['.name'];
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/* Single-section + list sections grouped under a domain sub-object.
|
||||
* Each list entry is the normalised section dict produced by
|
||||
* normalize_section(): the UCI `.name`/`.index`/`.type` pseudo-fields
|
||||
* are gone, `name` is set to the section name, and `enabled` is a
|
||||
* boolean. Sing-box field names are still produced by the generator
|
||||
* modules; this loader just preserves what UCI has. */
|
||||
function load_sections(uci, type) {
|
||||
const items = [];
|
||||
|
||||
uci.foreach(UCICONFIG, type, (cfg) => push(items, normalize_section(cfg)));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function load_dns(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.dns, [
|
||||
'default_strategy', 'default_server',
|
||||
'disable_cache', 'disable_cache_expire',
|
||||
'client_subnet',
|
||||
'optimistic_cache', 'optimistic_timeout',
|
||||
'dns_timeout', 'cache_file_store_dns'
|
||||
]),
|
||||
servers: load_sections(uci, SECTION.dns_server),
|
||||
rules: load_sections(uci, SECTION.dns_rule)
|
||||
};
|
||||
}
|
||||
|
||||
function load_routing(uci) {
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.routing, [
|
||||
'default_outbound', 'default_outbound_dns',
|
||||
'domain_strategy', 'find_neighbor',
|
||||
'udp_timeout', 'tcpip_stack', 'endpoint_independent_nat'
|
||||
]),
|
||||
nodes: load_sections(uci, SECTION.routing_node),
|
||||
rules: load_sections(uci, SECTION.routing_rule),
|
||||
rulesets: load_sections(uci, SECTION.ruleset)
|
||||
};
|
||||
}
|
||||
|
||||
/* access_control is two single sections: `control` (lan_proxy_mode,
|
||||
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
|
||||
function load_access_control(uci) {
|
||||
return {
|
||||
control: load_settings(uci, SECTION.control, [
|
||||
'bind_interface', 'lan_proxy_mode'
|
||||
]),
|
||||
/* wan_proxy_*_ips are list options on the control section; collect
|
||||
them in their canonical form. */
|
||||
wan_proxy_ipv4_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv4_ips') || [],
|
||||
wan_proxy_ipv6_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv6_ips') || [],
|
||||
subscription: load_settings(uci, SECTION.subscription, [
|
||||
'auto_update', 'allow_insecure',
|
||||
'packet_encoding', 'update_via_proxy',
|
||||
'filter_nodes', 'user_agent'
|
||||
]),
|
||||
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
|
||||
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
|
||||
};
|
||||
}
|
||||
|
||||
/* The two "read a table of canonical -> UCI names off a section"
|
||||
* helpers shared by the node and inbound paths. Same shape as
|
||||
* load_protocol_options() above; split out so the server side reads
|
||||
* the same way the client side does. */
|
||||
function load_table(get, mapping) {
|
||||
const out = {};
|
||||
|
||||
for (let canonical, uci_name in mapping)
|
||||
out[canonical] = get(uci_name);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* A server inbound is now a
|
||||
* domain object like a Node, built here and shaped into sing-box JSON
|
||||
* by config/adapter.uc's InboundFactory. Before this the generator read
|
||||
* the flat UCI section directly (`cfg.snell_version`,
|
||||
* `cfg.shadowsocks_encrypt_method`, ...), so the server path had no
|
||||
* domain model at all.
|
||||
*
|
||||
* Same sub-object split as Node: common (listener-level), credentials,
|
||||
* tls (the shared shape the client uses too), tls_server (the
|
||||
* server-only tail), transport, multiplex, protocol_options. */
|
||||
function load_inbound(get, section) {
|
||||
const type = get('type');
|
||||
|
||||
return Inbound.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: type,
|
||||
enabled: section.enabled === '1',
|
||||
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
firewall: get('firewall'),
|
||||
|
||||
common: load_table(get, INBOUND_COMMON),
|
||||
credentials: load_table(get, INBOUND_CREDENTIALS[type] || {}),
|
||||
tls: load_tls(get),
|
||||
tls_server: load_table(get, INBOUND_TLS_SERVER),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_table(get, INBOUND_OPTIONS[type] || {})
|
||||
});
|
||||
}
|
||||
|
||||
/* server has one enabled/log_level single section + N inbound sections
|
||||
* (each with a `type` of vless / trojan / shadowsocks / ...). */
|
||||
function load_server(uci) {
|
||||
const inbounds = [];
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.server, (section) => {
|
||||
push(inbounds, load_inbound((name) => node_opt(uci, section['.name'], name), section));
|
||||
});
|
||||
|
||||
return {
|
||||
settings: load_settings(uci, SECTION.server, [
|
||||
'enabled', 'log_level'
|
||||
]),
|
||||
inbounds
|
||||
};
|
||||
}
|
||||
|
||||
/* --- loader ------------------------------------------------------------- */
|
||||
|
||||
export const Loader = {
|
||||
/* Load the client side of the configuration. `dir` defaults to the
|
||||
* production UCI directory but can be overridden, which removes the need
|
||||
* for the test suite's `sed` of `const uci = cursor();`. */
|
||||
load: (dir) => {
|
||||
const uci = dir ? cursor(dir) : cursor();
|
||||
|
||||
uci.load(UCICONFIG);
|
||||
|
||||
const config = Config.create('homeproxy-pro');
|
||||
|
||||
config.general = {
|
||||
routing_mode: opt(uci, 'main', 'routing_mode') || 'bypass_mainland_china',
|
||||
proxy_mode: opt(uci, 'main', 'proxy_mode') || 'redirect_tproxy',
|
||||
main_node: opt(uci, 'main', 'main_node') || 'nil',
|
||||
main_udp_node: opt(uci, 'main', 'main_udp_node') || 'nil',
|
||||
/* Booleans stay as raw UCI strings here, same as Node. The
|
||||
Adapter (A3) is the one that calls strToBool(); coercing in
|
||||
the Loader and again in the Adapter produced two different
|
||||
defaults for absent / garbage values during A1.1. */
|
||||
ipv6_support: opt(uci, 'main', 'ipv6_support'),
|
||||
|
||||
/* A2.1: scalar + list fields on the `config` section. */
|
||||
dns_server: opt(uci, 'main', 'dns_server'),
|
||||
china_dns_server: opt(uci, 'main', 'china_dns_server'),
|
||||
log_level: opt(uci, 'main', 'log_level') || 'warn',
|
||||
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
|
||||
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
|
||||
udp_mapping: opt(uci, 'main', 'udp_mapping'),
|
||||
udp_filtering: opt(uci, 'main', 'udp_filtering'),
|
||||
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
|
||||
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
|
||||
sniffer_advanced_mode: opt(uci, 'main', 'sniffer_advanced_mode'),
|
||||
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
|
||||
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
|
||||
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
|
||||
main_udp_urltest_nodes: opt(uci, 'main', 'main_udp_urltest_nodes') || [],
|
||||
main_udp_urltest_interval: opt(uci, 'main', 'main_udp_urltest_interval'),
|
||||
main_udp_urltest_tolerance: opt(uci, 'main', 'main_udp_urltest_tolerance')
|
||||
};
|
||||
|
||||
/* udp_timeout lives on two UCI sections: routing.udp_timeout
|
||||
* (custom mode) and infra.udp_timeout (everything else). Mirror
|
||||
* that split here so A2 can read either path without falling back
|
||||
* to uci.get(). */
|
||||
config.infra = load_settings(uci, SECTION.infra, [
|
||||
'common_port', 'mixed_port', 'redirect_port', 'tproxy_port',
|
||||
'dns_port', 'dns_redirect',
|
||||
'tun_name', 'tun_addr4', 'tun_addr6', 'tun_mtu',
|
||||
'table_mark', 'self_mark', 'tproxy_mark', 'tun_mark',
|
||||
'ntp_server', 'udp_timeout'
|
||||
]);
|
||||
|
||||
uci.foreach(UCICONFIG, SECTION.node, (section) => {
|
||||
const get = (name) => node_opt(uci, section['.name'], name);
|
||||
|
||||
push(config.nodes, Node.create({
|
||||
id: section['.name'],
|
||||
name: get('label') || section['.name'],
|
||||
type: get('type'),
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
common: load_common(get),
|
||||
credentials: load_credentials(get, get('type')),
|
||||
tls: load_tls(get),
|
||||
transport: load_transport(get),
|
||||
multiplex: load_multiplex(get),
|
||||
protocol_options: load_protocol_options(get, get('type'))
|
||||
}));
|
||||
});
|
||||
|
||||
config.dns = load_dns(uci);
|
||||
config.routing = load_routing(uci);
|
||||
config.access_control = load_access_control(uci);
|
||||
config.server = load_server(uci);
|
||||
|
||||
return config;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.1 of the architecture refactor:
|
||||
*
|
||||
* UCI -> Config Loader -> HomeProxyConfig / Node -> Adapter -> sing-box
|
||||
*
|
||||
* The point of this layer is that nothing below it may touch UCI, and nothing
|
||||
* in it may know about sing-box JSON. A Node therefore describes *what the
|
||||
* user configured*, not what sing-box wants: no `outbound`, no `type` field
|
||||
* named after a sing-box outbound kind, no `tag` arithmetic.
|
||||
*
|
||||
* Every field the Adapter used to read off
|
||||
* `node.raw.*` has been promoted to an explicit Node sub-object
|
||||
* (`credentials`, `tls`, `transport`, `multiplex`, `common`,
|
||||
* `protocol_options`), so the legacy `raw` opaque bag was dropped - no code
|
||||
* reads it and leaving it in only made the next reader assume it was
|
||||
* authoritative.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy-pro.uc';
|
||||
|
||||
/* Canonical credential multiplexing for a Node. The current generator picks
|
||||
* these apart with ternaries per field (`username` vs `user` vs `password` vs
|
||||
* `psk`, keyed on every protocol); expressing them as data is what makes a new
|
||||
* protocol a table entry instead of another branch.
|
||||
*
|
||||
* The value is the UCI option name (as the loader sees it on the flat UCI
|
||||
* section), the key is the canonical credential name. The adapter later
|
||||
* emits the field under whatever name the sing-box outbound wants
|
||||
* (e.g. snell: credentials.psk is emitted as the outbound `psk` field,
|
||||
* shadowsocks: credentials.method becomes outbound `method`).
|
||||
*
|
||||
* Fixed during Stage A1.1: snell `userkey` actually comes from the
|
||||
* `snell_userkey` UCI option (not `username`), and shadowsocks `method`
|
||||
* actually comes from `shadowsocks_encrypt_method` (not `method`).
|
||||
* The earlier table assumed both mapped to bare option names and failed
|
||||
* the skeleton test against the real fixture. */
|
||||
export const CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
socks: { username: 'username', password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||
/* The node form writes ssh_priv_key / ssh_priv_key_pp, not the bare
|
||||
* private_key this table used to claim, so the key the user pasted was
|
||||
* never read. (There was also no PROTOCOL_OPTIONS row for ssh, so the
|
||||
* rest of the SSH options were dropped as well.) */
|
||||
ssh: { user: 'username', password: 'password',
|
||||
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||
anytls: { password: 'password' },
|
||||
shadowtls: { password: 'password' },
|
||||
direct: {}
|
||||
};
|
||||
|
||||
/* Canonical credentials for a server Inbound. A different table from
|
||||
* the client's CREDENTIALS because the server form exposes a smaller
|
||||
* set (no ssh material, no snell userkey) and because snell's server
|
||||
* side reads the psk off the same `password` UCI option the client
|
||||
* node uses for its own psk. Keeping the two tables separate means a
|
||||
* change to one side cannot silently alter the other.
|
||||
*/
|
||||
export const INBOUND_CREDENTIALS = {
|
||||
vless: { uuid: 'uuid' },
|
||||
vmess: { uuid: 'uuid' },
|
||||
trojan: { password: 'password' },
|
||||
hysteria: { auth_str: 'hysteria_auth_payload', auth_base64: 'hysteria_auth_payload' },
|
||||
hysteria2: { password: 'password' },
|
||||
tuic: { uuid: 'uuid', password: 'password' },
|
||||
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||
shadowtls: { password: 'password' },
|
||||
snell: { psk: 'password' },
|
||||
anytls: { password: 'password' },
|
||||
http: { username: 'username', password: 'password' },
|
||||
mixed: { username: 'username', password: 'password' },
|
||||
naive: { username: 'username', password: 'password' },
|
||||
socks: { username: 'username', password: 'password' }
|
||||
};
|
||||
|
||||
/* Per-protocol options on a server Inbound, canonical -> UCI. This is
|
||||
* the server-side counterpart of parser/mapping.uc's PROTOCOL_TO_UCI;
|
||||
* the two are NOT identical - the server side exposes inbound-only
|
||||
* knobs (anytls padding_scheme, hysteria masquerade / packet sizes /
|
||||
* ignore_client_bandwidth, tuic auth_timeout) and does not expose the
|
||||
* client's port-hopping options - so they stay separate tables. A
|
||||
* protocol whose server-side row is missing here produces no
|
||||
* protocol_options rather than reading a flat UCI key. */
|
||||
export const INBOUND_OPTIONS = {
|
||||
anytls: {
|
||||
padding_scheme: 'anytls_padding_scheme'
|
||||
},
|
||||
hysteria: {
|
||||
auth_type: 'hysteria_auth_type',
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
hysteria2: {
|
||||
up_mbps: 'hysteria_up_mbps',
|
||||
down_mbps: 'hysteria_down_mbps',
|
||||
obfs_type: 'hysteria_obfs_type',
|
||||
obfs_password: 'hysteria_obfs_password',
|
||||
obfs_min_packet_size: 'hysteria_obfs_min_packet_size',
|
||||
obfs_max_packet_size: 'hysteria_obfs_max_packet_size',
|
||||
masquerade: 'hysteria_masquerade',
|
||||
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||
},
|
||||
shadowsocks: {
|
||||
method: 'shadowsocks_encrypt_method'
|
||||
},
|
||||
snell: {
|
||||
version: 'snell_version',
|
||||
obfs_mode: 'snell_obfs_mode'
|
||||
},
|
||||
tuic: {
|
||||
congestion_control: 'tuic_congestion_control',
|
||||
auth_timeout: 'tuic_auth_timeout',
|
||||
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||
heartbeat: 'tuic_heartbeat'
|
||||
},
|
||||
vless: {
|
||||
flow: 'vless_flow'
|
||||
},
|
||||
vmess: {
|
||||
alter_id: 'vmess_alterid'
|
||||
}
|
||||
};
|
||||
|
||||
/* Listener-level fields every server inbound carries. Inbound-only
|
||||
* (no client outbound emits them), so they are not part of the
|
||||
* client's load_common() table. */
|
||||
export const INBOUND_COMMON = {
|
||||
bind_interface: 'bind_interface',
|
||||
reuse_addr: 'reuse_addr',
|
||||
tcp_fast_open: 'tcp_fast_open',
|
||||
tcp_multi_path: 'tcp_multi_path',
|
||||
udp_fragment: 'udp_fragment',
|
||||
udp_timeout: 'udp_timeout',
|
||||
network: 'network'
|
||||
};
|
||||
|
||||
/* The server-only TLS tail. These options exist because a client
|
||||
* outbound never needs them (key material, ACME, the server half of
|
||||
* the REALITY handshake), so they live on the Inbound rather than
|
||||
* being folded into the shared `tls` sub-object the Loader builds for
|
||||
* both sides. Canonical -> UCI; the Adapter re-keys them back to the
|
||||
* UCI names buildTLSObject() reads, which is the one place the flat
|
||||
* names still exist. */
|
||||
export const INBOUND_TLS_SERVER = {
|
||||
key_path: 'tls_key_path',
|
||||
ech_key: 'tls_ech_key',
|
||||
reality_private_key: 'tls_reality_private_key',
|
||||
reality_max_time_difference: 'tls_reality_max_time_difference',
|
||||
reality_server_addr: 'tls_reality_server_addr',
|
||||
reality_server_port: 'tls_reality_server_port',
|
||||
acme: 'tls_acme',
|
||||
acme_domain: 'tls_acme_domain',
|
||||
acme_dsn: 'tls_acme_dsn',
|
||||
acme_email: 'tls_acme_email',
|
||||
acme_provider: 'tls_acme_provider',
|
||||
acme_account_key: 'tls_acme_account_key',
|
||||
acme_key_type: 'tls_acme_key_type',
|
||||
acme_profile: 'tls_acme_profile',
|
||||
acme_disable_http_challenge: 'tls_acme_dhc',
|
||||
acme_disable_tls_alpn_challenge: 'tls_acme_dtac',
|
||||
acme_alternative_http_port: 'tls_acme_ahp',
|
||||
acme_alternative_tls_port: 'tls_acme_atp',
|
||||
acme_external_account: 'tls_acme_external_account',
|
||||
acme_external_account_key_id: 'tls_acme_ea_keyid',
|
||||
acme_external_account_mac_key: 'tls_acme_ea_mackey',
|
||||
dns01_challenge: 'tls_dns01_challenge',
|
||||
dns01_provider: 'tls_dns01_provider',
|
||||
dns01_access_key_id: 'tls_dns01_ali_akid',
|
||||
dns01_access_key_secret: 'tls_dns01_ali_aksec',
|
||||
dns01_region_id: 'tls_dns01_ali_rid',
|
||||
dns01_api_token: 'tls_dns01_cf_api_token'
|
||||
};
|
||||
|
||||
/* --- Domain types ------------------------------------------------------- */
|
||||
|
||||
export const Config = {
|
||||
create: (name) => ({
|
||||
name: name,
|
||||
general: {},
|
||||
nodes: [],
|
||||
|
||||
/* The five domain sub-objects the Loader fills in from the
|
||||
* corresponding UCI sections (single + list). The Loader now
|
||||
* normalises the list sections; downstream generators read only
|
||||
* these sub-objects. */
|
||||
dns: {},
|
||||
routing: {},
|
||||
access_control: {},
|
||||
server: {}
|
||||
})
|
||||
};
|
||||
|
||||
export const Node = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
|
||||
/* endpoint */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* cross-protocol common fields (proxy_protocol, tcp_fast_open,
|
||||
* tcp_multi_path, udp_fragment). The Adapter reads from here so
|
||||
* it never has to reach back into UCI for these. */
|
||||
common: opts.common || {},
|
||||
|
||||
/* credentials, already canonical */
|
||||
credentials: opts.credentials || {},
|
||||
|
||||
/* transport/TLS are sub-objects, not flat prefixed keys: that is the
|
||||
boundary the adapter needs in order to reuse the shared builders. */
|
||||
tls: opts.tls || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
/* per-protocol extras, canonical names only */
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* A node is usable only if the adapter can build a valid outbound from it.
|
||||
* The old code discovers this by crashing inside the JSON builder.
|
||||
*
|
||||
* Scope: generic, cross-protocol sanity only. The Adapter layer owns
|
||||
* per-protocol required-field rules (a vless needs uuid, vmess needs
|
||||
* both uuid and a security cipher, snell needs a psk, ...) because
|
||||
* those rules are sing-box-outbound-shaped. Anything the model can
|
||||
* express without naming a protocol or a JSON field goes here.
|
||||
*
|
||||
* Booleans are still raw UCI strings at this layer; this validator
|
||||
* compares against the literal '1' the way UCI emits them.
|
||||
*
|
||||
* Implementation note: ucode's push() returns the value pushed, not
|
||||
* the new array, so the natural-looking `problems = push(problems, X)`
|
||||
* pattern reassigns problems to a string and silently corrupts the
|
||||
* accumulator. Build the array via spread instead - cheap because the
|
||||
* validator is at most four checks. */
|
||||
validate: (node) => {
|
||||
let problems = [];
|
||||
|
||||
if (!node.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* A direct outbound can run without a server/server_port - sing-box
|
||||
* uses the inbound's destination as the upstream, which is the
|
||||
* transparent-proxy case. For every other protocol the server
|
||||
* and port are mandatory. */
|
||||
if (node.type !== 'direct') {
|
||||
if (!node.address)
|
||||
problems = [...problems, 'missing address'];
|
||||
|
||||
if (node.port == null || int(node.port) < 1 || int(node.port) > 65535)
|
||||
problems = [...problems, `invalid port '${node.port}'`];
|
||||
}
|
||||
|
||||
/* TLS without server_name is a sing-box-side rejection, but the
|
||||
* rule itself is protocol-agnostic - the reality branch covers the
|
||||
* only legitimate escape - so it stays here. */
|
||||
if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1')
|
||||
problems = [...problems, 'TLS enabled without server_name'];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention lives here, not scattered through the generator */
|
||||
tag: (node) => 'cfg-' + node.id + '-out'
|
||||
};
|
||||
|
||||
/* The server-side counterpart of Node. Same layering discipline: it
|
||||
* describes what the user configured (listen address/port, protocol,
|
||||
* credentials, TLS, transport, per-protocol knobs) and knows nothing
|
||||
* about sing-box JSON. The Inbound adapter (config/adapter.uc,
|
||||
* InboundFactory) is what turns it into a sing-box inbound.
|
||||
*
|
||||
* Field grouping mirrors Node deliberately so the Adapter's two halves
|
||||
* read the same way:
|
||||
* common listener-level shared fields (bind_interface,
|
||||
* reuse_addr, tcp_fast_open, ...)
|
||||
* credentials canonical credential names
|
||||
* tls shared TLS shape (same load_tls() the client uses)
|
||||
* tls_server server-only TLS tail (key material, ACME, the
|
||||
* server half of the REALITY handshake)
|
||||
* transport shared transport shape (same load_transport())
|
||||
* multiplex shared multiplex shape
|
||||
* protocol_options per-protocol knobs
|
||||
*/
|
||||
export const Inbound = {
|
||||
create: (opts) => ({
|
||||
/* identity */
|
||||
id: opts.id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
enabled: opts.enabled,
|
||||
|
||||
/* listener */
|
||||
address: opts.address,
|
||||
port: opts.port,
|
||||
|
||||
/* firewall is consumed by firewall_pre.uc (which decides the
|
||||
* nft accept rules), not by the inbound adapter. It stays on the
|
||||
* model so the server section is described in one place. */
|
||||
firewall: opts.firewall,
|
||||
|
||||
common: opts.common || {},
|
||||
credentials: opts.credentials || {},
|
||||
tls: opts.tls || {},
|
||||
tls_server: opts.tls_server || {},
|
||||
transport: opts.transport || {},
|
||||
multiplex: opts.multiplex || {},
|
||||
|
||||
protocol_options: opts.protocol_options || {}
|
||||
}),
|
||||
|
||||
/* Same scope as Node.validate(): cross-protocol sanity the model
|
||||
* can express without naming a sing-box field. The per-protocol
|
||||
* rules live in the Adapter (InboundFactory.problems). */
|
||||
validate: (inbound) => {
|
||||
let problems = [];
|
||||
|
||||
if (!inbound.type)
|
||||
problems = [...problems, 'missing type'];
|
||||
|
||||
/* An inbound with no listen address is legitimate (sing-box
|
||||
* defaults to all interfaces), but a port is required. */
|
||||
if (inbound.port == null || int(inbound.port) < 1 || int(inbound.port) > 65535)
|
||||
problems = [...problems, `invalid port '${inbound.port}'`];
|
||||
|
||||
return problems;
|
||||
},
|
||||
|
||||
/* sing-box tag convention for a server inbound. Uses `id` (the UCI
|
||||
* section name) rather than `name` (the user's label) so renaming a
|
||||
* label does not change the tag. */
|
||||
tag: (inbound) => 'cfg-' + inbound.id + '-in'
|
||||
};
|
||||
|
||||
/* --- Config helpers ----------------------------------------------------- */
|
||||
|
||||
export const ConfigQuery = {
|
||||
node_by_id: (config, id) => {
|
||||
for (let node in config.nodes)
|
||||
if (node.id === id)
|
||||
return node;
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Linear search by section name. The Loader normalises list sections so
|
||||
* `name` is the UCI section name; the dotted `.name` UCI uses
|
||||
* internally is gone from the loaded shape. */
|
||||
find_by_name: (items, name) => {
|
||||
for (let it in items)
|
||||
if (it.name === name)
|
||||
return it;
|
||||
return null;
|
||||
},
|
||||
|
||||
/* Which UDP node the routing modes route through. The default (`nil`)
|
||||
* means "no proxy node", which is a legitimate configuration. The
|
||||
* equivalent main_node read is one-liner enough that no helper
|
||||
* exists. */
|
||||
main_udp_node_id: (config) => config.general.main_udp_node || 'nil'
|
||||
};
|
||||
Reference in new issue
Block a user