Use the exact target root and profile for branch, release, and cache decisions. Keep the desktop as a transport and handoff adapter. Remove the competing TypeScript checker and switch the banner, dashboard, and updater count consumers. Preserve fork origins, unknown counts, publication checks, old-probe recovery, and official SSH branch healing through public HTTPS. Keep the historical unstamped-root policy unchanged.
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
"""Linux live Git/OpenSSH PTY probe; fixture ssh config, no user state."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pty
|
|
import pwd
|
|
import select
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
BASE = Path(os.environ['AUDIT_DIR'])
|
|
REPO = Path(os.environ['AUDIT_REPO'])
|
|
BASE.mkdir(parents=True, exist_ok=True)
|
|
env = {'PATH': f'{BASE}/bin:/usr/bin:/bin', 'HOME': str(BASE/'home'),
|
|
'HERMES_HOME': str(BASE/'hermes'), 'PYTHONPATH': str(REPO),
|
|
'PYTHONDONTWRITEBYTECODE': '1', 'AUDIT_DIR': str(BASE), 'AUDIT_REPO': str(REPO)}
|
|
for name in ('home', 'hermes', 'bin'):
|
|
(BASE/name).mkdir(exist_ok=True)
|
|
sys.path.insert(0, str(REPO))
|
|
from hermes_cli import source_check
|
|
|
|
if len(sys.argv) > 1:
|
|
os.environ.clear()
|
|
os.environ.update(env)
|
|
case = json.loads((BASE/'case.json').read_text())
|
|
os.environ.update(case['env'])
|
|
start = time.monotonic()
|
|
result = source_check.check_for_updates(install_root=BASE/'checkout').get('behind')
|
|
print('PRODUCTION_RETURN '+json.dumps({'result': result, 'elapsed': time.monotonic()-start}), flush=True)
|
|
time.sleep(2)
|
|
sys.exit(0)
|
|
|
|
|
|
def run(args, **kw):
|
|
return subprocess.run(args, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, check=True, timeout=15, **kw).stdout.strip()
|
|
|
|
|
|
def git(*args):
|
|
return run(['git', '-C', str(BASE/'checkout'), *args])
|
|
|
|
|
|
def probe(name, extra_env):
|
|
(BASE/'case.json').write_text(json.dumps({'env': extra_env}))
|
|
child, fd = pty.fork()
|
|
if child == 0:
|
|
python = str(REPO/'.venv/bin/python')
|
|
os.execve(python, [python, '-B', __file__, 'child'], env)
|
|
text = ''
|
|
start = time.monotonic()
|
|
try:
|
|
while time.monotonic()-start < 13:
|
|
ready, _, _ = select.select([fd], [], [], .2)
|
|
if ready:
|
|
try:
|
|
data = os.read(fd, 65536)
|
|
except OSError:
|
|
break
|
|
if not data:
|
|
break
|
|
text += data.decode(errors='replace')
|
|
descendants = []
|
|
for path in Path('/proc').iterdir():
|
|
if not path.name.isdigit():
|
|
continue
|
|
try:
|
|
pid = int(path.name)
|
|
if os.getpgid(pid) == child and pid != child:
|
|
cmd = (path/'cmdline').read_bytes().replace(b'\0', b' ').decode(errors='replace')
|
|
if cmd:
|
|
descendants.append({'pid': pid, 'cmdline': cmd})
|
|
except (OSError, ProcessLookupError):
|
|
pass
|
|
returned = [line.split('PRODUCTION_RETURN ', 1)[1] for line in text.splitlines()
|
|
if 'PRODUCTION_RETURN ' in line]
|
|
return {'name': name, 'pty': text, 'prompt': 'Are you sure you want' in text,
|
|
'return': json.loads(returned[-1]) if returned else None,
|
|
'live_descendants': descendants}
|
|
finally:
|
|
os.close(fd)
|
|
try:
|
|
os.killpg(child, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
os.waitpid(child, 0)
|
|
|
|
|
|
for key in ('host_key', 'client_key'):
|
|
run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(BASE/key)])
|
|
(BASE/'authorized_keys').write_text((BASE/'client_key.pub').read_text())
|
|
(BASE/'checkout').mkdir()
|
|
git('init', '-b', 'main')
|
|
git('-c', 'user.name=Probe', '-c', 'user.email=probe@localhost', 'commit', '--allow-empty', '-m', 'probe')
|
|
run(['git', 'clone', '--bare', str(BASE/'checkout'), str(BASE/'remote.git')])
|
|
with socket.socket() as sock:
|
|
sock.bind(('127.0.0.1', 0))
|
|
port = sock.getsockname()[1]
|
|
user = pwd.getpwuid(os.getuid()).pw_name
|
|
config = BASE/'sshd_config'
|
|
config.write_text(f'Port {port}\nListenAddress 127.0.0.1\nHostKey {BASE}/host_key\nPidFile {BASE}/sshd.pid\nAuthorizedKeysFile {BASE}/authorized_keys\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nUsePAM no\nStrictModes no\nLogLevel VERBOSE\n')
|
|
ssh_config = BASE/'ssh_config'
|
|
ssh_config.write_text(f'Host *\n User {user}\n UserKnownHostsFile {BASE}/known_hosts\n GlobalKnownHostsFile /dev/null\n IdentityFile {BASE}/client_key\n ConnectTimeout 3\n')
|
|
wrapper = BASE/'bin/ssh'
|
|
wrapper.write_text(f'#!/bin/sh\nexec /usr/bin/ssh -F {ssh_config} "$@"\n')
|
|
wrapper.chmod(0o700)
|
|
git('remote', 'add', 'origin', f'ssh://{user}@127.0.0.1:{port}{BASE}/remote.git')
|
|
log = (BASE/'sshd.log').open('w')
|
|
server = subprocess.Popen(['/usr/sbin/sshd', '-D', '-e', '-f', str(config)], env=env,
|
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
|
agent = None
|
|
rows = []
|
|
try:
|
|
for _ in range(40):
|
|
if server.poll() is not None:
|
|
raise RuntimeError('sshd exited: '+(BASE/'sshd.log').read_text())
|
|
try:
|
|
with socket.create_connection(('127.0.0.1', port), timeout=.2) as conn:
|
|
assert conn.recv(256).startswith(b'SSH-')
|
|
break
|
|
except OSError:
|
|
time.sleep(.1)
|
|
else:
|
|
raise RuntimeError('sshd readiness timed out')
|
|
rows.append(probe('unofficial_unknown_host_default', {}))
|
|
pub = (BASE/'host_key.pub').read_text().split()
|
|
(BASE/'known_hosts').write_text(f'[127.0.0.1]:{port} {pub[0]} {pub[1]}\n')
|
|
rows.append(probe('trusted_host_file_key', {}))
|
|
agent = subprocess.Popen(['ssh-agent', '-D', '-a', str(BASE/'agent.sock')], env=env,
|
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
|
for _ in range(40):
|
|
if (BASE/'agent.sock').exists():
|
|
break
|
|
time.sleep(.1)
|
|
env['SSH_AUTH_SOCK'] = str(BASE/'agent.sock')
|
|
run(['ssh-add', str(BASE/'client_key')])
|
|
ssh_config.write_text(ssh_config.read_text().replace(f'IdentityFile {BASE}/client_key', 'IdentityFile none'))
|
|
rows.append(probe('trusted_host_agent_key', {'SSH_AUTH_SOCK': str(BASE/'agent.sock')}))
|
|
(BASE/'known_hosts').unlink()
|
|
rows.append(probe('explicit_interactive_override', {'GIT_SSH_COMMAND': 'ssh -o BatchMode=no'}))
|
|
finally:
|
|
for proc in (agent, server):
|
|
if proc is not None:
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
proc.wait(timeout=5)
|
|
log.close()
|
|
result = {'platform': sys.platform, 'production': source_check.__file__, 'rows': rows,
|
|
'isolation': 'PATH ssh adapter only adds -F fixture config; execs real /usr/bin/ssh; loopback sshd and disposable git repos',
|
|
'server_stopped': server.poll() is not None, 'agent_stopped': agent is None or agent.poll() is not None}
|
|
(BASE/'result.json').write_text(json.dumps(result, indent=2))
|
|
print(json.dumps(result, indent=2))
|
|
|