Files

261 lines
15 KiB
Python

#!/usr/bin/env python3
"""Collect a bounded diagnostic ZIP without requiring a running Framely service."""
import argparse
import base64
import datetime
import io
import json
import os
import pathlib
import platform
import pwd
import re
import subprocess
import zipfile
MAX_FILE = 256 * 1024
MAX_TOTAL = 8 * 1024 * 1024
SECRET = re.compile(r'(?i)((?:password|passwd|token|secret|authorization|api[_-]?key)[\"\']?\s*[=:]\s*)(?:"[^"]*"|\'[^\']*\'|[^\s,;]+)')
URL_CREDENTIALS = re.compile(r'(https?://)[^/\s:@]+:[^/\s@]+@')
def redact(text):
text = re.sub(r'(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+', 'Bearer [redacted]', text)
text = SECRET.sub(r'\1[redacted]', text)
text = re.sub(r'(/boot/)[a-f0-9]{32,}', r'\1[redacted]', text)
return URL_CREDENTIALS.sub(r'\1[redacted]@', text)
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None, steam_home=None):
report = {'format': 1, 'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'warnings': []}
entries = {}
budget = MAX_TOTAL
def add(name, data):
nonlocal budget
data = redact(data).encode('utf-8', errors='replace')
if len(data) > MAX_FILE:
report['warnings'].append(name + ': truncated to recent output')
data = data[-MAX_FILE:]
if len(data) > budget:
report['warnings'].append(name + ': omitted (archive budget)')
return
entries[name] = data
budget -= len(data)
def tail(path, name):
try:
if path.is_symlink() or not path.is_file():
return
with path.open('rb') as source:
size = os.fstat(source.fileno()).st_size
if size > MAX_FILE:
report['warnings'].append(name + ': truncated to recent output')
source.seek(max(0, size - MAX_FILE))
add(name, source.read(MAX_FILE).decode('utf-8', errors='replace'))
except (OSError, ValueError) as error:
report['warnings'].append(name + ': ' + str(error))
def command(name, args):
try:
# Temporary files avoid buffering unbounded journal output in memory.
import tempfile
with tempfile.TemporaryFile() as output:
try:
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
code = result.returncode
except subprocess.TimeoutExpired:
code = None
report['warnings'].append(name + ': command timed out; partial output included')
output.seek(0, 2)
size = output.tell()
if size > MAX_FILE:
report['warnings'].append(name + ': truncated to recent output')
output.seek(max(0, size - MAX_FILE))
text = output.read(MAX_FILE).decode('utf-8', errors='replace')
add(name, text)
if code:
report['warnings'].append(name + ': command exited ' + str(code))
return text if code == 0 else None
except (OSError, subprocess.TimeoutExpired) as error:
report['warnings'].append(name + ': ' + str(error))
if installer_log is not None:
add('installer.log', installer_log)
add('system.json', json.dumps({'system': platform.system(), 'release': platform.release(), 'machine': platform.machine()}, indent=2))
for name in ('current', 'previous'):
tail(state / name / 'VERSION', 'versions/' + name + '.txt')
tail(state / 'previous-release', 'versions/previous-release.txt')
# Only a summary is included; plugin configuration, proxy credentials,
# inbox contents and authentication files must never enter the archive.
try:
database = json.loads((state / 'state.json').read_text())
plugins = database.get('plugins', {})
add('plugins.json', json.dumps({'safeMode': database.get('safeMode'), 'plugins': [
{'id': key, 'name': value.get('manifest', {}).get('name'), 'version': value.get('manifest', {}).get('version'), 'enabled': value.get('enabled'), 'error': value.get('error')}
for key, value in plugins.items()
]}, ensure_ascii=False, indent=2))
except (OSError, ValueError, TypeError, AttributeError) as error:
report['warnings'].append('plugins.json: ' + str(error))
# APK management runs as the configured Steam user, not the root daemon.
# Resolve that user explicitly: root's HOME and rootful Podman are wrong.
steam_user = None
try:
if steam_home is None:
user_file = state / 'steam-user'
if user_file.is_file() and not user_file.is_symlink():
name = user_file.read_text()[:256].strip()
steam_user = pwd.getpwnam(name)
elif os.geteuid() != 0:
steam_user = pwd.getpwuid(os.geteuid())
if steam_user is not None and steam_user.pw_uid >= 1000:
steam_home = pathlib.Path(steam_user.pw_dir)
if steam_home is None:
report['warnings'].append('apk: Steam session user unavailable; APK logs not collected')
else:
steam_home = pathlib.Path(steam_home)
apk = steam_home / '.local/share/framely/apk-manager'
storage_file = steam_home / '.local/share/framely/storage.json'
if storage_file.is_file() and not storage_file.is_symlink() and storage_file.stat().st_uid == steam_home.stat().st_uid:
try:
with storage_file.open('rb') as source:
raw = source.read(65537)
if len(raw) > 65536:
raise ValueError('storage setting exceeds diagnostic size limit')
configured = json.loads(raw).get('apk')
if isinstance(configured, str) and pathlib.Path(configured).is_absolute():
apk = pathlib.Path(configured)
except (OSError, ValueError, TypeError) as error:
report['warnings'].append(f'apk: invalid storage setting: {error}')
try:
with (apk / 'state.json').open('rb') as source:
raw = source.read(16 * 1024 * 1024 + 1)
if len(raw) > 16 * 1024 * 1024:
raise ValueError('APK state exceeds diagnostic size limit')
database = json.loads(raw)
records = database.get('records', {})
# Never include tokens, bindings, icons or the complete state.
summary = {'apps': [
{'id': key, 'context': item.get('context'), 'removed': item.get('removed'),
'pending': item.get('pending'), 'steamAppId': item.get('steamAppId'),
'steamLaunch': item.get('steamLaunch'),
'package': item.get('metadata', {}).get('package'),
'name': item.get('metadata', {}).get('name'),
'version': item.get('metadata', {}).get('version')}
for key, item in records.items()
], 'gamepadEnabled': database.get('gamepad_enabled'),
'gamepadSource': database.get('gamepad_source')}
add('apk/summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
except (OSError, ValueError, TypeError, AttributeError) as error:
report['warnings'].append('apk/summary.json: ' + str(error))
def recent_logs(directory, pattern, limit, prefix):
try:
if directory.is_symlink():
raise ValueError('Diagnostic log directory must not be a symlink')
if not directory.is_dir():
report['warnings'].append(prefix + ': log directory unavailable')
return
paths = [p for p in directory.glob(pattern)
if not p.is_symlink() and not p.parent.is_symlink() and p.is_file()]
paths.sort(key=lambda p: p.stat().st_mtime, reverse=True)
for path in paths[:limit]:
tail(path, prefix + '/' + path.relative_to(directory).as_posix())
if len(paths) > limit:
report['warnings'].append(prefix + ': only the ' + str(limit) + ' newest logs included')
except (OSError, ValueError) as error:
report['warnings'].append(prefix + ': ' + str(error))
recent_logs(apk / 'logs', '*.log', 12, 'apk/operations')
steam = steam_home / '.local/share/Steam'
if not steam.is_dir():
steam = steam_home / '.steam/steam'
# Only known diagnostic files, never media, APKs, saves or tombstones.
recent_logs(steam / 'logs/lepton-logcats', '*/logcat-*.log', 12, 'apk/android-saved')
if commands and steam_user is not None:
prefix = []
if os.geteuid() == 0:
prefix = ['runuser', '-u', steam_user.pw_name, '--']
prefix += ['env', 'HOME=' + str(steam_home),
'XDG_RUNTIME_DIR=/run/user/' + str(steam_user.pw_uid), 'podman']
listing = command('apk/containers.txt', prefix + ['ps', '-a', '--filter', 'name=^lepton-', '--format', '{{.Names}}\t{{.Status}}'])
if listing:
active = []
for line in listing.splitlines():
name, _, status = line.partition('\t')
if re.fullmatch(r'lepton-[A-Za-z0-9_.-]+', name) and status.startswith('Up '):
active.append(name)
for name in active[:4]:
# A single read-only exec per active container bounds export
# latency and works even when PackageManager is not ready.
probe = ('echo "=== boot ==="; getprop sys.boot_completed; '
'echo "=== user ==="; am get-started-user-state 0; '
'echo "=== storage ==="; ls -ld /storage/emulated/0; dumpsys mount; '
'echo "=== package manager ==="; pm path android; '
'echo "=== recent Android logs ==="; logcat -d -b main -b system -b crash -t 500')
command('apk/android-live/' + name + '.log', prefix + ['exec', name, 'sh', '-c', probe])
if len(active) > 4:
report['warnings'].append('apk/android-live: only four active containers queried')
except (OSError, ValueError, KeyError) as error:
report['warnings'].append('apk: ' + str(error))
try:
for path in sorted((state / 'logs').glob('*.log'))[:64]:
tail(path, 'logs/' + path.name)
for path in sorted(runtime.glob('*/framely-session-*/cef.log'))[-8:]:
tail(path, 'renderer/' + path.parent.parent.name + '-' + path.parent.name + '-cef.log')
except OSError as error:
report['warnings'].append('logs: ' + str(error))
# Record GPU evidence without copying command lines or authentication tokens.
gpu = []
if commands:
for process in pathlib.Path('/proc').glob('[0-9]*'):
try:
if pathlib.Path(os.readlink(process / 'exe')).name != 'framely-vr':
continue
arguments = (process / 'cmdline').read_bytes()
match = re.search(rb'--type=([a-z-]+)', arguments)
kind = match.group(1).decode() if match else 'browser'
info = {'pid': int(process.name), 'type': kind, 'gpuDisabled': b'--disable-gpu' in arguments.replace(b'\0', b' ').split()}
if kind == 'gpu-process':
maps = (process / 'maps').read_text(errors='replace')
info['drivers'] = sorted(set(line.split()[-1] for line in maps.splitlines() if any(word in line.lower() for word in ('freedreno', 'adreno', 'swiftshader', 'llvmpipe', 'vulkan', 'libegl', 'libgles'))))
devices = []
for fd in (process / 'fd').iterdir():
try:
target = os.readlink(fd)
if target.startswith('/dev/dri/') or target == '/dev/kgsl-3d0':
devices.append(target)
except OSError:
pass
info['devices'] = sorted(set(devices))
gpu.append(info)
except (OSError, ValueError):
continue
add('renderer/gpu.json', json.dumps(gpu, indent=2))
if commands:
command('services.txt', ['systemctl', 'status', 'framely.service', 'framely-session.service', '--no-pager', '--full'])
command('journal.txt', ['journalctl', '-u', 'framely.service', '-u', 'framely-session.service', '-u', 'framely-plugin-*', '-u', 'framely-backend-*', '-u', 'framely-hook-*', '--since=-24h', '-n', '3000', '--no-pager', '-o', 'short-iso'])
add('README.txt', 'Framely diagnostics. Recent Framely and APK operation logs, saved/active Lepton Android log tails, boot/unlock/storage diagnostics, service state and installed version summary only. Application files, media, APK archives and raw memory dumps are excluded. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
entries['report.json'] = json.dumps(report, ensure_ascii=False, indent=2).encode()
memory = io.BytesIO()
with zipfile.ZipFile(memory, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, data in entries.items():
archive.writestr(name, data)
name = 'framely-logs-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '.zip'
return {'name': name, 'data': base64.b64encode(memory.getvalue()).decode('ascii')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--state', type=pathlib.Path)
parser.add_argument('--output', type=pathlib.Path)
parser.add_argument('--installer-log-base64')
args = parser.parse_args()
state = args.state or (pathlib.Path('/home/.framely/state') if pathlib.Path('/home/.framely/state').is_dir() else pathlib.Path('/var/lib/framely'))
extra = base64.b64decode(args.installer_log_base64).decode('utf-8', errors='replace') if args.installer_log_base64 else None
result = collect(state, installer_log=extra)
if args.output:
with args.output.open('xb') as output:
os.chmod(args.output, 0o600)
output.write(base64.b64decode(result['data']))
print(args.output)
else:
print(json.dumps(result))
if __name__ == '__main__':
main()