From 7b53d1b1746b64fcff53b595e76e73b9936dd9e3 Mon Sep 17 00:00:00 2001 From: msm <249715549+msm9527@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:38:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(openwrt):=20=E5=81=9C=E6=AD=A2=E5=8F=8A?= =?UTF-8?q?=E5=8D=B8=E8=BD=BD=E6=97=B6=E6=81=A2=E5=A4=8D=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=20DNS=20/=20restore=20host=20DNS=20before=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/openwrt/README.md | 17 ++++++++ .github/openwrt/files/msm/etc/init.d/msm | 14 +++++++ .github/openwrt/prepare-luci-update.cjs | 12 +++++- .github/openwrt/scripts/msm-pre-upgrade | 2 +- .github/openwrt/scripts/msm-prerm | 2 +- .github/workflows/daily-build-msm-beta.yml | 1 + test/openwrt-dns-recovery.test.cjs | 38 ++++++++++++++++++ test/openwrt-luci-release.test.cjs | 46 +++++++++++++++++++++- 8 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 test/openwrt-dns-recovery.test.cjs diff --git a/.github/openwrt/README.md b/.github/openwrt/README.md index 69f4c60..2c49078 100644 --- a/.github/openwrt/README.md +++ b/.github/openwrt/README.md @@ -105,6 +105,23 @@ It uses the same `DEPLOY_SERVERS` configuration as the daily publisher and chang only `/beta/`. 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 diff --git a/.github/openwrt/files/msm/etc/init.d/msm b/.github/openwrt/files/msm/etc/init.d/msm index 1cf4ec3..e16437d 100644 --- a/.github/openwrt/files/msm/etc/init.d/msm +++ b/.github/openwrt/files/msm/etc/init.d/msm @@ -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 +} diff --git a/.github/openwrt/prepare-luci-update.cjs b/.github/openwrt/prepare-luci-update.cjs index ecfc6ce..9e6977a 100644 --- a/.github/openwrt/prepare-luci-update.cjs +++ b/.github/openwrt/prepare-luci-update.cjs @@ -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'); diff --git a/.github/openwrt/scripts/msm-pre-upgrade b/.github/openwrt/scripts/msm-pre-upgrade index 62ced48..77474dd 100644 --- a/.github/openwrt/scripts/msm-pre-upgrade +++ b/.github/openwrt/scripts/msm-pre-upgrade @@ -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 diff --git a/.github/openwrt/scripts/msm-prerm b/.github/openwrt/scripts/msm-prerm index d9cffc1..48f2747 100644 --- a/.github/openwrt/scripts/msm-prerm +++ b/.github/openwrt/scripts/msm-prerm @@ -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 ;; diff --git a/.github/workflows/daily-build-msm-beta.yml b/.github/workflows/daily-build-msm-beta.yml index edc845f..b2876a5 100644 --- a/.github/workflows/daily-build-msm-beta.yml +++ b/.github/workflows/daily-build-msm-beta.yml @@ -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 \ diff --git a/test/openwrt-dns-recovery.test.cjs b/test/openwrt-dns-recovery.test.cjs new file mode 100644 index 0000000..ab66e09 --- /dev/null +++ b/test/openwrt-dns-recovery.test.cjs @@ -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, '<-c><--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}`); + } +}); diff --git a/test/openwrt-luci-release.test.cjs b/test/openwrt-luci-release.test.cjs index 635965d..e2dc0ef 100644 --- a/test/openwrt-luci-release.test.cjs +++ b/test/openwrt-luci-release.test.cjs @@ -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)}` })) }; @@ -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/);