A toy webserver with variable expansion based on connecting client

This commit is contained in:
2026-04-03 14:46:04 +02:00
commit 07d33913ca
9 changed files with 1180 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
clab-webserver

15
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
module clab-webserver
go 1.24
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View 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
View 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())
}