fix(deploy): require desktop storage admission before proxy cutover
This commit is contained in:
parent
91796343bf
commit
e83b0e9d5d
240
scripts/desktop-release-admission.py
Normal file
240
scripts/desktop-release-admission.py
Normal 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())
|
||||
178
scripts/desktop-release-admission.test.py
Normal file
178
scripts/desktop-release-admission.test.py
Normal 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()
|
||||
@ -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',
|
||||
|
||||
@ -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;必须逐个证明当前协议、
|
||||
|
||||
@ -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)
|
||||
|
||||
156
scripts/install-desktop-release-admission.py
Normal file
156
scripts/install-desktop-release-admission.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user