mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 20:14:10 +08:00
🎁 Sync 2026-10-28 19:35:15
This commit is contained in:
1 parent
85209f310d
commit
390801c844
90 files changed
+5821
-1967
No files matched your search
@@ -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'))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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>&-/);
|
||||
});
|
||||
@@ -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, /不是公网地址<b>/);
|
||||
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_\]\+\$"\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user