Files

458 lines
15 KiB
Ucode

#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* PR-04 (Protocol Adapter Completion): unit tests for InboundFactory, the
* server-side half of the adapter.
*
* The inbound golden snapshot (tests/ucode/test_golden_inbounds.sh) pins
* what the adapter *emits* for the fixture's seven protocols. This test
* covers the decisions that a snapshot cannot express:
*
* - a protocol with no users[] block (snell, shadowsocks) must not get
* one: sing-box interprets a snell users entry as an extra user key
* and rejects the config outright ("snell: bad user key")
* - vless / vmess carry flow / alterId per user, never top-level
* - the snell listener set omits udp_fragment / udp_timeout / network
* even when the section carries them
* - REQUIRED_INBOUND_CREDENTIALS is enforced by problems()/buildable()
* - the server-only TLS tail reaches buildTLSObject() under the UCI
* names it reads (key_path, ech_key, reality, ACME)
* - hysteria v1 emits obfs as a string while hysteria2 emits the
* {type, password, ...} object
*
* Run through tests/ucode/run.sh, which stages config/ + parser/ next to
* the homeproxy-pro test double.
*/
'use strict';
import { Inbound, INBOUND_CREDENTIALS, INBOUND_OPTIONS } from './config/model.uc';
import { InboundFactory } from './config/adapter.uc';
let failures = 0, checks = 0;
function expect(name, actual, expected) {
checks++;
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
failures++;
}
}
function not_in(name, obj, key) {
checks++;
if (key in obj) {
printf('FAIL %s: %s should be absent, got %J\n', name, key, obj[key]);
failures++;
}
}
/* Build an Inbound the way the Loader would, from a flat option map.
* `opts` uses UCI option names; the tables in model.uc do the renaming,
* so this helper exercises the same canonical boundary the Loader does. */
function make_inbound(id, type, opts) {
const get = (name) => (name in opts) ? opts[name] : null;
const credentials = {};
const protocol_options = {};
for (let canonical, uci in (INBOUND_CREDENTIALS[type] || {}))
credentials[canonical] = get(uci);
for (let canonical, uci in (INBOUND_OPTIONS[type] || {}))
protocol_options[canonical] = get(uci);
const tls = {
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')
}
};
return Inbound.create({
id: id,
name: opts.label || id,
type: type,
enabled: true,
address: get('address'),
port: get('port'),
firewall: get('firewall'),
common: {
bind_interface: get('bind_interface'),
reuse_addr: get('reuse_addr'),
tcp_fast_open: get('tcp_fast_open'),
tcp_multi_path: get('tcp_multi_path'),
udp_fragment: get('udp_fragment'),
udp_timeout: get('udp_timeout'),
network: get('network')
},
credentials: credentials,
tls: tls,
tls_server: {
key_path: get('tls_key_path'),
ech_key: get('tls_ech_key'),
reality_private_key: get('tls_reality_private_key'),
reality_max_time_difference: get('tls_reality_max_time_difference'),
reality_server_addr: get('tls_reality_server_addr'),
reality_server_port: get('tls_reality_server_port'),
acme: get('tls_acme'),
acme_domain: get('tls_acme_domain'),
acme_email: get('tls_acme_email'),
acme_provider: get('tls_acme_provider'),
dns01_challenge: get('tls_dns01_challenge'),
dns01_provider: get('tls_dns01_provider')
},
transport: {
type: get('transport'),
host: get('ws_host') || get('httpupgrade_host') || get('http_host'),
path: get('ws_path') || get('http_path'),
headers: get('ws_host') ? { Host: get('ws_host') } : null,
service_name: get('grpc_servicename')
},
multiplex: {
enabled: get('multiplex'),
padding: get('multiplex_padding'),
brutal: {
enabled: get('multiplex_brutal'),
up_mbps: get('multiplex_brutal_up'),
down_mbps: get('multiplex_brutal_down')
}
},
protocol_options: protocol_options
});
}
/* --- snell: no users[], reduced listener set ------------------------- */
{
/* Deliberately set the fields snell must not be given, so the test
* fails if INBOUND_COMMON_OMIT is ever dropped. */
const snell = make_inbound('s_snell', 'snell', {
port: '8448',
password: 'pskvalue',
snell_version: '5',
snell_obfs_mode: 'http',
udp_fragment: '1',
udp_timeout: '300',
network: 'tcp'
});
const out = InboundFactory.create(snell);
expect('snell: type', out.type, 'snell');
expect('snell: tag', out.tag, 'cfg-s_snell-in');
expect('snell: listen defaults to ::', out.listen, '::');
expect('snell: listen_port', out.listen_port, 8448);
expect('snell: psk', out.psk, 'pskvalue');
expect('snell: version', out.version, 5);
expect('snell: obfs_mode', out.obfs_mode, 'http');
not_in('snell', out, 'users');
not_in('snell', out, 'udp_fragment');
not_in('snell', out, 'udp_timeout');
not_in('snell', out, 'network');
/* no `mode`: v6-only, sing-box 1.14 rejects it. */
not_in('snell', out, 'mode');
}
/* --- shadowsocks: no users[], top-level method + password ------------ */
{
const ss = make_inbound('s_ss', 'shadowsocks', {
address: '10.0.0.1',
port: '8447',
password: 'secret',
shadowsocks_encrypt_method: 'aes-256-gcm',
network: 'tcp',
udp_timeout: '300'
});
const out = InboundFactory.create(ss);
expect('ss: listen honours the address', out.listen, '10.0.0.1');
expect('ss: method', out.method, 'aes-256-gcm');
expect('ss: password', out.password, 'secret');
expect('ss: network is kept', out.network, 'tcp');
expect('ss: udp_timeout is a duration', out.udp_timeout, '300s');
not_in('ss', out, 'users');
}
/* --- vless: flow is per-user, never top-level ------------------------ */
{
const vless = make_inbound('s_vless', 'vless', {
port: '443',
uuid: 'uuid-x',
vless_flow: 'xtls-rprx-vision',
tls: '1',
tls_sni: 'v.example.com'
});
const out = InboundFactory.create(vless);
expect('vless: user name', out.users[0].name, 'cfg-s_vless-server');
expect('vless: user uuid', out.users[0].uuid, 'uuid-x');
expect('vless: user flow', out.users[0].flow, 'xtls-rprx-vision');
not_in('vless', out, 'flow');
not_in('vless', out, 'uuid');
expect('vless: tls enabled', out.tls.enabled, true);
expect('vless: tls server_name', out.tls.server_name, 'v.example.com');
}
/* --- vmess: alterId is per-user, never top-level --------------------- */
{
const vmess = make_inbound('s_vmess', 'vmess', {
port: '443',
uuid: 'uuid-y',
vmess_alterid: '1'
});
const out = InboundFactory.create(vmess);
expect('vmess: user alterId', out.users[0].alterId, 1);
not_in('vmess', out, 'alterId');
not_in('vmess', out, 'alter_id');
/* vmess_alterid='0' is the modern (AEAD) setting, but the shared
* strToInt() helper maps '0' to null (`int('0') || null`), so the
* field is omitted and sing-box applies its own default. That is
* pre-existing behaviour of the common helper, not something PR-04
* changes; pinned here so a future strToInt() fix shows up as a
* deliberate diff rather than a surprise. */
const vmess0 = InboundFactory.create(make_inbound('s_vmess0', 'vmess', {
port: '443',
uuid: 'uuid-y',
vmess_alterid: '0'
}));
not_in('vmess0: user alterId', vmess0.users[0], 'alterId');
}
/* --- http / mixed / socks / naive: user has no name ------------------ */
{
const http = make_inbound('s_http', 'http', {
port: '8080',
username: 'u',
password: 'p'
});
const out = InboundFactory.create(http);
expect('http: user username', out.users[0].username, 'u');
expect('http: user has no name', out.users[0].name, null);
}
/* --- hysteria v1 vs hysteria2 obfs shape ----------------------------- */
{
const hy1 = make_inbound('s_hy1', 'hysteria', {
port: '36712',
hysteria_auth_type: 'string',
hysteria_auth_payload: 'secret',
hysteria_obfs_password: 'obfspass',
hysteria_up_mbps: '100',
hysteria_down_mbps: '500',
hysteria_masquerade: 'https://example.com',
tls: '1',
tls_sni: 'h.example.com'
});
const out = InboundFactory.create(hy1);
expect('hy1: obfs is a plain string', out.obfs, 'obfspass');
expect('hy1: up_mbps', out.up_mbps, 100);
expect('hy1: down_mbps', out.down_mbps, 500);
expect('hy1: masquerade', out.masquerade, 'https://example.com');
expect('hy1: user auth_str', out.users[0].auth_str, 'secret');
not_in('hy1: user auth', out.users[0], 'auth');
}
{
const hy2 = make_inbound('s_hy2', 'hysteria2', {
port: '8445',
password: 'secret',
hysteria_obfs_type: 'salamander',
hysteria_obfs_password: 'obfspass',
hysteria_obfs_min_packet_size: '512',
hysteria_obfs_max_packet_size: '1200'
});
const out = InboundFactory.create(hy2);
expect('hy2: obfs.type', out.obfs.type, 'salamander');
expect('hy2: obfs.password', out.obfs.password, 'obfspass');
expect('hy2: obfs.min_packet_size', out.obfs.min_packet_size, 512);
expect('hy2: obfs.max_packet_size', out.obfs.max_packet_size, 1200);
/* auth is not modelled on hysteria2's options row, so neither
* spelling is emitted. */
not_in('hy2: user auth', out.users[0], 'auth');
not_in('hy2: user auth_str', out.users[0], 'auth_str');
}
/* --- tuic ------------------------------------------------------------ */
{
const tuic = make_inbound('s_tuic', 'tuic', {
port: '8449',
uuid: 'uuid-z',
password: 'pw',
tuic_congestion_control: 'bbr',
tuic_auth_timeout: '3',
tuic_enable_zero_rtt: '1',
tuic_heartbeat: '10',
tls: '1',
tls_sni: 't.example.com'
});
const out = InboundFactory.create(tuic);
expect('tuic: congestion_control', out.congestion_control, 'bbr');
expect('tuic: auth_timeout', out.auth_timeout, '3s');
expect('tuic: zero_rtt_handshake', out.zero_rtt_handshake, true);
expect('tuic: heartbeat', out.heartbeat, '10s');
expect('tuic: user uuid', out.users[0].uuid, 'uuid-z');
}
/* --- anytls: padding_scheme ----------------------------------------- */
{
const anytls = make_inbound('s_anytls', 'anytls', {
port: '8444',
password: 'secret',
anytls_padding_scheme: 'stop=8'
});
const out = InboundFactory.create(anytls);
expect('anytls: padding_scheme', out.padding_scheme, 'stop=8');
expect('anytls: user password', out.users[0].password, 'secret');
}
/* --- multiplex (server shape: no dialling knobs) -------------------- */
{
const trojan = make_inbound('s_trojan', 'trojan', {
port: '8446',
password: 'secret',
multiplex: '1',
multiplex_padding: '1'
});
const out = InboundFactory.create(trojan);
expect('trojan: multiplex enabled', out.multiplex.enabled, true);
expect('trojan: multiplex padding', out.multiplex.padding, true);
/* The server side has no protocol / max_connections / min_streams /
* max_streams: those are client dialling knobs. */
not_in('trojan: multiplex', out.multiplex, 'protocol');
not_in('trojan: multiplex', out.multiplex, 'max_connections');
not_in('trojan: multiplex', out.multiplex, 'min_streams');
not_in('trojan: multiplex', out.multiplex, 'max_streams');
}
/* --- the server-only TLS tail reaches buildTLSObject() --------------- */
{
const inbound = make_inbound('s_tls', 'vless', {
port: '443',
uuid: 'u',
tls: '1',
tls_sni: 't.example.com',
tls_key_path: '/etc/homeproxy-pro/certs/t.key',
tls_ech_key: 'ECHKEY',
tls_acme: '1',
tls_acme_domain: 't.example.com',
tls_acme_email: 'admin@example.com',
tls_acme_provider: 'letsencrypt'
});
const out = InboundFactory.create(inbound);
expect('tls: key_path', out.tls.key_path, '/etc/homeproxy-pro/certs/t.key');
expect('tls: ech.key', out.tls.ech.key, ['ECHKEY']);
expect('tls: acme type', out.tls.certificate_provider.type, 'acme');
expect('tls: acme domain is an array', out.tls.certificate_provider.domain, ['t.example.com']);
expect('tls: acme email', out.tls.certificate_provider.email, 'admin@example.com');
expect('tls: acme provider', out.tls.certificate_provider.provider, 'letsencrypt');
/* A client-side field must not appear on a server inbound. */
not_in('tls', out.tls, 'insecure');
not_in('tls', out.tls, 'utls');
}
{
/* reality: the server side gets the private key + the handshake
* address, the client side's public key stays out. */
const inbound = make_inbound('s_reality', 'vless', {
port: '443',
uuid: 'u',
tls: '1',
tls_reality: '1',
tls_sni: 'r.example.com',
tls_reality_private_key: 'PRIVKEY',
tls_reality_short_id: 'abcd',
tls_reality_server_addr: 'hw.example.com',
tls_reality_server_port: '443'
});
const out = InboundFactory.create(inbound);
expect('reality: private_key', out.tls.reality.private_key, 'PRIVKEY');
expect('reality: short_id', out.tls.reality.short_id, 'abcd');
expect('reality: handshake server', out.tls.reality.handshake.server, 'hw.example.com');
expect('reality: handshake server_port', out.tls.reality.handshake.server_port, 443);
not_in('reality', out.tls.reality, 'public_key');
}
/* --- validation ------------------------------------------------------ */
expect('vless without uuid is not buildable',
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443' })), false);
expect('vless with uuid is buildable',
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443', uuid: 'u' })), true);
expect('snell without psk is not buildable',
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443' })), false);
expect('snell with psk is buildable',
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443', password: 'k' })), true);
expect('shadowsocks without password is not buildable',
InboundFactory.buildable(make_inbound('x', 'shadowsocks', { port: '443' })), false);
expect('http needs no credential',
InboundFactory.buildable(make_inbound('x', 'http', { port: '8080' })), true);
{
const bad = InboundFactory.tryCreate(make_inbound('s_bad', 'vless', { port: '443' }));
expect('tryCreate returns no inbound on problems', bad.inbound, null);
expect('tryCreate names the missing credential',
length(filter(bad.problems, (p) => match(p, /requires uuid/))), 1);
}
{
/* The port check is protocol-agnostic and lives on Inbound.validate(). */
const bad = InboundFactory.tryCreate(make_inbound('s_badport', 'http', { port: '99999' }));
expect('tryCreate rejects an out-of-range port', bad.inbound, null);
expect('tryCreate reports the port',
length(filter(bad.problems, (p) => match(p, /invalid port/))), 1);
}
/* --- transport ------------------------------------------------------- */
{
const ws = make_inbound('s_ws', 'vless', {
port: '443',
uuid: 'u',
transport: 'ws',
ws_host: 'a.example.com',
ws_path: '/ws'
});
const out = InboundFactory.create(ws);
expect('transport: type', out.transport.type, 'ws');
expect('transport: path', out.transport.path, '/ws');
expect('transport: host from headers', out.transport.host, 'a.example.com');
}
printf('%d checks, %d failures\n', checks, failures);
exit(failures === 0 ? 0 : 1);