⛄ Sync 2026-10-25 10:56:54

This commit is contained in:
github-actions[bot] committed 2026-10-25 10:56:54 +08:00
1 parent 37e1475e87
commit 816dd89fe9
68 files changed
+55036 -377

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const net = require('node:net');
const { spawn, spawnSync } = require('node:child_process');
const { config, geoDir } = require('./helpers/access-fixture.cjs');
const nginx = process.env.MINIGATE_TEST_NGINX;
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
function request(socketPath, source, { spoof = false, websocket = false } = {}) {
return new Promise((resolve, reject) => {
let response = '';
const socket = net.connect(socketPath, () => {
const v6 = source.includes(':');
socket.write(`PROXY ${v6 ? 'TCP6' : 'TCP4'} ${source} ${v6 ? '::1' : '127.0.0.1'} 12345 2000\r\n` +
'GET / HTTP/1.1\r\nHost: app.example.com\r\n' +
(spoof ? 'X-Forwarded-For: 14.196.0.1\r\nX-Real-IP: 14.196.0.1\r\n' : '') +
(websocket ? 'Connection: Upgrade\r\nUpgrade: websocket\r\n' : 'Connection: close\r\n') + '\r\n');
});
socket.setTimeout(3000, () => socket.destroy(new Error('请求超时')));
socket.on('data', chunk => { response += chunk; });
socket.on('error', error => error.code === 'ECONNRESET' ? resolve(response) : reject(error));
socket.on('close', () => resolve(response));
});
}
async function running(t, options = {}) {
let reached = 0;
const backend = http.createServer((req, res) => { reached++; res.end('backend-reached'); });
backend.on('upgrade', (req, socket) => { reached++; socket.end('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'); });
await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => backend.close(resolve)));
const port = backend.address().port;
const fixture = config(`
write_server "$SITES_DIR/site.conf" app.example.com 2000 127.0.0.1 ${port} 0 0 1 old 0 0
write_redirect_server "$SITES_DIR/redirect.conf" app.example.com 2000
generate_main_conf
`, options);
assert.equal(fixture.status, 0, fixture.stderr);
const site = path.join(fixture.dir, 'sites/site.conf');
const socketPath = path.join(fixture.dir, 'proxy.sock');
fs.writeFileSync(site, fs.readFileSync(site, 'utf8').replace('listen 2000;', `listen unix:${socketPath} proxy_protocol;`));
const main = path.join(fixture.dir, 'main.conf');
fs.mkdirSync(path.join(fixture.dir, 'logs'));
const tempPaths = ['client_body', 'proxy', 'fastcgi', 'uwsgi', 'scgi']
.map(name => ` ${name}_temp_path ${fixture.dir}/${name}-temp;`).join('\n');
fs.writeFileSync(main, fs.readFileSync(main, 'utf8').replace('http {', `http {\n${tempPaths}`));
const check = spawnSync(nginx, ['-t', '-c', main, '-p', fixture.dir], { encoding: 'utf8' });
assert.equal(check.status, 0, check.stderr);
const child = spawn(nginx, ['-c', main, '-p', fixture.dir, '-g', 'daemon off;'], { stdio: 'ignore' });
t.after(async () => {
if (child.exitCode === null) {
child.kill('SIGQUIT');
await new Promise(resolve => child.once('exit', resolve));
}
});
for (let i = 0; i < 50 && !fs.existsSync(socketPath); i++) await pause(50);
assert.ok(fs.existsSync(socketPath), fs.existsSync(path.join(fixture.dir, 'error.log')) ? fs.readFileSync(path.join(fixture.dir, 'error.log'), 'utf8') : 'Nginx 未启动');
return { ...fixture, socketPath, redirectPath: path.join(fixture.dir, 'http_2000.sock'), reached: () => reached };
}
test('real nginx enforces source policy before upstream, redirect and websocket', { skip: !nginx }, async t => {
const fixture = await running(t);
for (const ip of ['14.196.0.1', '::ffff:14.196.0.1', '2001:250:2000::1', '192.168.2.15', '10.0.0.2', 'fd00::15', 'fe80::15']) {
const response = await request(fixture.socketPath, ip);
assert.match(response, /200 OK/, ip);
assert.match(response, /backend-reached/, ip);
}
const reached = fixture.reached();
for (const ip of ['8.8.8.8', '::ffff:8.8.8.8', '1.0.1.1', '203.0.113.10', '2001:4860:4860::8888', '2001:db8::1']) {
assert.equal(await request(fixture.socketPath, ip, { spoof: true }), '', ip);
assert.equal(await request(fixture.redirectPath, ip), '', `redirect ${ip}`);
assert.equal(await request(fixture.socketPath, ip, { websocket: true }), '', `websocket ${ip}`);
}
assert.equal(fixture.reached(), reached, '拒绝的请求不得到达后端');
assert.match(await request(fixture.redirectPath, '14.196.0.1'), /308 Permanent Redirect/);
assert.match(await request(fixture.socketPath, '14.196.0.1', { websocket: true }), /101 Switching Protocols/);
});
test('real nginx permits all sources when disabled and fails closed if selected data is missing', { skip: !nginx }, async t => {
const open = await running(t, { enabled: '0' });
assert.match(await request(open.socketPath, '8.8.8.8'), /200 OK/);
const missing = await running(t, { directory: '/missing-minigate-geo-data' });
assert.equal(await request(missing.socketPath, '14.196.0.1'), '');
assert.match(await request(missing.socketPath, '192.168.2.15'), /200 OK/);
});
test('real nginx accepts all bundled province includes together', { skip: !nginx }, async t => {
const regions = fs.readFileSync(path.join(geoDir, 'regions.txt'), 'utf8').trim().split('\n').map(row => row.split(' ')[0]).join(' ');
const fixture = await running(t, { regions });
assert.match(await request(fixture.socketPath, '1.0.1.1'), /200 OK/);
assert.equal(await request(fixture.socketPath, '8.8.8.8'), '');
});
+53
View File
@@ -0,0 +1,53 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const { root, geoDir } = require('./helpers/access-fixture.cjs');
const path = require('node:path');
const lua = process.env.MINIGATE_TEST_LUA;
test('LuCI access settings offer all provinces, default off, validate multiple choices and reject invalid values', { skip: !lua }, () => {
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: `
local options = {}
package.preload['luci.sys'] = function() return {} 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'
function Map()
return {section=function()
return {option=function(self, kind, name)
local option = {kind=kind, choices={}}
function option:value(key, label) self.choices[key] = label end
function option:depends(key, value) self.dependency={key,value} end
options[name] = option
return option
end}
end}
end
local real_open = io.open
io.open = function(file, mode)
if file == '/usr/lib/minigate/geo/regions.txt' then file = ${JSON.stringify(path.join(geoDir, 'regions.txt'))} end
return real_open(file, mode)
end
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.default == '510000')
assert(regions.dependency[1] == 'access_enabled' and regions.dependency[2] == '1')
assert(regions.choices['510000'] == '四川省')
local count = 0; for _ in pairs(regions.choices) do count = count + 1 end
assert(count == 34)
assert(regions:validate({'510000', '500000'}, 'global'))
assert(regions:validate('510000', 'global'))
assert(not regions:validate({}, 'global'))
assert(not regions:validate({''}, 'global'))
assert(not regions:validate({'990000'}, 'global'))
assert(not regions:validate({'510000', '../500000'}, 'global'))
print('LuCI access validation passed')
` });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /validation passed/);
});
+114
View File
@@ -0,0 +1,114 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { config, geoDir, root } = require('./helpers/access-fixture.cjs');
test('disabled or absent access option preserves unrestricted generated sites', () => {
for (const enabled of ['', '0']) {
const result = config('write_geo_access; write_server "$conf" app.example.com 2000 127.0.0.1 80 0 0 1 old 1 0', { enabled });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '');
assert.doesNotMatch(result.conf, /minigate_geo_allowed/);
assert.match(result.conf, /proxy_pass http:\/\/127.0.0.1:80/);
}
});
test('province policy defaults to deny, uses connection IP and preserves private LAN', () => {
const result = config('write_geo_access');
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /geo \$minigate_client_addr \$minigate_geo_allowed/);
assert.match(result.stdout, /default 0;/);
assert.match(result.stdout, /192\.168\.0\.0\/16 1;/);
assert.match(result.stdout, /fc00::\/7 1;/);
assert.match(result.stdout, /fe80::\/10 1;/);
assert.match(result.stdout, /510000\.conf;/);
assert.doesNotMatch(result.stdout, /http_x_|100\.64|0\.0\.0\.0\/0/);
});
test('multiple selected provinces are included once and other provinces stay absent', () => {
const result = config('write_geo_access', { regions: '510000 500000 510000' });
assert.equal(result.status, 0, result.stderr);
assert.equal((result.stdout.match(/510000\.conf/g) || []).length, 1);
assert.match(result.stdout, /500000\.conf/);
assert.doesNotMatch(result.stdout, /440000\.conf/);
});
test('empty, invalid or missing region data never falls back to public access', () => {
for (const options of [
{ regions: '' }, { regions: '../510000; return 200;' }, { regions: '990000' },
{ directory: '/missing-minigate-geo-data' }
]) {
const result = config('write_geo_access', options);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /default 0;/);
assert.doesNotMatch(result.stdout, /include .*\.conf|return 200/);
}
});
test('both ordinary and multiplexed proxy sites reject before proxy_pass and websocket upgrade', () => {
for (const mux of [0, 1]) {
const result = config(`write_server "$conf" app.example.com 2000 127.0.0.1 80 0 0 1 old 1 ${mux}`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.conf, /if \(\$minigate_geo_allowed = 0\) \{ return 444; \}/);
assert.ok(result.conf.indexOf('return 444;') < result.conf.indexOf('proxy_pass'));
assert.match(result.conf, /proxy_set_header Upgrade/);
}
});
test('HTTP redirect rejects unapproved source before sending a redirect', () => {
const result = config('write_redirect_server "$conf" app.example.com 2000');
assert.equal(result.status, 0, result.stderr);
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');
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
`], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
});
test('bundled regions all cover IPv4 and IPv6 and data follows every install path', () => {
const metadata = JSON.parse(fs.readFileSync(path.join(geoDir, 'metadata.json')));
const regions = fs.readFileSync(path.join(geoDir, 'regions.txt'), 'utf8').trim().split('\n');
assert.equal(regions.length, 34);
for (const row of regions) {
const code = row.split(' ')[0];
const data = fs.readFileSync(path.join(geoDir, `${code}.conf`), 'utf8');
assert.match(data, /^\d+\.\d+\.\d+\.\d+\/\d+ 1;$/m, code);
assert.match(data, /^[a-f0-9:]+\/\d+ 1;$/m, code);
assert.equal(data.trim().split('\n').length - 1, metadata.networks[code]['4'] + metadata.networks[code]['6']);
}
for (const file of ['Makefile', 'install.sh', 'scripts/build-ipk.sh']) {
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /root\/usr\/lib\/minigate\/geo/, file);
}
assert.match(fs.readFileSync(path.join(geoDir, 'LICENSE.ip2region'), 'utf8'), /Copyright.*Lionsoul/);
});
test('assigned LAN global IPv6 subnet is exempted without allowing a broad remote prefix', () => {
const run = mask => config(`
ubus() { echo fixture; }
jsonfilter() {
case "$2" in
*'[0].address') echo 2001:db8:1234:: ;;
*'[0].mask') echo ${mask} ;;
esac
}
write_geo_access
`);
const assigned = run('64');
assert.equal(assigned.status, 0, assigned.stderr);
assert.match(assigned.stdout, /2001:db8:1234::\/64 1;/);
assert.doesNotMatch(run('0').stdout, /2001:db8:1234/);
assert.doesNotMatch(run('32').stdout, /2001:db8:1234/);
});
+44
View File
@@ -0,0 +1,44 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '../..');
const script = path.join(root, 'root/usr/lib/minigate/proxy.sh');
const geoDir = path.join(root, 'root/usr/lib/minigate/geo');
function config(body, { enabled = '1', regions = '510000', directory = geoDir } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mg-access-'));
const file = path.join(dir, 'site.conf');
fs.writeFileSync(file, '');
fs.mkdirSync(path.join(dir, 'sites'));
fs.mkdirSync(path.join(dir, 'streams'));
fs.writeFileSync(path.join(dir, 'mime.types'), '');
const quote = s => `'${s.replaceAll("'", "'\\''")}'`;
const command = `
export MINIGATE_PROXY_LIBRARY_ONLY=1
export MINIGATE_NGINX_CONF=${quote(path.join(dir, 'main.conf'))}
export MINIGATE_SITES_DIR=${quote(path.join(dir, 'sites'))}
export MINIGATE_STREAMS_DIR=${quote(path.join(dir, 'streams'))}
export MINIGATE_MIME_TYPES=${quote(path.join(dir, 'mime.types'))}
export MINIGATE_PID_FILE=${quote(path.join(dir, 'nginx.pid'))}
export MINIGATE_NGINX_ERROR_LOG=${quote(path.join(dir, 'error.log'))}
export MINIGATE_ACCESS_LOG=${quote(path.join(dir, 'access.log'))}
export MINIGATE_GEO_DIR=${quote(directory)}
export MINIGATE_LOGFILE=${quote(path.join(dir, 'proxy.log'))}
export MINIGATE_SOCKET_DIR=${quote(dir)}
. ${quote(script)}
uci() {
case "$3" in
minigate.global.access_enabled) printf '%s\\n' ${quote(enabled)} ;;
minigate.global.access_regions) printf '%s\\n' ${quote(regions)} ;;
esac
}
conf=${quote(file)}
${body}
`;
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
return { ...result, conf: fs.readFileSync(file, 'utf8'), dir };
}
module.exports = { config, geoDir, root };