chore(receiver): packaging, k8s manifests, CI/release, docs
CI / test (pull_request) Successful in 14m14s
CI / docker (pull_request) Has been skipped

Dockerfile.receiver (CGO-free, /data volume), receiver image in CI and
tag releases, receiver binary in release archives, make build-receiver,
example k8s manifests (deployment/service/ingress/secret/pvc) under
deploy/k8s/receiver/, and README coverage including sender/receiver
token pairing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gary Hansen
2026-07-08 02:43:51 +10:00
co-authored by Claude Fable 5
parent beb595442f
commit c9a963ecdd
13 changed files with 306 additions and 16 deletions
+10
View File
@@ -67,3 +67,13 @@ jobs:
tags: | tags: |
gitea.hansenits.com.au/hits/exploredns-web:latest gitea.hansenits.com.au/hits/exploredns-web:latest
gitea.hansenits.com.au/hits/exploredns-web:${{ github.sha }} gitea.hansenits.com.au/hits/exploredns-web:${{ github.sha }}
- name: Build and push receiver image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.receiver
push: true
tags: |
gitea.hansenits.com.au/hits/exploredns-receiver:latest
gitea.hansenits.com.au/hits/exploredns-receiver:${{ github.sha }}
+19 -4
View File
@@ -1,8 +1,8 @@
name: Release name: Release
# Builds release artifacts when a version tag (v*) is pushed: # Builds release artifacts when a version tag (v*) is pushed:
# - cross-compiled CLI + server binaries attached to the Gitea release # - cross-compiled CLI + server + receiver binaries attached to the Gitea release
# - version-tagged docker images for the CLI and web server # - version-tagged docker images for the CLI, web server, and receiver
# The owner usually creates the Gitea release by hand with notes; this # The owner usually creates the Gitea release by hand with notes; this
# workflow attaches assets to it (creating a bare release only when none # workflow attaches assets to it (creating a bare release only when none
# exists) and skips assets that are already attached, so re-runs are safe. # exists) and skips assets that are already attached, so re-runs are safe.
@@ -42,10 +42,13 @@ jobs:
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath -ldflags "-s -w -X main.version=${TAG}" \ go build -trimpath -ldflags "-s -w -X main.version=${TAG}" \
-o "${OUT}/exploredns-server${EXT}" ./cmd/server -o "${OUT}/exploredns-server${EXT}" ./cmd/server
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath -ldflags "-s -w -X main.version=${TAG}" \
-o "${OUT}/exploredns-receiver${EXT}" ./cmd/exploredns-receiver
if [ "$GOOS" = "windows" ]; then if [ "$GOOS" = "windows" ]; then
(cd "$OUT" && zip -q "../exploredns_${TAG}_${GOOS}_${GOARCH}.zip" exploredns.exe exploredns-server.exe) (cd "$OUT" && zip -q "../exploredns_${TAG}_${GOOS}_${GOARCH}.zip" exploredns.exe exploredns-server.exe exploredns-receiver.exe)
else else
tar -czf "dist/exploredns_${TAG}_${GOOS}_${GOARCH}.tar.gz" -C "$OUT" exploredns exploredns-server tar -czf "dist/exploredns_${TAG}_${GOOS}_${GOARCH}.tar.gz" -C "$OUT" exploredns exploredns-server exploredns-receiver
fi fi
rm -rf "$OUT" rm -rf "$OUT"
done done
@@ -130,3 +133,15 @@ jobs:
tags: | tags: |
gitea.hansenits.com.au/hits/exploredns-web:latest gitea.hansenits.com.au/hits/exploredns-web:latest
gitea.hansenits.com.au/hits/exploredns-web:${{ github.ref_name }} gitea.hansenits.com.au/hits/exploredns-web:${{ github.ref_name }}
- name: Build and push receiver image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.receiver
push: true
build-args: |
VERSION=${{ github.ref_name }}
tags: |
gitea.hansenits.com.au/hits/exploredns-receiver:latest
gitea.hansenits.com.au/hits/exploredns-receiver:${{ github.ref_name }}
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
+26
View File
@@ -0,0 +1,26 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/exploredns-receiver ./cmd/exploredns-receiver
# Final stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
COPY --from=builder /out/exploredns-receiver /usr/local/bin/exploredns-receiver
ENV RECEIVER_SQLITE_PATH=/data/exploredns-receiver.db
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["exploredns-receiver"]
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
+6 -2
View File
@@ -1,12 +1,13 @@
BINARY_NAME=exploredns BINARY_NAME=exploredns
SERVER_BINARY_NAME=exploredns-server SERVER_BINARY_NAME=exploredns-server
RECEIVER_BINARY_NAME=exploredns-receiver
BUILD_DIR=bin BUILD_DIR=bin
GO=go GO=go
GOFLAGS=-v GOFLAGS=-v
VERSION?=$(shell git describe --tags --always 2>/dev/null || echo dev) VERSION?=$(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS=-ldflags "-X main.version=$(VERSION)" LDFLAGS=-ldflags "-X main.version=$(VERSION)"
.PHONY: build build-server build-all test lint clean cover deploy deploy-status .PHONY: build build-server build-receiver build-all test lint clean cover deploy deploy-status
build: build:
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns
@@ -14,7 +15,10 @@ build:
build-server: build-server:
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(SERVER_BINARY_NAME) ./cmd/server $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(SERVER_BINARY_NAME) ./cmd/server
build-all: build build-server build-receiver:
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(RECEIVER_BINARY_NAME) ./cmd/exploredns-receiver
build-all: build build-server build-receiver
test: test:
$(GO) test -v -race -coverprofile=coverage.out ./... $(GO) test -v -race -coverprofile=coverage.out ./...
+90 -8
View File
@@ -297,6 +297,7 @@ variables at startup:
| `EXPLOREDNS_CORS_ORIGIN` | *(unset)* | Off by default (the SPA is same-origin). Set an origin — or `*` for development — to enable cross-origin API access. | | `EXPLOREDNS_CORS_ORIGIN` | *(unset)* | Off by default (the SPA is same-origin). Set an origin — or `*` for development — to enable cross-origin API access. |
| `EXPLOREDNS_RATE_LIMIT` | `30/1h` | Per-client-IP token-bucket limit on `POST /api/traverse` in `N/duration` form (e.g. `10/10m`); invalid values fall back to the default. Over-limit requests get `429`. Buckets refill continuously. Direct localhost connections are exempt (dev loop, tests), but proxied requests are always limited by the real client IP from `Fly-Client-IP` / `X-Forwarded-For`. | | `EXPLOREDNS_RATE_LIMIT` | `30/1h` | Per-client-IP token-bucket limit on `POST /api/traverse` in `N/duration` form (e.g. `10/10m`); invalid values fall back to the default. Over-limit requests get `429`. Buckets refill continuously. Direct localhost connections are exempt (dev loop, tests), but proxied requests are always limited by the real client IP from `Fly-Client-IP` / `X-Forwarded-For`. |
| `EXPLOREDNS_WEBHOOK_URL` | *(unset)* | Off by default. When set, the server POSTs a usage-reporting JSON event to this URL on every traversal start and completion (see below). | | `EXPLOREDNS_WEBHOOK_URL` | *(unset)* | Off by default. When set, the server POSTs a usage-reporting JSON event to this URL on every traversal start and completion (see below). |
| `EXPLOREDNS_WEBHOOK_TOKEN` | *(unset)* | Optional bearer token for webhook deliveries. When set, every webhook POST carries `Authorization: Bearer <token>`; pair it with the receiver's `RECEIVER_INGEST_TOKEN`. |
### Usage reporting ### Usage reporting
@@ -312,12 +313,17 @@ traversal, each with header `X-ExploreDNS-Event` naming the event:
`X-Forwarded-For` entry, else the connection address). Delivery is `X-Forwarded-For` entry, else the connection address). Delivery is
fire-and-forget: a 5-second timeout, one retry after 2 seconds, and failures fire-and-forget: a 5-second timeout, one retry after 2 seconds, and failures
are logged without ever affecting the traversal or the API response. On are logged without ever affecting the traversal or the API response. On
Fly.io, configure it as a secret rather than in `fly.toml`: Fly.io, configure it (and the optional bearer token) as secrets rather than
in `fly.toml`:
```sh ```sh
fly secrets set EXPLOREDNS_WEBHOOK_URL=https://example.com/hook fly secrets set EXPLOREDNS_WEBHOOK_URL=https://example.com/hook \
EXPLOREDNS_WEBHOOK_TOKEN=some-long-random-string
``` ```
This repo ships a matching receiver for these events — see
[Usage telemetry receiver](#usage-telemetry-receiver).
--- ---
## Deploying to Fly.io ## Deploying to Fly.io
@@ -366,9 +372,9 @@ flyctl tokens create deploy -x 999999h
Pushing a `v*` tag triggers the full release pipeline: Pushing a `v*` tag triggers the full release pipeline:
1. `.gitea/workflows/release.yml` (`binaries` job) cross-compiles the CLI 1. `.gitea/workflows/release.yml` (`binaries` job) cross-compiles the CLI,
and server for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 and server, and receiver for linux/amd64, linux/arm64, darwin/amd64,
windows/amd64, packages them as darwin/arm64 and windows/amd64, packages them as
`exploredns_<tag>_<os>_<arch>.tar.gz` (`.zip` on Windows) plus a `exploredns_<tag>_<os>_<arch>.tar.gz` (`.zip` on Windows) plus a
`SHA256SUMS` file, and attaches everything to the Gitea release for the `SHA256SUMS` file, and attaches everything to the Gitea release for the
tag. Create the release with notes by hand before (or after) pushing tag. Create the release with notes by hand before (or after) pushing
@@ -376,8 +382,8 @@ Pushing a `v*` tag triggers the full release pipeline:
a bare one only when none exists, and skips already-attached assets so a bare one only when none exists, and skips already-attached assets so
re-runs are safe. re-runs are safe.
2. `.gitea/workflows/release.yml` (`docker` job) pushes 2. `.gitea/workflows/release.yml` (`docker` job) pushes
`gitea.hansenits.com.au/hits/exploredns-cli` and `…/exploredns-web` `gitea.hansenits.com.au/hits/exploredns-cli`, `…/exploredns-web`, and
images tagged `<tag>` and `latest`. `…/exploredns-receiver` images tagged `<tag>` and `latest`.
3. `.gitea/workflows/deploy.yml` deploys the web server to Fly.io. 3. `.gitea/workflows/deploy.yml` deploys the web server to Fly.io.
All binaries are stamped with the tag via All binaries are stamped with the tag via
@@ -420,6 +426,78 @@ with `--show-servers`. Suitable for piping into `jq`.
--- ---
## Usage telemetry receiver
`cmd/exploredns-receiver` is a small companion service that receives the
usage webhooks described above (`start`/`complete` events from
`EXPLOREDNS_WEBHOOK_URL`), stores them in MySQL or SQLite, and serves a
basic-auth-protected admin dashboard (`/admin`) plus JSON API
(`/admin/api/traversals`, `/admin/api/stats`) over the collected data. It
is a separate binary intended to run wherever you keep long-lived storage
(e.g. a home Kubernetes cluster) while the public web server stays
stateless.
Endpoints: `POST /webhook` (ingest, bearer-token protected when configured),
`GET /healthz` (liveness/readiness), `GET /admin` and `GET /admin/api/*`
(basic auth, always required).
### Configuration
| Variable | Default | Meaning |
|---|---|---|
| `RECEIVER_ADDR` | `:8080` | Listen address. |
| `RECEIVER_MYSQL_DSN` | *(unset)* | [go-sql-driver DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) (`user:pass@tcp(host:3306)/dbname`). When set, events are stored in MySQL and the SQLite settings are ignored. |
| `RECEIVER_SQLITE_PATH` | `data/exploredns-receiver.db` | SQLite database path, used when no MySQL DSN is set (the container image defaults it to `/data/exploredns-receiver.db`). Parent directories are created automatically. |
| `RECEIVER_INGEST_TOKEN` | *(unset)* | When set, `POST /webhook` requires `Authorization: Bearer <token>`. Leave unset only on trusted networks. |
| `RECEIVER_ADMIN_USER` | `admin` | Basic-auth username for `/admin`. |
| `RECEIVER_ADMIN_PASSWORD` | *(required)* | Basic-auth password for `/admin`; the receiver refuses to start without it. |
Both storage backends share one portable schema; pure-Go drivers
(`modernc.org/sqlite`, `github.com/go-sql-driver/mysql`) keep the binary
CGO-free. SQLite is the zero-setup default; point `RECEIVER_MYSQL_DSN` at
an external MySQL when you want the data outside the pod/VM.
### Running with Docker
```sh
docker run -d --name exploredns-receiver \
-p 8080:8080 \
-v exploredns-receiver-data:/data \
-e RECEIVER_ADMIN_PASSWORD=change-me \
-e RECEIVER_INGEST_TOKEN=some-long-random-string \
gitea.hansenits.com.au/hits/exploredns-receiver:latest
```
The image stores SQLite data under the `/data` volume; add
`-e RECEIVER_MYSQL_DSN=...` to use MySQL instead.
### Running on Kubernetes
[deploy/k8s/receiver/](deploy/k8s/receiver/) contains commented template
manifests: a single-replica deployment (SQLite on a 1Gi PVC mounted at
`/data`, probes on `/healthz`), ClusterIP service, ingress with TLS
placeholders, and a secret template for the `RECEIVER_*` variables. Edit
the placeholder host/credentials, then:
```sh
kubectl apply -f deploy/k8s/receiver/
```
Keep one replica while on SQLite; MySQL removes that constraint.
### Pairing with the web server
Set the same token on both ends so the receiver only accepts events from
your server — e.g. on Fly.io:
```sh
fly secrets set EXPLOREDNS_WEBHOOK_URL=https://receiver.example.com/webhook \
EXPLOREDNS_WEBHOOK_TOKEN=some-long-random-string
# receiver side: RECEIVER_INGEST_TOKEN=some-long-random-string
```
---
## Comparison with dnstraverse ## Comparison with dnstraverse
| Feature | dnstraverse (Ruby) | ExploreDNS (Go) | | Feature | dnstraverse (Ruby) | ExploreDNS (Go) |
@@ -440,13 +518,16 @@ with `--show-servers`. Suitable for piping into `jq`.
``` ```
cmd/exploredns/ CLI entry point and flag parsing cmd/exploredns/ CLI entry point and flag parsing
cmd/server/ HTTP API server entry point cmd/server/ HTTP API server entry point
cmd/exploredns-receiver/ Usage telemetry receiver entry point
internal/config/ Configuration types, validation, and usage text internal/config/ Configuration types, validation, and usage text
internal/dns/ DNS query layer, root discovery, transport internal/dns/ DNS query layer, root discovery, transport
internal/traverse/ Core traversal engine, referral resolution, caching internal/traverse/ Core traversal engine, referral resolution, caching
internal/fingerprint/ DNS server version fingerprinting (version.bind CHAOS) internal/fingerprint/ DNS server version fingerprinting (version.bind CHAOS)
internal/output/ Result formatting — text tree and JSON renderers internal/output/ Result formatting — text tree and JSON renderers
internal/integration/ End-to-end integration tests internal/integration/ End-to-end integration tests
internal/receiver/ Telemetry receiver: HTTP server, admin UI, event store
web/api/ HTTP handler, job store, SSE streaming, static assets web/api/ HTTP handler, job store, SSE streaming, static assets
deploy/k8s/receiver/ Kubernetes manifest templates for the receiver
``` ```
--- ---
@@ -456,7 +537,8 @@ web/api/ HTTP handler, job store, SSE streaming, static assets
```sh ```sh
make build # compile CLI binary to bin/exploredns make build # compile CLI binary to bin/exploredns
make build-server # compile server binary to bin/exploredns-server make build-server # compile server binary to bin/exploredns-server
make build-all # compile both binaries make build-receiver # compile telemetry receiver to bin/exploredns-receiver
make build-all # compile all three binaries
make test # run all unit and integration tests make test # run all unit and integration tests
make lint # run go vet make lint # run go vet
make clean # remove build artefacts make clean # remove build artefacts
+57
View File
@@ -0,0 +1,57 @@
# TEMPLATE — single-replica receiver deployment. Keep replicas at 1 while
# using the SQLite backend: the database file on the RWO volume supports only
# one writer. With RECEIVER_MYSQL_DSN you may scale out and drop the volume.
apiVersion: apps/v1
kind: Deployment
metadata:
name: exploredns-receiver
namespace: exploredns-receiver
labels:
app: exploredns-receiver
spec:
replicas: 1
strategy:
type: Recreate # RWO volume: never run old and new pods concurrently
selector:
matchLabels:
app: exploredns-receiver
template:
metadata:
labels:
app: exploredns-receiver
spec:
containers:
- name: receiver
image: gitea.hansenits.com.au/hits/exploredns-receiver:latest
ports:
- name: http
containerPort: 8080
envFrom:
- secretRef:
name: exploredns-receiver
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: exploredns-receiver-data
+33
View File
@@ -0,0 +1,33 @@
# TEMPLATE — replace receiver.example.com with your real host and wire up
# TLS for your cluster (the webhook bearer token and admin password travel
# in headers, so plain HTTP is not acceptable across the internet).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: exploredns-receiver
namespace: exploredns-receiver
annotations: {}
# nginx ingress controller:
# cert-manager.io/cluster-issuer: letsencrypt
# nginx.ingress.kubernetes.io/proxy-body-size: 1m
#
# traefik:
# traefik.ingress.kubernetes.io/router.entrypoints: websecure
# traefik.ingress.kubernetes.io/router.tls: "true"
spec:
# ingressClassName: nginx
tls:
- hosts:
- receiver.example.com
secretName: exploredns-receiver-tls # created by cert-manager or by hand
rules:
- host: receiver.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: exploredns-receiver
port:
name: http
+6
View File
@@ -0,0 +1,6 @@
# TEMPLATE — optional. Skip this file (and drop the namespace fields from the
# other manifests) to deploy into an existing namespace.
apiVersion: v1
kind: Namespace
metadata:
name: exploredns-receiver
+16
View File
@@ -0,0 +1,16 @@
# TEMPLATE — backing storage for the SQLite database (RECEIVER_SQLITE_PATH
# defaults to /data/exploredns-receiver.db in the container image). Not needed
# when RECEIVER_MYSQL_DSN is set, but harmless to keep. Set storageClassName
# if your cluster has no default class.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: exploredns-receiver-data
namespace: exploredns-receiver
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
# storageClassName: standard
+25
View File
@@ -0,0 +1,25 @@
# TEMPLATE — fill in real values before applying, or create the secret
# imperatively instead and never commit credentials:
#
# kubectl -n exploredns-receiver create secret generic exploredns-receiver \
# --from-literal=RECEIVER_ADMIN_USER=admin \
# --from-literal=RECEIVER_ADMIN_PASSWORD='change-me' \
# --from-literal=RECEIVER_INGEST_TOKEN='change-me-too'
#
# The deployment loads every key here as an environment variable (envFrom).
apiVersion: v1
kind: Secret
metadata:
name: exploredns-receiver
namespace: exploredns-receiver
type: Opaque
stringData:
RECEIVER_ADMIN_USER: admin
RECEIVER_ADMIN_PASSWORD: change-me
# Bearer token the main app must send on POST /webhook. Must match the
# sender's EXPLOREDNS_WEBHOOK_TOKEN. Leave unset to accept unauthenticated
# posts (not recommended for an internet-facing receiver).
RECEIVER_INGEST_TOKEN: change-me-too
# Uncomment to store events in an external MySQL instead of the SQLite
# file on the PVC (go-sql-driver DSN).
# RECEIVER_MYSQL_DSN: "user:pass@tcp(mysql.example.com:3306)/exploredns"
+16
View File
@@ -0,0 +1,16 @@
# TEMPLATE — cluster-internal service in front of the receiver pod.
apiVersion: v1
kind: Service
metadata:
name: exploredns-receiver
namespace: exploredns-receiver
labels:
app: exploredns-receiver
spec:
type: ClusterIP
selector:
app: exploredns-receiver
ports:
- name: http
port: 8080
targetPort: http