Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user