136 lines
6.8 KiB
Python
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())
|