Skip to content

Commit 9a317be

Browse files
author
Nils Bars
committed
Auto-generate app secrets on first run via prepare.py
Rewrite prepare.py as a first-run initializer that generates settings.yaml with cryptographically secure random secrets (ADMIN_PASSWORD, SECRET_KEY, SSH_TO_WEB_KEY, POSTGRES_PASSWORD) using Python's secrets module, auto-detects DOCKER_GROUP_ID, and renders settings.env from settings.yaml so docker-compose keeps working unchanged. Refuses to run when settings.yaml already exists so existing secrets are never silently overwritten. ctrl.sh now bootstraps configuration by invoking prepare.py automatically on the first run (when no settings files are present) and otherwise verifies that all generated artifacts exist. settings.yaml is added to .gitignore so the plaintext secrets cannot be committed by accident. README and template.env are updated to point at the new flow. Closes #11
1 parent ef478f8 commit 9a317be

5 files changed

Lines changed: 214 additions & 69 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
docker-compose.yml
1010
settings.env
11+
settings.env.backup
12+
settings.yaml
1113
exercises
1214
data
1315

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@ git clone git@github.com:remote-exercise-framework/ref.git
1616
cd ref
1717
git submodule update --init --recursive
1818

19-
# Create an environment file used for configuration and adapt the values in settings.env.
20-
# Make sure to uncomment the settings and to change the password!
21-
cp template.env settings.env
19+
# Generate configuration (settings.yaml + settings.env), docker-compose.yml, and
20+
# container SSH keys. This MUST be run once before the first ./ctrl.sh build.
21+
# All secrets are generated with a cryptographically secure RNG. The generated
22+
# settings.yaml file is the canonical configuration; settings.env is rendered
23+
# from it for docker-compose. The admin password is printed at the end of the
24+
# run — note it down, it can also be found in settings.yaml.
25+
#
26+
# prepare.py refuses to run if settings.yaml already exists, so existing
27+
# secrets are never silently overwritten.
28+
./prepare.py
2229

2330
# Build all images. This command will check if your system meets the requirements
2431
# and will print error messages in case something is not working as expected.
@@ -157,7 +164,7 @@ The webinterface to manage the exercises and users. This endpoint is alow used b
157164
Hostname: web
158165
Port: 8000
159166
User: 0
160-
Password: See settings.env
167+
Password: See settings.yaml (admin.password)
161168
```
162169

163170
#### Postgres Database
@@ -167,5 +174,5 @@ Hostname: db
167174
Port: Not expose to the host
168175
User: ref
169176
Database name: ref
170-
Password: See settings.env
177+
Password: See settings.yaml (secrets.postgres_password)
171178
```

ctrl.sh

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,34 @@ fi
221221

222222
#Check the .env files used to parametrize the docker-compose file.
223223
ENV_SETTINGS_FILE="settings.env"
224+
YAML_SETTINGS_FILE="settings.yaml"
225+
226+
# First-run bootstrap: if no configuration exists yet, run prepare.py to
227+
# generate settings.yaml (with secure random secrets), settings.env,
228+
# docker-compose.yml, and the container SSH host keys. prepare.py refuses to
229+
# run if settings.yaml already exists, so this branch only triggers on a
230+
# fresh setup.
231+
if [[ ! -f $YAML_SETTINGS_FILE && ! -f $ENV_SETTINGS_FILE ]]; then
232+
info "No configuration found, running ./prepare.py for first-run setup"
233+
if ! ./prepare.py; then
234+
error "Failed to run prepare.py"
235+
exit 1
236+
fi
237+
fi
238+
239+
if [[ ! -f $YAML_SETTINGS_FILE || ! -f $ENV_SETTINGS_FILE ]]; then
240+
error "Configuration is incomplete: expected both $YAML_SETTINGS_FILE and $ENV_SETTINGS_FILE."
241+
error "Delete any leftover file and re-run ./prepare.py to regenerate from scratch."
242+
exit 1
243+
fi
224244

225-
if [[ ! -f $ENV_SETTINGS_FILE ]]; then
226-
error "Please copy template.env to $ENV_SETTINGS_FILE and adapt the values"
245+
if [[ ! -f "docker-compose.yml" ]]; then
246+
error "docker-compose.yml is missing. Delete $YAML_SETTINGS_FILE and re-run ./prepare.py to regenerate it."
247+
exit 1
248+
fi
249+
250+
if [[ ! -f "container-keys/root_key" || ! -f "container-keys/user_key" ]]; then
251+
error "Container SSH keys are missing. Delete $YAML_SETTINGS_FILE and re-run ./prepare.py to regenerate them."
227252
exit 1
228253
fi
229254

@@ -287,11 +312,6 @@ else
287312
fi
288313
fi
289314

290-
# Generate docker-compose files and generate keys.
291-
if ! ./prepare.py; then
292-
error "Failed to run prepare.py"
293-
exit 1
294-
fi
295315

296316
function update {
297317
(

prepare.py

Lines changed: 160 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,200 @@
11
#!/usr/bin/env python3
22

33
"""
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.
514
"""
615

7-
import jinja2
8-
import subprocess
16+
import secrets
917
import shutil
18+
import subprocess
19+
import sys
1020
from pathlib import Path
21+
from typing import Any, Dict
1122

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"
1229
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+
"""
1385

1486

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))
17134
template_env = jinja2.Environment(loader=template_loader)
18135
template = template_env.get_template(COMPOSE_TEMPLATE)
19136

20-
# TODO: Load settings.ini and use values to generate the docker file.
21-
22137
cgroup_base = "ref"
23138
cgroup_parent = f"{cgroup_base}-core.slice"
24139
instances_cgroup_parent = f"{cgroup_base}-instances.slice"
25140

26141
render_out = template.render(
27142
testing=False,
28-
bridge_id="", # Not used when testing=False, template uses 'ref' suffix
143+
bridge_id="",
29144
data_path="./data",
30145
exercises_path="./exercises",
31146
cgroup_parent=cgroup_parent,
32147
instances_cgroup_parent=instances_cgroup_parent,
33148
binfmt_support=False,
34149
)
35-
with open("docker-compose.yml", "w") as f:
36-
f.write(render_out)
37-
150+
COMPOSE_OUT.write_text(render_out)
38151

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)
45152

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)
50156

51-
for key_path in key_paths:
157+
for name in ("root_key", "user_key"):
158+
key_path = CONTAINER_KEYS_DIR / name
52159
if not key_path.exists():
53160
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)]
56162
)
57163

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)
64165

65166

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)
67181
generate_docker_compose()
68182
generate_ssh_keys()
69183

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+
70198

71199
if __name__ == "__main__":
72-
main()
200+
sys.exit(main())

template.env

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
1-
#!!!!! PLEASE CHANGE THE PASSWORDS !!!!!
2-
3-
DEBUG=0
4-
MAINTENANCE_ENABLED=0
5-
6-
# Password of the admin user. The user name of the admin user is "0".
7-
# ADMIN_PASSWORD=sWbAqDuchwhwNXFBr2Z6qzfhD5Sy
8-
9-
# SSH key that is deployed as the public key for the admin account.
10-
# If unset, a key-pair is generated and available through the webinterface.
11-
# ADMIN_SSH_KEY="ssh-rsa [...]== my-key"
12-
13-
# The docker group ID on the docker host.
14-
# DOCKER_GROUP_ID=974
15-
16-
# Ports on which the services are exposed on the host.
17-
# SSH_HOST_PORT=2222
18-
# HTTP_HOST_PORT=8000
19-
20-
# Keys used to sign/encrypt messages between different services.
21-
# SECRET_KEY=B5SKufDhIaR+B4uY8XNhVsPSoKVn32
22-
# SSH_TO_WEB_KEY=GfYaFeFqlXEZCze30dGmtB9zFQVNjX
23-
24-
# The database password used for initial setup.
25-
# POSTGRES_PASSWORD=RAgGmG0DisQvo1I+ll+GUV9nrh4bgV
1+
# This file is no longer used as a setup template.
2+
#
3+
# Configuration is now generated automatically by ./prepare.py, which writes:
4+
# - settings.yaml (canonical configuration, contains generated secrets)
5+
# - settings.env (rendered from settings.yaml, consumed by docker-compose)
6+
#
7+
# On a fresh checkout, either run ./prepare.py directly or just run
8+
# ./ctrl.sh up -- ctrl.sh will bootstrap the configuration automatically
9+
# on the first invocation when no settings files exist yet.
10+
#
11+
# prepare.py refuses to run if settings.yaml already exists, so your secrets
12+
# will never be silently overwritten. To regenerate from scratch, delete
13+
# settings.yaml and settings.env before re-running it.

0 commit comments

Comments
 (0)