227 lines
6.9 KiB
Markdown
227 lines
6.9 KiB
Markdown
# clab-webserver
|
|
|
|
A lightweight, template-driven HTTP server designed to run as a node inside a
|
|
[containerlab](https://containerlab.dev) topology. It serves personalised plain-text
|
|
pages to participants based on their source IP address, making it easy to hand out
|
|
unique tokens, course names, or any per-prefix data during instructor-led lab sessions.
|
|
|
|
---
|
|
|
|
## How it works
|
|
|
|
1. Participants are assigned a unique IPv4 or IPv6 prefix in the lab topology (e.g. `10.0.3.0/24`).
|
|
2. When a participant runs `curl http://<webserver-ip>/`, the server looks up their
|
|
source address against a prefix map (`client-map.yaml`) using longest-prefix match.
|
|
3. All matching prefixes are merged (least- to most-specific), producing a set of
|
|
template variables for that request.
|
|
4. Every file under `docroot/` is rendered as a Go template with those variables
|
|
substituted before being returned as `text/plain`.
|
|
|
|
Catch-all entries (`0.0.0.0/0`, `::/0`) provide defaults for all clients; more-specific
|
|
prefixes override individual variables for specific participants.
|
|
|
|
---
|
|
|
|
## Quickstart — adding to a containerlab topology
|
|
|
|
Add `clab-webserver` as a `linux` node and bind-mount your config files:
|
|
|
|
```yaml
|
|
# webserver.clab.yml
|
|
name: my-lab
|
|
|
|
topology:
|
|
nodes:
|
|
webserver:
|
|
kind: linux
|
|
image: git.ipng.ch/ipng/clab-webserver:latest
|
|
binds:
|
|
- config:/app/config:ro
|
|
- docroot:/app/docroot:ro
|
|
env:
|
|
CLIENT_MAP: /app/config/client-map.yaml
|
|
DOCROOT: /app/docroot
|
|
LISTEN: ":80"
|
|
ports:
|
|
- "80:80"
|
|
|
|
participant1:
|
|
kind: linux
|
|
image: alpine:latest
|
|
|
|
links:
|
|
- endpoints: ["webserver:eth1", "participant1:eth1"]
|
|
```
|
|
|
|
The server starts automatically as the container entrypoint — no extra configuration needed.
|
|
|
|
---
|
|
|
|
## client-map.yaml
|
|
|
|
This file maps IPv4/IPv6 prefixes to template variables. Prefix matching is
|
|
longest-match; all matching entries are merged with more-specific values winning.
|
|
|
|
```yaml
|
|
# Default for all IPv4 clients
|
|
0.0.0.0/0:
|
|
course: "My Lab"
|
|
client_map_token: "not set"
|
|
|
|
# Default for all IPv6 clients
|
|
::/0:
|
|
course: "My Lab"
|
|
client_map_token: "not set"
|
|
|
|
# Per-participant overrides
|
|
10.0.1.0/24:
|
|
client_map_token: "swift grey pirate"
|
|
|
|
2001:db8:1::/48:
|
|
client_map_token: "swift grey pirate"
|
|
|
|
# A prefix can override any variable, including course
|
|
2001:db8:2::/48:
|
|
client_map_token: "brave lime falcon"
|
|
course: "Advanced Track"
|
|
```
|
|
|
|
A client at `2001:db8:2::1` would receive `course = "Advanced Track"` and
|
|
`client_map_token = "brave lime falcon"`, merged from `::/0` and `2001:db8:2::/48`.
|
|
|
|
The file is watched at runtime — edits take effect within 5 seconds without restarting
|
|
the container.
|
|
|
|
---
|
|
|
|
## docroot/
|
|
|
|
Static files served from this directory are processed as Go templates. Any
|
|
`{{ .variable }}` reference is substituted with the value from the merged
|
|
prefix-match result for that client.
|
|
|
|
Three variables are always available regardless of `client-map.yaml`:
|
|
|
|
| Variable | Value |
|
|
|---|---|
|
|
| `{{ .remote_address }}` | Client IP address |
|
|
| `{{ .host }}` | HTTP `Host` header |
|
|
| `{{ .user_agent }}` | HTTP `User-Agent` header |
|
|
|
|
A request to `/` serves `docroot/index.txt`. Subdirectories are supported.
|
|
|
|
If a template references a variable that has no value for a given client, the
|
|
variable renders as an empty string and a warning is logged.
|
|
|
|
**Example `docroot/index.txt`:**
|
|
|
|
```
|
|
Welcome, {{ .course }} participant from {{ .remote_address }}!
|
|
I see you are using "{{ .user_agent }}".
|
|
|
|
Your token is "{{ .client_map_token }}"
|
|
|
|
You may write it in your workbook and give to your teacher. Thank you for
|
|
participating in the {{ .course }} session.
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
All options can be set via CLI flag or environment variable. Flags take precedence.
|
|
|
|
| Flag | Env var | Default | Description |
|
|
|---|---|---|---|
|
|
| `-client-map` | `CLIENT_MAP` | `client-map.yaml` | Path to prefix/variable map |
|
|
| `-docroot` | `DOCROOT` | `docroot` | Directory of template files to serve |
|
|
| `-listen` | `LISTEN` | `:80` | Listen address |
|
|
|
|
---
|
|
|
|
## Building and running locally
|
|
|
|
```sh
|
|
# Run directly
|
|
go run . -client-map config/client-map.yaml
|
|
|
|
# Build and run
|
|
go build -o clab-webserver .
|
|
./clab-webserver -client-map config/client-map.yaml -listen :8080
|
|
|
|
# Docker
|
|
docker compose build
|
|
docker compose up
|
|
```
|
|
|
|
---
|
|
|
|
## Logging
|
|
|
|
All requests are logged to stdout in nginx combined-log format:
|
|
|
|
```
|
|
10.0.1.5 - - [03/Apr/2026:14:22:01 +0200] "GET /index.txt HTTP/1.1" 200 312 "-" "curl/7.88.1"
|
|
```
|
|
|
|
Warnings for missing template variables appear as structured log lines:
|
|
|
|
```
|
|
2026/04/03 14:22:01 WARNING: template variable .client_map_token has no value for client 10.0.3.1
|
|
```
|
|
|
|
---
|
|
|
|
## Appendix — Specification
|
|
|
|
### Purpose
|
|
|
|
A small, portable Go HTTP server for use in distributed containerlab exercises.
|
|
Each participant is assigned a unique IPv4 and/or IPv6 prefix. When they send a
|
|
request to the server, they receive a personalised plain-text response identifying
|
|
them and returning a unique token they can record and submit.
|
|
|
|
### File serving
|
|
|
|
- Files are served from a configurable `docroot/` directory.
|
|
- All files are rendered through Go's `text/template` engine before being returned.
|
|
- The `Content-Type` is always `text/plain; charset=utf-8`.
|
|
- Requests to `/` are served from `docroot/index.txt`.
|
|
- Path traversal attempts are rejected with `403 Forbidden`.
|
|
|
|
### Client map
|
|
|
|
- Configured via a single YAML file (`-client-map` / `$CLIENT_MAP`).
|
|
- Top-level keys are IPv4 or IPv6 CIDR prefixes.
|
|
- Values are dictionaries of `variable_name: "string value"`.
|
|
- On each request, all prefixes that contain the client IP are collected,
|
|
sorted least- to most-specific, and merged. More-specific entries overwrite
|
|
less-specific ones for any shared key.
|
|
- Variables produced by the merge are injected into the template alongside the
|
|
HTTP-derived variables (`remote_address`, `host`, `user_agent`), which always win.
|
|
- IPv4-mapped IPv6 addresses (from dual-stack sockets) are normalised to IPv4
|
|
before matching.
|
|
- The file is polled every 5 seconds. A changed file is reloaded atomically;
|
|
a file with invalid YAML is rejected with a log warning and the previous data
|
|
is kept in service.
|
|
|
|
### Template variables
|
|
|
|
- Any key defined in `client-map.yaml` under a matching prefix becomes a template variable.
|
|
- HTTP-derived variables (`remote_address`, `host`, `user_agent`) are always set and
|
|
cannot be overridden by `client-map.yaml`.
|
|
- Template variables with no value for a given client default to `""` and a warning
|
|
is logged.
|
|
|
|
### Logging
|
|
|
|
- All requests are logged to stdout in nginx combined-log format after the response is sent.
|
|
- Warnings and errors are written to stdout via the standard Go logger.
|
|
|
|
### Deployment
|
|
|
|
- Packaged as a multi-stage Docker image based on Alpine.
|
|
- `docroot/` is baked into the image; `config/` (containing `client-map.yaml`) is expected as a bind-mounted directory.
|
|
- All configuration is available as both CLI flags and environment variables.
|
|
- Image: `git.ipng.ch/ipng/clab-webserver:latest`
|