#!/usr/bin/env python3 """Build a secret-bearing ECS user-data file without printing its contents.""" import argparse import base64 import json import os import re import shlex import sys import tempfile from pathlib import Path try: from .render_asterisk import render except ImportError: # Direct execution: python3 deploy/build_asterisk_userdata.py from render_asterisk import render CONFIG_FILES = ("http.conf", "ari.conf", "pjsip.conf", "rtp.conf", "extensions.conf") IMAGE_RE = re.compile(r"^[A-Za-z0-9._/-]+(?::[A-Za-z0-9_.-]+)?@sha256:[0-9a-f]{64}$") def read_secret(path): if path is None: value = os.environ.get("ARI_PASSWORD", "") else: path = Path(path) if path.is_symlink() or not path.is_file(): raise ValueError("ARI password file must be an existing non-symlink file") value = path.read_text().rstrip("\n") if not value: raise ValueError("set ARI_PASSWORD or --ari-password-file") return value def immutable_image(value): if not isinstance(value, str) or not IMAGE_RE.fullmatch(value): raise ValueError("--image must be a registry image pinned by @sha256:<64 hex>") return value def build(cfg, image, password, config_gid=1000, template=None, environment=None): if ( isinstance(config_gid, bool) or not isinstance(config_gid, int) or not 1 <= config_gid <= 65535 ): raise ValueError("config GID must be between 1 and 65535") env = dict(os.environ if environment is None else environment) env["ARI_PASSWORD"] = password rendered = render(cfg, env) payloads = { name.upper().replace(".", "_") + "_B64": base64.b64encode( rendered[name].encode() ).decode() for name in CONFIG_FILES } variables = [ "#!/usr/bin/env bash", "# Generated by deploy/build_asterisk_userdata.py; do not commit this file.", f"ASTERISK_IMAGE={shlex.quote(image)}", f"ASTERISK_CONFIG_GID={config_gid}", ] variables.extend(f"{key}={shlex.quote(value)}" for key, value in payloads.items()) variables.append("") if template is None: template = Path(__file__).with_name("asterisk_bootstrap.sh") template_text = Path(template).read_text() if template_text.startswith("#!"): template_text = template_text.split("\n", 1)[1] return "\n".join(variables) + template_text def write_output(content, path): path = Path(path) if path.exists() or path.is_symlink(): raise ValueError( "output already exists; choose a new local secret-bearing path" ) path.parent.mkdir(parents=True, exist_ok=True) temporary_path = None try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=path.parent, prefix=".userdata-", delete=False, ) as temporary: temporary.write(content) temporary.flush() os.fsync(temporary.fileno()) temporary_path = Path(temporary.name) temporary_path.chmod(0o600) temporary_path.replace(path) path.chmod(0o600) except Exception: if temporary_path is not None: temporary_path.unlink(missing_ok=True) raise def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", required=True, help="render_asterisk JSON config") parser.add_argument("--image", help="immutable registry image@sha256:digest") parser.add_argument("--ari-password-file") parser.add_argument( "--output", required=True, help="local secret-bearing user-data path" ) parser.add_argument("--config-gid", type=int, default=1000) args = parser.parse_args() try: cfg = json.loads(Path(args.config).read_text()) image = immutable_image( args.image or cfg.get("asterisk_image") or os.environ.get("ASTERISK_IMAGE") ) content = build( cfg, image, read_secret(args.ari_password_file), args.config_gid ) if len(content.encode()) > 16 * 1024: raise ValueError("generated user-data exceeds the ECS 16 KiB limit") write_output(content, args.output) print( f"wrote {args.output} ({len(content.encode())} bytes); " "secret payload not printed" ) except (OSError, ValueError, json.JSONDecodeError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())