|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 |
|
3 | 3 | """ |
4 | | -Used to generate the docker-compose configs used by ref. |
| 4 | +First-run initialization for REF. |
| 5 | +
|
| 6 | +Generates the ``settings.yaml`` configuration file with cryptographically |
| 7 | +secure secrets, renders ``settings.env`` (consumed by docker-compose) from it, |
| 8 | +generates ``docker-compose.yml`` from its template, and creates the container |
| 9 | +SSH host keys used by the SSH reverse proxy. |
| 10 | +
|
| 11 | +This script must be run once before the first ``./ctrl.sh up``. It refuses to |
| 12 | +run if ``settings.yaml`` already exists so that existing secrets are never |
| 13 | +overwritten silently. |
5 | 14 | """ |
6 | 15 |
|
7 | | -import jinja2 |
8 | | -import subprocess |
| 16 | +import secrets |
9 | 17 | import shutil |
| 18 | +import subprocess |
| 19 | +import sys |
10 | 20 | from pathlib import Path |
| 21 | +from typing import Any, Dict |
11 | 22 |
|
| 23 | +import jinja2 |
| 24 | +import yaml |
| 25 | + |
| 26 | +REPO_ROOT = Path(__file__).resolve().parent |
| 27 | +SETTINGS_YAML = REPO_ROOT / "settings.yaml" |
| 28 | +SETTINGS_ENV = REPO_ROOT / "settings.env" |
12 | 29 | COMPOSE_TEMPLATE = "docker-compose.template.yml" |
| 30 | +COMPOSE_OUT = REPO_ROOT / "docker-compose.yml" |
| 31 | +CONTAINER_KEYS_DIR = REPO_ROOT / "container-keys" |
| 32 | +DOCKER_BASE_KEYS_DIR = REPO_ROOT / "ref-docker-base" / "container-keys" |
| 33 | + |
| 34 | +SECRET_BYTES = 32 |
| 35 | + |
| 36 | + |
| 37 | +def detect_docker_group_id() -> int: |
| 38 | + """Look up the host's docker group ID; fall back to 999 if unavailable.""" |
| 39 | + try: |
| 40 | + out = subprocess.check_output(["getent", "group", "docker"], text=True).strip() |
| 41 | + # Format: docker:x:<gid>:<members> |
| 42 | + return int(out.split(":")[2]) |
| 43 | + except (subprocess.CalledProcessError, FileNotFoundError, IndexError, ValueError): |
| 44 | + return 999 |
| 45 | + |
| 46 | + |
| 47 | +def build_default_settings() -> Dict[str, Any]: |
| 48 | + """Assemble a fresh settings dict with cryptographically secure secrets.""" |
| 49 | + return { |
| 50 | + "debug": False, |
| 51 | + "maintenance_enabled": False, |
| 52 | + "docker_group_id": detect_docker_group_id(), |
| 53 | + "ports": { |
| 54 | + "ssh_host_port": 2222, |
| 55 | + "http_host_port": 8000, |
| 56 | + }, |
| 57 | + "admin": { |
| 58 | + # Auto-generated on first boot. The user logs in with username "0". |
| 59 | + "password": secrets.token_urlsafe(SECRET_BYTES), |
| 60 | + # If null, the web app generates a keypair on first boot and |
| 61 | + # exposes the private key via the admin web interface. |
| 62 | + "ssh_key": None, |
| 63 | + }, |
| 64 | + "secrets": { |
| 65 | + # Flask session / CSRF signing key. |
| 66 | + "secret_key": secrets.token_urlsafe(SECRET_BYTES), |
| 67 | + # HMAC key shared between the SSH reverse proxy and the web API. |
| 68 | + "ssh_to_web_key": secrets.token_urlsafe(SECRET_BYTES), |
| 69 | + # PostgreSQL superuser password used for initial DB setup. |
| 70 | + "postgres_password": secrets.token_urlsafe(SECRET_BYTES), |
| 71 | + }, |
| 72 | + } |
| 73 | + |
| 74 | + |
| 75 | +SETTINGS_YAML_HEADER = """\ |
| 76 | +# REF configuration file. |
| 77 | +# |
| 78 | +# Generated by prepare.py. All secrets were created with a cryptographically |
| 79 | +# secure random generator (Python's `secrets` module). Treat this file as |
| 80 | +# sensitive: it grants full administrative access to the REF instance. |
| 81 | +# |
| 82 | +# To regenerate from scratch, delete this file together with settings.env and |
| 83 | +# re-run ./prepare.py. |
| 84 | +""" |
13 | 85 |
|
14 | 86 |
|
15 | | -def generate_docker_compose(): |
16 | | - template_loader = jinja2.FileSystemLoader(searchpath="./") |
| 87 | +def write_settings_yaml(settings: Dict[str, Any]) -> None: |
| 88 | + with SETTINGS_YAML.open("w") as f: |
| 89 | + f.write(SETTINGS_YAML_HEADER) |
| 90 | + yaml.safe_dump(settings, f, sort_keys=False, default_flow_style=False) |
| 91 | + SETTINGS_YAML.chmod(0o600) |
| 92 | + |
| 93 | + |
| 94 | +def _env_value(value: Any) -> str: |
| 95 | + """Render a Python value as an env-file scalar.""" |
| 96 | + if value is None or value is False: |
| 97 | + return "" |
| 98 | + if value is True: |
| 99 | + return "1" |
| 100 | + return str(value) |
| 101 | + |
| 102 | + |
| 103 | +def render_settings_env(settings: Dict[str, Any]) -> None: |
| 104 | + """Write settings.env so that docker-compose can consume the values.""" |
| 105 | + admin_password = settings["admin"]["password"] |
| 106 | + admin_ssh_key = settings["admin"]["ssh_key"] or "" |
| 107 | + lines = [ |
| 108 | + "# Auto-generated by prepare.py from settings.yaml. Do not edit by hand.", |
| 109 | + "# Edit settings.yaml instead and re-render via ./prepare.py (after", |
| 110 | + "# deleting settings.yaml if you want fresh secrets).", |
| 111 | + "", |
| 112 | + f"DEBUG={1 if settings['debug'] else 0}", |
| 113 | + f"MAINTENANCE_ENABLED={1 if settings['maintenance_enabled'] else 0}", |
| 114 | + "", |
| 115 | + f"ADMIN_PASSWORD={admin_password}", |
| 116 | + f'ADMIN_SSH_KEY="{admin_ssh_key}"', |
| 117 | + "", |
| 118 | + f"DOCKER_GROUP_ID={settings['docker_group_id']}", |
| 119 | + "", |
| 120 | + f"SSH_HOST_PORT={settings['ports']['ssh_host_port']}", |
| 121 | + f"HTTP_HOST_PORT={settings['ports']['http_host_port']}", |
| 122 | + "", |
| 123 | + f"SECRET_KEY={settings['secrets']['secret_key']}", |
| 124 | + f"SSH_TO_WEB_KEY={settings['secrets']['ssh_to_web_key']}", |
| 125 | + f"POSTGRES_PASSWORD={settings['secrets']['postgres_password']}", |
| 126 | + "", |
| 127 | + ] |
| 128 | + SETTINGS_ENV.write_text("\n".join(lines)) |
| 129 | + SETTINGS_ENV.chmod(0o600) |
| 130 | + |
| 131 | + |
| 132 | +def generate_docker_compose() -> None: |
| 133 | + template_loader = jinja2.FileSystemLoader(searchpath=str(REPO_ROOT)) |
17 | 134 | template_env = jinja2.Environment(loader=template_loader) |
18 | 135 | template = template_env.get_template(COMPOSE_TEMPLATE) |
19 | 136 |
|
20 | | - # TODO: Load settings.ini and use values to generate the docker file. |
21 | | - |
22 | 137 | cgroup_base = "ref" |
23 | 138 | cgroup_parent = f"{cgroup_base}-core.slice" |
24 | 139 | instances_cgroup_parent = f"{cgroup_base}-instances.slice" |
25 | 140 |
|
26 | 141 | render_out = template.render( |
27 | 142 | testing=False, |
28 | | - bridge_id="", # Not used when testing=False, template uses 'ref' suffix |
| 143 | + bridge_id="", |
29 | 144 | data_path="./data", |
30 | 145 | exercises_path="./exercises", |
31 | 146 | cgroup_parent=cgroup_parent, |
32 | 147 | instances_cgroup_parent=instances_cgroup_parent, |
33 | 148 | binfmt_support=False, |
34 | 149 | ) |
35 | | - with open("docker-compose.yml", "w") as f: |
36 | | - f.write(render_out) |
37 | | - |
| 150 | + COMPOSE_OUT.write_text(render_out) |
38 | 151 |
|
39 | | -def generate_ssh_keys(): |
40 | | - """ |
41 | | - Generate the SSH keys that are used by the SSH reverse proxy to authenticate at the containers. |
42 | | - """ |
43 | | - container_keys_dir = Path("container-keys") |
44 | | - container_keys_dir.mkdir(exist_ok=True) |
45 | 152 |
|
46 | | - key_paths = [ |
47 | | - container_keys_dir / "root_key", |
48 | | - container_keys_dir / "user_key", |
49 | | - ] |
| 153 | +def generate_ssh_keys() -> None: |
| 154 | + """Generate the SSH host keys used by the SSH reverse proxy.""" |
| 155 | + CONTAINER_KEYS_DIR.mkdir(exist_ok=True) |
50 | 156 |
|
51 | | - for key_path in key_paths: |
| 157 | + for name in ("root_key", "user_key"): |
| 158 | + key_path = CONTAINER_KEYS_DIR / name |
52 | 159 | if not key_path.exists(): |
53 | 160 | subprocess.check_call( |
54 | | - f"ssh-keygen -t ed25519 -N '' -f {key_path.as_posix()}", |
55 | | - shell=True, |
| 161 | + ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)] |
56 | 162 | ) |
57 | 163 |
|
58 | | - # Copy keys to ref-docker-base for container builds |
59 | | - shutil.copytree( |
60 | | - container_keys_dir, |
61 | | - Path("ref-docker-base") / "container-keys", |
62 | | - dirs_exist_ok=True, |
63 | | - ) |
| 164 | + shutil.copytree(CONTAINER_KEYS_DIR, DOCKER_BASE_KEYS_DIR, dirs_exist_ok=True) |
64 | 165 |
|
65 | 166 |
|
66 | | -def main(): |
| 167 | +def main() -> int: |
| 168 | + if SETTINGS_YAML.exists(): |
| 169 | + print( |
| 170 | + f"error: {SETTINGS_YAML.name} already exists. Refusing to overwrite " |
| 171 | + "existing secrets.\n" |
| 172 | + "If you want to regenerate configuration from scratch, delete " |
| 173 | + f"{SETTINGS_YAML.name} and settings.env and re-run this script.", |
| 174 | + file=sys.stderr, |
| 175 | + ) |
| 176 | + return 1 |
| 177 | + |
| 178 | + settings = build_default_settings() |
| 179 | + write_settings_yaml(settings) |
| 180 | + render_settings_env(settings) |
67 | 181 | generate_docker_compose() |
68 | 182 | generate_ssh_keys() |
69 | 183 |
|
| 184 | + print(f"Wrote {SETTINGS_YAML.name} (0600)") |
| 185 | + print(f"Wrote {SETTINGS_ENV.name} (0600)") |
| 186 | + print(f"Wrote {COMPOSE_OUT.name}") |
| 187 | + print(f"Generated container SSH keys in {CONTAINER_KEYS_DIR.name}/") |
| 188 | + print() |
| 189 | + print("Admin credentials for first login:") |
| 190 | + print(" user: 0") |
| 191 | + print(f" password: {settings['admin']['password']}") |
| 192 | + print() |
| 193 | + print("Next steps:") |
| 194 | + print(" ./ctrl.sh build") |
| 195 | + print(" ./ctrl.sh up") |
| 196 | + return 0 |
| 197 | + |
70 | 198 |
|
71 | 199 | if __name__ == "__main__": |
72 | | - main() |
| 200 | + sys.exit(main()) |
0 commit comments