Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/openwrt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ It uses the same `DEPLOY_SERVERS` configuration as the daily publisher and chang
only `<target>/beta/<tag>`. The remote mirror requires Bash and GNU coreutils.
Local mirror tests on macOS use GNU `gmv` from `brew install coreutils`.

## DNS and port handoff

MSM's Beta core package revision 2 checks the active MosDNS, Mihomo and Sing-box
TCP/UDP listeners before starting a component. On OpenWrt, when port 53 belongs
to the system dnsmasq, MSM temporarily disables only that instance's DNS listener
and retains DHCP. The original port setting is saved before any change and is
restored after the final managed DNS owner stops or fails to start. User edits
made while DNS is leased are preserved. Other occupied ports are reported with
their protocol, address and process when available; unrelated services are not
terminated automatically.

The packaged service waits for DNS recovery during stop, removal and upgrade.
If recovery cannot complete, package removal/upgrade returns an error and keeps
the recovery executable and data available. For an interrupted shutdown, run
`msm service recover-dns -c /etc/msm --wait 40s` after stopping MSM. This command
refuses to restore dnsmasq over a live managed process or another DNS listener.

## Updating only the Beta LuCI plugin

Run `openwrt-update-luci.yml` on `main` with an existing numeric Beta tag and a
Expand Down
14 changes: 14 additions & 0 deletions .github/openwrt/files/msm/etc/init.d/msm
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,17 @@ start_service() {
service_triggers() {
procd_add_reload_trigger msm
}

# procd termination is asynchronous. Recover only after the managed processes
# and their DNS sockets have gone; the CLI verifies persisted ownership.
service_stopped() {
local config_dir
config_load msm
config_get config_dir main config_dir /etc/msm
case "$config_dir" in
/|*//*|/../*|/..|/./*|/.|/*/../*|/*/..|/*/./*|/*/.) return 1 ;;
/*) ;;
*) return 1 ;;
esac
/usr/bin/msm service recover-dns -c "$config_dir" --wait 40s
}
12 changes: 10 additions & 2 deletions .github/openwrt/prepare-luci-update.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,17 @@ function prepareLuciUpdate(release, checksums, packages, tag, revision = 2, back
const asset = assets.get(item.name);
check(asset && asset.state === 'uploaded' && asset.digest === `sha256:${item.hash}`, `Existing asset checksum mismatch: ${item.name}`);
}
const coreNames = ARCHES.flatMap(arch => [`msm_${version}-r1_${arch}.ipk`, `msm-${version}-r1_${arch}.apk`]);
const coreAssets = release.assets.filter(asset => /\.(ipk|apk)$/.test(asset.name) && !isLuci(asset.name));
check(coreAssets.length === 18 && coreAssets.every(asset => coreNames.includes(asset.name)), 'Expected exactly the 18 canonical r1 core packages');
check(coreAssets.length === 18, 'Expected exactly the 18 canonical core packages');
const coreRevisions = new Set(coreAssets.map(asset => {
const match = /-r([1-9][0-9]*)_/.exec(asset.name);
check(match && Number.isSafeInteger(Number(match[1])), 'Invalid core package revision');
return match[1];
}));
check(coreRevisions.size === 1, 'Core package revisions must agree');
const [coreRevision] = coreRevisions;
const coreNames = ARCHES.flatMap(arch => [`msm_${version}-r${coreRevision}_${arch}.ipk`, `msm-${version}-r${coreRevision}_${arch}.apk`]);
check(coreAssets.every(asset => coreNames.includes(asset.name)), 'Expected exactly the 18 canonical core packages');
check(coreNames.every(name => sums.has(name)), 'Missing core package checksum');
const luciLines = lines.filter(item => isLuci(item.name));
check(luciLines.length === 2 && ['.ipk', '.apk'].every(ext => luciLines.filter(item => item.name.endsWith(ext)).length === 1), 'Expected two authoritative LuCI checksums');
Expand Down
2 changes: 1 addition & 1 deletion .github/openwrt/scripts/msm-pre-upgrade
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/sh
[ -z "${IPKG_INSTROOT:-}" ] || exit 0
[ "${IPKG_NO_SCRIPT:-0}" != 1 ] || exit 0
[ ! -x /etc/init.d/msm ] || /etc/init.d/msm stop
[ ! -x /etc/init.d/msm ] || /etc/init.d/msm stop || exit $?
exit 0
2 changes: 1 addition & 1 deletion .github/openwrt/scripts/msm-prerm
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
[ -z "${IPKG_INSTROOT:-}" ] || exit 0
[ "${IPKG_NO_SCRIPT:-0}" != 1 ] || exit 0
/etc/init.d/msm stop
/etc/init.d/msm stop || exit $?
case "${1:-}" in
upgrade) ;;
*) /etc/init.d/msm disable ;;
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/daily-build-msm-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ jobs:
VERSION="${{ needs.prepare.outputs.version }}"
python3 .github/openwrt/build-packages.py \
--version "${VERSION}" \
--release 2 \
--target "${{ matrix.target }}" \
--input "dist/msm-${VERSION}-${{ matrix.target }}.tar.gz" \
--output-dir dist/openwrt \
Expand Down
38 changes: 38 additions & 0 deletions test/openwrt-dns-recovery.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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.join(__dirname, '../.github/openwrt');

test('service_stopped passes the configured directory and propagates DNS recovery errors', () => {
const template = fs.readFileSync(path.join(root, 'files/msm/etc/init.d/msm'), 'utf8').replaceAll('/usr/bin/msm', 'fake_msm');
for (const code of [0, 7]) {
const result = spawnSync('sh', ['-s'], {
encoding: 'utf8',
env: { ...process.env, DNS_RECOVERY_CODE: String(code) },
input: `config_load() { :; }
fake_msm() { printf '<%s>' "$@"; return "$DNS_RECOVERY_CODE"; }
${template}
config_get() { config_dir='/tmp/msm data'; }
service_stopped
`,
});
assert.equal(result.status, code, result.stderr);
assert.equal(result.stdout, '<service><recover-dns><-c></tmp/msm data><--wait><40s>');
}
});

test('package removal and upgrade abort when stop cannot restore system DNS', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msm-dns-recovery-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const init = path.join(dir, 'init');
fs.writeFileSync(init, '#!/bin/sh\n[ "$1" != stop ] || exit 7\nexit 0\n', { mode: 0o755 });
for (const script of ['msm-prerm', 'msm-pre-upgrade']) {
const body = fs.readFileSync(path.join(root, 'scripts', script), 'utf8').replaceAll('/etc/init.d/msm', init);
const result = spawnSync('sh', ['-s'], { input: body, encoding: 'utf8' });
assert.equal(result.status, 7, `${script}: ${result.stderr}`);
}
});
46 changes: 44 additions & 2 deletions test/openwrt-luci-release.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { prepareLuciUpdate, mirrorScript, publishGithub, finalizeGithub, recoverGithub, parseMirrors, sha256, BACKUP_NAME } = require('../.github/openwrt/prepare-luci-update.cjs');

function fixture() {
function fixture(coreRevision = 1) {
const tag = 'beta-1.4.8', version = '1.4.8_beta';
const arches = ['x86_64', 'aarch64_generic', 'aarch64_cortex-a53', 'aarch64_cortex-a72', 'arm_cortex-a7_neon-vfpv4', 'arm_cortex-a9_vfpv3-d16', 'arm_cortex-a9_neon', 'arm_cortex-a15_neon-vfpv4', 'arm_arm1176jzf-s_vfp'];
const targets = ['darwin-amd64', 'darwin-arm64', 'linux-amd64', 'linux-amd64-v3', 'linux-amd64-musl', 'linux-amd64-musl-v3', 'linux-arm64', 'linux-arm64-musl', 'linux-armv7', 'linux-armv6'];
const oldNames = [`luci-app-msm_${version}-r1_all.ipk`, `luci-app-msm-${version}-r1_all.apk`];
const names = [...targets.map(target => `msm-${tag}-${target}.tar.gz`), 'msm-beta-1.4.8-linux-amd64-panabit.apx', 'msm-beta-1.4.8-linux-arm64-panabit.apx', ...oldNames, ...arches.flatMap(arch => [`msm_${version}-r1_${arch}.ipk`, `msm-${version}-r1_${arch}.apk`])];
const names = [...targets.map(target => `msm-${tag}-${target}.tar.gz`), 'msm-beta-1.4.8-linux-amd64-panabit.apx', 'msm-beta-1.4.8-linux-arm64-panabit.apx', ...oldNames, ...arches.flatMap(arch => [`msm_${version}-r${coreRevision}_${arch}.ipk`, `msm-${version}-r${coreRevision}_${arch}.apk`])];
const files = new Map(names.map(name => [name, Buffer.from(`original package:${name}\n`)]));
const checksums = [...files].map(([name, data]) => `${sha256(data)} ${name}\n`).join('');
const release = { id: 77, tag_name: tag, name: 'Existing Beta title', draft: false, prerelease: true, target_commitish: 'main', published_at: '2026-09-12T00:00:00Z', body: `Existing introduction\n${oldNames.map(name => `[${name}](https://example.test/${name})`).join('\n')}\nOther platform links and notes stay unchanged.`, assets: [...files].map(([name, bytes], index) => ({ id: index + 1, name, state: 'uploaded', size: bytes.length, digest: `sha256:${sha256(bytes)}` })) };
Expand Down Expand Up @@ -60,6 +60,48 @@ test('LuCI plan changes only two manifest lines and the corresponding release fi
assert.equal(input.plan.afterBody, expected);
});

test('LuCI updates preserve all 18 packages when the core uses revision 2', () => {
const input = fixture(2), fake = fakeApi(input);
const oldCore = input.release.assets.filter(asset => /^msm[_-].*\.(ipk|apk)$/.test(asset.name));
assert.equal(oldCore.length, 18);
assert.ok(oldCore.every(asset => asset.name.includes('-r2_')));
const untouched = text => text.split('\n').filter(line => !line.includes('luci-app-msm'));
assert.deepEqual(untouched(input.plan.afterChecksums), untouched(input.checksums));
publishGithub(input.plan, fake.api);
const final = finalizeGithub(input.plan, fake.api);
for (const asset of oldCore) assert.deepEqual(final.assets.find(item => item.id === asset.id), asset);
assert.equal(final.assets.filter(asset => /^msm[_-].*\.(ipk|apk)$/.test(asset.name)).length, 18);
});

test('LuCI plans reject mixed, missing, unknown or noncanonical core revisions', () => {
for (const scenario of ['mixed', 'missing', 'unknown-arch', 'wrong-version', 'wrong-format', 'zero', 'leading-zero', 'negative', 'unsafe-integer']) {
const input = fixture(2);
const asset = input.release.assets.find(item => item.name === 'msm_1.4.8_beta-r2_x86_64.ipk');
if (scenario === 'missing') input.release.assets = input.release.assets.filter(item => item !== asset);
else {
const names = {
mixed: asset.name.replace('-r2_', '-r1_'),
'unknown-arch': asset.name.replace('_x86_64.', '_unknown.'),
'wrong-version': asset.name.replace('1.4.8', '1.4.9'),
'wrong-format': asset.name.replace('.ipk', '.apk'),
zero: asset.name.replace('-r2_', '-r0_'),
'leading-zero': asset.name.replace('-r2_', '-r02_'),
negative: asset.name.replace('-r2_', '-r-2_'),
'unsafe-integer': asset.name.replace('-r2_', '-r9007199254740992_'),
};
asset.name = names[scenario];
}
// Authenticate the modified inventory so rejection tests the core contract,
// rather than merely detecting a stale checksum manifest.
const checksums = input.release.assets.filter(item => item.name !== 'SHA256SUMS' && !item.name.endsWith('.dmg'))
.map(item => `${item.digest.slice(7)} ${item.name}\n`).join('');
const manifest = input.release.assets.find(item => item.name === 'SHA256SUMS');
manifest.digest = `sha256:${sha256(checksums)}`;
manifest.size = Buffer.byteLength(checksums);
assert.throws(() => prepareLuciUpdate(input.release, checksums, input.packages, input.tag), /18 canonical|core package revision|Core package revisions/, scenario);
}
});

test('LuCI plan rejects stable tags, wrong release state, malformed hashes and same-revision replacement', () => {
const input = fixture();
assert.throws(() => prepareLuciUpdate(input.release, input.checksums, input.packages, '1.4.8'), /Only numeric Beta/);
Expand Down
Loading