commit 07d33913cab141ec7eae08b77abbec1d115dc07d Author: Pim van Pelt Date: Fri Apr 3 14:46:04 2026 +0200 A toy webserver with variable expansion based on connecting client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d23a90 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +clab-webserver diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2539d09 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2757d9e --- /dev/null +++ b/README.md @@ -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:///`, 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` diff --git a/client-map.yaml b/client-map.yaml new file mode 100644 index 0000000..8c0feb5 --- /dev/null +++ b/client-map.yaml @@ -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" + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2adcb23 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docroot/index.txt b/docroot/index.txt new file mode 100644 index 0000000..e4338f8 --- /dev/null +++ b/docroot/index.txt @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e8541d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module clab-webserver + +go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9558b88 --- /dev/null +++ b/main.go @@ -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()) +}