fix(deploy): require desktop storage admission before proxy cutover

This commit is contained in:
WuYongJie 2026-10-29 11:31:23 +08:00
parent 91796343bf
commit e83b0e9d5d
6 changed files with 650 additions and 1 deletions

View File

@ -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())

View File

@ -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=[]), '<installer>', '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()

View File

@ -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',

View File

@ -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;必须逐个证明当前协议、

View File

@ -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)

View File

@ -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())