A toy webserver with variable expansion based on connecting client
This commit is contained in:
226
README.md
Normal file
226
README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 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-priv/clab-webserver:latest
|
||||
binds:
|
||||
- client-map.yaml:/app/client-map.yaml:ro
|
||||
- docroot:/app/docroot:ro
|
||||
env:
|
||||
CLIENT_MAP: /app/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 client-map.yaml
|
||||
|
||||
# Build and run
|
||||
go build -o clab-webserver .
|
||||
./clab-webserver -client-map 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; `client-map.yaml` is expected as a bind mount.
|
||||
- All configuration is available as both CLI flags and environment variables.
|
||||
- Image: `git.ipng.ch/ipng-priv/clab-webserver:latest`
|
||||
Reference in New Issue
Block a user