Skip to content

Commit d03b5e1

Browse files
author
Nils Bars
committed
Add configurable TLS support to frontend-proxy
The frontend-proxy (Caddy) now supports three TLS modes controlled by tls.mode in settings.yaml: - off: plain HTTP on a single port (previous behavior) - internal: self-signed certificate with HTTPS + plain HTTP side-by-side - acme: Let's Encrypt certificate via ACME with automatic renewal The Caddyfile is rendered at container start from a Jinja2 template (Caddyfile.prod.j2) by render_caddyfile.py based on environment variables. Shared routing directives live in Caddyfile.routes, imported by all prod configurations. New settings: tls.mode, tls.domain (required when TLS is enabled), tls.redirect_http_to_https, and ports.https_host_port. New installs default to internal mode on ports 8080/8443. Existing configs are backfilled to off mode preserving current port assignments. A caddy-data Docker volume persists certificate state across restarts.
1 parent 019c6ea commit d03b5e1

11 files changed

Lines changed: 330 additions & 126 deletions

File tree

README.md

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,59 @@ The framework consists of multiple components that have to be built the first ti
44

55
The following describes how to build REF, how to run it, and how to upgrade it. To learn more about creating new exercises, head to [exercises.md](./EXERCISES.md).
66

7+
### Configuration
8+
9+
All configuration lives in `settings.yaml`, the single source of truth. On first use, `./ctrl.sh build` auto-runs `./prepare.py` which generates `settings.yaml` with cryptographically random secrets and renders two downstream artifacts from it:
10+
11+
* **`settings.env`** — environment variables consumed by docker-compose.
12+
* **`docker-compose.yml`** — rendered from `docker-compose.template.yml`.
13+
14+
To inspect or edit `settings.yaml` before the first build, run `./prepare.py` manually. After editing an existing `settings.yaml`, re-run `./prepare.py` to propagate changes to the downstream files. The script backfills new fields and prunes obsolete ones automatically, so it is safe to re-run on upgrades. Use `./prepare.py --fresh` to regenerate everything from scratch (the old `settings.yaml` is backed up first).
15+
16+
#### Settings reference
17+
18+
| Section | Key | Default | Description |
19+
|---------|-----|---------|-------------|
20+
| `ports` | `ssh_host_port` | 2222 | SSH reverse-proxy listen port |
21+
| | `http_host_port` | 8080 | HTTP port (plain or redirect) |
22+
| | `https_host_port` | 8443 | HTTPS port |
23+
| `tls` | `mode` | `internal` | `off` (plain HTTP), `internal` (self-signed), or `acme` (Let's Encrypt) |
24+
| | `domain` || Required for `internal` and `acme` modes |
25+
| | `redirect_http_to_https` | `false` | Redirect HTTP port to HTTPS (`internal` and `acme` modes only) |
26+
| `paths` | `data` | `./data` | Persistent data directory on the host |
27+
| | `exercises` | `./exercises` | Exercise definitions directory |
28+
| `runtime` | `binfmt_support` | `false` | Enable multi-architecture container support |
29+
| `admin` | `password` | *(random)* | Admin password (username is `0`); printed on first run |
30+
| | `ssh_key` || Optional SSH public key for the admin account |
31+
32+
Secrets (`secrets` section) are auto-generated and should not normally be edited. They include the Flask session key, the HMAC key shared between the SSH proxy and the web API, and the PostgreSQL password.
33+
34+
#### TLS modes
35+
36+
The `tls.mode` setting controls how the frontend-proxy serves traffic:
37+
38+
| Mode | Ports | Behavior |
39+
|------|-------|----------|
40+
| `off` | `http_host_port` only | Plain HTTP on a single port. No TLS. |
41+
| `internal` | `http_host_port` + `https_host_port` | Self-signed TLS certificate (generated by Caddy). HTTPS on `https_host_port`, plain HTTP on `http_host_port`. Both serve the full site independently by default. Set `redirect_http_to_https: true` to redirect HTTP to HTTPS instead. Accessible by domain and by IP. |
42+
| `acme` | `http_host_port` + `https_host_port` | Let's Encrypt certificate via ACME. HTTP automatically redirects to HTTPS. Requires `domain` to resolve to the server and host ports 80 + 443 to be publicly reachable. |
43+
44+
After changing `tls.mode` or `tls.domain`, run `./prepare.py && ./ctrl.sh build && ./ctrl.sh restart`.
45+
46+
#### Web entry points
47+
48+
All HTTP traffic is served through a single Caddy reverse proxy
49+
(`frontend-proxy/`). Students reach `/spa/register`; admins reach
50+
`/admin/` (redirects to the exercise view). SSH connections go through
51+
the SSH reverse proxy on the configured SSH port.
52+
53+
In production the Vue SPA is baked into the `frontend-proxy` image as a
54+
static bundle — rebuild with `./ctrl.sh build` after any SPA change.
55+
56+
#### Hot reloading (development only)
57+
58+
`./ctrl.sh up --hot-reloading` starts an extra Vite dev server for the SPA and enables Flask auto-reload. Do **not** use this on a publicly reachable host — Vite's dev server is not hardened (see `docs/ARCHITECTURE.md` for details).
59+
760
### Building REF
861
The build process is split into two parts. While the first part is mandatory and entails building the framework itself, the second part is only required if you plan to host exercises where ASLR is disabled for setuid binaries.
962

@@ -16,19 +69,11 @@ git clone git@github.com:remote-exercise-framework/ref.git
1669
cd ref
1770
git submodule update --init --recursive
1871

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
29-
30-
# Build all images. This command will check if your system meets the requirements
31-
# and will print error messages in case something is not working as expected.
72+
# Optional: run prepare.py manually to inspect or edit settings.yaml
73+
# before building. If skipped, ctrl.sh build auto-runs it on first use
74+
# with secure random defaults.
75+
# ./prepare.py
76+
3277
./ctrl.sh build
3378
```
3479

@@ -44,39 +89,6 @@ After successfully building REF, the database has to be initialized:
4489
./ctrl.sh down
4590
```
4691

47-
### Single-port web interface
48-
49-
The Flask web app (`/`, `/admin/*`, `/api/*`), the Vue student SPA
50-
(`/spa/*`), and Flask's static assets (`/static/*`) are all fronted by
51-
a single Caddy reverse proxy (`frontend-proxy/`) on host port 8000.
52-
Students go to `http://<host>:8000/spa/register` (or whichever landing
53-
page is configured); admins go to `http://<host>:8000/admin/` (which
54-
redirects to the exercise view). Student SSH connections go through the
55-
SSH reverse proxy on port 2222.
56-
57-
In production, `frontend-proxy` serves the Vue SPA as a static bundle
58-
that is baked into the image at `docker build` time via a multi-stage
59-
Dockerfile — rebuild with `./ctrl.sh build` after any change under
60-
`spa-frontend/`.
61-
62-
### Hot reloading (local development only)
63-
64-
Use `./ctrl.sh up --hot-reloading` during local development to get
65-
Flask auto-reload **and** Vite HMR for the SPA. This flag starts an
66-
extra `spa-frontend` container (running `vite dev`) which is gated
67-
behind the `dev` compose profile; Caddy then reverse-proxies `/spa/*`
68-
(including the HMR websocket) to that container.
69-
70-
> ⚠️ **Do not run `--hot-reloading` on a publicly reachable host.**
71-
> Vite's dev server is not designed for hostile clients — it serves
72-
> raw source, exposes `/@fs/`, and has had several path-traversal CVEs
73-
> in recent releases. While `--hot-reloading` is active, the SPA
74-
> renders a yellow/black hazard-striped warning strip at the top of
75-
> every page as a visible reminder; the whole warning is tree-shaken
76-
> out of the production `vite build`, so nothing about it leaks into
77-
> the prod bundle. See `docs/ARCHITECTURE.md` for the full model.
78-
79-
8092
#### Build the custom Linux kernel
8193
Building the custom Linux kernel is only required if you need the `no-randomize` attribute for some exercises. This attribute allows you to disable ASLR for a specific binary, even if it is a setuid binary. This is not allowed for unmodified kernels. The following assumes that your system is based on Debian and uses GRUB as a bootloader. For other systems or bootloaders, the instructions have to be adapted accordingly.
8294

@@ -183,18 +195,18 @@ In case the update fails, remove the `data` directory and move the backup to `da
183195
After starting the application, the following services are running on the host:
184196

185197
#### SSH Entry-Server
186-
This services is the entry server for all SSH connections to the exercises. Based on the clients user name and the public key, incoming SSH connection are forwarded to a container of the respective exercise.
198+
The entry server for all SSH connections to the exercises. Based on the client's username and public key, incoming SSH connections are forwarded to a container of the respective exercise.
187199

188200
```
189201
Hostname: ssh-reverse-proxy
190-
Port: 2222
202+
Port: See settings.yaml (ports.ssh_host_port, default 2222)
191203
```
192204

193205
#### Web Interface
194-
The web interface for managing exercises and users. Students also use it to register. All HTTP traffic is served through the `frontend-proxy` (Caddy) on port 8000, which reverse-proxies to the `web` (Flask) service internally.
206+
The web interface for managing exercises and users. Students also use it to register. All HTTP traffic is served through the `frontend-proxy` (Caddy), which reverse-proxies to the `web` (Flask) service internally.
195207
```
196208
Hostname: frontend-proxy (host-facing), web (internal Flask app)
197-
Port: 8000
209+
Port: See settings.yaml (ports.http_host_port / ports.https_host_port)
198210
User: 0
199211
Password: See settings.yaml (admin.password)
200212
```

docker-compose.template.yml

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
version: "3.7"
2-
{% if testing %}
32
volumes:
3+
caddy-data:
4+
{% if testing %}
45
coverage-data:
56
name: "{{ prefix }}_coverage_data"
67
{% endif %}
@@ -125,21 +126,43 @@ services:
125126
dockerfile: frontend-proxy/Dockerfile
126127
environment:
127128
- HOT_RELOADING=${HOT_RELOADING:-false}
129+
- TLS_MODE=${TLS_MODE:-off}
130+
- DOMAIN=${TLS_DOMAIN:-}
131+
- HTTPS_HOST_PORT=${HTTPS_HOST_PORT:-8443}
132+
- REDIRECT_HTTP_TO_HTTPS=${TLS_REDIRECT_HTTP:-false}
128133
volumes:
129134
# Serve Flask's static assets directly from Caddy, skipping
130135
# uWSGI. Read-only to keep the proxy sandboxed.
131136
- ./webapp/ref/static:/srv/flask-static:ro
132-
{% if not testing %}
137+
# Persist Caddy's TLS certificates and ACME state across
138+
# container restarts. Essential for acme mode (avoids
139+
# hitting Let's Encrypt rate limits).
140+
- caddy-data:/data
141+
{%- if not testing %}
133142
ports:
143+
{%- if tls_mode == 'off' %}
134144
- "${HTTP_HOST_PORT}:8000"
135-
{% endif %}
145+
{%- elif tls_mode == 'internal' %}
146+
- "${HTTP_HOST_PORT}:8080"
147+
- "${HTTPS_HOST_PORT}:8443"
148+
{%- elif tls_mode == 'acme' %}
149+
- "${HTTP_HOST_PORT}:80"
150+
- "${HTTPS_HOST_PORT}:443"
151+
{%- endif %}
152+
{%- endif %}
136153
networks:
137154
- web-host
138155
depends_on:
139156
- web
140157
cgroup_parent: "{{ cgroup_parent }}-core.slice"
141158
healthcheck:
159+
{%- if tls_mode == 'off' %}
142160
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/static/favicon.ico"]
161+
{%- elif tls_mode == 'internal' %}
162+
test: ["CMD", "wget", "-q", "--spider", "--no-check-certificate", "https://localhost:8443/static/favicon.ico"]
163+
{%- elif tls_mode == 'acme' %}
164+
test: ["CMD", "wget", "-q", "--spider", "--no-check-certificate", "https://localhost:443/static/favicon.ico"]
165+
{%- endif %}
143166
interval: 10s
144167
timeout: 3s
145168
retries: 5

docs/ARCHITECTURE.md

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ Remote Exercise Framework - A platform for hosting programming exercises with is
88
┌──────────────────────────────────────────────────────────────────────┐
99
│ HOST SYSTEM │
1010
├──────────────────────────────────────────────────────────────────────┤
11-
Port 2222 ──> ssh-reverse-proxy (Rust) ──> Instance (SSH)
12-
Port 8000 ──> frontend-proxy (Caddy) ──┬─> web (Flask)
13-
│ ├─> spa-frontend (vite dev)
14-
│ └─> baked SPA dist/ (prod)
11+
ssh_host_port ──> ssh-reverse-proxy (Rust) ──> Instance (SSH) │
12+
http(s)_host_port ──> frontend-proxy (Caddy) ──┬─> web (Flask) │
13+
├─> spa-frontend
14+
└─> baked SPA dist/ │
1515
└──────────────────────────────────────────────────────────────────────┘
1616
```
1717

18-
The `frontend-proxy` Caddy container terminates host port 8000 and routes
19-
traffic by URL prefix:
18+
The `frontend-proxy` Caddy container serves HTTP and/or HTTPS (depending
19+
on `tls.mode` in `settings.yaml`) and routes traffic by URL prefix:
2020

2121
- `/spa/*` — the Vue SPA. In dev (`--hot-reloading`) proxied to the
2222
`spa-frontend` container running `vite dev` with HMR; in prod served as
@@ -28,7 +28,7 @@ traffic by URL prefix:
2828
- Everything else (`/`, `/admin/*`, `/api/*`, `/student/*`) — reverse-proxied
2929
to the Flask `web` container on the internal `web-host` network.
3030

31-
The `ssh-reverse-proxy` still calls `http://web:8000` over the internal
31+
The `ssh-reverse-proxy` calls `http://web:8000` over the internal
3232
`web-and-ssh` network and does **not** go through Caddy.
3333

3434
## Components
@@ -116,22 +116,39 @@ Isolated Docker container per student/exercise based on Ubuntu 24.04.
116116

117117
### 2b. Frontend Proxy (`frontend-proxy/`)
118118

119-
Caddy-based reverse proxy container that terminates host port 8000 and fans
120-
out to the Flask webapp and the Vue SPA. Built from a multi-stage
121-
Dockerfile that compiles the SPA bundle (stage 1: `node:22-alpine`,
122-
`npm run build`) and copies it into a `caddy:2-alpine` runtime image
123-
(stage 2). At container start, `entrypoint.sh` picks `Caddyfile.dev`
124-
(reverse-proxies `/spa/*` to `spa-frontend:5173` for HMR) or
125-
`Caddyfile.prod` (serves the baked `/srv/spa-dist` with SPA history-mode
126-
fallback) based on `$HOT_RELOADING`.
119+
Caddy-based reverse proxy container that serves the web interface over
120+
HTTP and/or HTTPS depending on `tls.mode` in `settings.yaml`. Built from
121+
a multi-stage Dockerfile that compiles the SPA bundle (stage 1:
122+
`node:22-alpine`, `npm run build`) and copies it into a
123+
`caddy:2.8-alpine` runtime image (stage 2) along with Python 3 and
124+
Jinja2.
127125

128-
**Stack:** Caddy 2 + multi-stage Node builder
126+
At container start, `entrypoint.sh` either uses `Caddyfile.dev` (for
127+
`--hot-reloading`) or calls `render_caddyfile.py` which renders
128+
`Caddyfile.prod.j2` into a Caddyfile based on `TLS_MODE`, `DOMAIN`, and
129+
`HTTPS_HOST_PORT` environment variables passed from docker-compose.
130+
131+
**TLS modes** (set via `tls.mode` in `settings.yaml`):
132+
133+
| Mode | Container ports | Description |
134+
|------|-----------------|-------------|
135+
| `off` | `:8000` (HTTP) | Plain HTTP, no TLS. |
136+
| `internal` | `:8443` (HTTPS) + `:8080` (HTTP) | Self-signed certificate generated by Caddy. Both ports serve the full site independently; the HTTP port does not redirect to HTTPS by default. Accessible by domain name and by IP address. |
137+
| `acme` | `:443` (HTTPS) + `:80` (HTTP) | Let's Encrypt certificate via ACME. Caddy handles provisioning and renewal automatically. HTTP redirects to HTTPS. |
138+
139+
A `caddy-data` Docker volume persists Caddy's certificate storage across
140+
container restarts (essential for `acme` mode to avoid hitting Let's
141+
Encrypt rate limits).
142+
143+
**Stack:** Caddy 2 + Python 3 / Jinja2 + multi-stage Node builder
129144

130145
**Key files:**
131-
- `Dockerfile` — multi-stage SPA build + Caddy runtime
146+
- `Dockerfile` — multi-stage SPA build + Caddy runtime with Python/Jinja2
132147
- `Caddyfile.dev` — dev routing (proxies `/spa/*` to vite dev)
133-
- `Caddyfile.prod` — prod routing (serves baked dist with cache headers)
134-
- `entrypoint.sh` — selects config based on `HOT_RELOADING`
148+
- `Caddyfile.prod.j2` — Jinja2 template rendered at container start per TLS mode
149+
- `Caddyfile.routes` — shared routing directives imported by all prod configs
150+
- `render_caddyfile.py` — renders the Jinja2 template from environment variables
151+
- `entrypoint.sh` — selects dev config or renders prod config, then starts Caddy
135152

136153
**Notes:**
137154
- The Flask rate limiter reads `X-Tinyproxy` to key on the real client IP;
@@ -278,8 +295,7 @@ profile-gated services can still be built and cleaned up.
278295
> serving raw source over `/spa/*` (including the `/@fs/` endpoint,
279296
> which has had repeated path-traversal CVEs). The SPA itself renders
280297
> a hazard-striped warning strip at the top of every page in this mode
281-
> as a visible reminder. Intended use is local development against
282-
> `http://localhost:8000` only.
298+
> as a visible reminder. Intended for local development only.
283299
284300
## Test Structure
285301

frontend-proxy/Caddyfile.prod

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)