Files
kefu/deploy/mobile-http-20260928/server-bundle/remote_server_guard.py
T
2026-09-29 15:52:07 +08:00

139 lines
7.7 KiB
Python

"""Read-only checks for mobile-http-20260928: baseline / candidate / deployed.
Prints hashes and status only; never reads chat content, patients or credentials.
"""
from pathlib import Path
import argparse, hashlib, json, subprocess, urllib.error, urllib.request
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['baseline', 'candidate', 'deployed'])
args = parser.parse_args()
RELEASE = 'mobile-http-20260928'
BANNER = '只能在公司内网登录'
folder = Path(__file__).resolve().parent
project = Path('/opt/im-admin')
base = json.loads((folder / 'server-baseline.json').read_text())
plan = json.loads((folder / 'release-plan.json').read_text())
manifest = json.loads((folder / 'manifest.json').read_text())
payload_rows = [row for row in manifest if row['path'].startswith('payload/')]
frontend = plan['frontendDir']
sha = lambda data: hashlib.sha256(data).hexdigest()
image = lambda ref: subprocess.check_output(['docker', 'image', 'inspect', ref, '--format', '{{.Id}}'], text=True).strip()
# The unpacked bundle itself must be intact.
for row in manifest:
path = (folder / row['path']).resolve()
assert path.is_relative_to(folder), row['path']
assert path.stat().st_size == row['bytes'] and sha(path.read_bytes()) == row['sha256'], row['path']
# project-relative path -> expected sha256 on the host
if args.mode == 'baseline':
expected_host = dict(base['expected'])
else:
expected_host = {row['path'][len('payload/'):]: row['sha256'] for row in payload_rows}
container_code = {k: v for k, v in expected_host.items() if k.startswith(('wechat_rpa/', 'admin-web/'))}
frontend_files = sorted(k[len(frontend) + 1:] for k in expected_host if k.startswith(frontend + '/'))
def in_container(code, target, ref=None):
command = (['docker', 'run', '--rm', '-i', '--network=none', '--entrypoint', 'python', ref, '-c', code]
if ref else ['docker', 'exec', '-i', 'im-admin-api-1', 'python', '-c', code])
return json.loads(subprocess.check_output(command, input=json.dumps(target).encode()))
HASHES = ('from pathlib import Path;import hashlib,json,sys;names=json.load(sys.stdin);'
'print(json.dumps({n:(hashlib.sha256((Path("/app")/n).read_bytes()).hexdigest() '
'if (Path("/app")/n).is_file() else None) for n in names}))')
# /m/ 目录里的完整文件清单(不能残留旧版带横幅的页面文件)和横幅文字检查。
FRONTEND = ('from pathlib import Path;import json,sys;q=json.load(sys.stdin);root=Path("/app")/q["root"];'
'files=sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file());'
'print(json.dumps({"files":files,"banner":[f for f in files if q["banner"] in '
'(root/f).read_bytes().decode("utf-8","ignore")]}))')
FRONTEND_QUERY = {'root': frontend, 'banner': BANNER}
current = image('zyt/wecom-admin:current')
result = {'mode': args.mode, 'release': RELEASE, 'image': current}
# 只重建 api:网关和知识库 worker 继续跑它们自己的镜像,部署后核对它们没有被动过。
OTHERS = ['im-admin-gateway-1', 'im-admin-knowledge-worker-1']
def inspect(container):
raw = json.loads(subprocess.check_output(['docker', 'inspect', container]))[0]
return {'id': raw['Id'], 'image': raw['Image'], 'running': raw['State']['Running'],
'health': raw['State'].get('Health', {}).get('Status', 'not_configured')}
if args.mode == 'baseline':
api = inspect('im-admin-api-1')
assert api['image'] == current, 'API container is not on zyt/wecom-admin:current'
assert api['running'] and api['health'] == 'healthy', 'API container is not healthy'
others = {name: inspect(name) for name in OTHERS}
for name, state in others.items():
assert state['running'], 'Not running: ' + name
result['others'] = others
if args.mode in ('baseline', 'deployed'):
for name, wanted in expected_host.items():
assert sha((project / name).read_bytes()) == wanted, 'Host drift: ' + name
actual = in_container(HASHES, sorted(container_code))
assert actual == container_code, 'Running api container differs from expected code'
result['files'] = len(expected_host)
if args.mode == 'candidate':
candidate = 'zyt/wecom-admin:' + RELEASE
actual = in_container(HASHES, sorted(container_code), candidate)
assert actual == container_code, 'Candidate image differs from manifest'
listing = in_container(FRONTEND, FRONTEND_QUERY, candidate)
assert listing['files'] == frontend_files, 'Candidate /m/ has extra or missing files'
assert not listing['banner'], listing['banner']
result['candidate'] = image(candidate)
result['files'] = len(container_code)
if args.mode == 'deployed':
wanted = image('zyt/wecom-admin:' + RELEASE)
assert current == wanted, 'current tag does not point at the release image'
raw = json.loads(subprocess.check_output(['docker', 'inspect', 'im-admin-api-1']))[0]
assert raw['Image'] == wanted and raw['State']['Running'] and raw['RestartCount'] == 0
assert raw['State'].get('Health', {}).get('Status') == 'healthy'
recorded = folder / 'baseline-check.json'
if recorded.exists():
before = json.loads(recorded.read_text())['others']
for name in OTHERS:
now = inspect(name)
assert now['id'] == before[name]['id'] and now['running'], 'Unrelated container changed: ' + name
result['others_unchanged'] = OTHERS
host_files = sorted(p.relative_to(project / frontend).as_posix() for p in (project / frontend).rglob('*') if p.is_file())
assert host_files == frontend_files, 'Host /m/ has extra or missing files'
listing = in_container(FRONTEND, FRONTEND_QUERY)
assert listing['files'] == frontend_files and not listing['banner'], listing
for url in ['http://127.0.0.1:18766/api/v2/health', 'http://127.0.0.1:18770/health']:
with urllib.request.urlopen(url, timeout=15) as response:
assert response.status == 200, url
# New routes still refuse anonymous callers. No login is attempted.
for method, path in [('GET', '/api/v2/mobile/me'), ('GET', '/api/v2/mobile/desktops'),
('POST', '/api/v2/desktop/commands/poll'), ('GET', '/api/v2/desktop/blacklist')]:
request = urllib.request.Request('http://127.0.0.1:18766' + path, method=method,
data=b'{}' if method == 'POST' else None,
headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(request, timeout=15)
except urllib.error.HTTPError as exc:
assert exc.code == 401, (path, exc.code)
else:
raise AssertionError('Accessible without authentication: ' + path)
with urllib.request.urlopen('http://127.0.0.1:18766/m/', timeout=15) as response:
body = response.read()
assert response.status == 200 and '企微客服助手'.encode() in body, 'mobile console entry missing'
# 本次目的:公网来源的明文 HTTP 登录不再被拒绝(在运行中的容器里、用它的真实环境变量判断)。
code = ("import remote_api,json;from starlette.requests import Request;"
"r=Request({'type':'http','method':'POST','path':'/api/v2/mobile/auth/login','root_path':'',"
"'headers':[(b'x-real-ip',b'114.114.114.114')],'scheme':'http','query_string':b'',"
"'client':('172.18.0.1',40000),'server':('127.0.0.1',8766)});"
"print(json.dumps({'public_http_login_refused':remote_api.https_required(r)}))")
policy = json.loads(subprocess.check_output(['docker', 'exec', '-i', 'im-admin-api-1', 'python', '-c', code]))
assert policy == {'public_http_login_refused': False}, policy
result['policy'] = policy
result['status'] = 'passed'
print(json.dumps(result, ensure_ascii=False))