From e83b0e9d5de5bca00a93a15ea18ddd2fa1e24598 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 29 Oct 2026 11:31:23 +0800 Subject: [PATCH] fix(deploy): require desktop storage admission before proxy cutover --- scripts/desktop-release-admission.py | 240 +++++++++++++++++++ scripts/desktop-release-admission.test.py | 178 ++++++++++++++ scripts/git-incremental-deploy-macos.mjs | 6 + scripts/git-incremental-deploy.ps1 | 36 ++- scripts/git-incremental-deploy.test.mjs | 35 +++ scripts/install-desktop-release-admission.py | 156 ++++++++++++ 6 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 scripts/desktop-release-admission.py create mode 100644 scripts/desktop-release-admission.test.py create mode 100644 scripts/install-desktop-release-admission.py diff --git a/scripts/desktop-release-admission.py b/scripts/desktop-release-admission.py new file mode 100644 index 0000000..e0544a4 --- /dev/null +++ b/scripts/desktop-release-admission.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Project-scoped, read-only traffic admission; attestation never calls a model.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time +import urllib.request + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +RECEIPTS = POLICY / 'receipts' +ENV_FILE = BASE / '.env' +PRIVATE = BASE / 'detail-generator-private' +PROXY = BASE / 'local-proxy/youyidian-3d-material/nginx.conf' +NETWORK = 'youyidian-3d_app-network' +NAMES = {'youyidian-3d-material-blue', 'youyidian-3d-material-green'} +VERSION = 1 + + +class Rejected(Exception): + """Only fixed, non-secret reason codes may leave this process.""" + + +def require(condition, code): + if not condition: + raise Rejected(code) + + +def command(args): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=12) + require(result.returncode == 0, 'runtime_inspection_failed') + return result.stdout + except (OSError, subprocess.TimeoutExpired): + raise Rejected('runtime_inspection_failed') from None + + +def physical(path, directory=False, owner=None, mode=None): + """Never follow a substituted private store, policy file or ancestor.""" + path = Path(path) + for part in (path, *path.parents): + require(not part.is_symlink(), 'symlink_rejected') + try: + value = path.stat() + except OSError: + raise Rejected('required_path_missing') from None + require(stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode), + 'path_type_mismatch') + if owner is not None: + require((value.st_uid, value.st_gid) == owner, 'path_owner_mismatch') + if mode is not None: + require(stat.S_IMODE(value.st_mode) == mode, 'path_mode_mismatch') + return value + + +def enabled_from_env(text): + # Accept the unambiguous subset of Docker --env-file syntax. In particular, + # quotes/comments in a value are literal, not dotenv syntax. Ambiguity stops + # the release instead of silently turning authorization off. + values = [] + for line in text.removeprefix('\ufeff').splitlines(): + line = line.lstrip() + if not line or line.startswith('#'): + continue + key, sep, value = line.partition('=') + if key == 'DETAIL_GENERATOR_ENABLED': + require(sep and value in ('true', 'false'), 'approved_flag_invalid') + values.append(value) + elif key.strip() == 'DETAIL_GENERATOR_ENABLED': + raise Rejected('approved_flag_invalid') + require(len(values) == 1, 'approved_flag_ambiguous') + return values[0] == 'true' + + +def environment_values(lines, key): + return [line.partition('=')[2] for line in lines if line.partition('=')[0] == key] + + +def config_target(text): + names = set(re.findall(r'^\s*add_header X-Material-Proxy-Target ([^;\s]+) always;\s*$', text, re.M)) + ips = set(re.findall(r'^\s*server (\d+\.\d+\.\d+\.\d+):3000\s+[^;]*;\s*$', text, re.M)) + require(len(names) == 1 and names <= NAMES and len(ips) == 1, 'proxy_target_ambiguous') + return next(iter(names)), next(iter(ips)) + + +def inspect_target(name, expected_enabled, private_identity): + require(name in NAMES, 'container_scope_rejected') + try: + data = json.loads(command(['docker', 'inspect', name])) + except (ValueError, TypeError): + raise Rejected('runtime_inspection_invalid') from None + require(isinstance(data, list) and len(data) == 1, 'runtime_inspection_invalid') + d = data[0] + require(d.get('Name') == '/' + name and d.get('State', {}).get('Running') is True, + 'container_not_running') + container_id, image_id = d.get('Id', ''), d.get('Image', '') + require(re.fullmatch(r'[a-f0-9]{64}', container_id), 'container_identity_invalid') + require(re.fullmatch(r'sha256:[a-f0-9]{64}', image_id), 'image_identity_invalid') + cfg = d.get('Config') or {} + env = cfg.get('Env') or [] + expected = 'true' if expected_enabled else 'false' + require(environment_values(env, 'DETAIL_GENERATOR_ENABLED') == [expected], 'runtime_flag_mismatch') + if expected_enabled: + require(environment_values(env, 'DETAIL_GENERATOR_STORAGE_DIR') == ['/app/detail-generator-private'], + 'runtime_storage_config_mismatch') + mounts = [m for m in d.get('Mounts', []) if m.get('Destination') == '/app/detail-generator-private'] + require(len(mounts) == 1, 'private_mount_missing_or_ambiguous') + mount = mounts[0] + require(mount.get('Source') == str(PRIVATE) and mount.get('Type') == 'bind' and mount.get('RW') is True, + 'private_mount_mismatch') + # Access check only. Never create/delete material files in a gate. + access = "require('fs').accessSync('/app/detail-generator-private',require('fs').constants.R_OK|require('fs').constants.W_OK)" + command(['docker', 'exec', '--user', '1001:1001', name, 'node', '-e', access]) + revision = (cfg.get('Labels') or {}).get('org.opencontainers.image.revision', '') + require(re.fullmatch(r'[a-f0-9]{40}', revision), 'revision_invalid') + require(command(['docker', 'exec', name, 'cat', '/app/DEPLOYMENT_COMMIT']).strip() == revision, + 'baked_revision_mismatch') + network = (d.get('NetworkSettings', {}).get('Networks') or {}).get(NETWORK) or {} + ip = network.get('IPAddress', '') + require(re.fullmatch(r'\d+\.\d+\.\d+\.\d+', ip), 'network_identity_invalid') + return {'container': name, 'containerId': container_id, 'imageId': image_id, + 'revision': revision, 'enabled': expected_enabled, 'privateIdentity': private_identity, + 'ip': ip} + + +def http_json(url): + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={'User-Agent': 'gongxiangp2-desktop-admission/1', 'Connection': 'close'}) + with opener.open(request, timeout=5) as response: + require(response.status == 200, 'candidate_config_unavailable') + return json.loads(response.read(16385)) + except Rejected: + raise + except Exception: + raise Rejected('candidate_config_unavailable') from None + + +def current_snapshot(): + physical(ENV_FILE) + enabled = enabled_from_env(ENV_FILE.read_text(encoding='utf-8-sig')) + identity = None + if enabled: + value = physical(PRIVATE, directory=True, owner=(1001, 1001), mode=0o700) + identity = [value.st_dev, value.st_ino, value.st_uid, value.st_gid] + physical(PROXY) + name, ip = config_target(PROXY.read_text(encoding='utf-8')) + value = inspect_target(name, enabled, identity) + require(value['ip'] == ip, 'proxy_network_mismatch') + config = http_json('http://' + ip + ':3000/api/desktop/config') + require(isinstance(config, dict) and config.get('enabled') is enabled + and config.get('clientId') == 'jiansun-detail-desktop' and config.get('pkce') == 'S256', + 'candidate_config_mismatch') + value.pop('ip') + return value + + +def policy_digest(): + return hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def receipt_path(snapshot): + return RECEIPTS / (snapshot['containerId'] + '.json') + + +def attest(snapshot, entry_sha): + require(re.fullmatch(r'[a-f0-9]{64}', entry_sha), 'entry_digest_invalid') + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + receipt = {'schema': VERSION, 'policySha256': policy_digest(), 'entrySha256': entry_sha, + 'verifiedAt': int(time.time()), 'snapshot': snapshot} + target = receipt_path(snapshot) + if target.exists(): + physical(target, owner=(0, 0), mode=0o600) + descriptor, temp = tempfile.mkstemp(prefix='.attest-', dir=RECEIPTS) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), 0o600) + json.dump(receipt, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + # Recheck identity after HTTP/filesystem observation, before publishing. + require(current_snapshot() == snapshot, 'candidate_changed_during_attestation') + os.replace(temp, target) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def validate_receipt(snapshot): + physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + physical(RECEIPTS, directory=True, owner=(0, 0), mode=0o700) + target = receipt_path(snapshot) + physical(target, owner=(0, 0), mode=0o600) + try: + receipt = json.loads(target.read_text(encoding='utf-8')) + except (OSError, ValueError): + raise Rejected('receipt_invalid') from None + require(receipt.get('schema') == VERSION and receipt.get('policySha256') == policy_digest() + and re.fullmatch(r'[a-f0-9]{64}', receipt.get('entrySha256', '')) + and receipt.get('snapshot') == snapshot, 'candidate_not_attested') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--attest-config', action='store_true') + parser.add_argument('--entry-sha256', default='') + args = parser.parse_args() + try: + require(os.geteuid() == 0, 'root_required') + snapshot = current_snapshot() + if args.attest_config: + attest(snapshot, args.entry_sha256) + else: + require(not args.entry_sha256, 'unexpected_entry_digest') + validate_receipt(snapshot) + print(json.dumps({'desktopAdmission': 'accepted', 'mode': 'attest' if args.attest_config else 'check', + 'containerId': snapshot['containerId'], 'revision': snapshot['revision'], + 'enabled': snapshot['enabled'], 'privateMountVerified': snapshot['enabled']})) + return 0 + except Rejected as error: + print(json.dumps({'desktopAdmission': 'rejected', 'reason': str(error)}), file=sys.stderr) + return 1 + except Exception: + # Docker output, env values, HTTP bodies and exception details can carry + # credentials. Never forward them to deployment logs or journald. + print(json.dumps({'desktopAdmission': 'rejected', 'reason': 'inspection_failed'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/desktop-release-admission.test.py b/scripts/desktop-release-admission.test.py new file mode 100644 index 0000000..d196ea7 --- /dev/null +++ b/scripts/desktop-release-admission.test.py @@ -0,0 +1,178 @@ +import ast +from contextlib import redirect_stderr, redirect_stdout +import copy +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).with_name('desktop-release-admission.py') +spec = importlib.util.spec_from_file_location('admission', SOURCE) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +def fixture(): + return {'Name': '/youyidian-3d-material-blue', 'Id': 'a' * 64, 'Image': 'sha256:' + 'b' * 64, + 'State': {'Running': True}, 'Config': {'Env': [ + 'DETAIL_GENERATOR_ENABLED=true', 'DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private', + 'DETAIL_GENERATOR_API_KEY=synthetic-never-print-this'], + 'Labels': {'org.opencontainers.image.revision': 'c' * 40}}, + 'Mounts': [{'Source': str(gate.PRIVATE), 'Destination': '/app/detail-generator-private', 'Type': 'bind', 'RW': True}], + 'NetworkSettings': {'Networks': {gate.NETWORK: {'IPAddress': '172.25.0.4'}}}} + + +class AdmissionTests(unittest.TestCase): + def test_approved_flag_is_explicit_and_unambiguous(self): + self.assertTrue(gate.enabled_from_env('\ufeff# heading\n DETAIL_GENERATOR_ENABLED=true\n')) + self.assertFalse(gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=false\r\n')) + for value in ['', 'true\nDETAIL_GENERATOR_ENABLED=false', '"true"', 'true # comment', 'TRUE', 'true ', '']: + text = '' if not value else 'DETAIL_GENERATOR_ENABLED=' + value + with self.subTest(value=value), self.assertRaises(gate.Rejected): + gate.enabled_from_env(text) + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED =true') + with self.assertRaises(gate.Rejected): + gate.enabled_from_env('DETAIL_GENERATOR_ENABLED=true\nDETAIL_GENERATOR_ENABLED=true') + + def test_exact_project_proxy_target_only(self): + text = ' server 172.25.0.4:3000 max_fails=3;\n add_header X-Material-Proxy-Target youyidian-3d-material-blue always;\n' + self.assertEqual(gate.config_target(text), ('youyidian-3d-material-blue', '172.25.0.4')) + for other in [text.replace('youyidian-3d-material-blue', 'other-project'), + text + 'server 172.25.0.5:3000 max_fails=3;\n', + text + 'add_header X-Material-Proxy-Target youyidian-3d-material-green always;\n', '']: + with self.subTest(other=other), self.assertRaises(gate.Rejected): + gate.config_target(other) + + def inspect(self, data): + calls = [] + def command(args): + calls.append(args) + if args[:2] == ['docker', 'inspect']: + return json.dumps([data]) + if args[-2:] == ['cat', '/app/DEPLOYMENT_COMMIT']: + return 'c' * 40 + return '' + with patch.object(gate, 'command', command): + result = gate.inspect_target('youyidian-3d-material-blue', True, [10, 20, 1001, 1001]) + return result, calls + + def test_valid_runtime_checks_access_without_creating_files(self): + result, calls = self.inspect(fixture()) + self.assertEqual(result['enabled'], True) + self.assertEqual(result['containerId'], 'a' * 64) + self.assertEqual(result['ip'], '172.25.0.4') + self.assertTrue(any('--user' in args and '1001:1001' in args for args in calls)) + self.assertNotIn('synthetic-never-print-this', json.dumps(result)) + self.assertFalse(any(any(x in args for x in ['create', 'run', 'start', 'rm', 'stop']) for args in calls)) + + def test_reproduced_incident_rejected(self): + data = fixture() + data['Config']['Env'][0] = 'DETAIL_GENERATOR_ENABLED=false' + data['Mounts'] = [] + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_every_mount_boundary_is_enforced(self): + cases = [([], 'missing_or_ambiguous'), + ([{**fixture()['Mounts'][0], 'Source': '/other-project/private'}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'RW': False}], 'mount_mismatch'), + ([{**fixture()['Mounts'][0], 'Type': 'volume'}], 'mount_mismatch'), + (fixture()['Mounts'] * 2, 'missing_or_ambiguous')] + for mounts, reason in cases: + data = fixture() + data['Mounts'] = mounts + with self.subTest(mounts=mounts), self.assertRaisesRegex(gate.Rejected, reason): + self.inspect(data) + + def test_duplicate_runtime_flags_never_shadow_approved_setting(self): + data = fixture() + data['Config']['Env'].append('DETAIL_GENERATOR_ENABLED=false') + with self.assertRaisesRegex(gate.Rejected, 'runtime_flag_mismatch'): + self.inspect(data) + + def test_baked_identity_and_network_cannot_be_substituted(self): + for field, value, code in [('Id', 'bad', 'container_identity_invalid'), + ('Image', 'image:latest', 'image_identity_invalid'), + ('Name', '/other-project', 'container_not_running')]: + data = fixture() + data[field] = value + with self.subTest(field=field), self.assertRaisesRegex(gate.Rejected, code): + self.inspect(data) + data = fixture() + data['NetworkSettings']['Networks'] = {'other-network': {'IPAddress': '172.25.0.4'}} + with self.assertRaisesRegex(gate.Rejected, 'network_identity_invalid'): + self.inspect(data) + + def test_missing_receipt_blocks_legacy_entry(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', side_effect=lambda p, **kw: p.stat()): + with self.assertRaises(FileNotFoundError): + gate.validate_receipt(snapshot) + + def test_receipt_is_bound_to_every_current_container_fact(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True, 'imageId': 'sha256:' + 'b' * 64, + 'privateIdentity': [10, 20, 1001, 1001]} + receipt = {'schema': 1, 'policySha256': gate.policy_digest(), 'entrySha256': 'd' * 64, 'snapshot': snapshot} + path = root / ('a' * 64 + '.json') + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None): + path.write_text(json.dumps(receipt)) + gate.validate_receipt(snapshot) + for field, value in [('schema', 2), ('policySha256', 'e' * 64), ('entrySha256', '')]: + changed = {**receipt, field: value} + path.write_text(json.dumps(changed)) + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(snapshot) + path.write_text(json.dumps(receipt)) + for field, value in [('enabled', False), ('imageId', 'sha256:' + 'e' * 64), ('privateIdentity', [10, 99, 1001, 1001])]: + changed = {**snapshot, field: value} + with self.subTest(field=field), self.assertRaises(gate.Rejected): + gate.validate_receipt(changed) + + def test_attestation_rechecks_identity_before_atomic_publication(self): + with tempfile.TemporaryDirectory(prefix='desktop-admission-test-') as folder: + root = Path(folder) + snapshot = {'containerId': 'a' * 64, 'enabled': True} + with patch.object(gate, 'RECEIPTS', root), patch.object(gate, 'physical', return_value=None), \ + patch.object(gate.os, 'fchmod', create=True), patch.object(gate.os, 'fchown', create=True), \ + patch.object(gate, 'current_snapshot', return_value={**snapshot, 'enabled': False}): + with self.assertRaisesRegex(gate.Rejected, 'candidate_changed_during_attestation'): + gate.attest(snapshot, 'd' * 64) + self.assertEqual(list(root.iterdir()), []) + + def test_inspection_failures_do_not_echo_secrets(self): + error_output = io.StringIO() + with patch.object(gate.sys, 'argv', ['guard.py']), patch.object(gate.os, 'geteuid', return_value=0, create=True), \ + patch.object(gate, 'current_snapshot', side_effect=ValueError('synthetic-never-print-this')), redirect_stderr(error_output): + self.assertEqual(gate.main(), 1) + self.assertNotIn('synthetic-never-print-this', error_output.getvalue()) + self.assertIn('inspection_failed', error_output.getvalue()) + + def test_installer_only_hooks_project_start_and_graceful_reload(self): + text = SOURCE.with_name('install-desktop-release-admission.py').read_text(encoding='utf-8') + tree = ast.parse(text) + names = {'BASE', 'POLICY', 'GUARD', 'UNIT', 'UNIT_FILE', 'DROPIN', 'PROXY'} + statements = [n for n in tree.body if (isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name) and n.targets[0].id in names) + or (isinstance(n, ast.FunctionDef) and n.name == 'dropin_text')] + scope = {'Path': Path} + exec(compile(ast.Module(body=statements, type_ignores=[]), '', 'exec'), scope) + dropin = scope['dropin_text']() + self.assertIn('ExecStartPre=/usr/bin/python3', dropin) + self.assertIn('ExecReload=\nExecReload=/usr/bin/python3', dropin) + self.assertLess(dropin.index('ExecReload=/usr/bin/python3'), dropin.index('ExecReload=/usr/sbin/nginx')) + self.assertNotIn("'restart'", text) + self.assertNotIn('docker stop', text) + self.assertIn('proxyPidUnchanged', text) + self.assertIn('os.fchown(stream.fileno(), 0, 0)', text) + self.assertIn('os.chown(path, 0, 0)', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/git-incremental-deploy-macos.mjs b/scripts/git-incremental-deploy-macos.mjs index 2d88dc3..40e7c36 100755 --- a/scripts/git-incremental-deploy-macos.mjs +++ b/scripts/git-incremental-deploy-macos.mjs @@ -12,6 +12,7 @@ const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)) const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, '..') const POWERSHELL_DEPLOY_SCRIPT = path.join(SCRIPT_DIRECTORY, 'git-incremental-deploy.ps1') const PROXY_RECONCILER = path.join(SCRIPT_DIRECTORY, 'local-proxy-reconcile.sh') +const DESKTOP_ADMISSION = path.join(SCRIPT_DIRECTORY, 'desktop-release-admission.py') const TRUSTED_REPOSITORY = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' const TRUSTED_BRANCH = 'main' @@ -330,6 +331,9 @@ async function main() { const targetCommit = await resolveAndScanExactCommit(options) const powerShellSource = await fs.readFile(POWERSHELL_DEPLOY_SCRIPT, 'utf8') const proxyReconcilerBase64 = (await fs.readFile(PROXY_RECONCILER)).toString('base64') + const admissionSource = (await fs.readFile(DESKTOP_ADMISSION, 'utf8')).replaceAll('\r\n', '\n').replaceAll('\r', '\n') + const admissionDigest = createHash('sha256').update(admissionSource).digest('hex') + const entryDigest = createHash('sha256').update(powerShellSource.replaceAll('\r\n', '\n').replaceAll('\r', '\n')).digest('hex') const values = { __REPO_URL__: TRUSTED_REPOSITORY, @@ -359,6 +363,8 @@ async function main() { __S3_PUBLIC_DOMAIN__: 'https://s3.jiansun.vip/gongxiangpan', __DOWNLOAD_TOKEN_SECRET__: process.env.DOWNLOAD_TOKEN_SECRET || '', __PROXY_RECONCILE_SCRIPT_B64__: proxyReconcilerBase64, + __DESKTOP_ADMISSION_SHA256__: admissionDigest, + __DEPLOY_ENTRY_SHA256__: entryDigest, __DRY_RUN__: options.dryRun ? '1' : '0', __NO_CACHE__: options.noCache ? '1' : '0', __SKIP_BUILD__: '0', diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 0aa5f45..39870e9 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -697,6 +697,17 @@ $proxyReconcilerText = [System.IO.File]::ReadAllText($proxyReconcilerPath) $proxyReconcilerText = $proxyReconcilerText.Replace("`r`n", "`n").Replace("`r", "`n") $proxyReconcilerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($proxyReconcilerText) $proxyReconcilerBase64 = [Convert]::ToBase64String($proxyReconcilerBytes) +$admissionPath = Join-Path $PSScriptRoot 'desktop-release-admission.py' +if (-not (Test-Path -LiteralPath $admissionPath -PathType Leaf)) { + throw 'Desktop release admission policy is missing; deployment is blocked.' +} +$admissionText = [System.IO.File]::ReadAllText($admissionPath).Replace("`r`n", "`n").Replace("`r", "`n") +$entryText = [System.IO.File]::ReadAllText($PSCommandPath).Replace("`r`n", "`n").Replace("`r", "`n") +$digestAlgorithm = [System.Security.Cryptography.SHA256]::Create() +try { + $admissionDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($admissionText))).Replace('-', '').ToLowerInvariant() + $entryDigest = [BitConverter]::ToString($digestAlgorithm.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($entryText))).Replace('-', '').ToLowerInvariant() +} finally { $digestAlgorithm.Dispose() } $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl @@ -726,6 +737,8 @@ $tokens = @{ '__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain '__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret '__PROXY_RECONCILE_SCRIPT_B64__' = ConvertTo-ShellSingleQuoted $proxyReconcilerBase64 + '__DESKTOP_ADMISSION_SHA256__' = ConvertTo-ShellSingleQuoted $admissionDigest + '__DEPLOY_ENTRY_SHA256__' = ConvertTo-ShellSingleQuoted $entryDigest '__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' })) '__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' })) '__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' })) @@ -1086,6 +1099,9 @@ REQUIRE_MIGRATION_AUDIT="$3" DEPLOYMENT_STRATEGY="${4:-direct}" DETAIL_PRIVATE_MOUNT="/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" DETAIL_FEATURE_ENABLED=uninitialized +DESKTOP_ADMISSION="/mnt/PoolOne/apps/youyidian-3d/release-policy/desktop-admission/guard.py" +DESKTOP_ADMISSION_SHA256=__DESKTOP_ADMISSION_SHA256__ +DEPLOY_ENTRY_SHA256=__DEPLOY_ENTRY_SHA256__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } @@ -1209,9 +1225,23 @@ command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" command -v systemctl >/dev/null 2>&1 || fail "Missing systemctl on production server" command -v sha256sum >/dev/null 2>&1 || fail "Missing sha256sum on production server" command -v timeout >/dev/null 2>&1 || fail "Missing timeout on production server" +command -v python3 >/dev/null 2>&1 || fail "Missing python3 on production server; desktop admission cannot be verified" require_file "$ENV_FILE_PATH" +require_desktop_release_admission() { + [ -f "$DESKTOP_ADMISSION" ] && [ ! -L "$DESKTOP_ADMISSION" ] || return 1 + [ "$(stat -c '%u:%g:%a' "$DESKTOP_ADMISSION")" = "0:0:555" ] || return 1 + [ "$(sha256sum "$DESKTOP_ADMISSION" | awk '{print $1}')" = "$DESKTOP_ADMISSION_SHA256" ] || return 1 + # This drop-in survives an older workstation rewriting the base service. + # Require the real server reload path to invoke admission before nginx. + local reload="" + reload="$(systemctl show "${CONTAINER_NAME}-local-proxy.service" -p ExecReload --value)" || return 1 + case "$reload" in *"$DESKTOP_ADMISSION"*"/usr/sbin/nginx"*) ;; *) return 1 ;; esac +} + +require_desktop_release_admission || fail "Server desktop admission gate is missing or outdated; no container was stopped. Install the reviewed project-scoped policy before releasing." + detail_private_mount_is_safe() { [ "$DETAIL_PRIVATE_MOUNT" = "/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" ] || return 1 case "${DETAIL_PRIVATE_MOUNT}/" in "${UPLOADS_MOUNT}/"*) return 1 ;; esac @@ -2559,6 +2589,8 @@ EOF } switch_proxy() { + require_desktop_release_admission || return 1 + python3 "$DESKTOP_ADMISSION" --attest-config --entry-sha256 "$DEPLOY_ENTRY_SHA256" || return 1 ensure_proxy_service || return 1 if is_proxy_running; then systemctl reload "$PROXY_SERVICE" @@ -3856,7 +3888,9 @@ if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then esac fi -# Resolve the approved feature configuration before blue-green creates a container. +# Resolve the approved env-file in the normal blue-green path as well as direct +# replacement. Otherwise its initial value silently disables desktop access and +# omits the private persistent mount, even when production configuration is true. prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid; no candidate was created" # 蓝绿入口和意外残留的直连容器都可能运行 worker;必须逐个证明当前协议、 diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index b6fc68e..a520d01 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -27,6 +27,41 @@ test('generated remote deployment payload is valid bash', () => { assert.equal(result.status, 0, result.stderr || result.stdout) }) +test('server admission blocks proxy changes before any reload or unit rewrite', () => { + for (const phase of ['missing', 'unattested']) { + const input = `set -eu +DESKTOP_ADMISSION=/project/guard.py +DEPLOY_ENTRY_SHA256=${'a'.repeat(64)} +require_desktop_release_admission() { ${phase === 'missing' ? 'return 1' : 'return 0'}; } +python3() { printf 'attestation-rejected\\n'; return 1; } +ensure_proxy_service() { printf 'UNEXPECTED_UNIT_WRITE'; } +is_proxy_running() { return 0; } +systemctl() { printf 'UNEXPECTED_RELOAD'; } +${shellFunction('switch_proxy')} +switch_proxy +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, 1) + assert.doesNotMatch(result.stdout + result.stderr, /UNEXPECTED_/) + assert.equal(result.stdout.includes('attestation-rejected'), phase === 'unattested') + } +}) + +test('both publishing clients pin the same server policy and persist a canonical entry digest', () => { + const source = deploySource() + const mac = readFileSync(new URL('./git-incremental-deploy-macos.mjs', import.meta.url), 'utf8') + for (const token of ['__DESKTOP_ADMISSION_SHA256__', '__DEPLOY_ENTRY_SHA256__']) { + assert.ok(source.includes(token)) + assert.ok(mac.includes(token)) + } + const requireGuard = shellFunction('require_desktop_release_admission') + assert.match(requireGuard, /sha256sum "\$DESKTOP_ADMISSION"/) + assert.match(requireGuard, /0:0:555/) + assert.match(requireGuard, /systemctl show .*ExecReload/) + assert.match(source, /require_desktop_release_admission \|\| fail "Server desktop admission gate is missing/) + assert.match(shellFunction('switch_proxy'), /--attest-config --entry-sha256 "\$DEPLOY_ENTRY_SHA256"/) +}) + test('Mac entry renders every canonical remote deployment token', () => { const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8') const template = extractRemoteTemplate(source) diff --git a/scripts/install-desktop-release-admission.py b/scripts/install-desktop-release-admission.py new file mode 100644 index 0000000..5ac4897 --- /dev/null +++ b/scripts/install-desktop-release-admission.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Install only this project's admission hook; never restart application containers.""" +import argparse +import base64 +import fcntl +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import types + +BASE = Path('/mnt/PoolOne/apps/youyidian-3d') +POLICY = BASE / 'release-policy/desktop-admission' +GUARD = POLICY / 'guard.py' +UNIT = 'youyidian-3d-material-local-proxy.service' +UNIT_FILE = Path('/etc/systemd/system') / UNIT +DROPIN = Path(str(UNIT_FILE) + '.d/30-desktop-admission.conf') +PROXY = BASE / 'local-proxy/youyidian-3d-material' + + +def dropin_text(): + # An independent drop-in survives legacy writers replacing the base unit. + return ('[Service]\n' + f'ExecStartPre=/usr/bin/python3 {GUARD}\n' + 'ExecReload=\n' + f'ExecReload=/usr/bin/python3 {GUARD}\n' + f'ExecReload=/usr/sbin/nginx -p {PROXY}/ -c {PROXY}/nginx.conf -s reload\n') + + +def run(args): + value = subprocess.run(args, capture_output=True, text=True, timeout=25) + if value.returncode != 0: + raise RuntimeError('system_command_failed') + return value.stdout.strip() + + +def atomic_write(path, data, mode): + fd, temp = tempfile.mkstemp(prefix='.admission-install-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + os.fchown(stream.fileno(), 0, 0) + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apply', action='store_true') + parser.add_argument('--verify-reload', action='store_true') + parser.add_argument('--expect-container', required=True) + parser.add_argument('--expect-image', required=True) + parser.add_argument('--expect-commit', required=True) + parser.add_argument('--entry-sha256', required=True) + args = parser.parse_args() + handles = [] + try: + if os.geteuid() != 0: + raise RuntimeError('root_required') + if 'STAGED_POLICY_BASE64' in globals(): + source = base64.b64decode(STAGED_POLICY_BASE64, validate=True) + else: + source = Path(__file__).with_name('desktop-release-admission.py').read_bytes() + source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + digest = hashlib.sha256(source).hexdigest() + module = types.ModuleType('desktop_admission_install_probe') + module.__file__ = str(GUARD) + exec(compile(source, str(GUARD), 'exec'), module.__dict__) + require = module.require + # Same lock order as the canonical deployment, scoped to this project. + for name in ['youyidian-3d-material-production-deploy.lock', 'youyidian-3d-material-local-proxy-reconcile.lock']: + flags = os.O_WRONLY | os.O_NOFOLLOW + if args.apply: + flags |= os.O_CREAT + fd = os.open('/run/' + name, flags, 0o600) + handles.append(fd) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + snapshot = module.current_snapshot() + require(snapshot['containerId'] == args.expect_container and snapshot['imageId'] == args.expect_image + and snapshot['revision'] == args.expect_commit, 'active_identity_changed') + module.physical(UNIT_FILE, owner=(0, 0)) + require(run(['systemctl', 'show', UNIT, '-p', 'FragmentPath', '--value']) == str(UNIT_FILE), 'unit_identity_changed') + require(run(['systemctl', 'is-active', UNIT]) == 'active', 'proxy_not_active') + before_pid = run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) + require(before_pid.isdecimal() and before_pid != '0', 'proxy_pid_invalid') + if GUARD.exists(): + module.physical(GUARD, owner=(0, 0), mode=0o555) + require(hashlib.sha256(GUARD.read_bytes()).hexdigest() == digest, 'existing_policy_requires_reviewed_upgrade') + for path in [POLICY.parent, POLICY, POLICY / 'receipts', DROPIN.parent]: + require(not path.is_symlink(), 'symlink_rejected') + if path.exists(): + module.physical(path, directory=True, owner=(0, 0)) + if DROPIN.exists(): + module.physical(DROPIN, owner=(0, 0), mode=0o644) + require(DROPIN.read_text() == dropin_text(), 'existing_dropin_differs') + result = {'mode': 'apply' if args.apply else 'plan', 'policySha256': digest, + 'unit': UNIT, 'dropIn': str(DROPIN), 'applicationContainerId': snapshot['containerId'], + 'imageId': snapshot['imageId'], 'revision': snapshot['revision'], 'beforeProxyPid': before_pid} + if not args.apply: + print(json.dumps(result)) + return 0 + for path in [POLICY.parent, POLICY, POLICY / 'receipts']: + created = not path.exists() + path.mkdir(mode=0o700, exist_ok=True) + if created: + # NAS root's primary group need not be gid 0. + os.chown(path, 0, 0) + os.chmod(path, 0o700) + module.physical(POLICY, directory=True, owner=(0, 0), mode=0o700) + module.physical(POLICY / 'receipts', directory=True, owner=(0, 0), mode=0o700) + if not GUARD.exists(): + atomic_write(GUARD, source, 0o555) + module.attest(snapshot, args.entry_sha256) + module.validate_receipt(snapshot) + created_dropin_dir = not DROPIN.parent.exists() + DROPIN.parent.mkdir(mode=0o755, exist_ok=True) + if created_dropin_dir: + os.chown(DROPIN.parent, 0, 0) + os.chmod(DROPIN.parent, 0o755) + if not DROPIN.exists(): + atomic_write(DROPIN, dropin_text().encode(), 0o644) + run(['systemctl', 'daemon-reload']) + reload_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecReload', '--value']) + start_commands = run(['systemctl', 'show', UNIT, '-p', 'ExecStartPre', '--value']) + require(str(GUARD) in reload_commands and reload_commands.index(str(GUARD)) < reload_commands.index('/usr/sbin/nginx'), + 'reload_hook_not_active') + require(str(GUARD) in start_commands, 'start_hook_not_active') + if args.verify_reload: + run(['systemctl', 'reload', UNIT]) + require(run(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value']) == before_pid, 'proxy_process_restarted') + require(module.current_snapshot() == snapshot, 'application_changed_during_install') + result.update({'installed': True, 'reloadVerified': args.verify_reload, 'applicationUnchanged': True, + 'proxyPidUnchanged': True, 'startHookVerified': True, 'reloadHookVerified': True}) + print(json.dumps(result)) + return 0 + except Exception as error: + # Do not emit arbitrary subprocess output, HTTP bodies or env values. + allowed = {'root_required', 'system_command_failed'} + reason = str(error) if type(error).__name__ == 'Rejected' or str(error) in allowed else 'installation_stopped' + print(json.dumps({'installed': False, 'reason': reason}), file=sys.stderr) + return 1 + finally: + for fd in handles: + os.close(fd) + + +if __name__ == '__main__': + sys.exit(main())