mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 14:55:13 +08:00
458 lines
15 KiB
Ucode
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);
|