193 lines
10 KiB
Python
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()
|