🎁 Sync 2026-10-28 19:35:15

This commit is contained in:
github-actions[bot] committed 2026-10-28 19:35:15 +08:00
1 parent 85209f310d
commit 390801c844
90 files changed
+5821 -1967

No files matched your search

+6 -3
View File
@@ -10,11 +10,12 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: `
local options = {}
package.preload['luci.sys'] = function() return {} end
package.preload['luci.controller.minigate'] = function() return {ddns_state=function() return {} end} end
package.preload['luci.model.uci'] = function()
return {cursor=function() return {foreach=function() end, get=function() end} end}
end
Flag, Value, DynamicList, ListValue, DummyValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue'
NamedSection, TypedSection = 'NamedSection', 'TypedSection'
Flag, Value, DynamicList, ListValue, DummyValue, MultiValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue', 'MultiValue'
NamedSection, TypedSection, SimpleSection = 'NamedSection', 'TypedSection', 'SimpleSection'
function Map()
return {section=function()
return {option=function(self, kind, name)
@@ -34,7 +35,7 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate/proxy.lua'))}))
assert(options.access_enabled.default == '0')
local regions = options.access_regions
assert(regions.kind == 'DynamicList')
assert(regions.kind == 'MultiValue' and regions.widget == 'checkbox')
assert(regions.default == '510000')
assert(regions.dependency[1] == 'access_enabled' and regions.dependency[2] == '1')
assert(regions.choices['510000'] == '四川省')
@@ -42,6 +43,8 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
assert(count == 34)
assert(regions:validate({'510000', '500000'}, 'global'))
assert(regions:validate('510000', 'global'))
assert(regions:validate('510000 500000', 'global'))
assert(not regions:validate('510000 ../x', 'global'))
assert(not regions:validate({}, 'global'))
assert(not regions:validate({''}, 'global'))
assert(not regions:validate({'990000'}, 'global'))
+27 -8
View File
@@ -64,17 +64,36 @@ test('HTTP redirect rejects unapproved source before sending a redirect', () =>
assert.ok(result.conf.indexOf('return 444;') < result.conf.indexOf('return 308'));
});
test('the legacy scanner cannot add global firewall bans in request access mode', () => {
const scanner = path.join(root, 'root/usr/lib/minigate/geofence.sh');
test('the installer removes the legacy scanner and its global firewall set', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/geofence.sh')), false);
assert.doesNotMatch(init, /geofence/);
assert.match(installer, /rm -f \/usr\/lib\/minigate\/after_commit\.sh \/usr\/lib\/minigate\/geofence\.sh/);
assert.match(installer, /nft delete set inet fw4 minigate_blocked/);
assert.match(installer, /uci -q delete minigate\.global\.blocked_ips/);
});
test('legacy province names migrate to request access region codes', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const body = installer.slice(installer.indexOf('migrate_legacy_geofence() {'), installer.indexOf('\nmigrate_legacy_geofence\n'));
const result = spawnSync('/bin/sh', ['-c', `
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
. '${scanner}'
uci() { [ "$3" = minigate.global.access_enabled ] && echo 1; }
init_block_set() { echo unexpected >&2; return 1; }
scan_and_block
D=${JSON.stringify(root)}
uci() {
case "$*" in
'-q get minigate.global.geo_enabled') echo 1 ;;
'-q get minigate.global.access_enabled') echo 0 ;;
'-q get minigate.global.geo_allowed') echo '四川,重庆' ;;
*) echo "uci $*" ;;
esac
}
${body}
migrate_legacy_geofence
`], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.match(result.stdout, /add_list minigate\.global\.access_regions=510000/);
assert.match(result.stdout, /add_list minigate\.global\.access_regions=500000/);
assert.match(result.stdout, /set minigate\.global\.access_enabled=1/);
});
test('bundled regions all cover IPv4 and IPv6 and data follows every install path', () => {
-92
View File
@@ -1,92 +0,0 @@
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/);
});
+47 -9
View File
@@ -31,11 +31,12 @@ function readRows(file) {
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
}
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0, currentIp = '198.51.100.20' }) {
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');
const stateDir = path.join(tempDir, 'state');
if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`);
const command = `
@@ -44,6 +45,7 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
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'))}
export MINIGATE_DDNS_STATE_DIR=${JSON.stringify(stateDir)}
. ${JSON.stringify(ddnsScript)}
uci() {
[ "\${1:-}" = "-q" ] && shift
@@ -57,11 +59,11 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
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)} ;;
set:*|commit:*) printf '%s\\n' "\$*" >> ${JSON.stringify(uciLog)} ;;
esac
return 0
}
get_ip4_url() { printf '198.51.100.20\\n'; }
get_ip4_url() { printf '%s\\n' ${JSON.stringify(currentIp)}; }
record_ip_history() { return 0; }
calc_next() { printf '12:00:00\\n'; }
cf_update() {
@@ -78,7 +80,15 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
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 };
const stateFile = path.join(stateDir, '__example_com.state');
const state = {};
if (fs.existsSync(stateFile)) {
for (const line of readRows(stateFile)) {
const at = line.indexOf('=');
state[line.slice(0, at)] = line.slice(at + 1);
}
}
return { result, refreshes, uciWrites, cache, state };
}
function runCloudflareScenario(mode) {
@@ -251,14 +261,14 @@ function runScheduled({ force = false, checked }) {
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']);
assert.deepEqual(due.calls, ['cfg01 0'], 'scheduled runs never commit /etc/config');
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']);
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1']);
});
test('DDNS interval is at least five minutes and the next run matches the cron grid', () => {
@@ -280,7 +290,9 @@ test('successful Cloudflare changes refresh local DNS caches once', () => {
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'));
assert.equal(scenario.state.status, 'ok');
assert.equal(scenario.state.ip, '198.51.100.20');
assert.deepEqual(scenario.uciWrites, [], 'runtime state must not be written to /etc/config');
});
test('unchanged forced sync does not flush caches, but a stale local IP does', () => {
@@ -299,14 +311,40 @@ test('Cloudflare failure never flushes local caches', () => {
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'));
assert.equal(scenario.state.status, 'error');
assert.deepEqual(scenario.uciWrites, []);
});
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'));
assert.equal(scenario.state.status, 'ok');
});
test('a private interface address is never pushed to Cloudflare', () => {
for (const ip of ['192.168.2.2', '10.0.0.5', '100.64.1.1', '172.20.0.1']) {
const scenario = runUpdateScenario({ cfResult: 'changed', currentIp: ip });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.equal(scenario.state.status, 'error', ip);
assert.match(scenario.state.message, /不是公网地址/);
assert.equal(scenario.cache, '', 'cf_update must not run for ' + ip);
assert.deepEqual(scenario.refreshes, []);
}
});
test('public address checks reject private, carrier NAT, ULA and link-local ranges', () => {
const { output } = runHistory(`
for ip in 1.2.3.4 182.147.210.9 10.1.1.1 172.16.0.1 172.32.0.1 192.168.1.1 100.64.0.1 100.128.0.1 127.0.0.1 169.254.1.1 0.1.2.3 224.0.0.1 256.1.1.1 1.2.3; do
is_public_ipv4 "$ip" && printf '%s ' "$ip"
done
printf '| '
for ip in 2001:db8::1 240e:1::5 fd00::1 fe80::1 ::1 'x:y'; do
is_public_ipv6 "$ip" && printf '%s ' "$ip"
done
true
`);
assert.equal(output, '1.2.3.4 182.147.210.9 172.32.0.1 100.128.0.1 | 2001:db8::1 240e:1::5');
});
test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => {
-81
View File
@@ -1,81 +0,0 @@
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/geofence.sh');
function runLibrary(body, accessLog = '') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-geofence-test-'));
const log = path.join(dir, 'access.log');
fs.writeFileSync(log, accessLog);
const command = `
set -u
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
export MINIGATE_ACCESS_LOG=${JSON.stringify(log)}
export MINIGATE_GEOFENCE_LOG=${JSON.stringify(path.join(dir, 'geofence.log'))}
export MINIGATE_GEO_CACHE_DIR=${JSON.stringify(path.join(dir, 'cache'))}
. ${JSON.stringify(script)}
${body}
`;
return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
}
const entry = client => JSON.stringify({ time: '2026-10-17T07:22:40+08:00', domain: 'box.example.com', client, status: 200 });
const ACCESS_LOG = [
entry('192.168.2.1'),
entry('198.51.100.7'),
entry('172.20.0.5'),
entry('203.0.113.9'),
entry('198.51.100.7'),
entry('2001:db8::1'),
entry('100.100.1.1'),
entry('192.168.2.15')
].join('\n') + '\n';
test('private, loopback, link-local and carrier NAT addresses are recognised', () => {
const privateIps = ['0.0.0.0', '10.1.2.3', '127.0.0.1', '169.254.1.1', '172.16.0.1', '172.31.255.255',
'192.168.2.15', '100.64.0.1', '100.99.1.1', '100.127.255.255'];
const publicIps = ['172.15.0.1', '172.32.0.1', '100.63.255.255', '100.128.0.1', '198.51.100.7', '1.10.0.1', '192.169.0.1'];
const check = ips => ips.map(ip => `is_private_ip ${ip} && echo "${ip} private" || echo "${ip} public"`).join('\n');
const result = runLibrary(check([...privateIps, ...publicIps]));
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stdout.trim().split('\n'), [
...privateIps.map(ip => `${ip} private`),
...publicIps.map(ip => `${ip} public`)
]);
});
test('client extraction works without grep -P and keeps only unique public IPv4 visitors', () => {
const result = runLibrary(`
grep() { [ "\${1:-}" = "-oP" ] && { echo "grep: unrecognized option: P" >&2; return 2; }; command grep "$@"; }
extract_public_ips
`, ACCESS_LOG);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stdout.trim().split('\n'), ['198.51.100.7', '203.0.113.9']);
});
test('scan never looks up or blocks LAN visitors', () => {
const result = runLibrary(`
uci() {
[ "\${1:-}" = "-q" ] && shift
case "\${2:-}" in
minigate.global.geo_enabled) echo 1 ;;
minigate.global.geo_allowed) echo 四川 ;;
esac
}
init_block_set() { :; }
nft() { :; }
sleep() { :; }
lookup_geo() { echo "lookup $1" >&2; echo 广东; }
block_ip() { echo "block $1"; }
mkdir -p "$CACHE_DIR"
scan_and_block
`, ACCESS_LOG);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stderr.trim().split('\n'), ['lookup 198.51.100.7', 'lookup 203.0.113.9']);
assert.deepEqual(result.stdout.trim().split('\n'), ['block 198.51.100.7', 'block 203.0.113.9']);
});
+77
View File
@@ -0,0 +1,77 @@
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 init = path.join(root, 'root/etc/init.d/minigate');
function runInit(body, env = {}) {
return spawnSync('/bin/sh', ['-c', `. ${JSON.stringify(init)}\n${body}`], { encoding: 'utf8', env: { ...process.env, ...env } });
}
test('cron entries are managed by script path and user jobs mentioning minigate survive', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cron-'));
const cron = path.join(dir, 'root');
fs.writeFileSync(cron, '0 1 * * * /root/backup-minigate-certs.sh\n*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan\n');
let result = runInit('update_cron 1', { MINIGATE_CRON_FILE: cron });
assert.equal(result.status, 0, result.stderr);
const enabled = fs.readFileSync(cron, 'utf8');
assert.match(enabled, /^0 1 \* \* \* \/root\/backup-minigate-certs\.sh$/m);
assert.doesNotMatch(enabled, /geofence/);
assert.match(enabled, /\/usr\/lib\/minigate\/ddns\.sh$/m);
assert.match(enabled, /\/usr\/lib\/minigate\/acme\.sh renew$/m);
result = runInit('update_cron 0', { MINIGATE_CRON_FILE: cron });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(cron, 'utf8'), '0 1 * * * /root/backup-minigate-certs.sh\n');
});
test('reload digests only include the settings of the page that changed', () => {
const result = runInit(`
md5sum() { cat; }
uci() {
cat <<'UCI'
minigate.global=global
minigate.global.enabled='1'
minigate.@ddns[0]=ddns
minigate.@ddns[0].domain='a.example.com'
minigate.web=proxy
minigate.web.domain='b.example.com'
minigate.login_guard=login_guard
minigate.login_guard.threshold='3'
UCI
}
echo "== ddns"; config_digest ddns
echo "== proxy"; config_digest "global proxy proxy_wildcard subproxy"
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, [
'== ddns',
'minigate.@ddns[0]=ddns',
"minigate.@ddns[0].domain='a.example.com'",
'== proxy',
'minigate.global=global',
"minigate.global.enabled='1'",
'minigate.web=proxy',
"minigate.web.domain='b.example.com'",
''
].join('\n'));
});
test('services run under procd, background work drops the procd lock and LuCI pages no longer poll for commits', () => {
const source = fs.readFileSync(init, 'utf8');
assert.match(source, /procd_open_instance login_guard/);
assert.match(source, /procd_set_param env MINIGATE_LG_DIGEST=/);
assert.match(source, /run_background\(\) \{\n \( close_procd_lock; "\$@" \) <\/dev\/null >\/dev\/null 2>&1 &/);
assert.match(source, /\( close_procd_lock; \/etc\/init\.d\/cron restart \)/);
// 只有 eval 字符串里出现多位数描述符,dash 等 shell 才能解析本文件
assert.deepEqual(source.split('\n').filter(line => /\d{2,}>&/.test(line)), [" [ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'"]);
for (const page of ['general', 'ddns', 'proxy', 'login_guard']) {
const lua = fs.readFileSync(path.join(root, `luasrc/model/cbi/minigate/${page}.lua`), 'utf8');
assert.doesNotMatch(lua, /on_after_commit|after_commit\.sh/, page);
}
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/after_commit.sh')), false);
});
+4 -3
View File
@@ -127,9 +127,10 @@ test('no generated sites means no hosts file', () => {
test('reload syncs LAN hosts only after nginx accepted the new configuration', () => {
const box = sandbox();
const body = ok => `
PID_FILE=${JSON.stringify(path.join(box.dir, 'missing.pid'))}
prepare_config() { return 0; }
start_prepared() { return ${ok ? 0 : 1}; }
uci() { echo 1; }
lock_proxy() { :; }
prepare_config() { return ${ok ? 0 : 1}; }
apply_config() { return 0; }
sync_lan_hosts() { echo synced; }
do_reload
`;
+106 -39
View File
@@ -8,24 +8,29 @@ 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 }) {
// firewallReady: 集合和规则都在;setsEmpty: 防火墙重载后随包集合存在但内容为空
function runRepair({ firewallReady, setsEmpty = false }) {
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');
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n2001:db8::9 9999999999\n');
const before = fs.statSync(bans).ino;
const command = `
set -u
logger() { :; }
uci() { return 1; }
ubus() { 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 ;;
'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6')
[ ${firewallReady ? 1 : 0} = 1 ] || return 1
[ ${setsEmpty ? 1 : 0} = 1 ] || printf 'elements = { 198.51.100.4 }\\n'
return 0 ;;
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\nip6 saddr @login_banned_v6 counter drop\\n'; return 0 ;;
esac
return 0
}
@@ -52,56 +57,118 @@ test('periodic nft check does not rewrite the persisted ban list when rules are
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', () => {
test('a firewall reload that drops the set restores persisted IPv4 and IPv6 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, /add set inet fw4 login_banned_v4 \{ type ipv4_addr; flags timeout; \}/);
assert.match(scenario.calls, /add set inet fw4 login_banned_v6 \{ type ipv6_addr; flags timeout; \}/);
assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/);
assert.match(scenario.calls, /insert rule inet fw4 input ip6 saddr @login_banned_v6/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v6 \{ 2001:db8::9 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');
assert.equal(scenario.bans, '198.51.100.4 9999999999\n2001:db8::9 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('packaged firewall sets that come back empty after a reload are refilled', () => {
const scenario = runRepair({ firewallReady: true, setsEmpty: true });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.doesNotMatch(scenario.calls, /add set|insert rule/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
});
test('fw4 includes declare both ban sets before the input rules that use them', () => {
const tablePre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft'), 'utf8');
const chainPre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft'), 'utf8');
assert.match(tablePre, /set login_banned_v4 \{\n\ttype ipv4_addr\n\tflags timeout\n\}/);
assert.match(tablePre, /set login_banned_v6 \{\n\ttype ipv6_addr\n\tflags timeout\n\}/);
assert.match(chainPre, /^ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"$/m);
assert.match(chainPre, /^ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"$/m);
for (const file of ['Makefile', 'install.sh', 'scripts/build-ipk.sh', 'root/usr/lib/minigate/uninstall.sh']) {
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /nftables\.d\/chain-pre\/input\/20-minigate-login-guard\.nft/, file);
}
});
function library(body) {
return spawnSync('/bin/sh', ['-c', `
export MINIGATE_LG_LIBRARY_ONLY=1
export MINIGATE_LG_DATA_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-lib-')))}
export MINIGATE_LG_RUN_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-run-')))}
export MINIGATE_LG_LOG=/dev/null
logger() { :; }
uci() { [ "$3" = minigate.login_guard.whitelist ] && echo "203.0.113.0/20 2001:db8:abcd::/48"; return 0; }
. ${JSON.stringify(script)}
${body}
`], { encoding: 'utf8' });
}
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');
test('addresses from dropbear and LuCI logs are parsed for both IP families and spoofed usernames', () => {
const result = library(`
extract_ip "authpriv.warn dropbear[1]: Bad password attempt for 'root' from 198.51.100.7:52144" 1
extract_ip "dropbear[1]: Login attempt for nonexistent user 'x from 9.9.9.9:1' from 2001:db8::7:52144" 1
extract_ip "luci: failed login on / for root from 2001:db8::8" 0
extract_ip "luci: failed login on / for root from bad;rm" 0 || echo rejected
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), '198.51.100.7\n2001:db8::7\n2001:db8::8\nrejected');
});
test('whitelist matches any IPv4 prefix length and IPv6 subnets, private ranges stay exempt', () => {
const result = library(`
for ip in 203.0.120.9 203.0.128.1 2001:db8:abcd:1::5 2001:db8:abce::5 192.168.2.5 fd00::5 fe80::1 198.51.100.7 2400:da00::1; do
is_whitelisted "$ip" && printf 'white:%s ' "$ip" || printf 'watch:%s ' "$ip"
done
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), 'white:203.0.120.9 watch:203.0.128.1 white:2001:db8:abcd:1::5 watch:2001:db8:abce::5 white:192.168.2.5 white:fd00::5 white:fe80::1 watch:198.51.100.7 watch:2400:da00::1');
});
test('a ban the firewall rejects is reported instead of being logged as banned', () => {
const result = library(`
nft() { return 1; }
ban_ip 198.51.100.9 && echo banned || echo failed
cat "$BANS_FILE"
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), 'failed');
});
test('procd stopping the login guard ends logread and tail children and lifts enforcement', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-'));
const bin = path.join(dir, 'bin');
const followed = path.join(dir, 'access.log');
const guardScript = path.join(dir, 'login_guard.sh');
const marker = path.join(dir, 'caller-survived');
const nftLog = path.join(dir, 'nft.log');
fs.mkdirSync(bin);
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 shim = (name, body) => fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
shim('uci', 'case "$3" in minigate.login_guard.enabled) echo 1 ;; *) exit 1 ;; esac');
shim('nft', `printf '%s\\n' "$*" >> ${JSON.stringify(nftLog)}; case "$*" in 'list chain'*) echo '@login_banned_v4 @login_banned_v6' ;; 'list set'*) echo elements ;; esac; exit 0`);
shim('logger', 'exit 0');
shim('ubus', 'exit 1');
// logread -f 与 tail -f 一样会一直阻塞,用 tail 模拟
shim('logread', `exec tail -n 0 -f ${JSON.stringify(followed)}`);
const guard = spawn('/bin/sh', [script, 'run'], {
stdio: 'ignore',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
MINIGATE_LG_DATA_DIR: path.join(dir, 'data'),
MINIGATE_LG_RUN_DIR: path.join(dir, 'run'),
MINIGATE_LG_LOG: path.join(dir, 'lg.log'),
MINIGATE_ACCESS_LOG: followed
}
});
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');
while (pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length < 2 && Date.now() - started < 4000) {
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.equal(pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length, 2, 'both followers should run');
guard.kill('SIGTERM');
await new Promise(resolve => guard.once('exit', resolve));
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`), '');
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f / logread -f must not be left behind');
assert.match(fs.readFileSync(nftLog, 'utf8'), /flush set inet fw4 login_banned_v4[\s\S]*flush set inet fw4 login_banned_v6/);
});
test('upgrades stop the old login guard tree before running the previous init script', () => {
+210
View File
@@ -0,0 +1,210 @@
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 lua = process.env.MINIGATE_TEST_LUA;
const root = path.resolve(__dirname, '..');
const controller = path.join(root, 'luasrc/controller/minigate.lua');
function runLua(source) {
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: source });
assert.equal(result.status, 0, result.stderr || result.stdout);
return result.stdout.trim();
}
function luaFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
return entry.isDirectory() ? luaFiles(full) : (entry.name.endsWith('.lua') ? [full] : []);
});
}
test('every LuCI Lua file compiles', { skip: !lua }, () => {
const files = luaFiles(path.join(root, 'luasrc'));
assert.ok(files.length >= 6);
const checks = files.map(file => `assert(loadfile(${JSON.stringify(file)}))`).join('\n');
runLua(checks + '\nprint("ok")');
});
// 加载控制器时只需要 luci.http;其他模块在函数内部按需 require
const controllerPrelude = `
local response, status_code, calls, form = nil, nil, {}, {}
luci = {http={
formvalue=function(name) return form[name] end,
prepare_content=function() end,
write_json=function(v) response=v end,
status=function(code) status_code=code end
}}
package.preload['luci.sys'] = function() return {
call=function(cmd) calls[#calls + 1] = cmd; return 0 end,
exec=function() return '' end
} end
package.preload['luci.model.uci'] = function() return {cursor=function() return {
get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end,
foreach=function() end
} end} end
dofile(${JSON.stringify(controller)})
local c = luci.controller.minigate
`;
test('certificate expiry parsing is timezone independent', { skip: !lua }, () => {
const expected = Date.UTC(2026, 11, 16, 12, 0, 0) / 1000;
const out = runLua(controllerPrelude + `
print(c.parse_cert_time('Dec 16 12:00:00 2026 GMT'))
print(c.parse_cert_time('Feb 1 00:00:00 2028 GMT'))
print(tostring(c.parse_cert_time('garbage')))
`);
assert.deepEqual(out.split('\n'), [String(expected), String(Date.UTC(2028, 1, 1) / 1000), 'nil']);
});
test('log view merges sources by time and keeps untimed lines with their entry', { skip: !lua }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-logs-'));
fs.writeFileSync(path.join(dir, 'ddns.log'), '2026-10-28 10:00:01 [DDNS] a\n2026-10-28 10:00:05 [DDNS] c\n');
fs.writeFileSync(path.join(dir, 'nginx.log'), '2026/10/28 10:00:03 [emerg] b\n');
fs.writeFileSync(path.join(dir, 'update.log'), '2026-10-28 10:00:04 start\ncurl output without time\n');
const out = runLua(controllerPrelude + `
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}, {'Nginx', ${JSON.stringify(path.join(dir, 'nginx.log'))}},
{'更新', ${JSON.stringify(path.join(dir, 'update.log'))}}, {'缺失', '/missing.log'}}, 300))
print('--')
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}}, 1))
`);
assert.equal(out, [
'[DDNS] 2026-10-28 10:00:01 [DDNS] a',
'[Nginx] 2026/10/28 10:00:03 [emerg] b',
'[更新] 2026-10-28 10:00:04 start',
'[更新] curl output without time',
'[DDNS] 2026-10-28 10:00:05 [DDNS] c',
'--',
'[DDNS] 2026-10-28 10:00:05 [DDNS] c'
].join('\n'));
});
test('state files are read by the same domain key the shell scripts write', { skip: !lua }, () => {
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-state-')), 'proxy.status');
fs.writeFileSync(file, 'result=ok\nmessage=配置检查通过\nwarning=one\nwarning=two=2\n');
const out = runLua(controllerPrelude + `
local st = c.read_state(${JSON.stringify(file)})
print(st.result, st.message, #st.warnings, st.warnings[2])
print(c.state_key('*.example.com'), c.state_key('home.example.com'))
print(tostring(c.read_state('/missing/state')))
`);
assert.equal(out, 'ok\t配置检查通过\t2\ttwo=2\n__example_com\thome_example_com\nnil');
});
test('login guard endpoints accept IPv6, quote the address and reject shell text', { skip: !lua }, () => {
const out = runLua(controllerPrelude + `
for _, ip in ipairs({'2001:db8::1', '198.51.100.7', "1.2.3.4'; reboot; '", 'abc'}) do
form.ip = ip; calls = {}; response = nil
c.action_lg_ban()
print(response.success and calls[1] or 'rejected')
end
`);
assert.equal(out, [
"/bin/sh /usr/lib/minigate/login_guard.sh ban '2001:db8::1' >/dev/null 2>&1",
"/bin/sh /usr/lib/minigate/login_guard.sh ban '198.51.100.7' >/dev/null 2>&1",
'rejected',
'rejected'
].join('\n'));
});
test('manual DDNS sync starts a background job only for a real DDNS section', { skip: !lua }, () => {
const out = runLua(controllerPrelude + `
form.section = 'cfg01'; c.action_ddns_sync()
print(response.success, calls[1])
form.section = 'cfg01;reboot'; calls = {}; c.action_ddns_sync()
print(response.success, status_code, #calls)
`);
const [started, rejected] = out.split('\n');
assert.match(started, /^true\t\( \/bin\/sh \/usr\/lib\/minigate\/job\.sh ddns-cfg01 \/bin\/sh \/usr\/lib\/minigate\/ddns\.sh single 'cfg01' \) <\/dev\/null >\/dev\/null 2>&1 &$/);
assert.equal(rejected, 'false\t400\t0');
});
// 用最小 CBI 桩加载每个页面,并执行表单渲染时会调用的 cfgvalue
const cbiPrelude = `
local options = {}
local function node()
local n = {}
function n:option(kind, name, title)
local o = {kind=kind, name=name, choices={}}
function o:value(k, v) self.choices[k] = v or k end
function o:depends() end
options[name] = o
return o
end
function n:tab() end
function n:taboption(_, ...) return n:option(...) end
return n
end
function Map(config, title)
local m = node()
m.title = title
m.uci = {get=function(_, _, sec, opt) local v; if opt == 'domain' then v = 'home.example.com' end; return v end}
function m:section() return node() end
return m
end
for _, name in ipairs({'Flag','Value','DynamicList','ListValue','DummyValue','MultiValue','NamedSection','TypedSection','SimpleSection'}) do _G[name] = name end
luci = {dispatcher={build_url=function(...) return '/cgi-bin/luci/' .. table.concat({...}, '/') end}}
package.preload['luci.sys'] = function() return {exec=function() return '' end, call=function() return 0 end} end
package.preload['nixio.fs'] = function() return {access=function() return false end, dir=function() return nil end, stat=function() return nil end} end
package.preload['luci.model.uci'] = function() return {cursor=function() return {
foreach=function(_, config, kind, fn) if kind == 'ddns' then fn({domain='home.example.com'}); fn({domain='*.example.com'}) end end,
get=function(_, _, _, opt) local v; if opt == 'staging' then v = '1' end; return v end
} end} end
package.preload['luci.controller.minigate'] = function() return {
ddns_state=function() return {status='error', message='IPv4 192.168.2.2 不是公网地址', ip='198.51.100.1'} end,
cert_list=function() return {{domain='*.example.com', expires_date='2026-11-02', days_left=5, staging=true}} end
} end
package.preload['luci.cbi.datatypes'] = function() return {
ip4addr=function(v) return v:match('^%d+%.%d+%.%d+%.%d+$') ~= nil end,
ip6addr=function(v) return v:match('^[%x:]+$') ~= nil and v:find(':') ~= nil end
} end
local function page(name) return assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate'))} .. '/' .. name .. '.lua')) end
`;
test('every LuCI page loads and renders its status blocks', { skip: !lua }, () => {
const out = runLua(cbiPrelude + `
for _, name in ipairs({'general', 'ddns', 'acme', 'proxy', 'login_guard'}) do
options = {}
local m = page(name)
assert(m.title:match('^MiniGate'), name .. ' title')
for key, o in pairs(options) do
if o.kind == 'DummyValue' and type(o.cfgvalue) == 'function' then
assert(type(o.cfgvalue(o, 'cfg01')) == 'string', name .. '.' .. key)
end
end
end
options = {}; page('ddns')
print(options._info.cfgvalue(options._info, 'cfg01'):match('不是公网地址') ~= nil)
options = {}; page('acme')
local certs = options._certs.cfgvalue(options._certs)
print(certs:match('剩 5 天') ~= nil, certs:match('测试证书') ~= nil, certs:match('测试模式已开启') ~= nil)
options = {}; page('general')
print(options.enabled ~= nil, options.ipv6_listen == nil)
`);
assert.equal(out, 'true\ntrue\ttrue\ttrue\ntrue\ttrue');
});
test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whitelists', { skip: !lua }, () => {
const out = runLua(cbiPrelude + `
local function ok(o, v) return (o.validate(o, v, 'cfg01')) ~= nil end
options = {}; page('ddns')
print(ok(options.domain, 'home.example.com'), ok(options.domain, '*.example.com'), ok(options.domain, 'bad domain.com'),
ok(options.domain, '-a.example.com'), ok(options.domain, 'localhost'))
print(ok(options.cf_zone_id, string.rep('a1', 16)), ok(options.cf_zone_id, 'zone'), ok(options.cf_api_token, 'abc_DEF-123'), ok(options.cf_api_token, 'abc def'))
print(ok(options.check_interval, '300'), ok(options.check_interval, '60'))
options = {}; page('proxy')
print(ok(options.prefix, 'app'), ok(options.prefix, 'a.b'), ok(options.prefix, '-x'), options.target_addr.datatype)
options = {}; page('login_guard')
print(options.threshold.datatype, ok(options.whitelist, {'203.0.113.0/20', '2001:db8::/48', '8.8.8.8'}), ok(options.whitelist, {'1.2.3.0/255.255.255.0'}), ok(options.whitelist, '10.0.0.0/33'))
`);
assert.equal(out, [
'true\ttrue\tfalse\tfalse\tfalse',
'true\tfalse\ttrue\tfalse',
'true\tfalse',
'true\tfalse\tfalse\thost',
'range(1,100)\ttrue\tfalse\tfalse'
].join('\n'));
});
+127
View File
@@ -65,3 +65,130 @@ test('log trimming is scheduled and the visitor list reads the rotated access lo
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/);
});
// 用假的 uci / nginx 生成完整配置,检查跳过规则、回滚和状态文件
function generate(db, { nginxFails = false } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-gen-'));
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin);
fs.mkdirSync(path.join(dir, 'sites'));
fs.writeFileSync(path.join(dir, 'uci.db'), db.trim() + '\n');
fs.writeFileSync(path.join(bin, 'uci'), `#!/bin/sh
DB=${JSON.stringify(path.join(dir, 'uci.db'))}
[ "$1" = "-q" ] && shift
case "$1" in
show) grep "^$2" "$DB" ;;
get) v=$(grep "^$2=" "$DB" | head -1 | cut -d= -f2-); [ -n "$v" ] && echo "$v" || exit 1 ;;
esac
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'nginx'), `#!/bin/sh
[ "$1" = "-v" ] && { echo "nginx version: nginx/1.26.3" >&2; exit 0; }
${nginxFails ? 'echo "nginx: [emerg] unknown directive in site_1.conf:3" >&2; exit 1' : 'exit 0'}
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'openssl'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(dir, 'sites/site_1.conf'), '# previous good site\n');
fs.writeFileSync(path.join(dir, 'main.conf'), '# previous good main\n');
const result = spawnSync('/bin/sh', [script, 'prepare'], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
TMPDIR: dir,
MINIGATE_PROXY_LOCK: path.join(dir, 'proxy.lock'),
MINIGATE_PROXY_STATUS: path.join(dir, 'proxy.status'),
MINIGATE_NGINX_CONF: path.join(dir, 'main.conf'),
MINIGATE_MIME_TYPES: '/dev/null',
MINIGATE_SITES_DIR: path.join(dir, 'sites'),
MINIGATE_STREAMS_DIR: path.join(dir, 'streams'),
MINIGATE_CERT_DIR: path.join(dir, 'certs'),
MINIGATE_LOGFILE: path.join(dir, 'proxy.log'),
MINIGATE_SOCKET_DIR: path.join(dir, 'sock')
}
});
const sites = fs.readdirSync(path.join(dir, 'sites')).sort();
const conf = sites.map(name => fs.readFileSync(path.join(dir, 'sites', name), 'utf8')).join('\n');
const status = fs.existsSync(path.join(dir, 'proxy.status')) ? fs.readFileSync(path.join(dir, 'proxy.status'), 'utf8') : '';
const leftovers = fs.readdirSync(dir).filter(name => name.startsWith('minigate-gen.'));
return { result, dir, sites, conf, status, leftovers, main: fs.readFileSync(path.join(dir, 'main.conf'), 'utf8') };
}
const BASE = `
minigate.global=global
minigate.global.enabled=1
minigate.global.http_redirect=0
minigate.wc=proxy_wildcard
minigate.wc.enabled=WC_ENABLED
minigate.wc.domain=*.example.com
minigate.wc.listen_port=443
minigate.wc.ssl=1
minigate.sub1=subproxy
minigate.sub1.parent_domain=example.com
minigate.sub1.prefix=nas
minigate.sub1.target_addr=192.168.2.10
minigate.sub1.target_port=5000
`;
test('subdomains are skipped when their wildcard parent is disabled instead of falling back to plain HTTP', () => {
const disabled = generate(BASE.replace('WC_ENABLED', '0'));
assert.equal(disabled.result.status, 0, disabled.result.stderr);
assert.doesNotMatch(disabled.conf, /nas\.example\.com/);
assert.match(disabled.status, /warning=跳过 nas\.example\.com:通配符主域名 \*\.example\.com 未启用或不存在/);
const enabled = generate(BASE.replace('WC_ENABLED', '1'));
assert.equal(enabled.result.status, 0, enabled.result.stderr);
assert.match(enabled.conf, /listen 443 ssl;\n server_name nas\.example\.com;/);
assert.doesNotMatch(enabled.conf, /listen 443;\n server_name nas/);
});
test('a disabled subdomain rule is not generated', () => {
const result = generate(BASE.replace('WC_ENABLED', '1') + 'minigate.sub1.enabled=0\n');
assert.equal(result.result.status, 0, result.result.stderr);
assert.doesNotMatch(result.conf, /nas\.example\.com/);
});
test('HTTP and HTTPS rules on one port never produce two default servers', () => {
const result = generate(BASE.replace('WC_ENABLED', '1') + `
minigate.plain=proxy
minigate.plain.enabled=1
minigate.plain.domain=plain.example.org
minigate.plain.listen_port=443
minigate.plain.target_addr=192.168.2.30
minigate.plain.ssl=0
`);
assert.equal(result.result.status, 0, result.result.stderr);
// 普通规则先生成并占用端口,之后的子域名 HTTPS 规则被跳过
assert.equal((result.conf.match(/default_server/g) || []).length, 1);
assert.match(result.conf, /server_name plain\.example\.org;/);
assert.doesNotMatch(result.conf, /nas\.example\.com/);
assert.match(result.status, /warning=跳过 nas\.example\.com:端口 443 已用于HTTP,不能同时用于HTTPS/);
});
test('missing certificates and every site get WebSocket support and a visible warning', () => {
const result = generate(BASE.replace('WC_ENABLED', '1'));
assert.match(result.status, /warning=nas\.example\.com 没有可用证书/);
assert.match(result.conf, /proxy_set_header Connection \$minigate_connection_upgrade;/);
assert.match(result.main, /map \$http_upgrade \$minigate_connection_upgrade/);
assert.match(result.status, /^result=ok$/m);
assert.deepEqual(result.leftovers, [], 'temporary generation directory is removed');
});
test('a configuration nginx rejects is rolled back so the last working files stay on disk', () => {
const result = generate(BASE.replace('WC_ENABLED', '1'), { nginxFails: true });
assert.equal(result.result.status, 1);
assert.deepEqual(result.sites, ['site_1.conf']);
assert.equal(fs.readFileSync(path.join(result.dir, 'sites/site_1.conf'), 'utf8'), '# previous good site\n');
assert.equal(result.main, '# previous good main\n');
assert.match(result.status, /^result=error$/m);
assert.match(result.status, /配置检查失败,已保留上一份配置:\[emerg\] unknown directive/);
assert.deepEqual(result.leftovers, []);
});
test('nginx runs in the foreground under procd and clears stale sockets first', () => {
const source = fs.readFileSync(script, 'utf8');
assert.match(source, /do_run\(\) \{\n mkdir -p "\$SOCKET_DIR"\n rm -f "\$SOCKET_DIR"\/\*\.sock\n exec nginx -c "\$NGINX_CONF" -g 'daemon off;'/);
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
assert.match(init, /USE_PROCD=1/);
assert.match(init, /procd_set_param command \/bin\/sh "\$PROXY" run/);
assert.match(init, /procd_add_reload_trigger "minigate"/);
assert.match(source, /MINIGATE_SKIP_PREPARE=1 "\$INIT_SCRIPT" start 6>&-/);
});
+95 -2
View File
@@ -131,6 +131,95 @@ test('overview renders legacy visitor responses as unknown and escapes log conte
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
});
const statusData = extra => ({
enabled: '1', ddns_list: [], proxy_rules: [], certs: [], acme: {}, proxy_warnings: [], ...extra
});
test('overview shows a notice only while MiniGate is disabled', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({ enabled: '0' }));
assert.equal(page.element('mg-disabled').hidden, false);
page.context.mgLoadStatus();
take(page, 'su').callback(null, statusData({ enabled: '1' }));
assert.equal(page.element('mg-disabled').hidden, true);
});
test('overview DDNS card lists every record and takes the worst status', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
ddns_status: 'error',
ddns_list: [
{ domain: 'a.example.com', enabled: '1', status: 'ok', last_ip: '198.51.100.1', last_update: '2026-10-28 10:00:00' },
{ domain: 'b.example.com', enabled: '1', status: 'error', status_msg: 'IPv4 192.168.2.2 不是公网地址<b>' }
]
}));
assert.match(page.element('mg-d1').innerHTML, /异常/);
const body = page.element('mg-d2').innerHTML;
assert.match(body, /a\.example\.com[\s\S]*A 198\.51\.100\.1/);
assert.match(body, /b\.example\.com/);
assert.match(body, /不是公网地址&lt;b&gt;/);
assert.doesNotMatch(body, /<b>/);
});
test('overview certificate card shows expiry dates, days left and test certificates', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
acme: { enabled: '1', result: 'error', message: '签发失败 (exit=1)' },
certs: [
{ domain: '*.example.com', expires_date: '2026-11-02', days_left: 5, staging: false },
{ domain: 'test.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }
]
}));
assert.match(page.element('mg-a1').innerHTML, /即将到期/);
const body = page.element('mg-a2').innerHTML;
assert.match(body, /到期 2026-11-02(剩 5 天)/);
assert.match(body, /测试证书,浏览器不信任/);
assert.match(body, /最近一次签发:签发失败/);
const staging = fakePage(overviewScript);
take(staging, 'su').callback(null, statusData({ certs: [{ domain: 'x.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }] }));
assert.match(staging.element('mg-a1').innerHTML, /测试证书/);
});
test('overview proxy card links sites safely and surfaces configuration problems', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
proxy_running: true,
proxy_check: 'error',
proxy_message: '配置检查失败,已保留上一份配置:[emerg] x',
proxy_warnings: ['跳过 nas.example.com:通配符主域名 *.example.com 未启用或不存在'],
proxy_rules: [{ scheme: 'https', domain: 'box.example.com', listen_port: '2000', target: '192.168.2.2:8080' },
{ scheme: 'https', domain: '"><img>', listen_port: '443', target: 'x' }]
}));
assert.match(page.element('mg-p1').innerHTML, /最近一次配置未生效/);
const body = page.element('mg-p2').innerHTML;
assert.match(body, /<a class="mg-link" href="https:\/\/box\.example\.com:2000" target="_blank" rel="noopener noreferrer">/);
assert.match(body, /已保留上一份配置/);
assert.match(body, /跳过 nas\.example\.com/);
assert.doesNotMatch(body, /"><img>/);
});
function ddnsPageScript() {
const source = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), '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');
}
test('manual DDNS sync runs in the background and updates the row without reloading the page', () => {
const page = fakePage(ddnsPageScript());
let reloaded = false;
page.context.location.reload = () => { reloaded = true; };
const button = page.element('sync-button');
page.context.doSync('cfg01', button);
take(page, 'url').callback(null, { success: true });
take(page, 'job').callback(null, { running: false, done: true, success: true, status: 'ok', ip: '198.51.100.20', updated: '2026-10-28 10:00:00' });
assert.equal(reloaded, false);
assert.equal(button.disabled, false);
assert.match(page.element('sr-cfg01').innerHTML, /同步完成 198\.51\.100\.20/);
assert.match(page.element('ss-cfg01').innerHTML, /A: 198\.51\.100\.20/);
});
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 });
@@ -151,7 +240,11 @@ test('state-changing LuCI endpoints require POST with the CSRF token', () => {
}
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(ddns, /mgRequest\('post',"\]\]\.\.url\.\.\[\[",\{section:s\}/);
// 安装与签发用 POST 启动后台任务,只有读取任务状态用 GET
assert.match(acme, /acmePost\('\]\]\.\.iu\.\.\[\['/);
assert.match(acme, /acmePost\('\]\]\.\.su\.\.\[\['/);
assert.match(acme, /acmeGet\('\]\]\.\.ju\.\.\[\['/);
assert.match(controller, /"acme_job"\}, call\("action_acme_job"\)\)/);
assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/);
});
+2 -1
View File
@@ -196,7 +196,8 @@ test('LuCI exposes only a confirmed POST uninstall action and runs a temporary s
test('the package contains the uninstaller and supports both OpenWrt package managers', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const source = fs.readFileSync(script, 'utf8');
assert.match(makefile, /root\/usr\/lib\/minigate\/uninstall\.sh/);
assert.match(makefile, /\$\(INSTALL_BIN\) \.\/root\/usr\/lib\/minigate\/\*\.sh /);
assert.ok(fs.existsSync(path.join(root, 'root/usr/lib/minigate/uninstall.sh')));
assert.match(source, /opkg remove "\$package"/);
assert.match(source, /apk del "\$package"/);
assert.match(source, /for package in minigate luci-app-minigate/);