Files
railwaysystem/scripts/qa/qa_isolation.py
T

261 lines
11 KiB
Python

#!/usr/bin/env python3
"""Owned, temporary QA transport. Never revive the legacy database endpoint.
The legacy port is a deny-only socket: no protocol response and no upstream.
SSH authentication stays interactive; no password is accepted in arguments/files.
This is runtime isolation, not database credential revocation or a host firewall.
"""
import argparse
import json
import os
from pathlib import Path
import re
import secrets
import selectors
import signal
import socket
import stat
import subprocess
import tempfile
import time
import urllib.parse
ROOT = Path(__file__).resolve().parents[2]
RUNTIME = ROOT / '.qa-runtime'
LEDGER = 'isolation.json'
DATABASE = 'railwaysystem_fusion_qa_20261013'
LEGACY_PORT = 14321
def run(args):
return subprocess.run(args, text=True, capture_output=True, timeout=8)
def identity(pid):
born = run(['ps', '-p', str(pid), '-o', 'lstart=']).stdout.strip()
command = run(['ps', '-p', str(pid), '-o', 'command=']).stdout.strip()
cwd = run(['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn']).stdout.splitlines()
return {'pid': pid, 'started': born, 'command': command,
'cwd': next((s[1:] for s in cwd if s.startswith('n')), '')}
def same_process(item):
return bool(item.get('started')) and identity(item['pid']) == item
def listener_owned(port, pid):
lines = run(['lsof', '-nP', '-iTCP:' + str(port), '-sTCP:LISTEN', '-FpFn']).stdout.splitlines()
owners = {int(s[1:]) for s in lines if s.startswith('p')}
addresses = {s[1:] for s in lines if s.startswith('n')}
return owners == {pid} and addresses == {'127.0.0.1:' + str(port)}
def conflicting_clients(port, isolation_id):
# Read locally, return only PIDs. Never print/store the captured environments.
conflicts = []
for line in run(['ps', '-axo', 'pid=,comm=']).stdout.splitlines():
fields = line.strip().split(None, 1)
if len(fields) != 2 or Path(fields[1]).name != 'java':
continue
pid = int(fields[0])
result = run(['ps', 'eww', '-p', str(pid), '-o', 'command='])
if result.returncode or not result.stdout.strip():
if run(['ps', '-p', str(pid), '-o', 'pid=']).stdout.strip():
raise ValueError('Cannot inspect a running Java client safely')
continue
text = result.stdout
owns_lease = re.search(r'(?:^|\s)FUSION_QA_ISOLATION_ID=' + re.escape(isolation_id) + r'(?:\s|$)', text)
if owns_lease and identity(pid)['cwd'] == str(ROOT):
continue
target = re.search(r'(?:127\.0\.0\.1|localhost|\[::1\]):' + str(port) + r'(?:[/\s?]|$)', text)
env_port = re.search(r'(?:^|\s)(?:DB_PORT|QA_DB_PORT)=' + str(port) + r'(?:\s|$)', text)
if target or env_port:
conflicts.append(pid)
return conflicts
def private_runtime():
RUNTIME.mkdir(mode=0o700, exist_ok=True)
info = RUNTIME.stat()
if RUNTIME.is_symlink() or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise ValueError('Unsafe isolation directory')
def read_lease():
private_runtime()
path = RUNTIME / LEDGER
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
raise ValueError('Unsafe isolation ledger')
return json.loads(path.read_text())
def write_lease(item):
fd, name = tempfile.mkstemp(prefix='isolation-', suffix='.tmp', dir=RUNTIME)
try:
with os.fdopen(fd, 'w') as stream:
json.dump(item, stream)
os.replace(name, RUNTIME / LEDGER)
finally:
if Path(name).exists():
Path(name).unlink()
def validate(item, url=None, scan_clients=True):
if item.get('state') != 'READY' or item.get('database') != DATABASE:
raise ValueError('No ready QA isolation lease')
port = item.get('port')
if not isinstance(port, int) or not 1024 <= port <= 65535 or port == LEGACY_PORT:
raise ValueError('Legacy or invalid database port refused')
if not 0 <= time.time() - item.get('heartbeat', 0) < 10:
raise ValueError('Isolation watchdog heartbeat expired')
if not same_process(item['owner']) or not same_process(item['ssh']):
raise ValueError('Isolation process identity mismatch')
if not listener_owned(LEGACY_PORT, item['owner']['pid']) or not listener_owned(port, item['ssh']['pid']):
raise ValueError('Isolation listener ownership mismatch')
if url:
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
if parsed.scheme != 'kingbase8' or parsed.hostname != '127.0.0.1' or parsed.port != port or parsed.path != '/' + DATABASE or parsed.username or parsed.password:
raise ValueError('Application must use its owned QA transport')
if scan_clients and conflicting_clients(port, item['isolation_id']):
raise ValueError('Another Java client depends on the new database endpoint')
return item
def require(url=None):
try:
return validate(read_lease(), url)
except (OSError, KeyError, TypeError, json.JSONDecodeError) as error:
raise ValueError('Managed QA isolation is unavailable') from error
def url_for(item):
return 'jdbc:kingbase8://127.0.0.1:' + str(item['port']) + '/' + DATABASE
def deny_socket():
guard = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# No reuse options: an existing listener/forward must cause refusal.
guard.bind(('127.0.0.1', LEGACY_PORT))
guard.listen(64)
guard.setblocking(False)
return guard
except BaseException:
guard.close()
raise
def ssh_command(port):
# The user's Host * may contain unrelated RemoteForward directives. Read the
# jump alias normally but clear its forwards; do not inherit target config.
proxy = ('ssh -o ClearAllForwardings=yes -o PermitLocalCommand=no -o ControlMaster=no '
'-o ControlPath=none -o ControlPersist=no -o StrictHostKeyChecking=yes '
'-W %h:%p wzbmatebook')
return ['ssh', '-F', '/dev/null', '-o', 'HostName=192.168.139.218', '-o', 'User=root', '-p', '22',
'-o', 'ProxyCommand=' + proxy, '-o', 'StrictHostKeyChecking=yes',
'-N', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
'-o', 'ControlPersist=no', '-o', 'ExitOnForwardFailure=yes', '-o', 'ConnectTimeout=8',
'-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2',
'-L127.0.0.1:' + str(port) + ':127.0.0.1:4321', 'sict-218']
def serve(port):
private_runtime()
if (RUNTIME / LEDGER).exists():
raise ValueError('Isolation ledger already exists; inspect its owner, never overwrite')
if port == LEGACY_PORT or port < 0 or (port and not 1024 <= port <= 65535):
raise ValueError('Legacy or invalid database port refused')
if not port:
with socket.socket() as candidate:
candidate.bind(('127.0.0.1', 0))
port = candidate.getsockname()[1]
lease_id = secrets.token_hex(16)
if conflicting_clients(port, lease_id):
raise ValueError('New database port is already configured by another Java client')
target = run(['ssh', '-G', 'sict-218'])
if target.returncode or not all(re.search(pattern, target.stdout, re.M) for pattern in
[r'^hostname 192\.168\.139\.218$', r'^user root$', r'^port 22$']):
raise ValueError('SSH target alias does not resolve to the approved host')
owner = identity(os.getpid())
if owner['cwd'] != str(ROOT):
raise ValueError('Start the isolation owner from this worktree root')
guard = deny_socket()
selector = selectors.DefaultSelector()
selector.register(guard, selectors.EVENT_READ)
child = None
item = None
def stopped(*_):
raise KeyboardInterrupt
previous = {s: signal.signal(s, stopped) for s in [signal.SIGTERM, signal.SIGINT]}
try:
child = subprocess.Popen(ssh_command(port), cwd=ROOT)
deadline = time.monotonic() + 90
refused = 0
while True:
if child.poll() is not None:
raise ValueError('Owned SSH transport exited; QA isolation refused')
for _, _ in selector.select(timeout=0.4):
try:
connection, _address = guard.accept()
except BlockingIOError:
continue
else:
connection.close() # Deliberately no read, write, proxy or DB authentication.
refused += 1
newly_ready = item is None
if newly_ready:
if not listener_owned(port, child.pid):
if time.monotonic() > deadline:
raise ValueError('Owned SSH transport readiness timed out')
continue
item = {'isolation_id': lease_id, 'state': 'READY', 'database': DATABASE, 'port': port,
'target': 'sict-218:127.0.0.1:4321', 'owner': owner, 'ssh': identity(child.pid),
'created': time.time(), 'heartbeat': time.time(), 'denied_legacy_connections': refused}
item.update(heartbeat=time.time(), denied_legacy_connections=refused)
# Guards and client dependencies are checked on each heartbeat, not just once at launch.
validate(item)
write_lease(item)
if newly_ready:
print('QA_ISOLATION_READY port=' + str(port) + ' legacy_port=DENY_ONLY', flush=True)
except KeyboardInterrupt:
pass
finally:
# Stop only matching lease-owned applications before removing their transport and guard.
cleanup_ok = False
try:
import qa
qa.stop(isolation_id=lease_id)
cleanup_ok = True
finally:
if child is not None and child.poll() is None:
child.terminate()
try:
child.wait(timeout=12)
except subprocess.TimeoutExpired:
# Exact child Popen object, never a name/port-selected process.
child.kill()
child.wait(timeout=5)
guard.close()
selector.close()
for sig, handler in previous.items():
signal.signal(sig, handler)
if item:
item.update(state='STOPPED' if cleanup_ok else 'CLEANUP_FAILED', heartbeat=time.time(),
denied_legacy_connections=refused, cleanup_ok=cleanup_ok)
write_lease(item)
history = RUNTIME / ('isolation-' + lease_id + '-final.json')
os.replace(RUNTIME / LEDGER, history)
print('QA_ISOLATION_CLOSED evidence=' + str(history), flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=0, help='0 selects an unused ephemeral port; legacy port is always refused')
args = parser.parse_args()
try:
serve(args.port)
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
print('QA_ISOLATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__))
raise SystemExit(2)