🌴 Sync 2026-10-16 01:08:07

This commit is contained in:
github-actions[bot] committed 2026-10-16 01:08:07 +08:00
1 parent f95bba4fb6
commit 16f740db5d
186 files changed
+9575 -5557

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/acme.sh');
function runRenew({ renewChangesCert }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-acme-test-'));
const acmeHome = path.join(dir, 'acme');
const certDir = path.join(dir, 'certs');
const reloadLog = path.join(dir, 'reload.log');
const cert = path.join(certDir, '_wildcard_.example.com/fullchain.pem');
fs.mkdirSync(path.join(acmeHome, 'dnsapi'), { recursive: true });
fs.mkdirSync(path.dirname(cert), { recursive: true });
fs.writeFileSync(cert, 'old-certificate\n');
fs.writeFileSync(path.join(acmeHome, 'acme.sh'), renewChangesCert
? `#!/bin/sh\nprintf 'renewed-certificate\\n' > ${JSON.stringify(cert)}\n`
: '#!/bin/sh\necho "Skip, Next renewal time is in the future"\n');
fs.chmodSync(path.join(acmeHome, 'acme.sh'), 0o755);
fs.writeFileSync(path.join(dir, 'proxy.sh'), `printf '%s\\n' "$1" >> ${JSON.stringify(reloadLog)}\n`);
const result = spawnSync('/bin/sh', [script, 'renew'], {
encoding: 'utf8',
env: {
...process.env,
MINIGATE_ACME_HOME: acmeHome,
MINIGATE_CERT_DIR: certDir,
MINIGATE_ACME_LOG: path.join(dir, 'acme.log'),
MINIGATE_PROXY_SCRIPT: path.join(dir, 'proxy.sh')
}
});
const reloads = fs.existsSync(reloadLog) ? fs.readFileSync(reloadLog, 'utf8').trim().split('\n') : [];
return { result, reloads };
}
test('renewal reloads the reverse proxy when a certificate file changes', () => {
const { result, reloads } = runRenew({ renewChangesCert: true });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(reloads, ['reload']);
});
test('renewal without certificate changes leaves the running proxy alone', () => {
const { result, reloads } = runRenew({ renewChangesCert: false });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(reloads, []);
});
+92
View File
@@ -0,0 +1,92 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/after_commit.sh');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-after-commit-test-'));
const config = path.join(dir, 'minigate');
const marker = path.join(dir, 'applied.log');
fs.writeFileSync(config, "option enabled '0'\n");
fs.mkdirSync(path.join(dir, 'lib'));
fs.writeFileSync(path.join(dir, 'lib/ddns.sh'), `printf 'ddns %s %s\\n' "$1" "$(cat ${JSON.stringify(config)})" >> ${JSON.stringify(marker)}\n`);
fs.writeFileSync(path.join(dir, 'lib/proxy.sh'), `printf 'proxy %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
fs.writeFileSync(path.join(dir, 'init'), `#!/bin/sh\nprintf 'init %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
fs.chmodSync(path.join(dir, 'init'), 0o755);
return { dir, config, marker };
}
function run(fx, action, wait) {
return spawnSync('/bin/sh', [script, action], {
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
MINIGATE_CONFIG_FILE: fx.config,
MINIGATE_COMMIT_WAIT: String(wait),
MINIGATE_INIT: path.join(fx.dir, 'init'),
MINIGATE_LIB_DIR: path.join(fx.dir, 'lib')
}
});
}
const applied = fx => (fs.existsSync(fx.marker) ? fs.readFileSync(fx.marker, 'utf8').trim() : '');
async function waitFor(check, timeout = 4000) {
const started = Date.now();
while (Date.now() - started < timeout) {
if (check()) return true;
await sleep(100);
}
return check();
}
test('saved settings are applied only after LuCI commits the new configuration', async () => {
const fx = fixture();
const started = Date.now();
const result = run(fx, 'ddns', 10);
assert.equal(result.status, 0, result.stderr);
assert.ok(Date.now() - started < 1000, 'the LuCI hook must return immediately');
await sleep(1300);
assert.equal(applied(fx), '', 'must not run against the old configuration');
fs.writeFileSync(fx.config, "option enabled '1'\n");
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), "ddns force option enabled '1'");
});
test('an unchanged save still applies after the wait limit', async () => {
const fx = fixture();
assert.equal(run(fx, 'proxy', 1).status, 0);
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), 'proxy reload');
});
test('service actions go through the init script and unknown actions are rejected', async () => {
const fx = fixture();
assert.equal(run(fx, 'restart', 1).status, 0);
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), 'init restart');
const bad = fixture();
assert.equal(run(bad, 'reboot', 1).status, 2);
await sleep(1500);
assert.equal(applied(bad), '');
});
test('LuCI pages defer their actions and saving DDNS no longer restarts the proxy', () => {
const page = name => fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate', name), 'utf8');
assert.match(page('general.lua'), /after_commit\.sh " \.\. \(en == "1" and "restart" or "stop"\)/);
assert.match(page('ddns.lua'), /after_commit\.sh ddns/);
assert.match(page('proxy.lua'), /after_commit\.sh proxy/);
assert.match(page('login_guard.lua'), /after_commit\.sh reload/);
assert.doesNotMatch(page('ddns.lua'), /minigate restart/);
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
assert.match(makefile, /root\/usr\/lib\/minigate\/after_commit\.sh/);
});
+254
View File
@@ -17,6 +17,7 @@ function runHistory(script, options = {}) {
'set -eu',
'export MINIGATE_DDNS_LIBRARY_ONLY=1',
`export MINIGATE_HISTORY_DIR=${JSON.stringify(historyDir)}`,
`export MINIGATE_CACHE_DIR=${JSON.stringify(historyDir)}`,
`export MINIGATE_HISTORY_WINDOW=${options.window || 86400}`,
`. ${JSON.stringify(ddnsScript)}`,
script
@@ -30,6 +31,85 @@ function readRows(file) {
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
}
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-update-'));
const cacheFile = path.join(tempDir, 'minigate_ddns_cfg01.cache');
const refreshLog = path.join(tempDir, 'refresh.log');
const uciLog = path.join(tempDir, 'uci.log');
if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`);
const command = `
set -u
export MINIGATE_DDNS_LIBRARY_ONLY=1
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
export MINIGATE_HISTORY_DIR=${JSON.stringify(path.join(tempDir, 'history'))}
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
. ${JSON.stringify(ddnsScript)}
uci() {
[ "\${1:-}" = "-q" ] && shift
case "\${1:-}:\${2:-}" in
get:minigate.cfg01.enabled) printf '1\\n' ;;
get:minigate.cfg01.domain) printf '*.example.com\\n' ;;
get:minigate.cfg01.cf_zone_id) printf 'zone\\n' ;;
get:minigate.cfg01.cf_api_token) printf 'token\\n' ;;
get:minigate.cfg01.ip_source) printf 'url\\n' ;;
get:minigate.cfg01.interface) printf 'wan\\n' ;;
get:minigate.cfg01.ip_url) printf 'http://ip.example/\\n' ;;
get:minigate.cfg01.check_interval) printf '300\\n' ;;
get:minigate.cfg01.ip_version) printf 'ipv4\\n' ;;
set:*) printf '%s\\n' "\$2" >> ${JSON.stringify(uciLog)} ;;
esac
return 0
}
get_ip4_url() { printf '198.51.100.20\\n'; }
record_ip_history() { return 0; }
calc_next() { printf '12:00:00\\n'; }
cf_update() {
printf '%s\\n' ${JSON.stringify(cfResult)}
[ ${JSON.stringify(cfResult)} != false ]
}
refresh_local_dns_caches() {
printf '%s\\n' "\$1" >> ${JSON.stringify(refreshLog)}
return ${Number(refreshResult)}
}
update_one cfg01 1
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
const refreshes = fs.existsSync(refreshLog) ? readRows(refreshLog) : [];
const uciWrites = fs.existsSync(uciLog) ? readRows(uciLog) : [];
const cache = fs.existsSync(cacheFile) ? fs.readFileSync(cacheFile, 'utf8').trim() : '';
return { result, refreshes, uciWrites, cache };
}
function runCloudflareScenario(mode) {
const command = `
set -u
export MINIGATE_DDNS_LIBRARY_ONLY=1
. ${JSON.stringify(ddnsScript)}
CF_MODE=${JSON.stringify(mode)}
curl() {
case "\$*" in
*'-X PUT'*|*'-X DELETE'*|*'-X POST'*) printf 'WRITE\\n' ;;
*'&content='*) printf 'MATCH\\n' ;;
*) printf 'LIST\\n' ;;
esac
}
jsonfilter() {
input=; IFS= read -r input || true
expression="\${2:-}"
case "\$expression:\$input:\$CF_MODE" in
'$.success:'*) [ "\$CF_MODE" = failure ] && printf 'false\\n' || printf 'true\\n' ;;
'$.result[*].id:LIST:create') ;;
'$.result[*].id:LIST:duplicates') printf 'r1\\nr2\\n' ;;
'$.result[*].id:LIST:'*) printf 'r1\\n' ;;
'$.result[0].id:MATCH:unchanged'|'$.result[0].id:MATCH:duplicates') printf 'r1\\n' ;;
esac
}
cf_update zone token example.com 198.51.100.20 A
`;
return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
}
test('DDNS history records only actual IPv4 changes', () => {
const { historyDir, output } = runHistory(`
export MINIGATE_HISTORY_NOW=100000
@@ -84,6 +164,180 @@ test('DDNS writer and controller both use the domain history key', () => {
assert.doesNotMatch(controller, /history_section_name\(section\.name\)/);
});
test('Cloudflare updater distinguishes unchanged records from authoritative changes', () => {
for (const mode of ['create', 'replace', 'duplicates']) {
const result = runCloudflareScenario(mode);
assert.equal(result.status, 0, `${mode}: ${result.stderr || result.stdout}`);
assert.equal(result.stdout.trim(), 'changed', mode);
}
const unchanged = runCloudflareScenario('unchanged');
assert.equal(unchanged.status, 0, unchanged.stderr || unchanged.stdout);
assert.equal(unchanged.stdout.trim(), 'unchanged');
const failure = runCloudflareScenario('failure');
assert.notEqual(failure.status, 0);
assert.equal(failure.stdout.trim(), 'false');
});
test('Cloudflare token stays out of curl arguments and every API call is time-bounded', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cf-token-'));
const calls = path.join(tempDir, 'calls.log');
const command = `
set -u
export MINIGATE_DDNS_LIBRARY_ONLY=1
. ${JSON.stringify(ddnsScript)}
curl() { printf 'args %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/stdin /' >> ${JSON.stringify(calls)}; printf '{}'; }
jsonfilter() { cat >/dev/null; [ "\${2:-}" = '$.success' ] && printf 'true\\n'; return 0; }
cf_update zone secret-token example.com 198.51.100.20 A
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout.trim(), 'changed');
const lines = readRows(calls);
const args = lines.filter(line => line.startsWith('args '));
assert.equal(args.length, 2, 'list + create');
for (const line of args) {
assert.doesNotMatch(line, /secret-token/);
assert.match(line, /--max-time \d+/);
assert.match(line, /-H @-/);
}
assert.equal(lines.filter(line => line === 'stdin Authorization: Bearer secret-token').length, 2);
});
test('scheduled and manual DDNS runs share section names and serialize with a lock', () => {
const source = fs.readFileSync(ddnsScript, 'utf8');
assert.match(source, /uci -q -X show minigate \| grep '=ddns\$'/);
assert.match(source, /if \[ "\$1" = "single" \] && \[ -n "\$2" \]; then\n lock_ddns wait/);
assert.match(source, /elif ! lock_ddns; then/);
for (const line of source.split('\n').filter(item => /\bcurl -[46]/.test(item))) {
assert.match(line, /--max-time \d+/, line.trim());
}
});
function runScheduled({ force = false, checked }) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-interval-'));
const calls = path.join(tempDir, 'calls.log');
for (const [sec, ts] of Object.entries(checked)) fs.writeFileSync(path.join(tempDir, `minigate_ddns_${sec}.checked`), `${ts}\n`);
const command = `
export MINIGATE_DDNS_LIBRARY_ONLY=1
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
export MINIGATE_DDNS_LOCK=${JSON.stringify(path.join(tempDir, 'ddns.lock'))}
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
. ${JSON.stringify(ddnsScript)}
date() { [ "\${1:-}" = '+%s' ] && printf '1000000\\n' || printf 'now\\n'; }
uci() {
[ "\${1:-}" = "-q" ] && shift
[ "\${1:-}" = "-X" ] && shift
case "\${1:-}:\${2:-}" in
get:minigate.global.enabled|get:minigate.cfg01.enabled|get:minigate.cfg02.enabled) printf '1\\n' ;;
get:minigate.cfg03.enabled) printf '0\\n' ;;
get:minigate.cfg01.check_interval) printf '300\\n' ;;
get:minigate.cfg02.check_interval) printf '900\\n' ;;
show:minigate) printf 'minigate.cfg01=ddns\\nminigate.cfg02=ddns\\nminigate.cfg03=ddns\\n' ;;
commit:minigate) printf 'commit\\n' >> ${JSON.stringify(calls)} ;;
esac
return 0
}
update_one() { printf '%s %s\\n' "\$1" "\$2" >> ${JSON.stringify(calls)}; }
main ${force ? 'force' : ''}
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
assert.equal(result.stderr, '');
return {
calls: fs.existsSync(calls) ? readRows(calls) : [],
logged: fs.existsSync(path.join(tempDir, 'ddns.log'))
};
}
test('scheduled DDNS checks honour each record interval and skip disabled records', () => {
const due = runScheduled({ checked: { cfg01: 1000000 - 280, cfg02: 1000000 - 600 } });
assert.deepEqual(due.calls, ['cfg01 0', 'commit']);
const idle = runScheduled({ checked: { cfg01: 1000000 - 100, cfg02: 1000000 - 600 } });
assert.deepEqual(idle.calls, [], 'nothing due: no update and no config commit');
assert.equal(idle.logged, false, 'nothing due: no log noise every five minutes');
const forced = runScheduled({ force: true, checked: { cfg01: 1000000 - 10, cfg02: 1000000 - 10 } });
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1', 'commit']);
});
test('DDNS interval is at least five minutes and the next run matches the cron grid', () => {
const { output } = runHistory(`
uci() { printf '%s\\n' "\${INTERVAL}"; }
date() { [ "\${1:-}" = '+%s' ] && printf '300150\\n' || printf '%s\\n' "\${2:-}"; }
INTERVAL=60; printf '%s ' "$(ddns_interval cfg01)"
INTERVAL=abc; printf '%s ' "$(ddns_interval cfg01)"
INTERVAL=600; printf '%s ' "$(ddns_interval cfg01)"
printf '%s %s ' "$(calc_next 300)" "$(calc_next 600)"
printf '%s\\n' 300300 > "\${MINIGATE_CACHE_DIR}/minigate_ddns_cfg09.checked"
INTERVAL=300; ddns_due cfg09 && printf 'clock-back-due'
`);
assert.equal(output, '300 300 600 @300600 @300900 clock-back-due');
});
test('successful Cloudflare changes refresh local DNS caches once', () => {
const scenario = runUpdateScenario({ cfResult: 'changed' });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, ['*.example.com']);
assert.equal(scenario.cache, '198.51.100.20');
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
});
test('unchanged forced sync does not flush caches, but a stale local IP does', () => {
const current = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.20' });
assert.equal(current.result.status, 0, current.result.stderr || current.result.stdout);
assert.deepEqual(current.refreshes, []);
const stale = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.19' });
assert.equal(stale.result.status, 0, stale.result.stderr || stale.result.stdout);
assert.deepEqual(stale.refreshes, ['*.example.com']);
assert.equal(stale.cache, '198.51.100.20');
});
test('Cloudflare failure never flushes local caches', () => {
const scenario = runUpdateScenario({ cfResult: 'false', cachedIp: '198.51.100.19' });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, []);
assert.equal(scenario.cache, '198.51.100.19');
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=error'));
});
test('cache refresh failure does not turn a successful DDNS update into an error', () => {
const scenario = runUpdateScenario({ cfResult: 'changed', refreshResult: 1 });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, ['*.example.com']);
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
});
test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-dns-flush-'));
const config = path.join(tempDir, 'config.json');
const calls = path.join(tempDir, 'calls.log');
fs.writeFileSync(config, '{}\n');
const command = `
set -eu
export MINIGATE_DDNS_LIBRARY_ONLY=1
export MINIGATE_SINGBOX_CONFIG=${JSON.stringify(config)}
export MINIGATE_CLASH_API_URL=http://127.0.0.1:19095
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
. ${JSON.stringify(ddnsScript)}
jsonfilter() { printf 'local-secret\\n'; }
curl() { printf 'curl %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/header /' >> ${JSON.stringify(calls)}; printf '204'; }
pidof() { [ "\$1" = dnsmasq ] && printf '101 202\\n'; }
send_hup() { printf 'hup %s\\n' "\$*" >> ${JSON.stringify(calls)}; }
refresh_local_dns_caches '*.example.com'
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
const callLog = fs.readFileSync(calls, 'utf8');
const curlLine = callLog.split('\n').find(line => line.startsWith('curl ')) || '';
assert.match(callLog, /curl .* -X POST -H @- http:\/\/127\.0\.0\.1:19095\/cache\/dns\/flush/);
assert.match(callLog, /header Authorization: Bearer local-secret/);
assert.doesNotMatch(curlLine, /local-secret/, 'secret must not appear in the curl process arguments');
assert.match(callLog, /hup 101 202/);
assert.doesNotMatch(callLog, /restart|reload|stop|start/);
});
test('controller exposes a read-only rolling 24-hour history endpoint', () => {
assert.match(controller, /entry\(\{"admin","services","minigate","ddns_history"\}, call\("action_ddns_history"\)\)/);
assert.match(controller, /local cutoff = now - 86400/);
+28
View File
@@ -149,3 +149,31 @@ test('README exposes the short command and keeps a fixed-version fallback', () =
assert.match(readme, /curl -fsSL https:\/\/raw\.githubusercontent\.com\/tpxcer\/minigate\/main\/scripts\/install\.sh \| sh/);
assert.ok(readme.includes(`releases/download/v${currentVersion}/minigate-v${currentVersion}-src.tar.gz`));
});
function runSourceDependencies({ cbiPresent, compatRegistered }) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-'));
const calls = path.join(tempDir, 'apk-calls');
const luciDir = path.join(tempDir, 'luci');
fs.mkdirSync(luciDir);
if (cbiPresent) fs.writeFileSync(path.join(luciDir, 'cbi.lua'), '');
fs.writeFileSync(path.join(tempDir, 'openssl'), '#!/bin/sh\n[ "$1" = version ]\n', { mode: 0o755 });
fs.writeFileSync(path.join(tempDir, 'apk'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\nif [ "$1" = info ]; then [ "$3" != luci-compat ] || [ ${compatRegistered ? 1 : 0} = 1 ]; else exit 0; fi\n`, { mode: 0o755 });
const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir, MINIGATE_LUCI_DIR: luciDir });
assert.equal(result.status, 0, result.stderr);
return fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
}
test('source installer skips luci-compat when the CBI module already exists', () => {
assert.doesNotMatch(runSourceDependencies({ cbiPresent: true, compatRegistered: false }), /luci-compat/);
});
test('source installer installs luci-compat when the CBI module is missing', () => {
assert.match(runSourceDependencies({ cbiPresent: false, compatRegistered: false }), /add luci-compat/);
});
test('packages declare the luci-compat dependency required by the CBI pages', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const builder = fs.readFileSync(path.join(root, 'scripts/build-ipk.sh'), 'utf8');
assert.match(makefile, /DEPENDS:=.*\+luci-compat\b/);
assert.match(builder, /^DEPS=".*\bluci-compat\b/m);
});
+113
View File
@@ -0,0 +1,113 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/login_guard.sh');
function runRepair({ firewallReady }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-test-'));
const dataDir = path.join(dir, 'data');
const bans = path.join(dataDir, 'bans.txt');
const calls = path.join(dir, 'nft.log');
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n');
const before = fs.statSync(bans).ino;
const command = `
set -u
logger() { :; }
uci() { return 1; }
nft() {
printf '%s\\n' "$*" >> ${JSON.stringify(calls)}
case "$*" in
'list set inet fw4 login_banned_v4') [ ${firewallReady ? 1 : 0} = 1 ]; return ;;
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\n'; return 0 ;;
esac
return 0
}
export MINIGATE_LG_LIBRARY_ONLY=1
export MINIGATE_LG_DATA_DIR=${JSON.stringify(dataDir)}
export MINIGATE_LG_RUN_DIR=${JSON.stringify(path.join(dir, 'run'))}
export MINIGATE_LG_LOG=${JSON.stringify(path.join(dir, 'login-guard.log'))}
. ${JSON.stringify(script)}
repair_nft
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
return {
result,
calls: fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '',
bans: fs.readFileSync(bans, 'utf8'),
rewritten: fs.statSync(bans).ino !== before
};
}
test('periodic nft check does not rewrite the persisted ban list when rules are intact', () => {
const scenario = runRepair({ firewallReady: true });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.doesNotMatch(scenario.calls, /add element/);
assert.equal(scenario.rewritten, false, 'bans.txt must not be rewritten on every watchdog tick');
});
test('a firewall reload that drops the set restores persisted bans immediately', () => {
const scenario = runRepair({ firewallReady: false });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.match(scenario.calls, /add set inet fw4 login_banned_v4/);
assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
assert.doesNotMatch(scenario.calls, /198\.51\.100\.5/, 'expired bans must not be restored');
assert.equal(scenario.bans, '198.51.100.4 9999999999\n');
});
// BusyBox pgrep 只排除自身,不像 macOS 那样默认排除祖先进程。
function busyboxPgrep(dir) {
const shim = path.join(dir, 'bin/pgrep');
fs.mkdirSync(path.dirname(shim), { recursive: true });
fs.writeFileSync(shim, `#!/bin/sh
self=$$
case "$1" in
-f) ps -A -o pid= -o ppid= -o args= | awk -v self="$self" -v re="$2" '
{ pid=$1; ppid=$2; if (pid == self || ppid == self) next; $1=""; $2=""; sub(/^ +/, ""); if ($0 ~ re) print pid }' ;;
-P) ps -A -o pid= -o ppid= | awk -v self="$self" -v parent="$2" '$2 == parent && $1 != self { print $1 }' ;;
esac
`);
fs.chmodSync(shim, 0o755);
return path.dirname(shim);
}
test('stopping the login guard ends its children but not a caller that mentions it', async () => {
const init = path.join(root, 'root/etc/init.d/minigate');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-'));
const followed = path.join(dir, 'access.log');
const guardScript = path.join(dir, 'login_guard.sh');
const marker = path.join(dir, 'caller-survived');
fs.writeFileSync(followed, '');
// 与 login_guard.sh run 相同的结构:后台子 shell + tail -f 管道;忽略 SIGPIPE 的 tail 失去读端后不会自行退出
fs.writeFileSync(guardScript, `trap "" PIPE\n( while :; do sleep 1; done ) &\ntail -n 0 -f ${JSON.stringify(followed)} | while read -r line; do :; done\n`);
const guard = spawn('/bin/sh', [guardScript, 'run'], { detached: true, stdio: 'ignore' });
guard.unref();
const pgrep = pattern => spawnSync('pgrep', ['-f', pattern], { encoding: 'utf8' }).stdout.trim();
const started = Date.now();
while (!pgrep(`tail -n 0 -f ${followed}`) && Date.now() - started < 3000) await new Promise(resolve => setTimeout(resolve, 100));
assert.ok(pgrep(`tail -n 0 -f ${followed}`), 'fake login guard did not start');
// 调用方的命令行里含有登录防护的命令文本(例如 SSH 一次性执行的脚本),不能被当成登录防护结束
const caller = `logger() { :; }; nft() { return 1; }; . ${JSON.stringify(init)}; LOGIN_GUARD=${JSON.stringify(guardScript)}; echo "checking ${guardScript} run" >/dev/null; stop_login_guard; echo ok > ${JSON.stringify(marker)}`;
const result = spawnSync('/bin/sh', ['-c', caller], { encoding: 'utf8', env: { ...process.env, PATH: `${busyboxPgrep(dir)}:${process.env.PATH}` } });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(marker), true, 'stop_login_guard killed its own caller');
await new Promise(resolve => setTimeout(resolve, 300));
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f must not be left behind');
assert.equal(pgrep(`${guardScript} run`), '');
});
test('upgrades stop the old login guard tree before running the previous init script', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const treeStop = installer.indexOf("pgrep -f '^/bin/sh /usr/lib/minigate/login_guard.sh run'");
const initStop = installer.indexOf('/etc/init.d/minigate stop');
assert.ok(treeStop > 0 && treeStop < initStop, 'installer must stop the login guard tree before the old init script');
assert.match(installer, /stop_process_tree\(\) \{[\s\S]*pgrep -P "\$1"/);
});
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/proxy.sh');
function trim(files, maxAccess = 4096) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-log-test-'));
const paths = {};
for (const [name, content] of Object.entries(files)) {
paths[name] = path.join(dir, name);
fs.writeFileSync(paths[name], content);
}
const result = spawnSync('/bin/sh', [script, 'trim-logs'], {
encoding: 'utf8',
env: {
...process.env,
MINIGATE_ACCESS_LOG: path.join(dir, 'access.log'),
MINIGATE_NGINX_ERROR_LOG: path.join(dir, 'error.log'),
MINIGATE_LOGFILE: path.join(dir, 'proxy.log'),
MINIGATE_ACCESS_LOG_MAX: String(maxAccess)
}
});
return { result, dir };
}
function lines(count, prefix) {
return Array.from({ length: count }, (_, i) => `${prefix}-${i + 1} ${'x'.repeat(60)}`).join('\n') + '\n';
}
test('oversized access log keeps recent records in .1 and is truncated in place', () => {
const { result, dir } = trim({ 'access.log': lines(1500, 'req') });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(dir, 'access.log'), 'utf8'), '');
const rotated = fs.readFileSync(path.join(dir, 'access.log.1'), 'utf8').trim().split('\n');
assert.equal(rotated.length, 1000);
assert.match(rotated[0], /^req-501 /);
assert.match(rotated[999], /^req-1500 /);
});
test('small logs are left untouched', () => {
const content = lines(10, 'req');
const { result, dir } = trim({ 'access.log': content, 'error.log': 'warn\n' });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(dir, 'access.log'), 'utf8'), content);
assert.equal(fs.existsSync(path.join(dir, 'access.log.1')), false);
assert.equal(fs.readFileSync(path.join(dir, 'error.log'), 'utf8'), 'warn\n');
});
test('oversized nginx error log keeps its newest lines in the same file', () => {
const { result, dir } = trim({ 'error.log': lines(5000, 'err') });
assert.equal(result.status, 0, result.stderr);
const kept = fs.readFileSync(path.join(dir, 'error.log'), 'utf8').trim().split('\n');
assert.equal(kept.length, 300);
assert.match(kept[299], /^err-5000 /);
});
test('log trimming is scheduled and the visitor list reads the rotated access log', () => {
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
assert.match(init, /\/bin\/sh \/usr\/lib\/minigate\/proxy\.sh trim-logs/);
assert.match(controller, /cat \/var\/log\/minigate-access\.log\.1 \/var\/log\/minigate-access\.log 2>\/dev\/null \| tail -n 500/);
});
+128
View File
@@ -0,0 +1,128 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
function pageScript(file) {
const source = fs.readFileSync(path.join(root, file), 'utf8');
const start = source.indexOf('<script type="text/javascript">');
const end = source.indexOf('</script>', start);
return source.slice(start + '<script type="text/javascript">'.length, end).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
}
function fakePage(script, { storageBlocked = false } = {}) {
const elements = new Map();
const element = id => {
if (!elements.has(id)) {
elements.set(id, {
id, style: { setProperty() {} }, className: '', textContent: '', innerHTML: '', value: '',
disabled: false, checked: false, hidden: false, children: [],
setAttribute() {}, getAttribute() { return ''; }, focus() {}, appendChild() {}, insertBefore() {},
querySelector(selector) { return selector === 'table' && this.innerHTML.includes('<table') ? {} : null; },
querySelectorAll() { return []; }, contains() { return false; }
});
}
return elements.get(id);
};
const listeners = {};
const requests = [];
const intervals = [];
const document = {
hidden: false, body: { style: {} }, activeElement: null,
getElementById: element, querySelector: () => null, querySelectorAll: () => [],
addEventListener: (name, handler) => { listeners[name] = handler; }
};
const request = method => (url, data, callback) => { requests.push({ method, url, data, callback }); };
const context = vm.createContext({
document, location: { reload() {} }, getComputedStyle: () => ({ getPropertyValue: () => '' }),
localStorage: storageBlocked
? { getItem() { throw new Error('storage blocked'); }, setItem() { throw new Error('storage blocked'); } }
: { getItem() { return null; }, setItem() {} },
setTimeout: callback => { callback(); return 0; }, setInterval: callback => { intervals.push(callback); return intervals.length; },
clearInterval() {}, confirm: () => true, alert() {},
XHR: { get: request('GET'), post: request('POST') }
});
vm.runInContext(script, context);
return { context, document, element, listeners, requests, intervals };
}
const overviewScript = pageScript('luasrc/model/cbi/minigate/general.lua');
const loginGuardScript = pageScript('luasrc/model/cbi/minigate/login_guard.lua');
const take = (page, url) => {
const index = page.requests.findIndex(item => item.url === url);
assert.ok(index >= 0, `missing request ${url}`);
return page.requests.splice(index, 1)[0];
};
test('overview loads even when browser storage is blocked', () => {
const page = fakePage(overviewScript, { storageBlocked: true });
assert.deepEqual(page.requests.map(item => item.url).sort(), ['au', 'su', 'xu']);
});
test('overview treats an empty visitor object as no records and keeps errors distinct', () => {
const page = fakePage(overviewScript);
take(page, 'au').callback(null, { visitors: {} });
assert.match(page.element('mg-v-list').innerHTML, /暂无访问记录/);
assert.equal(page.element('mg-v-count').textContent, '');
const failed = fakePage(overviewScript);
take(failed, 'au').callback(null, null);
assert.match(failed.element('mg-v-list').innerHTML, /读取失败/);
take(failed, 'su').callback(null, null);
assert.match(failed.element('mg-d1').innerHTML, /读取失败/);
});
test('overview shows cached locations immediately instead of flashing a lookup state', () => {
const page = fakePage(overviewScript);
const visitors = { visitors: [{ ip: '198.51.100.7', last_time: '2026-10-14T08:00:00+08:00', domain: 'a.example.com', count: 2, online: true }] };
take(page, 'au').callback(null, visitors);
take(page, 'gu').callback(null, { geo: '四川成都 电信' });
assert.match(page.element('geo-0').innerHTML, /四川成都 电信/);
page.context.loadVisitors();
take(page, 'au').callback(null, visitors);
assert.match(page.element('mg-v-list').innerHTML, /四川成都 电信/);
assert.doesNotMatch(page.element('mg-v-list').innerHTML, /查询中/);
assert.equal(page.requests.some(item => item.url === 'gu'), false);
});
test('overview polling pauses in a hidden tab and refreshes when visible again', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, { ddns_list: {}, proxy_rules: {}, acme: {} });
take(page, 'au').callback(null, { visitors: [] });
page.requests.length = 0;
page.document.hidden = true;
page.intervals.forEach(callback => callback());
assert.equal(page.requests.length, 0);
page.document.hidden = false;
page.listeners.visibilitychange();
assert.deepEqual(page.requests.map(item => item.url).sort(), ['au', 'su']);
});
test('login guard renders empty JSON objects as empty lists and uses POST for changes', () => {
const page = fakePage(loginGuardScript);
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });
assert.match(page.element('lg-banned-list').innerHTML, /暂无封禁/);
assert.match(page.element('lg-watching-list').innerHTML, /无/);
assert.doesNotMatch(page.element('lg-banned-list').innerHTML, /<table/);
page.element('lg-ban-input').value = '198.51.100.9';
page.context.lgManualBan();
page.context.lgFlushAll();
assert.deepEqual(page.requests.filter(item => item.url !== 'lu').map(item => `${item.method} ${item.url}`), ['POST bu', 'POST fu']);
});
test('state-changing LuCI endpoints require POST with the CSRF token', () => {
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
for (const name of ['acme_install', 'acme_issue', 'ddns_sync', 'lg_ban', 'lg_unban', 'lg_flush', 'update_apply', 'uninstall']) {
assert.match(controller, new RegExp(`"${name}"\\}, post\\("action_${name}"\\)\\)`), name);
}
const ddns = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
const acme = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/acme.lua'), 'utf8');
assert.match(ddns, /XHR\.post\("\]\]\.\.url\.\.\[\[",\{section:s\}/);
assert.doesNotMatch(acme, /XHR\.get\(/);
assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/);
});
+87 -2
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { spawn, spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/uninstall.sh');
@@ -30,6 +30,7 @@ function fixture() {
write(dir, 'usr/lib/lua/luci/i18n/minigate.zh-cn.lmo');
write(dir, 'usr/lib/lua/luci/controller/keep.lua');
write(dir, 'var/log/minigate-access.log', 'access-log');
write(dir, 'var/log/minigate-access.log.1', 'rotated-access-log');
write(dir, 'var/run/minigate/socket');
write(dir, 'var/run/minigate-nginx.pid', '123');
write(dir, 'tmp/minigate-update-state.json', '{}');
@@ -81,12 +82,96 @@ test('purge uninstall also removes configuration, certificates, bans and logs',
const dir = fixture();
const result = run(dir, '1');
assert.equal(result.status, 0, result.stderr);
for (const relative of ['etc/config/minigate', 'etc/minigate', 'var/log/minigate-access.log', 'usr/lib/minigate']) {
for (const relative of ['etc/config/minigate', 'etc/minigate', 'var/log/minigate-access.log', 'var/log/minigate-access.log.1', 'usr/lib/minigate']) {
assert.equal(fs.existsSync(path.join(dir, relative)), false, relative);
}
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/keep.lua')), true);
});
// BusyBox pgrep 只排除自身,不像 macOS 那样默认排除祖先进程。
function busyboxPgrep(dir) {
const shim = write(dir, 'test-bin/pgrep', `#!/bin/sh
self=$$
case "$1" in
-f) ps -A -o pid= -o ppid= -o args= | awk -v self="$self" -v re="$2" '
{ pid=$1; ppid=$2; if (pid == self || ppid == self) next; $1=""; $2=""; sub(/^ +/, ""); if ($0 ~ re) print pid }' ;;
-P) ps -A -o pid= -o ppid= | awk -v self="$self" -v parent="$2" '$2 == parent && $1 != self { print $1 }' ;;
esac
`);
fs.chmodSync(shim, 0o755);
return path.dirname(shim);
}
function startFakeService(dir) {
const service = write(dir, 'usr/lib/minigate/fake-service.sh', 'while :; do sleep 1; done\n');
const child = spawn('/bin/sh', [service], { detached: true, stdio: 'ignore' });
child.unref();
return child.pid;
}
function alive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
async function waitFor(check, timeout = 8000) {
const started = Date.now();
while (Date.now() - started < timeout) {
if (check()) return true;
await new Promise(resolve => setTimeout(resolve, 100));
}
return check();
}
function serviceEnv(dir) {
return {
...process.env,
PATH: `${busyboxPgrep(dir)}:${process.env.PATH}`,
MINIGATE_ROOT: dir,
MINIGATE_SKIP_PACKAGES: '1',
MINIGATE_SKIP_SERVICES: '0'
};
}
test('uninstall run directly from the program directory stops services without killing itself', async () => {
const dir = fixture();
const installed = write(dir, 'usr/lib/minigate/uninstall.sh', fs.readFileSync(script, 'utf8'));
const service = startFakeService(dir);
const result = spawnSync('/bin/sh', [installed, '0'], { encoding: 'utf8', env: serviceEnv(dir) });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/minigate')), false);
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/minigate.lua')), false);
assert.equal(fs.readFileSync(path.join(dir, 'etc/config/minigate'), 'utf8'), 'config-data');
assert.ok(await waitFor(() => !alive(service)), 'running minigate script must be stopped');
});
test('uninstall started like LuCI survives its own sh -c launcher', async () => {
const dir = fixture();
write(dir, 'usr/lib/minigate/uninstall.sh', fs.readFileSync(script, 'utf8'));
const service = startFakeService(dir);
const status = path.join(dir, 'uninstall.status');
const launcher = `runner=$(mktemp "${dir}/tmp/minigate-uninstall.XXXXXX") && cp "${dir}/usr/lib/minigate/uninstall.sh" "$runner" && chmod 700 "$runner" && ( ( /bin/sh "$runner" 0; echo $? > "${status}"; rm -f "$runner" ) >/dev/null 2>&1 & )`;
const result = spawnSync('/bin/sh', ['-c', launcher], { encoding: 'utf8', env: serviceEnv(dir) });
assert.equal(result.status, 0, result.stderr);
assert.ok(await waitFor(() => fs.existsSync(status) && fs.readFileSync(status, 'utf8').trim() !== ''), 'uninstall did not finish');
assert.equal(fs.readFileSync(status, 'utf8').trim(), '0');
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/minigate')), false);
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/view/minigate')), false);
assert.equal(fs.readFileSync(path.join(dir, 'etc/minigate/certs/site.pem'), 'utf8'), 'certificate-data');
assert.ok(await waitFor(() => !alive(service)), 'running minigate script must be stopped');
});
test('preserved uninstall data is written with a private umask', () => {
const source = fs.readFileSync(script, 'utf8');
assert.match(source, /^umask 077$/m);
});
test('invalid purge mode is rejected before files are changed', () => {
const dir = fixture();
const result = run(dir, 'yes');
+1 -1
View File
@@ -150,7 +150,7 @@ test('install requires an explicitly confirmed version and rejects a changed rel
function overview() {
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
const js = lua.slice(lua.indexOf('var _updatePolling'), lua.indexOf('var limitSel')).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
const js = lua.slice(lua.indexOf('function mgGet'), lua.indexOf('var limitSel')).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, { style: {}, className: '', textContent: '', disabled: false, checked: false, hidden: false, setAttribute() {}, focus() { document.activeElement = this; } });