#!/usr/bin/env python3 """Verify the dedicated deployed broker using bounded clients; never print passwords.""" import argparse import json import os import re from pathlib import Path import subprocess import sys import tempfile import time import uuid from prepare import IMAGE, USERS ROOT = Path(__file__).resolve().parents[1] def invoke(args, timeout=30): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) def publication_result(result): """Use the MQTT 5 PUBACK, not the CLI exit code, for broker acceptance.""" if result.returncode: return result acknowledgements = re.findall(r'received PUBACK \(Mid: \d+, RC:\s*(\d+)\)', result.stdout + result.stderr) if len(acknowledgements) != 1: raise RuntimeError('publish acknowledgement missing or ambiguous; check inconclusive') reason = int(acknowledgements[0]) return subprocess.CompletedProcess(result.args, reason if reason >= 128 else 0, result.stdout, result.stderr) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--insecure', action='store_true', help='also test the internal plaintext listener') parser.add_argument('--restart', action='store_true', help='gracefully restart THIS test broker to check persistence') args = parser.parse_args() compose = ['docker', 'compose', '--project-directory', str(ROOT), '-f', str(ROOT / 'compose.yaml')] if args.insecure: compose += ['-f', str(ROOT / 'compose.insecure.yaml')] try: if invoke(compose + ['config', '--quiet']).returncode: raise RuntimeError('Compose validation failed') result = invoke(compose + ['ps', '-q', 'broker']) container = result.stdout.strip() if result.returncode or not container or len(container.splitlines()) != 1: raise RuntimeError('one running test broker is required') if not all(c in '0123456789abcdef' for c in container): raise RuntimeError('invalid container ID') check = invoke(compose + ['exec', '-T', 'broker', '/bin/sh', '/usr/local/bin/mqtt-healthcheck']) if check.returncode: raise RuntimeError('authenticated TLS health exchange failed') print('Authenticated TLS publish/subscribe health check: PASS') with tempfile.TemporaryDirectory(prefix='mqtt5-verify-') as folder: temp = Path(folder) for user in USERS: if user == 'test-health': continue record = json.loads((ROOT / 'secrets' / 'clients' / (user + '.json')).read_text()) password = record['password'] if record['username'] != user or not password or '\n' in password or '\r' in password: raise RuntimeError('invalid credential handoff file') (temp / (user + '.conf')).write_text(f'-u {user}\n-P {password}\n') (temp / 'anonymous.conf').write_text('') (temp / 'wrong.conf').write_text('-u test-backend\n-P invalid-test-password\n') for path in temp.iterdir(): path.chmod(0o600) # A second unrelated test CA proves trust rejection; no existing CA is modified. if invoke(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=Untrusted TEST CA', '-keyout', str(temp / 'wrong.key'), '-out', str(temp / 'wrong.crt')]).returncode: raise RuntimeError('unable to generate negative-test CA') temp.joinpath('wrong.crt').chmod(0o644) def client(binary, user, options, plaintext=False, host='localhost', wrong_ca=False): base = ['docker', 'run', '--rm', '--network', 'container:' + container, '--user', f'{os.getuid()}:{os.getgid()}', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--mount', f'type=bind,src={temp},dst=/checks,readonly', '--mount', f'type=bind,src={ROOT / "certs" / "server" / "ca.crt"},dst=/ca.crt,readonly', '--entrypoint', '/usr/bin/' + binary, IMAGE, '-o', '/checks/' + user + '.conf', '-h', host, '-p', '1883' if plaintext else '8883', '-V', 'mqttv5', '-q', '1'] base += ['--no-tls'] if plaintext else ['--cafile', '/checks/wrong.crt' if wrong_ca else '/ca.crt'] result = invoke(base + (['-d'] if binary == 'mosquitto_pub' else []) + options, timeout=15) if result.returncode in (125, 126, 127): raise RuntimeError('Docker client container could not run; not a verified MQTT denial') return publication_result(result) if binary == 'mosquitto_pub' else result token = 'verify-' + uuid.uuid4().hex topic = 'robot-market/test/v1/esp32-lab-001/status' retained = False session_id = 'rm5-check-' + uuid.uuid4().hex[:16] try: publish = ['-t', topic, '-r', '-m', token] if client('mosquitto_pub', 'esp32-lab-001', publish).returncode: raise RuntimeError('authorized device publish failed') retained = True result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '4']) if result.returncode or result.stdout.strip() != token: raise RuntimeError('authorized backend receive failed') print('Device publication and backend reception: PASS') for name, user, kwargs in ( ('Anonymous authentication', 'anonymous', {}), ('Invalid credentials', 'wrong', {}), ('Untrusted CA', 'test-backend', {'wrong_ca': True}), ('Wrong hostname', 'test-backend', {'host': '127.0.0.2'}), ): result = client('mosquitto_sub', user, ['-t', topic, '-C', '1', '-W', '3'], **kwargs) if not result.returncode or result.stdout.strip() == token: raise RuntimeError(name + ' unexpectedly succeeded') # Denials must not simply be a subscriber timeout on a working connection. if result.returncode == 27: raise RuntimeError(name + ' was inconclusive (receive timeout)') print(name + ' rejection: PASS') result = client('mosquitto_pub', 'sim-001', ['-t', topic, '-m', token]) if result.returncode: raise RuntimeError('functional cross-device publish failed') print('Unrestricted cross-device publication: PASS') result = client('mosquitto_sub', 'sim-001', ['-t', topic, '-C', '1', '-W', '3']) if result.returncode or result.stdout.strip() != token: raise RuntimeError('functional cross-device read failed') print('Unrestricted cross-device reception: PASS') if args.insecure: result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '3'], plaintext=True) if result.returncode or result.stdout.strip() != token: raise RuntimeError('plaintext authenticated exchange failed') print('Optional plaintext authenticated reception: PASS') if args.restart: telemetry = 'robot-market/test/v1/sim-001/telemetry' result = client('mosquitto_sub', 'test-backend', ['-t', telemetry, '-c', '-x', '300', '-i', session_id, '-E']) if result.returncode: raise RuntimeError('persistent subscription setup failed') if invoke(compose + ['restart', 'broker'], timeout=45).returncode: raise RuntimeError('test broker restart failed') for attempt in range(10): if invoke(compose + ['exec', '-T', 'broker', '/bin/sh', '/usr/local/bin/mqtt-healthcheck']).returncode == 0: break time.sleep(1) else: raise RuntimeError('broker health did not recover') result = client('mosquitto_sub', 'test-backend', ['-t', topic, '-C', '1', '-W', '4']) if result.returncode or result.stdout.strip() != token: raise RuntimeError('retained status not preserved') if client('mosquitto_pub', 'sim-001', ['-t', telemetry, '-m', token]).returncode: raise RuntimeError('offline session queue publish failed') result = client('mosquitto_sub', 'test-backend', ['-t', telemetry, '-c', '-x', '0', '-i', session_id, '-C', '1', '-W', '4']) if result.returncode or result.stdout.strip() != token: raise RuntimeError('persistent subscription/queued message not restored') print('Graceful restart retained/session persistence: PASS (not crash durability)') finally: if retained: cleanup = client('mosquitto_pub', 'esp32-lab-001', ['-t', topic, '-r', '-n']) if cleanup.returncode: print('WARNING: verification retained status cleanup failed', file=sys.stderr) print('Server-container checks complete. External DNS/firewall/hostname reachability still needs a client check.') return 0 except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error: # All RuntimeError messages above are controlled and contain no credential values. message = str(error) if isinstance(error, RuntimeError) else 'missing prerequisites/files or a bounded operation failed' print('Verification FAILED/BLOCKED: ' + message, file=sys.stderr) return 1 if __name__ == '__main__': sys.exit(main())