A toy webserver with variable expansion based on connecting client
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
clab-webserver
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM golang:alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o clab-webserver .
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/clab-webserver .
|
||||
COPY docroot/ docroot/
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/app/clab-webserver"]
|
||||
CMD ["-listen", ":80"]
|
||||
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`
|
||||
608
client-map.yaml
Normal file
608
client-map.yaml
Normal file
@@ -0,0 +1,608 @@
|
||||
0.0.0.0/0:
|
||||
course: "My Containerlab"
|
||||
client_map_token: "not set (ipv4)"
|
||||
|
||||
::/0:
|
||||
course: "My Containerlab"
|
||||
client_map_token: "not set (ipv6)"
|
||||
|
||||
10.0.1.0/24:
|
||||
client_map_token: "swift grey pirate"
|
||||
|
||||
2001:db8:1::/48:
|
||||
client_map_token: "swift grey pirate"
|
||||
|
||||
10.0.2.0/24:
|
||||
client_map_token: "sharp red rocket"
|
||||
|
||||
2001:db8:2::/48:
|
||||
client_map_token: "sharp red rocket"
|
||||
|
||||
10.0.3.0/24:
|
||||
client_map_token: "brave lime falcon"
|
||||
|
||||
2001:db8:3::/48:
|
||||
client_map_token: "brave lime falcon"
|
||||
|
||||
10.0.4.0/24:
|
||||
client_map_token: "grand green bridge"
|
||||
|
||||
2001:db8:4::/48:
|
||||
client_map_token: "grand green bridge"
|
||||
|
||||
10.0.5.0/24:
|
||||
client_map_token: "big gold forest"
|
||||
|
||||
2001:db8:5::/48:
|
||||
client_map_token: "big gold forest"
|
||||
|
||||
10.0.6.0/24:
|
||||
client_map_token: "round cyan forest"
|
||||
|
||||
2001:db8:6::/48:
|
||||
client_map_token: "round cyan forest"
|
||||
|
||||
10.0.7.0/24:
|
||||
client_map_token: "big cyan falcon"
|
||||
|
||||
2001:db8:7::/48:
|
||||
client_map_token: "big cyan falcon"
|
||||
|
||||
10.0.8.0/24:
|
||||
client_map_token: "swift teal castle"
|
||||
|
||||
2001:db8:8::/48:
|
||||
client_map_token: "swift teal castle"
|
||||
|
||||
10.0.9.0/24:
|
||||
client_map_token: "tiny teal island"
|
||||
|
||||
2001:db8:9::/48:
|
||||
client_map_token: "tiny teal island"
|
||||
|
||||
10.0.10.0/24:
|
||||
client_map_token: "sharp green forest"
|
||||
|
||||
2001:db8:a::/48:
|
||||
client_map_token: "sharp green forest"
|
||||
|
||||
10.0.11.0/24:
|
||||
client_map_token: "gnarly cyan island"
|
||||
|
||||
2001:db8:b::/48:
|
||||
client_map_token: "gnarly cyan island"
|
||||
|
||||
10.0.12.0/24:
|
||||
client_map_token: "brave red jaguar"
|
||||
|
||||
2001:db8:c::/48:
|
||||
client_map_token: "brave red jaguar"
|
||||
|
||||
10.0.13.0/24:
|
||||
client_map_token: "round blue wizard"
|
||||
|
||||
2001:db8:d::/48:
|
||||
client_map_token: "round blue wizard"
|
||||
|
||||
10.0.14.0/24:
|
||||
client_map_token: "thick blue falcon"
|
||||
|
||||
2001:db8:e::/48:
|
||||
client_map_token: "thick blue falcon"
|
||||
|
||||
10.0.15.0/24:
|
||||
client_map_token: "thick cyan bridge"
|
||||
|
||||
2001:db8:f::/48:
|
||||
client_map_token: "thick cyan bridge"
|
||||
|
||||
10.0.16.0/24:
|
||||
client_map_token: "sharp red dragon"
|
||||
|
||||
2001:db8:10::/48:
|
||||
client_map_token: "sharp red dragon"
|
||||
|
||||
10.0.17.0/24:
|
||||
client_map_token: "swift pink castle"
|
||||
|
||||
2001:db8:11::/48:
|
||||
client_map_token: "swift pink castle"
|
||||
|
||||
10.0.18.0/24:
|
||||
client_map_token: "big dark jaguar"
|
||||
|
||||
2001:db8:12::/48:
|
||||
client_map_token: "big dark jaguar"
|
||||
|
||||
10.0.19.0/24:
|
||||
client_map_token: "swift gold bridge"
|
||||
|
||||
2001:db8:13::/48:
|
||||
client_map_token: "swift gold bridge"
|
||||
|
||||
10.0.20.0/24:
|
||||
client_map_token: "tiny gold pirate"
|
||||
|
||||
2001:db8:14::/48:
|
||||
client_map_token: "tiny gold pirate"
|
||||
|
||||
10.0.21.0/24:
|
||||
client_map_token: "round teal castle"
|
||||
|
||||
2001:db8:15::/48:
|
||||
client_map_token: "round teal castle"
|
||||
|
||||
10.0.22.0/24:
|
||||
client_map_token: "swift lime castle"
|
||||
|
||||
2001:db8:16::/48:
|
||||
client_map_token: "swift lime castle"
|
||||
|
||||
10.0.23.0/24:
|
||||
client_map_token: "tiny teal castle"
|
||||
|
||||
2001:db8:17::/48:
|
||||
client_map_token: "tiny teal castle"
|
||||
|
||||
10.0.24.0/24:
|
||||
client_map_token: "round pink rocket"
|
||||
|
||||
2001:db8:18::/48:
|
||||
client_map_token: "round pink rocket"
|
||||
|
||||
10.0.25.0/24:
|
||||
client_map_token: "thick green bridge"
|
||||
|
||||
2001:db8:19::/48:
|
||||
client_map_token: "thick green bridge"
|
||||
|
||||
10.0.26.0/24:
|
||||
client_map_token: "round grey pirate"
|
||||
|
||||
2001:db8:1a::/48:
|
||||
client_map_token: "round grey pirate"
|
||||
|
||||
10.0.27.0/24:
|
||||
client_map_token: "thick blue island"
|
||||
|
||||
2001:db8:1b::/48:
|
||||
client_map_token: "thick blue island"
|
||||
|
||||
10.0.28.0/24:
|
||||
client_map_token: "sharp teal island"
|
||||
|
||||
2001:db8:1c::/48:
|
||||
client_map_token: "sharp teal island"
|
||||
|
||||
10.0.29.0/24:
|
||||
client_map_token: "tiny pink falcon"
|
||||
|
||||
2001:db8:1d::/48:
|
||||
client_map_token: "tiny pink falcon"
|
||||
|
||||
10.0.30.0/24:
|
||||
client_map_token: "swift grey castle"
|
||||
|
||||
2001:db8:1e::/48:
|
||||
client_map_token: "swift grey castle"
|
||||
|
||||
10.0.31.0/24:
|
||||
client_map_token: "thick gold castle"
|
||||
|
||||
2001:db8:1f::/48:
|
||||
client_map_token: "thick gold castle"
|
||||
|
||||
10.0.32.0/24:
|
||||
client_map_token: "grand dark castle"
|
||||
|
||||
2001:db8:20::/48:
|
||||
client_map_token: "grand dark castle"
|
||||
|
||||
10.0.33.0/24:
|
||||
client_map_token: "swift lime rocket"
|
||||
|
||||
2001:db8:21::/48:
|
||||
client_map_token: "swift lime rocket"
|
||||
|
||||
10.0.34.0/24:
|
||||
client_map_token: "round lime pirate"
|
||||
|
||||
2001:db8:22::/48:
|
||||
client_map_token: "round lime pirate"
|
||||
|
||||
10.0.35.0/24:
|
||||
client_map_token: "grand green island"
|
||||
|
||||
2001:db8:23::/48:
|
||||
client_map_token: "grand green island"
|
||||
|
||||
10.0.36.0/24:
|
||||
client_map_token: "tiny pink jaguar"
|
||||
|
||||
2001:db8:24::/48:
|
||||
client_map_token: "tiny pink jaguar"
|
||||
|
||||
10.0.37.0/24:
|
||||
client_map_token: "sharp lime pirate"
|
||||
|
||||
2001:db8:25::/48:
|
||||
client_map_token: "sharp lime pirate"
|
||||
|
||||
10.0.38.0/24:
|
||||
client_map_token: "swift green castle"
|
||||
|
||||
2001:db8:26::/48:
|
||||
client_map_token: "swift green castle"
|
||||
|
||||
10.0.39.0/24:
|
||||
client_map_token: "big teal forest"
|
||||
|
||||
2001:db8:27::/48:
|
||||
client_map_token: "big teal forest"
|
||||
|
||||
10.0.40.0/24:
|
||||
client_map_token: "round green pirate"
|
||||
|
||||
2001:db8:28::/48:
|
||||
client_map_token: "round green pirate"
|
||||
|
||||
10.0.41.0/24:
|
||||
client_map_token: "big teal pirate"
|
||||
|
||||
2001:db8:29::/48:
|
||||
client_map_token: "big teal pirate"
|
||||
|
||||
10.0.42.0/24:
|
||||
client_map_token: "magic pink island"
|
||||
|
||||
2001:db8:2a::/48:
|
||||
client_map_token: "magic pink island"
|
||||
|
||||
10.0.43.0/24:
|
||||
client_map_token: "swift grey bridge"
|
||||
|
||||
2001:db8:2b::/48:
|
||||
client_map_token: "swift grey bridge"
|
||||
|
||||
10.0.44.0/24:
|
||||
client_map_token: "gnarly cyan falcon"
|
||||
|
||||
2001:db8:2c::/48:
|
||||
client_map_token: "gnarly cyan falcon"
|
||||
|
||||
10.0.45.0/24:
|
||||
client_map_token: "round pink pirate"
|
||||
|
||||
2001:db8:2d::/48:
|
||||
client_map_token: "round pink pirate"
|
||||
|
||||
10.0.46.0/24:
|
||||
client_map_token: "swift cyan jaguar"
|
||||
|
||||
2001:db8:2e::/48:
|
||||
client_map_token: "swift cyan jaguar"
|
||||
|
||||
10.0.47.0/24:
|
||||
client_map_token: "gnarly grey island"
|
||||
|
||||
2001:db8:2f::/48:
|
||||
client_map_token: "gnarly grey island"
|
||||
|
||||
10.0.48.0/24:
|
||||
client_map_token: "gnarly teal castle"
|
||||
|
||||
2001:db8:30::/48:
|
||||
client_map_token: "gnarly teal castle"
|
||||
|
||||
10.0.49.0/24:
|
||||
client_map_token: "swift dark dragon"
|
||||
|
||||
2001:db8:31::/48:
|
||||
client_map_token: "swift dark dragon"
|
||||
|
||||
10.0.50.0/24:
|
||||
client_map_token: "gnarly blue forest"
|
||||
|
||||
2001:db8:32::/48:
|
||||
client_map_token: "gnarly blue forest"
|
||||
|
||||
10.0.51.0/24:
|
||||
client_map_token: "round green wizard"
|
||||
|
||||
2001:db8:33::/48:
|
||||
client_map_token: "round green wizard"
|
||||
|
||||
10.0.52.0/24:
|
||||
client_map_token: "grand pink falcon"
|
||||
|
||||
2001:db8:34::/48:
|
||||
client_map_token: "grand pink falcon"
|
||||
|
||||
10.0.53.0/24:
|
||||
client_map_token: "big blue wizard"
|
||||
|
||||
2001:db8:35::/48:
|
||||
client_map_token: "big blue wizard"
|
||||
|
||||
10.0.54.0/24:
|
||||
client_map_token: "gnarly cyan jaguar"
|
||||
|
||||
2001:db8:36::/48:
|
||||
client_map_token: "gnarly cyan jaguar"
|
||||
|
||||
10.0.55.0/24:
|
||||
client_map_token: "round blue island"
|
||||
|
||||
2001:db8:37::/48:
|
||||
client_map_token: "round blue island"
|
||||
|
||||
10.0.56.0/24:
|
||||
client_map_token: "sharp cyan jaguar"
|
||||
|
||||
2001:db8:38::/48:
|
||||
client_map_token: "sharp cyan jaguar"
|
||||
|
||||
10.0.57.0/24:
|
||||
client_map_token: "thick pink pirate"
|
||||
|
||||
2001:db8:39::/48:
|
||||
client_map_token: "thick pink pirate"
|
||||
|
||||
10.0.58.0/24:
|
||||
client_map_token: "swift pink forest"
|
||||
|
||||
2001:db8:3a::/48:
|
||||
client_map_token: "swift pink forest"
|
||||
|
||||
10.0.59.0/24:
|
||||
client_map_token: "magic green dragon"
|
||||
|
||||
2001:db8:3b::/48:
|
||||
client_map_token: "magic green dragon"
|
||||
|
||||
10.0.60.0/24:
|
||||
client_map_token: "sharp cyan pirate"
|
||||
|
||||
2001:db8:3c::/48:
|
||||
client_map_token: "sharp cyan pirate"
|
||||
|
||||
10.0.61.0/24:
|
||||
client_map_token: "sharp cyan dragon"
|
||||
|
||||
2001:db8:3d::/48:
|
||||
client_map_token: "sharp cyan dragon"
|
||||
|
||||
10.0.62.0/24:
|
||||
client_map_token: "magic lime jaguar"
|
||||
|
||||
2001:db8:3e::/48:
|
||||
client_map_token: "magic lime jaguar"
|
||||
|
||||
10.0.63.0/24:
|
||||
client_map_token: "thick gold wizard"
|
||||
|
||||
2001:db8:3f::/48:
|
||||
client_map_token: "thick gold wizard"
|
||||
|
||||
10.0.64.0/24:
|
||||
client_map_token: "tiny cyan pirate"
|
||||
|
||||
2001:db8:40::/48:
|
||||
client_map_token: "tiny cyan pirate"
|
||||
|
||||
10.0.65.0/24:
|
||||
client_map_token: "tiny pink island"
|
||||
|
||||
2001:db8:41::/48:
|
||||
client_map_token: "tiny pink island"
|
||||
|
||||
10.0.66.0/24:
|
||||
client_map_token: "brave grey jaguar"
|
||||
|
||||
2001:db8:42::/48:
|
||||
client_map_token: "brave grey jaguar"
|
||||
|
||||
10.0.67.0/24:
|
||||
client_map_token: "tiny gold island"
|
||||
|
||||
2001:db8:43::/48:
|
||||
client_map_token: "tiny gold island"
|
||||
|
||||
10.0.68.0/24:
|
||||
client_map_token: "big blue pirate"
|
||||
|
||||
2001:db8:44::/48:
|
||||
client_map_token: "big blue pirate"
|
||||
|
||||
10.0.69.0/24:
|
||||
client_map_token: "magic red bridge"
|
||||
|
||||
2001:db8:45::/48:
|
||||
client_map_token: "magic red bridge"
|
||||
|
||||
10.0.70.0/24:
|
||||
client_map_token: "big red dragon"
|
||||
|
||||
2001:db8:46::/48:
|
||||
client_map_token: "big red dragon"
|
||||
|
||||
10.0.71.0/24:
|
||||
client_map_token: "big gold wizard"
|
||||
|
||||
2001:db8:47::/48:
|
||||
client_map_token: "big gold wizard"
|
||||
|
||||
10.0.72.0/24:
|
||||
client_map_token: "magic lime castle"
|
||||
|
||||
2001:db8:48::/48:
|
||||
client_map_token: "magic lime castle"
|
||||
|
||||
10.0.73.0/24:
|
||||
client_map_token: "round lime forest"
|
||||
|
||||
2001:db8:49::/48:
|
||||
client_map_token: "round lime forest"
|
||||
|
||||
10.0.74.0/24:
|
||||
client_map_token: "magic dark rocket"
|
||||
|
||||
2001:db8:4a::/48:
|
||||
client_map_token: "magic dark rocket"
|
||||
|
||||
10.0.75.0/24:
|
||||
client_map_token: "sharp red pirate"
|
||||
|
||||
2001:db8:4b::/48:
|
||||
client_map_token: "sharp red pirate"
|
||||
|
||||
10.0.76.0/24:
|
||||
client_map_token: "brave lime pirate"
|
||||
|
||||
2001:db8:4c::/48:
|
||||
client_map_token: "brave lime pirate"
|
||||
|
||||
10.0.77.0/24:
|
||||
client_map_token: "swift blue forest"
|
||||
|
||||
2001:db8:4d::/48:
|
||||
client_map_token: "swift blue forest"
|
||||
|
||||
10.0.78.0/24:
|
||||
client_map_token: "brave lime dragon"
|
||||
|
||||
2001:db8:4e::/48:
|
||||
client_map_token: "brave lime dragon"
|
||||
|
||||
10.0.79.0/24:
|
||||
client_map_token: "big grey pirate"
|
||||
|
||||
2001:db8:4f::/48:
|
||||
client_map_token: "big grey pirate"
|
||||
|
||||
10.0.80.0/24:
|
||||
client_map_token: "grand blue dragon"
|
||||
|
||||
2001:db8:50::/48:
|
||||
client_map_token: "grand blue dragon"
|
||||
|
||||
10.0.81.0/24:
|
||||
client_map_token: "swift lime bridge"
|
||||
|
||||
2001:db8:51::/48:
|
||||
client_map_token: "swift lime bridge"
|
||||
|
||||
10.0.82.0/24:
|
||||
client_map_token: "sharp red wizard"
|
||||
|
||||
2001:db8:52::/48:
|
||||
client_map_token: "sharp red wizard"
|
||||
|
||||
10.0.83.0/24:
|
||||
client_map_token: "gnarly grey rocket"
|
||||
|
||||
2001:db8:53::/48:
|
||||
client_map_token: "gnarly grey rocket"
|
||||
|
||||
10.0.84.0/24:
|
||||
client_map_token: "brave dark bridge"
|
||||
|
||||
2001:db8:54::/48:
|
||||
client_map_token: "brave dark bridge"
|
||||
|
||||
10.0.85.0/24:
|
||||
client_map_token: "magic red castle"
|
||||
|
||||
2001:db8:55::/48:
|
||||
client_map_token: "magic red castle"
|
||||
|
||||
10.0.86.0/24:
|
||||
client_map_token: "swift pink falcon"
|
||||
|
||||
2001:db8:56::/48:
|
||||
client_map_token: "swift pink falcon"
|
||||
|
||||
10.0.87.0/24:
|
||||
client_map_token: "grand red jaguar"
|
||||
|
||||
2001:db8:57::/48:
|
||||
client_map_token: "grand red jaguar"
|
||||
|
||||
10.0.88.0/24:
|
||||
client_map_token: "magic lime forest"
|
||||
|
||||
2001:db8:58::/48:
|
||||
client_map_token: "magic lime forest"
|
||||
|
||||
10.0.89.0/24:
|
||||
client_map_token: "sharp gold wizard"
|
||||
|
||||
2001:db8:59::/48:
|
||||
client_map_token: "sharp gold wizard"
|
||||
|
||||
10.0.90.0/24:
|
||||
client_map_token: "big green island"
|
||||
|
||||
2001:db8:5a::/48:
|
||||
client_map_token: "big green island"
|
||||
|
||||
10.0.91.0/24:
|
||||
client_map_token: "thick lime island"
|
||||
|
||||
2001:db8:5b::/48:
|
||||
client_map_token: "thick lime island"
|
||||
|
||||
10.0.92.0/24:
|
||||
client_map_token: "magic blue castle"
|
||||
|
||||
2001:db8:5c::/48:
|
||||
client_map_token: "magic blue castle"
|
||||
|
||||
10.0.93.0/24:
|
||||
client_map_token: "round green island"
|
||||
|
||||
2001:db8:5d::/48:
|
||||
client_map_token: "round green island"
|
||||
|
||||
10.0.94.0/24:
|
||||
client_map_token: "sharp green wizard"
|
||||
|
||||
2001:db8:5e::/48:
|
||||
client_map_token: "sharp green wizard"
|
||||
|
||||
10.0.95.0/24:
|
||||
client_map_token: "big gold island"
|
||||
|
||||
2001:db8:5f::/48:
|
||||
client_map_token: "big gold island"
|
||||
|
||||
10.0.96.0/24:
|
||||
client_map_token: "grand green wizard"
|
||||
|
||||
2001:db8:60::/48:
|
||||
client_map_token: "grand green wizard"
|
||||
|
||||
10.0.97.0/24:
|
||||
client_map_token: "tiny lime wizard"
|
||||
|
||||
2001:db8:61::/48:
|
||||
client_map_token: "tiny lime wizard"
|
||||
|
||||
10.0.98.0/24:
|
||||
client_map_token: "thick red rocket"
|
||||
|
||||
2001:db8:62::/48:
|
||||
client_map_token: "thick red rocket"
|
||||
|
||||
10.0.99.0/24:
|
||||
client_map_token: "magic lime wizard"
|
||||
|
||||
2001:db8:63::/48:
|
||||
client_map_token: "magic lime wizard"
|
||||
|
||||
10.0.100.0/24:
|
||||
client_map_token: "brave dark wizard"
|
||||
|
||||
2001:db8:64::/48:
|
||||
client_map_token: "brave dark wizard"
|
||||
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
clab-webserver:
|
||||
build: .
|
||||
image: git.ipng.ch/ipng/clab-webserver:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./client-map.yaml:/app/client-map.yaml:ro
|
||||
environment:
|
||||
LISTEN: ":80"
|
||||
CLIENT_MAP: /app/client-map.yaml
|
||||
DOCROOT: /app/docroot
|
||||
restart: unless-stopped
|
||||
7
docroot/index.txt
Normal file
7
docroot/index.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
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.
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module clab-webserver
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
301
main.go
Normal file
301
main.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type prefixEntry struct {
|
||||
network *net.IPNet
|
||||
ones int
|
||||
vars map[string]string
|
||||
}
|
||||
|
||||
func loadClientMap(path string) ([]prefixEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := map[string]map[string]string{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []prefixEntry
|
||||
for cidr, vars := range raw {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
ones, _ := network.Mask.Size()
|
||||
entries = append(entries, prefixEntry{network: network, ones: ones, vars: vars})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
type clientMap struct {
|
||||
mu sync.RWMutex
|
||||
entries []prefixEntry
|
||||
}
|
||||
|
||||
func (cm *clientMap) get() []prefixEntry {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.entries
|
||||
}
|
||||
|
||||
func (cm *clientMap) set(entries []prefixEntry) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
cm.entries = entries
|
||||
}
|
||||
|
||||
// watchClientMap polls path every 5 seconds and reloads when mtime changes.
|
||||
// On parse/CIDR error it logs a warning and keeps the previous data.
|
||||
func watchClientMap(path string, cm *clientMap, initialMod time.Time) {
|
||||
lastMod := initialMod
|
||||
for {
|
||||
time.Sleep(5 * time.Second)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: client-map stat error: %v", err)
|
||||
continue
|
||||
}
|
||||
if !info.ModTime().After(lastMod) {
|
||||
continue
|
||||
}
|
||||
entries, err := loadClientMap(path)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: client-map reload failed, keeping previous data: %v", err)
|
||||
continue
|
||||
}
|
||||
cm.set(entries)
|
||||
lastMod = info.ModTime()
|
||||
log.Printf("client-map reloaded from %s (%d prefixes)", path, len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// mergedVars collects all matching prefix entries, sorts least-to-most specific,
|
||||
// and merges their var maps so more-specific entries overwrite less-specific ones.
|
||||
func mergedVars(entries []prefixEntry, ip net.IP) map[string]interface{} {
|
||||
type match struct {
|
||||
ones int
|
||||
vars map[string]string
|
||||
}
|
||||
var matches []match
|
||||
for _, e := range entries {
|
||||
if e.network.Contains(ip) {
|
||||
matches = append(matches, match{e.ones, e.vars})
|
||||
}
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].ones < matches[j].ones
|
||||
})
|
||||
result := map[string]interface{}{}
|
||||
for _, m := range matches {
|
||||
for k, v := range m.vars {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractTemplateFields walks the parsed template AST and returns all top-level
|
||||
// field names referenced as {{ .fieldname }}.
|
||||
func extractTemplateFields(tmpl *template.Template) []string {
|
||||
if tmpl.Tree == nil || tmpl.Tree.Root == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var fields []string
|
||||
walkNode(tmpl.Tree.Root, seen, &fields)
|
||||
return fields
|
||||
}
|
||||
|
||||
func walkNode(node parse.Node, seen map[string]bool, fields *[]string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch n := node.(type) {
|
||||
case *parse.ListNode:
|
||||
for _, child := range n.Nodes {
|
||||
walkNode(child, seen, fields)
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
walkNode(n.Pipe, seen, fields)
|
||||
case *parse.PipeNode:
|
||||
for _, cmd := range n.Cmds {
|
||||
for _, arg := range cmd.Args {
|
||||
walkNode(arg, seen, fields)
|
||||
}
|
||||
}
|
||||
case *parse.FieldNode:
|
||||
if len(n.Ident) > 0 && !seen[n.Ident[0]] {
|
||||
seen[n.Ident[0]] = true
|
||||
*fields = append(*fields, n.Ident[0])
|
||||
}
|
||||
case *parse.IfNode:
|
||||
walkNode(n.Pipe, seen, fields)
|
||||
walkNode(n.List, seen, fields)
|
||||
walkNode(n.ElseList, seen, fields)
|
||||
case *parse.RangeNode:
|
||||
walkNode(n.Pipe, seen, fields)
|
||||
walkNode(n.List, seen, fields)
|
||||
walkNode(n.ElseList, seen, fields)
|
||||
case *parse.WithNode:
|
||||
walkNode(n.Pipe, seen, fields)
|
||||
walkNode(n.List, seen, fields)
|
||||
walkNode(n.ElseList, seen, fields)
|
||||
}
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
bytesWritten int
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
|
||||
n, err := lrw.ResponseWriter.Write(b)
|
||||
lrw.bytesWritten += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
clientMapPath := flag.String("client-map", getEnv("CLIENT_MAP", "client-map.yaml"), "path to client-map YAML file [$CLIENT_MAP]")
|
||||
listenAddr := flag.String("listen", getEnv("LISTEN", ":80"), "listen address [$LISTEN]")
|
||||
docrootDir := flag.String("docroot", getEnv("DOCROOT", "docroot"), "path to docroot directory [$DOCROOT]")
|
||||
flag.Parse()
|
||||
|
||||
entries, err := loadClientMap(*clientMapPath)
|
||||
if err != nil {
|
||||
log.Fatalf("loading client-map: %v", err)
|
||||
}
|
||||
info, err := os.Stat(*clientMapPath)
|
||||
if err != nil {
|
||||
log.Fatalf("stat client-map: %v", err)
|
||||
}
|
||||
cm := &clientMap{}
|
||||
cm.set(entries)
|
||||
go watchClientMap(*clientMapPath, cm, info.ModTime())
|
||||
|
||||
absDocroot, err := filepath.Abs(*docrootDir)
|
||||
if err != nil {
|
||||
log.Fatalf("resolving docroot: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: 200}
|
||||
serveRequest(lrw, r, cm.get(), absDocroot)
|
||||
fmt.Printf("%s - - [%s] \"%s %s %s\" %d %d \"-\" \"%s\"\n",
|
||||
clientIP(r),
|
||||
time.Now().Format("02/Jan/2006:15:04:05 -0700"),
|
||||
r.Method, r.URL.RequestURI(), r.Proto,
|
||||
lrw.statusCode, lrw.bytesWritten,
|
||||
r.Header.Get("User-Agent"),
|
||||
)
|
||||
})
|
||||
|
||||
log.Printf("listening on %s, docroot=%s", *listenAddr, absDocroot)
|
||||
log.Fatal(http.ListenAndServe(*listenAddr, mux))
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func serveRequest(w http.ResponseWriter, r *http.Request, entries []prefixEntry, absDocroot string) {
|
||||
// Parse and normalize client IP (handle IPv4-mapped IPv6 from dual-stack sockets)
|
||||
host := clientIP(r)
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
|
||||
// Build template data from merged prefix matches; HTTP-derived vars always win
|
||||
data := mergedVars(entries, ip)
|
||||
data["remote_address"] = host
|
||||
data["host"] = r.Host
|
||||
data["user_agent"] = r.Header.Get("User-Agent")
|
||||
|
||||
// Resolve and validate file path
|
||||
urlPath := r.URL.Path
|
||||
if urlPath == "/" || urlPath == "" {
|
||||
urlPath = "/index.txt"
|
||||
}
|
||||
relPath := strings.TrimPrefix(filepath.Clean(urlPath), "/")
|
||||
absPath, err := filepath.Abs(filepath.Join(absDocroot, relPath))
|
||||
if err != nil || !strings.HasPrefix(absPath+string(os.PathSeparator), absDocroot+string(os.PathSeparator)) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Parse(string(content))
|
||||
if err != nil {
|
||||
log.Printf("ERROR: template parse error for %s: %v", absPath, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Warn for each template variable with no value for this client; default to ""
|
||||
for _, field := range extractTemplateFields(tmpl) {
|
||||
if _, ok := data[field]; !ok {
|
||||
log.Printf("WARNING: template variable .%s has no value for client %s", field, host)
|
||||
data[field] = ""
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
log.Printf("ERROR: template execute error for %s: %v", absPath, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user