video_monitor/tests/windows_lifecycle.py
2026-09-04 18:16:14 +08:00

193 lines
10 KiB
Python

"""Real Windows worker lifecycle validation. Requires a vendor DPAPI key; never shipped."""
import argparse
import base64
import configparser
from datetime import datetime, timezone, timedelta
import json
import os
from pathlib import Path
import secrets
import socket
import subprocess
import sys
import threading
import time
from urllib.parse import urlsplit
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--executable',type=Path,required=True)
parser.add_argument('--resources',type=Path,required=True)
parser.add_argument('--private-key',type=Path,required=True)
parser.add_argument('--data',type=Path,required=True)
args=parser.parse_args()
target=args.data.resolve()
if target.exists():
raise RuntimeError('Use a fresh test data directory')
target.mkdir(parents=True)
os.environ['MONITOR_DATA_DIR']=str(target)
os.environ['MONITOR_DESKTOP']='1'
import psutil
import requests
from multiprocessing.connection import Listener
from cryptography.hazmat.primitives import serialization
from monitor_runtime.licensing import canonical
from monitor_runtime.windows import protect,Job
from monitor_runtime.bootstrap import local_addresses
cfg=json.loads((args.resources/'deploy/windows/default-config.json').read_text(encoding='utf-8'))
def free_port():
with socket.socket() as sock:
sock.bind(('127.0.0.1',0))
return sock.getsockname()[1]
ports=[free_port() for _ in range(5)]
cfg.update(adminPort=ports[0],mediaHttpPort=ports[1],mediaRtspPort=ports[2],mediaRtmpPort=ports[3],
isEnableLoginCaptcha=False,autoAddStreamProxy=False)
cfg['sipServer']['sipServerPort']=ports[4]
(target/'config.json').write_text(json.dumps(cfg),encoding='utf-8')
ini=configparser.ConfigParser(interpolation=None)
ini.optionxform=str
ini.read(args.resources/'deploy/windows/zlm-template.ini',encoding='utf-8')
for section,port in [('http',ports[1]),('rtsp',ports[2]),('rtmp',ports[3]),('rtp_proxy',0)]:
ini[section]['port']=str(port)
with (target/'zlm-template.ini').open('w',encoding='utf-8') as stream:ini.write(stream)
raw=args.private_key.read_bytes()
assert raw.startswith(b'MONITOR-DPAPI-V1\n')
key=serialization.load_pem_private_key(protect(base64.b64decode(raw.split(b'\n',1)[1]),decrypt=True),None)
command=[str(args.executable.resolve())]
if args.executable.suffix=='.py':command=[sys.executable]+command
env=os.environ.copy()
env['MONITOR_SERVICE_MODE']='disabled'
if args.executable.suffix.lower()=='.exe':
env.pop('PYTHONPATH',None);env.pop('PYTHONHOME',None)
windows=env['SYSTEMROOT']
env['PATH']=windows+'/System32;'+windows+';'+windows+'/System32/WindowsPowerShell/v1.0'
session=requests.Session()
session.trust_env=False
base='http://127.0.0.1:'+str(ports[0])
checks=[]
def start():
auth=secrets.token_bytes(32)
address=r'\\.\pipe\MonitorValidation-'+secrets.token_hex(12)
listener=Listener(address,family='AF_PIPE',authkey=auth)
job=Job()
log=(target/'worker.log').open('ab',buffering=0)
child=subprocess.Popen(command+['--worker',address,auth.hex()],env=env,cwd=target,
stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
log.close()
job.assign(child)
accepted=[]
threading.Thread(target=lambda:accepted.append(listener.accept()),daemon=True).start()
deadline=time.monotonic()+60
while not accepted:
if child.poll() is not None or time.monotonic()>deadline:
job.close();listener.close()
raise RuntimeError('Worker failed before pipe connection')
time.sleep(.1)
conn=accepted[0];listener.close();conn.send('run')
if not conn.poll(180):
job.close();raise RuntimeError('Worker startup timeout')
message=conn.recv()
return child,conn,job,message
def finish(child,conn,job,expected=0,stop=True):
descendants=psutil.Process(child.pid).children(recursive=True) if child.poll() is None else []
if stop:conn.send('stop')
try:
code=child.wait(timeout=40)
assert code==expected,(code,expected)
finally:
conn.close();job.close()
gone,alive=psutil.wait_procs(descendants,timeout=10)
assert not alive,[p.pid for p in alive]
child=conn=job=None
try:
child,conn,job,message=start()
assert message['phase']=='setup',message
assert session.get(base+'/setup',timeout=10).status_code==200
token=urlsplit(message['url']).fragment
csrf=session.cookies['csrftoken']
session.headers['X-CSRFToken']=csrf
assert session.post(base+'/license/request',data={'setup_token':'wrong'},timeout=10).status_code==403
request=session.post(base+'/license/request',data={'setup_token':token},timeout=30).json()
payload={'schema_version':1,'product':'monitor','license_id':'lifecycle-validation',
'customer':'local build validation only','machine_fingerprint':request['fingerprint'],
'issued_at':datetime.now(timezone.utc).isoformat(),
'expires_at':(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat()}
doc={'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode()}
response=session.post(base+'/license/import',data={'setup_token':token},
files={'license':('test.json',json.dumps(doc),'application/json')},timeout=15)
assert response.status_code==200,response.text[:200]
address=local_addresses()[0]
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
'password':'Test-setup!'+secrets.token_hex(12),'address':address,'confirm':'unused'},timeout=10)
assert response.status_code==400
password='Test-setup!'+secrets.token_hex(12)
response=session.post(base+'/setup',data={'setup_token':token,'username':'validation-admin',
'password':password,'confirm':password,'address':address},timeout=10)
assert response.status_code==200,response.text[:200]
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
checks.append('real HTTP bootstrap, CSRF, signed license, fresh admin and restart request')
child,conn,job,message=start()
assert message['phase']=='ready',message
assert (target/'log/zlm').is_dir()
assert not (args.resources/'zlm/log').exists(), 'ZLM wrote into program resources'
assert session.get(base+'/login',timeout=15).status_code==200
assert session.get(base+'/static/lib/js/app.js',timeout=15).status_code==200
assert session.get(base+'/setup',timeout=15).status_code==403
assert session.get(base+'/static/storage/private.jpg',timeout=15).status_code==403
with socket.create_connection(('127.0.0.1',ports[1]),timeout=5):pass
listeners=psutil.net_connections(kind='udp')
owned={child.pid}|{p.pid for p in psutil.Process(child.pid).children(recursive=True)}
assert any(c.pid in owned and c.laddr.port==ports[4] for c in listeners)
children=psutil.Process(child.pid).children(recursive=True)
assert any(p.name().lower()=='monitor_zlm.exe' for p in children)
assert not any('spawn_main' in ' '.join(p.cmdline()) for p in children)
response=session.post(base+'/login',data={'username':'validation-admin','password':password},timeout=15)
assert response.status_code==200,response.text[:200]
assert session.get(base+'/license/status',timeout=15).status_code==200,response.text[:200]
checks.append('Waitress login/static; ZLM and SIP ready; analysis not auto-started')
expiry_payload=dict(payload,expires_at=(datetime.now(timezone.utc)+timedelta(seconds=7)).isoformat())
expiry_doc={'payload':expiry_payload,'signature':base64.b64encode(key.sign(canonical(expiry_payload))).decode()}
(target/'license.json').write_text(json.dumps(expiry_doc),encoding='utf-8')
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
child,conn,job,message=start()
assert message['phase']=='license',message
assert not any(p.name().lower()=='monitor_zlm.exe' for p in psutil.Process(child.pid).children(recursive=True))
assert session.get(base+'/license/status',timeout=10).json()['valid'] is False
session.headers['X-CSRFToken']=session.cookies['csrftoken']
response=session.post(base+'/license/import',files={'license':('renew.json',json.dumps(doc),'application/json')},timeout=15)
assert response.status_code==200,response.text[:200]
finish(child,conn,job,expected=20,stop=False);child=conn=job=None
child,conn,job,message=start()
assert message['phase']=='ready',message
checks.append('runtime expiry stops services; authenticated renewal restores service')
finish(child,conn,job);child=conn=job=None
checks.append('graceful shutdown and owned descendant cleanup')
with socket.socket() as occupied:
occupied.bind(('0.0.0.0',ports[0]));occupied.listen()
child,conn,job,message=start()
assert message['phase']=='failed',message
finish(child,conn,job,expected=1,stop=False);child=conn=job=None
assert occupied.getsockname()[1]==ports[0]
checks.append('port conflict fails without killing the listener')
child,conn,job,message=start()
assert message['phase']=='ready',message
children=psutil.Process(child.pid).children(recursive=True)
job.close();job=None
child.wait(timeout=15)
_,alive=psutil.wait_procs(children,timeout=10)
assert not alive
conn.close();child=conn=None
checks.append('Job Object kills owned children on forced shutdown')
(target/'validation.json').write_text(json.dumps({'status':'passed','checks':checks},indent=2),encoding='utf-8')
print(json.dumps(checks,indent=2))
finally:
if child and child.poll() is None:
if conn:
try:conn.send('stop');child.wait(timeout=30)
except (OSError,subprocess.TimeoutExpired):pass
if job:job.close()
if conn:conn.close()
if __name__=='__main__':main()