mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 01:42:52 +08:00
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Frontend protocol inventory.
|
|
*
|
|
* homeproxy-pro.js carries one ordered `protocols` table and both forms render
|
|
* their type picker from it. Before that table existed each form had its own
|
|
* hand-written value list, and they had drifted in ways nothing could catch:
|
|
* `snell` had a complete form block, a CREDENTIALS row and a golden outbound
|
|
* but was not in the node form's list, so it could not be selected at all; the
|
|
* node form offered `chacha20`, which sing-box rejects outright.
|
|
*
|
|
* The form snapshots cannot catch this class of bug - they record an option's
|
|
* dependency and default, and the node form's picker lives in a subsection the
|
|
* dump does not reach - so this test reads the table directly and compares it
|
|
* with the backend tables that decide what the generators can actually build.
|
|
*
|
|
* Checks:
|
|
* 1. every offered type is modelled by the backend (parser/mapping.uc
|
|
* PROTOCOL_TO_UCI) - the table that makes a protocol reachable from UCI;
|
|
* 2. every protocol the outbound adapter can build (OPTION_FIELDS,
|
|
* REQUIRED_CREDENTIALS) is offered by the CLIENT form - "the backend
|
|
* supports it and the UI cannot select it" is the snell bug;
|
|
* 3. every protocol offered by the SERVER form has inbound credentials
|
|
* (model.uc INBOUND_CREDENTIALS) - the reverse direction;
|
|
* 4. the client and server lists match their expected order, because the
|
|
* order is part of the UI and filtering one table has to reproduce it;
|
|
* 5. protocols the backend models but the UI deliberately does not offer are
|
|
* listed explicitly, so an accidental omission has to be added here (and
|
|
* this file is reviewed) rather than passing silently.
|
|
*
|
|
* Usage: node tests/frontend-protocol-inventory.js <repo-root>
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { loadLuciModule } = require('./lib/luci-module.js');
|
|
|
|
const root = path.resolve(process.argv[2] || '.');
|
|
const scripts = path.join(root, 'root/etc/homeproxy-pro/scripts');
|
|
|
|
/* The registry is data plus the renderer; loading it through the whole LuCI
|
|
* mock would test the mock. `protocols` only needs the module to evaluate its
|
|
* top-level statements, so the imports are stubbed just far enough for that. */
|
|
function loadHomeproxy() {
|
|
return loadLuciModule(
|
|
path.join(root, 'htdocs/luci-static/resources/homeproxy-pro.js'),
|
|
{
|
|
baseclass: { extend: (o) => o },
|
|
form: { DynamicList: { extend: (o) => o } },
|
|
fs: {}, rpc: {}, uci: {}, ui: {}
|
|
});
|
|
}
|
|
|
|
/* Read the keys of one `export const NAME = { ... }` table. Only depth-1 keys
|
|
* count, so nested per-option maps and the `};` terminator are ignored. */
|
|
function tableKeys(file, name) {
|
|
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
const head = new RegExp('^export const ' + name + '\\s*=\\s*\\{');
|
|
let start = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (head.test(lines[i])) { start = i; break; }
|
|
}
|
|
if (start === -1)
|
|
throw new Error(`table ${name} not found in ${file}`);
|
|
|
|
const keys = [];
|
|
let depth = 1;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (depth === 1) {
|
|
const m = /^\t([A-Za-z0-9_]+)\s*:/.exec(line);
|
|
if (m) keys.push(m[1]);
|
|
}
|
|
for (const ch of line) {
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') depth--;
|
|
}
|
|
if (depth <= 0) break;
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/* Protocols whose table entry names `password`, for tables whose depth-1
|
|
* entries are single lines (arrays in adapter.uc, objects in model.uc). */
|
|
function tableKeysWith(file, name, field) {
|
|
const keys = tableKeys(file, name);
|
|
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
const head = new RegExp('^export const ' + name + '\\s*=\\s*\\{');
|
|
const found = [];
|
|
let start = -1, depth = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++)
|
|
if (head.test(lines[i])) { start = i; depth = 1; break; }
|
|
if (start === -1)
|
|
throw new Error('table ' + name + ' not found');
|
|
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (depth === 1) {
|
|
const m = /^\t([A-Za-z0-9_]+)\s*:/.exec(lines[i]);
|
|
if (m && keys.includes(m[1]) && new RegExp('(^|[^A-Za-z_])' + field + '([^A-Za-z_]|$)').test(lines[i]))
|
|
found.push(m[1]);
|
|
}
|
|
for (const ch of lines[i]) {
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') depth--;
|
|
}
|
|
if (depth <= 0) break;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function validatorTypes(file) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
const m = src.match(/validatePassword\(\s*\[([^\]]+)\]/);
|
|
return m ? m[1].split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) : [];
|
|
}
|
|
|
|
let checks = 0, failures = 0;
|
|
function check(what, ok, detail) {
|
|
checks++;
|
|
if (ok) return;
|
|
failures++;
|
|
console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`);
|
|
}
|
|
|
|
const hp = loadHomeproxy();
|
|
const protocols = hp.protocols;
|
|
check('homeproxy-pro.js exports a protocols table', Array.isArray(protocols) && protocols.length > 0);
|
|
if (!Array.isArray(protocols)) {
|
|
console.error(`\n${checks} checks, ${failures} failures`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const mapped = tableKeys(path.join(scripts, 'parser/mapping.uc'), 'PROTOCOL_TO_UCI');
|
|
const optionFields = tableKeys(path.join(scripts, 'config/adapter.uc'), 'OPTION_FIELDS');
|
|
const required = tableKeys(path.join(scripts, 'config/adapter.uc'), 'REQUIRED_CREDENTIALS');
|
|
const inboundCredentials = tableKeys(path.join(scripts, 'config/model.uc'), 'INBOUND_CREDENTIALS');
|
|
const credentials = tableKeys(path.join(scripts, 'config/model.uc'), 'CREDENTIALS');
|
|
|
|
const clientList = protocols.filter((p) => p.sides.includes('client')).map((p) => p.type);
|
|
const serverList = protocols.filter((p) => p.sides.includes('server')).map((p) => p.type);
|
|
|
|
check('mapping.uc PROTOCOL_TO_UCI parsed', mapped.length > 0, `${mapped.length} keys`);
|
|
check('adapter.uc OPTION_FIELDS parsed', optionFields.length > 0, `${optionFields.length} keys`);
|
|
check('adapter.uc REQUIRED_CREDENTIALS parsed', required.length > 0, `${required.length} keys`);
|
|
check('model.uc INBOUND_CREDENTIALS parsed', inboundCredentials.length > 0, `${inboundCredentials.length} keys`);
|
|
|
|
/* 1. Each side is checked against the table that makes it reachable there.
|
|
* The client side is the outbound half: PROTOCOL_TO_UCI is what the Loader
|
|
* reads options through. The server side is the inbound half, which lives
|
|
* in model.uc's INBOUND_CREDENTIALS - server-only protocols such as naive
|
|
* and mixed legitimately have no outbound mapping at all. */
|
|
for (const p of protocols) {
|
|
check(`'${p.type}' declares at least one side`, p.sides.length > 0);
|
|
/* A label is now wrapped in _() at the table, so it is a translated
|
|
* String object rather than a primitive when a translation exists. The
|
|
* property under test is that it is present and non-empty. */
|
|
check(`'${p.type}' has a label`, p.label != null && String(p.label).length > 0);
|
|
|
|
if (p.sides.includes('client'))
|
|
check(`client '${p.type}' is modelled by the backend (PROTOCOL_TO_UCI)`, mapped.includes(p.type));
|
|
|
|
if (p.sides.includes('server'))
|
|
check(`server '${p.type}' has inbound credentials (INBOUND_CREDENTIALS)`, inboundCredentials.includes(p.type));
|
|
}
|
|
|
|
/* 2. Outbound-capable in the backend => selectable in the client form. This
|
|
* is the direction that catches "the backend supports it and the UI cannot
|
|
* select it", which is how snell was lost. */
|
|
const outboundCapable = [...new Set([...optionFields, ...required])].sort();
|
|
for (const type of outboundCapable) {
|
|
check(`outbound-capable '${type}' can be selected in the node form`, clientList.includes(type));
|
|
}
|
|
|
|
/* 4. The rendered order is part of the UI. Pinned here because the form
|
|
* snapshots do not reach the node form's picker. */
|
|
const expectedClient = [
|
|
'direct', 'anytls', 'http', 'hysteria', 'hysteria2',
|
|
'shadowsocks', 'shadowtls', 'snell', 'socks', 'ssh',
|
|
'trojan', 'tuic', 'wireguard', 'vless', 'vmess'
|
|
];
|
|
const expectedServer = [
|
|
'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'mixed',
|
|
'shadowsocks', 'snell', 'socks', 'trojan', 'tuic', 'vless', 'vmess'
|
|
];
|
|
check('client protocol order', JSON.stringify(clientList) === JSON.stringify(expectedClient),
|
|
`got ${JSON.stringify(clientList)}`);
|
|
check('server protocol order', JSON.stringify(serverList) === JSON.stringify(expectedServer),
|
|
`got ${JSON.stringify(serverList)}`);
|
|
|
|
/* 5. Deliberate omissions, spelled out. A protocol the backend models but no
|
|
* form offers has to appear here; adding one to this list is a reviewable
|
|
* decision instead of a silent gap. */
|
|
const DELIBERATE = {
|
|
/* The backend models the inbound side (INBOUND_CREDENTIALS,
|
|
* REQUIRED_INBOUND_CREDENTIALS, INBOUND_CLAIM_FIELDS) but the inbound
|
|
* generation path has no fixture/golden coverage, so the server form must
|
|
* not offer it until that exists. The client form does offer it. */
|
|
shadowtls: 'server side has no inbound fixture/golden coverage yet',
|
|
/* Endpoint protocols: modelled and buildable, but the client form gates
|
|
* them behind runtime features (wireguard on with_wireguard+gvisor,
|
|
* direct is always offered). Listed here because they are not part of
|
|
* the adapter's per-protocol OPTION_FIELDS tables. */
|
|
wireguard: 'endpoint, gated on with_wireguard + with_gvisor',
|
|
direct: 'endpoint, always offered by the client form'
|
|
};
|
|
|
|
const unmapped = [...new Set([...mapped, ...credentials, ...inboundCredentials])]
|
|
.filter((t) => !clientList.includes(t) && !serverList.includes(t))
|
|
.sort();
|
|
for (const type of unmapped) {
|
|
check(`unoffered protocol '${type}' is a documented decision`, type in DELIBERATE,
|
|
'models it in the backend but no form offers it');
|
|
}
|
|
|
|
/* --- the required-credential surface must be enforced before save ---------
|
|
*
|
|
* A protocol whose backend entry requires a password but whose form validator
|
|
* does not check it can be saved empty; generation then dies and the user sees
|
|
* "my setting did not take". That is how hysteria2/tuic slipped through: the
|
|
* empty password was accepted at save time and OutboundFactory.create() die()d
|
|
* at generation time. The validator lists live in the two view files, so they
|
|
* are compared against the backend tables here. */
|
|
const clientNeedsPassword = new Set(tableKeysWith(path.join(scripts, 'config/adapter.uc'), 'REQUIRED_CREDENTIALS', 'password'));
|
|
const serverNeedsPassword = new Set(tableKeysWith(path.join(scripts, 'config/model.uc'), 'INBOUND_CREDENTIALS', 'password'));
|
|
const clientValidated = new Set(validatorTypes(path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/node.js')));
|
|
const serverValidated = new Set(validatorTypes(path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/server.js')));
|
|
|
|
check('adapter.uc REQUIRED_CREDENTIALS parsed for the password field', clientNeedsPassword.size > 0, [ ...clientNeedsPassword ].join(' '));
|
|
check('model.uc INBOUND_CREDENTIALS parsed for the password field', serverNeedsPassword.size > 0, [ ...serverNeedsPassword ].join(' '));
|
|
|
|
for (const type of clientList) {
|
|
if (!clientNeedsPassword.has(type))
|
|
continue;
|
|
check(`client form validates the password that ${type} requires`, clientValidated.has(type),
|
|
`${type} needs a password in adapter.uc but is missing from node.js's validatePassword list`);
|
|
}
|
|
|
|
for (const type of serverList) {
|
|
if (!serverNeedsPassword.has(type))
|
|
continue;
|
|
check(`server form validates the password that ${type} requires`, serverValidated.has(type),
|
|
`${type} needs a password in model.uc but is missing from server.js's validatePassword list`);
|
|
}
|
|
|
|
console.log(`frontend protocol inventory: ${checks} checks, ${failures} failures`);
|
|
console.log(` client: ${clientList.join(' ')}`);
|
|
console.log(` server: ${serverList.join(' ')}`);
|
|
if (unmapped.length)
|
|
console.log(` deliberately unoffered: ${unmapped.map((t) => `${t} (${DELIBERATE[t] || 'UNDOCUMENTED'})`).join(', ')}`);
|
|
|
|
process.exit(failures ? 1 : 0);
|