diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index b222fab..56b9043 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -135,6 +135,25 @@ jobs:
DDI_DATABASE_URL: postgres://ddi:ddi-ci-only@postgres:5432/ddi?sslmode=disable
run: scripts/ci/smoke-server.sh dist
+ # Needs a runner with access to a Docker daemon (see docs/adr/0003).
+ e2e:
+ name: End-to-end (server, agents, BIND, Kea)
+ runs-on: ubuntu-latest
+ env:
+ # The Docker daemon may be shared: unique compose project.
+ COMPOSE_PROJECT_NAME: ddi-e2e-${{ github.run_id }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: go.mod
+ cache: false
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: "24"
+ - name: API change reaches BIND and Kea through the agents
+ run: make test-e2e
+
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
integration:
name: Integration (real BIND and Kea)
diff --git a/.gitignore b/.gitignore
index dc4f39e..b08c73a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,4 @@ Thumbs.db
# Local worktrees of Claude Code agents
/.claude/worktrees/
+/test/e2e/bin/
diff --git a/Makefile b/Makefile
index 1a3b3bb..3e869d1 100644
--- a/Makefile
+++ b/Makefile
@@ -139,6 +139,11 @@ test-go:
test-web:
cd web && npm test
+## test-e2e: API change -> revision -> agents -> BIND/Kea in containers (needs docker)
+.PHONY: test-e2e
+test-e2e: build
+ test/e2e/run.sh
+
## test-integration: generated configs and agent roles against real BIND and Kea (needs docker)
.PHONY: test-integration
test-integration:
diff --git a/agent/core/agent_test.go b/agent/core/agent_test.go
index ab3810d..0e2db44 100644
--- a/agent/core/agent_test.go
+++ b/agent/core/agent_test.go
@@ -444,11 +444,18 @@ func TestDesiredAndPush(t *testing.T) {
t.Fatal("request queued for revision 0 or manual mode")
}
f.agent.Desired(3, true)
+ if r, ok := f.agent.dequeue(); !ok || r.revision != 3 || r.trigger != TriggerPull {
+ t.Errorf("pull request = %+v, %v", r, ok)
+ }
+ // A push replaces queued pulls up to its revision.
+ f.agent.Desired(3, true)
f.agent.Push(4, "dep")
- r1, _ := f.agent.dequeue()
- r2, _ := f.agent.dequeue()
- if r1.revision != 3 || r1.trigger != TriggerPull || r2.revision != 4 || r2.deploymentID != "dep" {
- t.Errorf("requests = %+v %+v", r1, r2)
+ r, _ := f.agent.dequeue()
+ if r.revision != 4 || r.trigger != TriggerPush || r.deploymentID != "dep" {
+ t.Errorf("push request = %+v", r)
+ }
+ if extra, ok := f.agent.dequeue(); ok {
+ t.Errorf("pull for revision 3 was not replaced: %+v", extra)
}
}
diff --git a/agent/role/internal/command/command_test.go b/agent/role/internal/command/command_test.go
index 39ad932..cdbe711 100644
--- a/agent/role/internal/command/command_test.go
+++ b/agent/role/internal/command/command_test.go
@@ -65,7 +65,8 @@ func TestExcerptAndLimit(t *testing.T) {
_, _ = l.Write([]byte("cc"))
_, _ = l.Write([]byte("dd"))
b := l.Bytes()
- if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b), "c") != 1 {
+ if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b[:MaxCapture]), "c") != 1 ||
+ strings.Contains(string(b), "d") {
t.Errorf("limited buffer kept %d bytes", len(b))
}
}
diff --git a/scripts/ci/smoke-server.sh b/scripts/ci/smoke-server.sh
index fa4a313..fe4055c 100755
--- a/scripts/ci/smoke-server.sh
+++ b/scripts/ci/smoke-server.sh
@@ -62,9 +62,9 @@ fail() {
exit 1
}
-curl -fsS "$base/readyz" | grep -q '"database":"ok"' || fail "readyz"
-curl -fsS "$base/metrics" | grep -q '^ddi_license_writable 1' || fail "metrics"
-curl -fsS "$base/api/v1/system/info" | grep -q '"license_state":"trial"' || fail "system info"
+grep -q '"database":"ok"' <<<"$(curl -fsS "$base/readyz")" || fail "readyz"
+grep -q '^ddi_license_writable 1' <<<"$(curl -fsS "$base/metrics")" || fail "metrics"
+grep -q '"license_state":"trial"' <<<"$(curl -fsS "$base/api/v1/system/info")" || fail "system info"
# The web UI is embedded: index.html references hashed assets that load.
index="$(curl -fsS -D "$work/headers" "$base/")"
@@ -75,7 +75,7 @@ asset="$(grep -o 'src="/assets/[^"]*\.js"' <<<"$index" | head -n1 | sed 's/src="
curl -fsS -o /dev/null -D "$work/asset-headers" "$base$asset" || fail "asset $asset"
grep -qi '^cache-control: .*immutable' "$work/asset-headers" || fail "assets must be immutable"
# Client-side routes fall back to the UI.
-curl -fsS "$base/addresses?q=x" | grep -q '
' || fail "SPA fallback"
+grep -q '
' <<<"$(curl -fsS "$base/addresses?q=x")" || fail "SPA fallback"
# Unknown API paths are JSON errors, not the UI.
code="$(curl -s -o /dev/null -w '%{http_code}' "$base/api/v1/nope")"
[[ "$code" == "401" || "$code" == "404" ]] || fail "unknown API path returned $code"
diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml
index dd618f2..121d721 100644
--- a/server/api/openapi.yaml
+++ b/server/api/openapi.yaml
@@ -704,7 +704,7 @@ paths:
parameters:
- { name: network_id, in: query, schema: { type: string } }
- { name: state, in: query, schema: { $ref: "#/components/schemas/LeaseState" } }
- - { name: q, in: query, schema: { type: string }, description: Substring of IP, MAC or hostname. }
+ - { name: q, in: query, schema: { type: string }, description: "Substring of IP, MAC or hostname." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000 } }
- { name: after, in: query, schema: { type: string }, description: Return leases with a higher IP (cursor). }
responses:
diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go
index ca4f745..fd98754 100644
--- a/server/internal/httpapi/api_test.go
+++ b/server/internal/httpapi/api_test.go
@@ -45,6 +45,13 @@ func TestSpecMatchesRoutes(t *testing.T) {
// TestEveryRouteIsProtected pins the list of routes reachable without a
// session and checks that permission routes name a permission.
func TestEveryRouteIsProtected(t *testing.T) {
+ // Locking agents out is a security function that read-only mode must
+ // not block (routes.go).
+ lockOutRoutes := []string{
+ "DELETE /api/v1/agents/{id}",
+ "POST /api/v1/agents/{id}/revoke",
+ "DELETE /api/v1/enrollment-tokens/{id}",
+ }
publicAllowed := []string{
"GET /api/v1/system/info",
"POST /api/v1/auth/login",
@@ -67,7 +74,8 @@ func TestEveryRouteIsProtected(t *testing.T) {
t.Errorf("%s: GET routes must not be write routes", key)
}
if r.Method != http.MethodGet && !r.Write && r.Access == permission && !strings.Contains(r.Path, "/license") &&
- !strings.Contains(r.Path, "/saved-filters") && !strings.HasSuffix(r.Path, "/preview") {
+ !strings.Contains(r.Path, "/saved-filters") && !strings.HasSuffix(r.Path, "/preview") &&
+ !slices.Contains(lockOutRoutes, key) {
t.Errorf("%s changes state but is not marked Write (read-only mode would not block it)", key)
}
}
diff --git a/server/internal/pki/pki_test.go b/server/internal/pki/pki_test.go
index 82d0100..4eebeb1 100644
--- a/server/internal/pki/pki_test.go
+++ b/server/internal/pki/pki_test.go
@@ -109,7 +109,8 @@ func TestSignAgentCSR(t *testing.T) {
if len(cert.DNSNames) != 0 || len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth || cert.IsCA {
t.Errorf("%s: unexpected certificate contents", name)
}
- if cert.NotAfter.Sub(clock) != pki.AgentCertLifetime {
+ // Certificates carry whole seconds.
+ if d := cert.NotAfter.Sub(clock); d > pki.AgentCertLifetime || d <= pki.AgentCertLifetime-time.Second {
t.Errorf("%s: lifetime %v", name, cert.NotAfter.Sub(clock))
}
if cert.SerialNumber.BitLen() > 128 || seen[pki.SerialHex(cert.SerialNumber)] {
diff --git a/test/e2e/bind.Dockerfile b/test/e2e/bind.Dockerfile
new file mode 100644
index 0000000..e34ec3e
--- /dev/null
+++ b/test/e2e/bind.Dockerfile
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: Apache-2.0
+# BIND with the DNS agent for the end-to-end test. The layout matches a
+# managed host: the agent owns /etc/bind/ddi-suite and the zone directory.
+FROM internetsystemsconsortium/bind9:9.20
+COPY --chmod=0755 bin/ddi-agent-dns /usr/local/bin/ddi-agent-dns
+COPY --chmod=0644 bind/named.conf /etc/bind/named.conf
+COPY --chmod=0644 bind/agent-dns.yaml /etc/ddi-suite/agent-dns.yaml
+COPY --chmod=0755 bind/entrypoint.sh /e2e/entrypoint.sh
+ENTRYPOINT ["/e2e/entrypoint.sh"]
diff --git a/test/e2e/bind/agent-dns.yaml b/test/e2e/bind/agent-dns.yaml
new file mode 100644
index 0000000..09f51d7
--- /dev/null
+++ b/test/e2e/bind/agent-dns.yaml
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: Apache-2.0
+# DNS agent configuration for the end-to-end test.
+log:
+ level: debug
+dns:
+ conf_dir: /etc/bind/ddi-suite
+ zone_dir: /var/lib/bind/ddi-suite
+ named_conf: /etc/bind/named.conf
+ rndc_args: ["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1", "-p", "953"]
+ dns_address: 127.0.0.1:53
diff --git a/test/e2e/bind/entrypoint.sh b/test/e2e/bind/entrypoint.sh
new file mode 100644
index 0000000..2a568a9
--- /dev/null
+++ b/test/e2e/bind/entrypoint.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# Prepares the host like the installer would and runs named in the
+# foreground. The agent is started later by run.sh (after enrollment).
+set -eu
+conf=/etc/bind/ddi-suite
+zones=/var/lib/bind/ddi-suite
+mkdir -p "$conf" "$zones" /run/named /var/cache/bind
+if [ ! -f /etc/bind/rndc.key ]; then
+ rndc-confgen -a -A hmac-sha256 -k rndc-key -c /etc/bind/rndc.key
+fi
+# Empty includes until the agent writes the first revision.
+for f in ddi-suite.conf ddi-suite-options.conf; do
+ [ -f "$conf/$f" ] || : >"$conf/$f"
+done
+chown -R root:bind "$conf" /etc/bind/rndc.key
+chmod 750 "$conf"
+chmod 640 "$conf"/*.conf /etc/bind/rndc.key
+chown bind:bind "$zones" /run/named /var/cache/bind
+chmod 750 "$zones"
+exec named -u bind -g -c /etc/bind/named.conf
diff --git a/test/e2e/bind/named.conf b/test/e2e/bind/named.conf
new file mode 100644
index 0000000..4fe8ee7
--- /dev/null
+++ b/test/e2e/bind/named.conf
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// Distribution-like named.conf for the end-to-end test: it only adds the
+// rndc channel and the two include files written by the DNS agent. Options
+// the generator sets (recursion, forwarders, transfers) must not appear here.
+
+include "/etc/bind/rndc.key";
+
+controls {
+ inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
+};
+
+options {
+ directory "/var/cache/bind";
+ pid-file "/run/named/named.pid";
+ listen-on { 127.0.0.1; };
+ listen-on-v6 { none; };
+ dnssec-validation no;
+ include "/etc/bind/ddi-suite/ddi-suite-options.conf";
+};
+
+include "/etc/bind/ddi-suite/ddi-suite.conf";
diff --git a/test/e2e/compose.yaml b/test/e2e/compose.yaml
new file mode 100644
index 0000000..6bdd4e2
--- /dev/null
+++ b/test/e2e/compose.yaml
@@ -0,0 +1,77 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# End-to-end environment (M3): management server, PostgreSQL, BIND with the
+# DNS agent and Kea with the DHCP agent, all in containers. Driven by
+# test/e2e/run.sh through `docker compose exec`, so it works with a remote or
+# shared Docker daemon: no bind mounts, no published ports, no fixed subnet.
+#
+# TEST ONLY. Passwords and keys are throwaway values.
+
+name: ddi-e2e
+
+services:
+ postgres:
+ image: postgres:17.11-alpine
+ environment:
+ POSTGRES_USER: ddi
+ POSTGRES_DB: ddi
+ POSTGRES_PASSWORD: e2e-only
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ddi -d ddi"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+
+ server:
+ build:
+ context: .
+ dockerfile: server.Dockerfile
+ environment:
+ DDI_DATABASE_URL: postgres://ddi:e2e-only@postgres:5432/ddi?sslmode=disable
+ DDI_HTTP_LISTEN: 127.0.0.1:8080
+ DDI_MASTER_KEY_FILE: /var/lib/ddi/master.key
+ DDI_STATE_DIR: /var/lib/ddi/state
+ DDI_AGENT_API_LISTEN: 0.0.0.0:8444
+ DDI_AGENT_API_PUBLIC_ADDRESS: server:8444
+ DDI_AGENT_API_SERVER_NAMES: server
+ DDI_LOG_LEVEL: debug
+ # Unreachable license server: the server runs in trial mode.
+ KEYGEN_URL: http://127.0.0.1:1
+ depends_on:
+ postgres:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/readyz | grep -q '\"database\":\"ok\"'"]
+ interval: 2s
+ timeout: 3s
+ retries: 60
+
+ bind:
+ build:
+ context: .
+ dockerfile: bind.Dockerfile
+ user: "0:0"
+ depends_on:
+ server:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "rndc -k /etc/bind/rndc.key -s 127.0.0.1 -p 953 status >/dev/null"]
+ interval: 2s
+ timeout: 5s
+ retries: 30
+
+ kea:
+ build:
+ context: .
+ dockerfile: kea.Dockerfile
+ user: "0:0"
+ environment:
+ KEA_CONTROL_SOCKET_DIR: /run/kea
+ depends_on:
+ server:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "test -S /run/kea/kea4-ctrl-socket"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
diff --git a/test/e2e/kea.Dockerfile b/test/e2e/kea.Dockerfile
new file mode 100644
index 0000000..2f9fbd1
--- /dev/null
+++ b/test/e2e/kea.Dockerfile
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: Apache-2.0
+# Kea DHCPv4 with the DHCP agent for the end-to-end test.
+FROM docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1
+COPY --chmod=0755 bin/ddi-agent-dhcp /usr/local/bin/ddi-agent-dhcp
+COPY --chmod=0644 kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
+COPY --chmod=0644 kea/agent-dhcp.yaml /etc/ddi-suite/agent-dhcp.yaml
+COPY --chmod=0755 kea/entrypoint.sh /e2e/entrypoint.sh
+ENTRYPOINT ["/e2e/entrypoint.sh"]
diff --git a/test/e2e/kea/agent-dhcp.yaml b/test/e2e/kea/agent-dhcp.yaml
new file mode 100644
index 0000000..ae35588
--- /dev/null
+++ b/test/e2e/kea/agent-dhcp.yaml
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: Apache-2.0
+# DHCP agent configuration for the end-to-end test.
+log:
+ level: debug
+dhcp:
+ config_file: /etc/kea/kea-dhcp4.conf
+ control_socket: /run/kea/kea4-ctrl-socket
+lease_interval: 2s
diff --git a/test/e2e/kea/entrypoint.sh b/test/e2e/kea/entrypoint.sh
new file mode 100644
index 0000000..933c519
--- /dev/null
+++ b/test/e2e/kea/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# Kea 3.x accepts the control socket only in KEA_CONTROL_SOCKET_DIR, which
+# must not be accessible by others. The agent is started later by run.sh.
+set -eu
+mkdir -p "$KEA_CONTROL_SOCKET_DIR" /var/lib/kea
+chmod 750 "$KEA_CONTROL_SOCKET_DIR"
+exec kea-dhcp4 -c /etc/kea/kea-dhcp4.conf
diff --git a/test/e2e/kea/kea-dhcp4.conf b/test/e2e/kea/kea-dhcp4.conf
new file mode 100644
index 0000000..702acdc
--- /dev/null
+++ b/test/e2e/kea/kea-dhcp4.conf
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: Apache-2.0
+//
+// Initial Kea configuration of the end-to-end test: no subnets, control
+// socket and lease_cmds hook as generated later by the server.
+{
+ "Dhcp4": {
+ "interfaces-config": { "interfaces": [] },
+ "control-socket": {
+ "socket-type": "unix",
+ "socket-name": "/run/kea/kea4-ctrl-socket"
+ },
+ "lease-database": {
+ "type": "memfile",
+ "persist": true,
+ "name": "/var/lib/kea/kea-leases4.csv"
+ },
+ "hooks-libraries": [{ "library": "libdhcp_lease_cmds.so" }],
+ "subnet4": []
+ }
+}
diff --git a/test/e2e/run.sh b/test/e2e/run.sh
new file mode 100644
index 0000000..e2fa7a9
--- /dev/null
+++ b/test/e2e/run.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: Apache-2.0
+#
+# End-to-end test of the M3 path: API change -> revision -> agent fetches,
+# verifies and applies -> BIND/Kea serve it. Needs Docker with compose and
+# the binaries from `make build` (BIN_DIR, default bin/).
+#
+# Usage: test/e2e/run.sh (make test-e2e)
+# Environment: BIN_DIR, KEEP=1 keeps the containers after the run.
+
+set -euo pipefail
+
+here="$(cd "$(dirname "$0")" && pwd)"
+root="$(cd "$here/../.." && pwd)"
+bin_dir="${BIN_DIR:-$root/bin}"
+case "$bin_dir" in /*) ;; *) bin_dir="$root/$bin_dir" ;; esac
+
+dc() { docker compose -f "$here/compose.yaml" "$@"; }
+api() { dc exec -T server /e2e/api.sh "$@"; }
+step() { printf '\n=== %s\n' "$*"; }
+
+cleanup() {
+ local rc=$?
+ if [[ $rc -ne 0 ]]; then
+ echo "FAILED (exit $rc); logs follow" >&2
+ dc logs --no-color --tail 300 >&2 || true
+ for svc in bind kea; do
+ echo "--- agent log ($svc)" >&2
+ dc exec -T "$svc" sh -c 'cat /tmp/agent.log 2>/dev/null' >&2 || true
+ done
+ fi
+ if [[ "${KEEP:-}" != 1 ]]; then
+ dc down -v --remove-orphans >/dev/null 2>&1 || true
+ fi
+ rm -rf "${here:?}/bin"
+}
+trap cleanup EXIT
+
+step "Build images"
+mkdir -p "$here/bin"
+for b in ddi-server ddi-agent-dns ddi-agent-dhcp; do
+ cp "$bin_dir/$b" "$here/bin/$b"
+done
+dc build --pull
+dc up -d --wait
+
+step "Set up data through the API"
+password="$(dc exec -T server ddi-server bootstrap-admin | sed -n 's/^password: //p')"
+[[ -n "$password" ]] || { echo "bootstrap-admin printed no password" >&2; exit 1; }
+api setup "$password"
+
+step "Enroll the agents"
+dns_cmd="$(api token dns)"
+dhcp_cmd="$(api token dhcp)"
+[[ "$dns_cmd" == *'--ca-hash sha256:'* ]] || { echo "unexpected command: $dns_cmd" >&2; exit 1; }
+# The commands come from our own API and are run as argument lists.
+read -r -a dns_args <<<"$dns_cmd"
+read -r -a dhcp_args <<<"$dhcp_cmd"
+dc exec -T bind "${dns_args[@]}"
+dc exec -T kea "${dhcp_args[@]}"
+dc exec -T -d bind sh -c 'exec ddi-agent-dns run >/tmp/agent.log 2>&1'
+dc exec -T -d kea sh -c 'exec ddi-agent-dhcp run >/tmp/agent.log 2>&1'
+
+step "Revision 1 reaches both agents"
+rev="$(api revision "initial data")"
+[[ "$rev" == 1 ]] || { echo "first revision is $rev" >&2; exit 1; }
+api wait-applied 1 2
+
+dig_short() { dc exec -T bind dig @127.0.0.1 "$@" +short +time=2 +tries=3; }
+expect() {
+ local want="$1"
+ shift
+ local got
+ got="$("$@")"
+ if [[ "$got" != "$want" ]]; then
+ echo "expected '$want', got '$got' from: $*" >&2
+ exit 1
+ fi
+ echo "ok $* -> $got"
+}
+expect "192.0.2.10" dig_short www.e2e.test A
+expect "www.e2e.test." dig_short -x 192.0.2.10
+expect "1" sh -c "docker compose -f '$here/compose.yaml' exec -T bind dig @127.0.0.1 e2e.test SOA +short | awk '{print \$3}'"
+dc exec -T kea grep -q '"hw-address": "00:11:22:33:44:55"' /etc/kea/kea-dhcp4.conf
+echo "ok Kea configuration contains the reservation"
+
+step "Changing an address deploys revision 2"
+api address 192.0.2.20 mail
+rev="$(api revision "mail server")"
+[[ "$rev" == 2 ]] || { echo "second revision is $rev" >&2; exit 1; }
+api wait-applied 2 2
+expect "192.0.2.20" dig_short mail.e2e.test A
+expect "mail.e2e.test." dig_short -x 192.0.2.20
+
+step "Unchanged data creates no revision"
+rev="$(api revision "nothing changed")"
+[[ "$rev" == 2 ]] || { echo "unchanged data produced revision $rev" >&2; exit 1; }
+
+step "Deployments were reported"
+api deployments | grep -c '"outcome": "succeeded"' | {
+ read -r n
+ [[ "$n" -ge 4 ]] || { echo "only $n successful deployments" >&2; exit 1; }
+ echo "ok $n successful deployments"
+}
+
+step "Revoking an agent disconnects it; BIND keeps serving"
+api revoke dns
+api wait-disconnected dns
+expect "192.0.2.20" dig_short mail.e2e.test A
+
+step "PASSED"
diff --git a/test/e2e/server.Dockerfile b/test/e2e/server.Dockerfile
new file mode 100644
index 0000000..fd49917
--- /dev/null
+++ b/test/e2e/server.Dockerfile
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: Apache-2.0
+# Management server for the end-to-end test. bin/ is filled by run.sh with
+# the binaries built by `make build` (static, CGO disabled).
+FROM alpine:3.22
+RUN apk add --no-cache curl jq
+COPY --chmod=0755 bin/ddi-server /usr/local/bin/ddi-server
+COPY --chmod=0755 server/entrypoint.sh /e2e/entrypoint.sh
+COPY --chmod=0755 server/api.sh /e2e/api.sh
+ENTRYPOINT ["/e2e/entrypoint.sh"]
diff --git a/test/e2e/server/api.sh b/test/e2e/server/api.sh
new file mode 100644
index 0000000..952d1f7
--- /dev/null
+++ b/test/e2e/server/api.sh
@@ -0,0 +1,118 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+#
+# REST API steps of the end-to-end test, run inside the server container
+# (curl and jq are available there; the API listens on 127.0.0.1 only).
+#
+# Usage: api.sh [args]
+# setup change the password, settings, network, addresses
+# token print the enrollment command
+# address add a static address
+# revision create a revision, print its number
+# wait-applied wait until agents run healthily
+# agents print the agent list
+# deployments print the deployment list
+# revoke revoke the agent of a role
+# wait-disconnected wait until that agent is disconnected
+set -eu
+
+base=http://127.0.0.1:8080/api/v1
+jar=/tmp/e2e-cookies
+csrf_file=/tmp/e2e-csrf
+password=e2e-admin-password-1
+
+# req METHOD PATH [JSON] prints the response body; fails on HTTP >= 400.
+req() {
+ csrf=$(cat "$csrf_file" 2>/dev/null || true)
+ out=$(curl -sS -b "$jar" -c "$jar" -X "$1" -H 'Content-Type: application/json' \
+ -H "X-CSRF-Token: $csrf" ${3:+--data "$3"} -w '\n%{http_code}' "$base$2")
+ code=$(printf '%s\n' "$out" | tail -n1)
+ body=$(printf '%s\n' "$out" | sed '$d')
+ case "$code" in
+ 2*) printf '%s\n' "$body" ;;
+ *)
+ echo "$1 $2 -> HTTP $code: $body" >&2
+ return 1
+ ;;
+ esac
+}
+
+login() {
+ req POST /auth/login "{\"username\":\"admin\",\"password\":\"$1\"}" | jq -r .csrf_token >"$csrf_file"
+}
+
+session() { login "$password"; }
+
+case "$1" in
+ setup)
+ login "$2"
+ req POST /auth/password "{\"current_password\":\"$2\",\"new_password\":\"$password\"}" >/dev/null
+ session
+ req GET /settings/generator | jq '
+ .dns.primary_ns = "ns1.e2e.test"
+ | .dns.hostmaster = "hostmaster@e2e.test"
+ | .dns.name_servers = [{"name": "ns1.e2e.test", "addresses": ["192.0.2.53"]}]' >/tmp/settings.json
+ req PUT /settings/generator "$(cat /tmp/settings.json)" >/dev/null
+ req POST /networks '{"cidr":"192.0.2.0/24","name":"E2E","gateway":"192.0.2.1","domain":"e2e.test",
+ "dhcp_enabled":true,"pools":[{"start":"192.0.2.100","end":"192.0.2.199"}],"dns_servers":["192.0.2.53"]}' >/dev/null
+ req POST /addresses '{"ip":"192.0.2.53","type":"static","hostname":"ns1"}' >/dev/null
+ req POST /addresses '{"ip":"192.0.2.10","type":"static","hostname":"www"}' >/dev/null
+ req POST /addresses '{"ip":"192.0.2.150","type":"manual_dhcp","hostname":"printer","mac":"00:11:22:33:44:55"}' >/dev/null
+ ;;
+ token)
+ session
+ req POST /enrollment-tokens "{\"role\":\"$2\",\"description\":\"e2e\"}" | jq -r .command
+ ;;
+ address)
+ session
+ req POST /addresses "{\"ip\":\"$2\",\"type\":\"static\",\"hostname\":\"$3\"}" >/dev/null
+ ;;
+ revision)
+ session
+ req POST /revisions "{\"comment\":\"$2\"}" | jq -r .number
+ ;;
+ wait-applied)
+ session
+ i=0
+ while [ "$i" -lt 90 ]; do
+ n=$(req GET /agents | jq --argjson rev "$2" \
+ '[.items[] | select(.applied_revision == $rev and .health.state == "ok")] | length')
+ [ "$n" -ge "$3" ] && exit 0
+ i=$((i + 1))
+ sleep 1
+ done
+ echo "agents did not reach revision $2:" >&2
+ req GET /agents | jq . >&2
+ req GET /deployments | jq . >&2
+ exit 1
+ ;;
+ agents)
+ session
+ req GET /agents | jq .
+ ;;
+ deployments)
+ session
+ req GET /deployments | jq .
+ ;;
+ revoke)
+ session
+ id=$(req GET /agents | jq -r --arg role "$2" '.items[] | select(.role == $role) | .id')
+ req POST "/agents/$id/revoke" >/dev/null
+ ;;
+ wait-disconnected)
+ session
+ i=0
+ while [ "$i" -lt 30 ]; do
+ c=$(req GET /agents | jq -r --arg role "$2" '.items[] | select(.role == $role) | .connected')
+ [ "$c" = "false" ] && exit 0
+ i=$((i + 1))
+ sleep 1
+ done
+ echo "agent $2 is still connected" >&2
+ exit 1
+ ;;
+ *)
+ echo "unknown step $1" >&2
+ exit 2
+ ;;
+esac
diff --git a/test/e2e/server/entrypoint.sh b/test/e2e/server/entrypoint.sh
new file mode 100644
index 0000000..24220cb
--- /dev/null
+++ b/test/e2e/server/entrypoint.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# SPDX-License-Identifier: Apache-2.0
+# Creates the master key on first start and runs the server.
+set -eu
+mkdir -p "$(dirname "$DDI_MASTER_KEY_FILE")" "$DDI_STATE_DIR"
+if [ ! -f "$DDI_MASTER_KEY_FILE" ]; then
+ ddi-server gen-master-key >/dev/null
+fi
+exec ddi-server serve
diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts
index f023b32..9c5d728 100644
--- a/web/src/api/schema.d.ts
+++ b/web/src/api/schema.d.ts
@@ -2209,7 +2209,7 @@ export interface operations {
query?: {
network_id?: string;
state?: components["schemas"]["LeaseState"];
- /** @description Substring of IP */
+ /** @description Substring of IP, MAC or hostname. */
q?: string;
limit?: number;
/** @description Return leases with a higher IP (cursor). */