#!/usr/bin/env python3 """Prepare test-only credentials/certificates without printing secrets or starting a broker.""" import argparse import ipaddress import json import os from pathlib import Path import re import secrets import shutil import subprocess import sys IMAGE = 'eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408' USERS = ('test-backend', 'sim-001', 'esp32-lab-001', 'test-health') def run(args): result = subprocess.run(args, capture_output=True, text=True, timeout=180) if result.returncode: # OpenSSL/CLI errors can contain paths or config values. Keep raw output private. raise RuntimeError(f'{args[0]} failed; check tool availability, Docker access and image availability') def valid_host(value): try: ipaddress.ip_address(value) return value except ValueError: if len(value) > 253 or not all(re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?', part) for part in value.split('.')): raise argparse.ArgumentTypeError('expected a DNS hostname or IP address, without scheme/port') return value def write(path, data, mode=0o600): with path.open('x') as stream: stream.write(data) path.chmod(mode) def prepare(root, host, uid, gid): root.mkdir(parents=True, exist_ok=True) for name in ('certs', 'secrets', 'data', '.env'): if (root / name).exists() or (root / name).is_symlink(): raise RuntimeError(f'{name} already exists; refusing to replace existing credentials, certificates or data') if os.geteuid() != 0 and (uid != os.getuid() or gid != os.getgid()): raise RuntimeError('run with sudo for UID/GID 1883, or supply --owner with your own UID:GID for isolated artifact tests') for tool in ('docker', 'openssl'): if not shutil.which(tool): raise RuntimeError(f'{tool} is required') # All following generated content is inside the explicitly selected deployment root. os.umask(0o077) authority = root / 'certs' / 'authority' server = root / 'certs' / 'server' runtime = root / 'secrets' / 'runtime' clients = root / 'secrets' / 'clients' for path in (authority, server, runtime, clients, root / 'data'): path.mkdir(parents=True) run(['openssl', 'req', '-x509', '-newkey', 'rsa:3072', '-nodes', '-sha256', '-days', '365', '-subj', '/CN=Robot Market TEST CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign', '-keyout', str(authority / 'ca.key'), '-out', str(server / 'ca.crt')]) try: ipaddress.ip_address(host) host_san = f'IP:{host}' except ValueError: host_san = f'DNS:{host}' write(server / 'server.ext', 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=' + host_san + ',DNS:localhost,IP:127.0.0.1\n') run(['openssl', 'req', '-new', '-newkey', 'rsa:3072', '-nodes', '-sha256', '-subj', '/CN=' + host, '-keyout', str(server / 'server.key'), '-out', str(server / 'server.csr')]) run(['openssl', 'x509', '-req', '-sha256', '-days', '90', '-in', str(server / 'server.csr'), '-CA', str(server / 'ca.crt'), '-CAkey', str(authority / 'ca.key'), '-CAcreateserial', '-extfile', str(server / 'server.ext'), '-out', str(server / 'server.crt')]) run(['openssl', 'verify', '-CAfile', str(server / 'ca.crt'), str(server / 'server.crt')]) passwords = {user: secrets.token_urlsafe(32) for user in USERS} # Hash a disposable file; never pass passwords as command arguments. raw = runtime / 'passwords.pending' write(raw, ''.join(f'{user}:{password}\n' for user, password in passwords.items())) try: run(['docker', 'run', '--rm', '--network', 'none', '--user', f'{os.getuid()}:{os.getgid()}', '--mount', f'type=bind,src={runtime.resolve()},dst=/setup', '--entrypoint', '/usr/bin/mosquitto_passwd', IMAGE, '-U', '/setup/passwords.pending']) # Check conversion without emitting hashes or passwords. entries = raw.read_text().splitlines() if len(entries) != len(USERS) or any(not line.split(':', 1)[1].startswith('$') for line in entries): raise RuntimeError('password hashing did not produce the expected output') raw.rename(runtime / 'passwords') finally: raw.unlink(missing_ok=True) write(runtime / 'health.conf', '-u test-health\n-P ' + passwords['test-health'] + '\n') for user in USERS: if user == 'test-health': continue write(clients / (user + '.json'), json.dumps({'host': host, 'tls_port': 8884, 'plaintext_port': 1884, 'username': user, 'password': passwords[user], 'device_id': user if user != 'test-backend' else None}, indent=2) + '\n') for path in (root / 'certs', server, root / 'secrets', runtime): os.chown(path, os.getuid(), gid) path.chmod(0o750) for path in (*server.iterdir(), *runtime.iterdir()): os.chown(path, os.getuid(), gid) path.chmod(0o640) # The CA signing key stays private and is never mounted into the broker. authority.chmod(0o700) (authority / 'ca.key').chmod(0o600) os.chown(root / 'data', uid, gid) (root / 'data').chmod(0o750) write(root / '.env', f'TLS_BIND_IP=0.0.0.0\nTLS_PORT=8884\nINSECURE_BIND_IP=0.0.0.0\nINSECURE_PORT=1884\nBROKER_UID={uid}\nBROKER_GID={gid}\n', 0o644) print('Prepared test certificates, hashed broker passwords and private client handoff files.') print('No broker was started. Edit .env for available ports; client JSON ports must match your edits.') print('Client secrets: secrets/clients/ (do not commit or paste into public logs). Public CA: certs/server/ca.crt') def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--host', required=True, type=valid_host, help='actual DNS name or IP clients will use') parser.add_argument('--owner', default='1883:1883', help='broker UID:GID (default 1883:1883)') parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parents[1]) args = parser.parse_args() try: uid, gid = map(int, args.owner.split(':')) if not (1 <= uid <= 2**31-1 and 1 <= gid <= 2**31-1): raise ValueError() except ValueError: parser.error('--owner must be non-root numeric UID:GID') try: prepare(args.output.resolve(), args.host, uid, gid) except (OSError, RuntimeError, subprocess.TimeoutExpired) as error: detail = str(error) if isinstance(error, RuntimeError) else 'a file operation or bounded tool execution failed' print('Preparation failed: ' + detail + '. Preserve partial output and inspect privately before retrying.', file=sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main())