mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 19:03:56 +08:00
🌴 Sync 2026-10-16 01:08:07
This commit is contained in:
1 parent
f95bba4fb6
commit
16f740db5d
186 files changed
+9575
-5557
No files matched your search
@@ -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, []);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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_\]\+\$"\)/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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; } });
|
||||
|
||||
Reference in new issue
Block a user