Files
mqtt-5-test/deploy/scripts/prepare.py
T
Ryan 71ef622b4f
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s
Deploy MQTT test backend with private viewer and Gitea CI/CD
2026-10-08 14:48:53 +03:30

136 lines
6.8 KiB
Python

#!/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())