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