169 Commits
Author SHA1 Message Date
sir_melonchenandClaude Opus 5.5 6329ce2427 fix(bindgen): accept Active Directory names in dynamic zones
ci / Go (lint, test, vuln) (push) Successful in 6m42s
ci / Shell and licence headers (push) Successful in 13s
ci / Release build (dry run) (push) Successful in 3m9s
install / Installation (debian:13) (push) Successful in 3m29s
install / Installation (rocky:9) (push) Successful in 4m6s
ci / Protobuf (push) Successful in 7s
ci / Web (lint, test, audit, build) (push) Successful in 1m43s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m27s
ci / Integration (real BIND and Kea) (push) Successful in 2m50s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m25s
install / Installation (ubuntu:22.04) (push) Successful in 3m36s
install / Installation (ubuntu:24.04) (push) Successful in 3m26s
release / release (push) Successful in 3m18s
Netlogon registers A records below _msdcs (gc._msdcs.<domain>), which the
default check-names of primary zones refuses; dynamic zones now get
check-names ignore. Troubleshooting for dcdiag in ACTIVE-DIRECTORY.md, an
end-to-end check of such an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:20:20 +02:00
sir_melonchenandClaude Opus 5.5 0079762df4 docs: note an intermittent reload delay seen in the install test
ci / Protobuf (push) Successful in 7s
ci / Release build (dry run) (push) Successful in 3m12s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m27s
ci / Integration (real BIND and Kea) (push) Successful in 2m51s
ci / Go (lint, test, vuln) (push) Successful in 6m30s
ci / Web (lint, test, audit, build) (push) Successful in 1m53s
ci / Shell and licence headers (push) Successful in 14s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m21s
install / Installation (debian:13) (push) Successful in 3m32s
install / Installation (rocky:9) (push) Successful in 4m4s
install / Installation (ubuntu:22.04) (push) Successful in 3m31s
install / Installation (ubuntu:24.04) (push) Successful in 3m28s
release / release (push) Successful in 3m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:12:54 +02:00
sir_melonchenandClaude Opus 5.5 9b68384822 test(configgen): skip the shell-driven dynamic update test for now
ci / Go (lint, test, vuln) (push) Successful in 6m31s
ci / Protobuf (push) Successful in 8s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m16s
install / Installation (debian:12) (push) Failing after 4m54s
install / Installation (debian:13) (push) Successful in 3m35s
install / Installation (rocky:9) (push) Successful in 13m8s
install / Installation (ubuntu:24.04) (push) Successful in 3m32s
ci / Web (lint, test, audit, build) (push) Successful in 1m47s
ci / Shell and licence headers (push) Successful in 13s
ci / Release build (dry run) (push) Successful in 3m7s
ci / Integration (real BIND and Kea) (push) Successful in 2m52s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (ubuntu:22.04) (push) Successful in 3m35s
Its container ends without output in CI; the path is covered by the agent
role's integration test against real BIND and by the end-to-end test.
Recorded as open point 19 in the project log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:24:26 +02:00
sir_melonchenandClaude Opus 5.5 6578635880 test(configgen): log container output line by line on failure
ci / Go (lint, test, vuln) (push) Successful in 6m25s
ci / Protobuf (push) Successful in 8s
ci / Shell and licence headers (push) Successful in 13s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m18s
ci / Integration (real BIND and Kea) (push) Failing after 2m40s
ci / Development environment (compose) (push) Successful in 14s
ci / Web (lint, test, audit, build) (push) Successful in 1m50s
ci / Release build (dry run) (push) Successful in 3m7s
install / Installation (debian:13) (push) Canceled after 0s
install / Installation (rocky:9) (push) Canceled after 0s
install / Installation (ubuntu:22.04) (push) Canceled after 0s
install / Installation (ubuntu:24.04) (push) Canceled after 0s
install / Installation (debian:12) (push) Canceled after 1m2s
The output of the failing dynamic update test did not show up in the CI
log; escape control characters and log each line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:05:36 +02:00
sir_melonchenandClaude Opus 5.5 5b16986e1a test(configgen): wait until named has loaded all zones
ci / Shell and licence headers (push) Successful in 15s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Protobuf (push) Successful in 8s
ci / Web (lint, test, audit, build) (push) Successful in 1m48s
ci / Release build (dry run) (push) Successful in 3m10s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m17s
ci / Integration (real BIND and Kea) (push) Failing after 2m43s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (rocky:9) (push) Successful in 4m7s
install / Installation (ubuntu:22.04) (push) Successful in 3m36s
install / Installation (debian:12) (push) Successful in 3m49s
install / Installation (debian:13) (push) Successful in 3m27s
install / Installation (ubuntu:24.04) (push) Successful in 3m27s
named -g logs "running as: named ..." right at the start, so waiting for
"running" returned at once and nsupdate met a server that did not listen
yet. Wait for the line that ends in " running".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:27:19 +02:00
sir_melonchenandClaude Opus 5.5 62aea7f741 test(configgen): show the complete container output when a check fails
ci / Go (lint, test, vuln) (push) Successful in 6m23s
ci / Protobuf (push) Successful in 7s
ci / Web (lint, test, audit, build) (push) Successful in 1m48s
ci / Shell and licence headers (push) Successful in 13s
ci / Release build (dry run) (push) Successful in 3m7s
ci / Integration (real BIND and Kea) (push) Failing after 2m50s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m26s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m27s
install / Installation (debian:13) (push) Successful in 3m37s
install / Installation (ubuntu:24.04) (push) Successful in 3m29s
install / Installation (rocky:9) (push) Successful in 4m4s
install / Installation (ubuntu:22.04) (push) Successful in 3m34s
The attached output of docker start can end early; the failure of the
dynamic update test was cut off mid-line in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:49:43 +02:00
sir_melonchenandClaude Opus 5.5 cab0b351e9 feat(web): system TSIG key, texts for managed records in dynamic zones
install / Installation (debian:13) (push) Successful in 3m33s
install / Installation (ubuntu:22.04) (push) Successful in 3m49s
install / Installation (rocky:9) (push) Successful in 4m9s
install / Installation (ubuntu:24.04) (push) Successful in 3m43s
ci / Protobuf (push) Successful in 14s
ci / Shell and licence headers (push) Successful in 15s
ci / Web (lint, test, audit, build) (push) Successful in 2m50s
ci / Release build (dry run) (push) Successful in 3m14s
ci / Go (lint, test, vuln) (push) Successful in 6m24s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m24s
ci / Integration (real BIND and Kea) (push) Failing after 2m50s
ci / Development environment (compose) (push) Successful in 14s
install / Installation (debian:12) (push) Successful in 3m22s
The DNS agent now maintains DDI's records in dynamic zones (ADR 0026):
updated zone and record hints, the system key is marked and cannot be
deleted (key_system), API types regenerated. Project log updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:16:28 +02:00
sir_melonchenandClaude Opus 5.5 2cc6f35777 Merge branch 'worktree-agent-a01a48da003cb2e32'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:05:29 +02:00
sir_melonchenandClaude Opus 5.5 0a4ea0ea7b docs: ADR 0026 details, migrating a Windows DNS zone, project log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:00:32 +02:00
sir_melonchenandClaude Opus 5.5 ffab2f7f21 Merge branch 'worktree-agent-ab1f2766a034ebd77'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:56:08 +02:00
sir_melonchenandClaude Opus 5.5 3534076e1c test(configgen): nsupdate with the generated update key against named
Starts named with a revision whose dynamic zones still hold the previous
initial files, applies dynamic-records.json with nsupdate -k as the DNS
agent will, and checks the records with dig, the per-record delete next
to a foreign record, and that unsigned updates from 127.0.0.1 are still
refused. The cluster check now covers the update key on the primary only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:55:12 +02:00
sir_melonchenandClaude Opus 5.5 aedf7709cd docs: operations note on records in dynamic zones and dns.nsupdate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:54:02 +02:00
sir_melonchenandClaude Opus 5.5 b222022339 test(e2e): address table entries in the dynamic zone (ADR 0026)
A static address in a second network with domain ad.e2e.test reaches the
existing dynamic zone on the primary and, after the transfer, on the
secondary; after deleting it the name is gone while the records added by
nsupdate earlier stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:51:05 +02:00
sir_melonchenandClaude Opus 5.5 174d371392 feat(server): system TSIG key for the DNS agents' dynamic updates
The server creates the system key ddi-agent-update (hmac-sha256, sealed
like every key) with the first dynamic zone and at start, marks it in the
new column tsig_keys.system (migration 00007) and puts it into the
generator input (Source version 4). System keys are listed, rotatable and
not deletable (key_system); their name is reserved (key_exists). The
address check no longer reports dynamic_zone (ADR 0026).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:49:48 +02:00
sir_melonchenandClaude Opus 5.5 cd69ff3aaf test(agent): dynamic records against real BIND
Revision 1 adds managed records to a dynamic zone by update; revision 2
removes and adds one while records of a third party (also at a managed
name) stay; a revision whose update key named does not accept rolls
back; a revision without managed records deletes only DDI's records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:49:35 +02:00
sir_melonchenandClaude Opus 5.5 b461fedab3 feat(agent): DNS role applies managed records of dynamic zones (ADR 0026)
Revisions for targets serving dynamic zones as primary carry
ddi-suite-update.key and dynamic-records.json. They stay in the revision
directory. Validate checks both strictly (one key matching the records
file and defined in ddi-suite-keys.conf; zones must be dynamic primary
zones of the revision; names inside their zone; types A/AAAA/PTR/CNAME/
MX/TXT/SRV/NS with presentation data on one line). After the reload,
Activate deletes exactly the records the previous revision managed and
the new one does not, adds all new ones with nsupdate -k (new option
dns.nsupdate), retries while a zone is not loaded yet and fails the
activation otherwise, so that the core rolls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:47:33 +02:00
sir_melonchenandClaude Opus 5.5 3e957d7b33 feat(agent): command runner with stdin and optional role logger
The DNS role needs to feed nsupdate scripts on stdin and to log its
dynamic updates. command.InputRunner adds RunInput (errors never quote the
input); roles implementing core.LogSetter get the core's logger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:47:32 +02:00
sir_melonchenandClaude Opus 5.5 5a6087c96e feat(configgen): update key and dynamic-records.json for dynamic zones
Targets that serve a dynamic zone as primary get the DNS agents' update
key (Source version 4, UpdateKeyID) in the update ACLs and two more
artifacts: ddi-suite-update.key (secret) and dynamic-records.json with
the records DDI owns in each dynamic zone (ADR 0026). Older sources and
targets without dynamic primary zones render byte-identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:39:43 +02:00
sir_melonchenandClaude Opus 5.5 d8882f6666 docs: ADR 0026, address table records in dynamic zones via dynamic updates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:27:54 +02:00
sir_melonchenandClaude Opus 5.5 2555028956 fix: agents enrolled by hand run as systemd services
ci / Protobuf (push) Successful in 12s
ci / Web (lint, test, audit, build) (push) Successful in 2m44s
ci / Shell and licence headers (push) Successful in 21s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m33s
ci / Development environment (compose) (push) Successful in 17s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m14s
install / Installation (debian:12) (push) Successful in 4m54s
install / Installation (debian:13) (push) Successful in 5m6s
install / Installation (ubuntu:22.04) (push) Successful in 5m16s
install / Installation (rocky:9) (push) Successful in 7m28s
install / Installation (ubuntu:24.04) (push) Successful in 8m58s
release / release (push) Successful in 4m11s
An agent installed without token and enrolled with the command of the web
interface never ran as a service: the installer left the unit disabled and
the root-run enrollment left an identity the service user could not read.
The installer now always enables the agent unit (it waits for the
identity via ConditionPathExists), enroll as root hands the state over to
the service user and enables and starts the unit, and rerunning or
upgrading the installer repairs agents enrolled by hand. The web interface
shows the matching installer command next to the enroll command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:44:03 +02:00
sir_melonchenandClaude Opus 5.5 346d24cb34 fix: address check pings with a raw socket and asks the DNS agents
ci / Protobuf (push) Successful in 16s
ci / Web (lint, test, audit, build) (push) Successful in 2m53s
ci / Shell and licence headers (push) Successful in 23s
ci / Release build (dry run) (push) Successful in 3m46s
ci / Integration (real BIND and Kea) (push) Successful in 2m47s
ci / Development environment (compose) (push) Successful in 18s
install / Installation (debian:13) (push) Successful in 6m59s
ci / Go (lint, test, vuln) (push) Successful in 6m4s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m50s
install / Installation (debian:12) (push) Successful in 6m17s
install / Installation (ubuntu:22.04) (push) Successful in 6m31s
install / Installation (rocky:9) (push) Successful in 9m53s
install / Installation (ubuntu:24.04) (push) Successful in 6m38s
release / release (push) Successful in 4m15s
On the user's server the check reported "socket: permission denied" for
ping and asked only the server's own resolver. Ping now falls back to a
raw ICMP socket (the server unit gets CAP_NET_RAW, like /bin/ping); DNS
asks the DNS agents outside clusters when no name server addresses are
configured, gives every server its own timeout, and explains missing
records (dynamic zone, system resolver, not deployed yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:06:55 +02:00
sir_melonchenandClaude Opus 5.5 3f8c9fc0fa docs: project log for the changes from ADR 0025
ci / Protobuf (push) Successful in 22s
ci / Web (lint, test, audit, build) (push) Successful in 3m4s
ci / Go (lint, test, vuln) (push) Successful in 7m6s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m46s
install / Installation (debian:13) (push) Successful in 5m42s
install / Installation (ubuntu:24.04) (push) Successful in 5m40s
ci / Shell and licence headers (push) Successful in 21s
ci / Development environment (compose) (push) Successful in 21s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m29s
install / Installation (debian:12) (push) Successful in 5m55s
install / Installation (ubuntu:22.04) (push) Successful in 5m33s
install / Installation (rocky:9) (push) Successful in 8m36s
release / release (push) Successful in 4m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:42:56 +02:00
sir_melonchenandClaude Opus 5.5 32bac7c34b Merge branch 'worktree-agent-aa6fa64cc15328e69'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:31:18 +02:00
sir_melonchenandClaude Opus 5.5 1a98ba659d test(web): IP view paging, range selection, range/bulk dry runs, checks
Covers expanding a network and paging its IPs, Shift-click and from/to
selection, range assignment and freeing with dry run, bulk dry run and
apply with per-address problems, the check dialog, adopting the lease
host name, the random host name hint, the network form without pools and
live reloads of the IP list. Test calls expose their query string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:17 +02:00
sir_melonchenandClaude Opus 5.5 fe04e4f4ca style: drop embedded field selectors in the DNS test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:18:50 +02:00
sir_melonchenandClaude Opus 5.5 aa9966251b style(web): narrower address table, truncated descriptions, taller IP list
Lease name moves into the lease column, long descriptions are cut with a
tooltip, row actions get some air and the IP list on the network page
uses the available height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:18:23 +02:00
sir_melonchenandClaude Opus 5.5 42a23255e5 fix(server): ask the DNS servers directly in address checks
The Go resolver reads /etc/hosts first, so a hosts entry could make a
missing DNS record look fine. Checks against configured servers now use a
small DNS client (UDP, TCP on truncation, random query IDs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:17:24 +02:00
sir_melonchenandClaude Opus 5.5 f4d7b2261f feat(web): translate the ADR 0025 server codes and summarized bulk effects
New problems dynamic_is_gateway, dynamic_ipv6_unsupported and check_size;
range and type errors with their parameters; pool problems that no longer
exist removed. Bulk dry runs report effects per code with a count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:13:28 +02:00
sir_melonchenandClaude Opus 5.5 613360a2ef Merge branch 'worktree-agent-a8931ab03518b5b2c'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:09:43 +02:00
sir_melonchenandClaude Opus 5.5 6cb0f86398 feat(web): network IP view, range assignment, bulk changes and checks
Networks expand inline (and on the detail page) to every host address from
GET /networks/{id}/ips with paging, checkbox and Shift-click selection and
from/to ranges. Selections can be assigned as dynamic, static or reserved
or freed via POST /networks/{id}/range after a dry run, changed in bulk
(dry run, then apply) and checked with POST /addresses/check. The pool
editor is gone; DHCP ranges are shown as derived. Dynamic entries hint at
the random host name and offer to adopt the lease host name. Live events
also refresh the IP lists. API types regenerated (ADR 0025).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:08:57 +02:00
sir_melonchenandClaude Opus 5.5 5a5b09ae8b style(web): sober admin layout with grouped sidebar and dense tables
Left sidebar navigation in groups, slim license/pending strips, page
headers with counts and one primary action, help texts behind a small
"?" instead of paragraphs, compact tables and panels, restrained palette
with small radii in light and dark mode, terse texts in both locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:08:57 +02:00
sir_melonchenandClaude Opus 5.5 4c53e26f5b docs: DHCP per address in architecture, API and operations
ARCHITECTURE §3.2 describes the type table and the DHCP range made of
dynamic entries, API.md the IP list, range operations, bulk dry runs and
the address check with their error codes, OPERATIONS the upgrade to
migration 00006 and what the check needs (ping_group_range, DNS ports).
ADR 0023 points to ADR 0025 for the lease adoption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:03:43 +02:00
sir_melonchenandClaude Opus 5.5 328c1d9c3c test: DHCP ranges as dynamic entries in e2e, install and bench scripts
Networks are created without pools; the e2e and installation tests assign
the old pool range with POST /networks/{id}/range and check that Kea gets
the pools around the reservation and that a dynamic entry resolves (A and
PTR). The lease step now expects the lease to update the existing dynamic
entry (MAC, lease_hostname) without renaming it. The benchmark assigns the
/16 DHCP test range at setup (65,521 dynamic entries, noted in the report);
the /21 data networks have no range any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:02:05 +02:00
sir_melonchenandClaude Opus 5.5 cb1c04393c feat(server): DHCP per address, IP lists, range operations, address check
ADR 0025 on the server:

- Migration 00006 turns the free addresses of every pool into dynamic
  entries with random host names (dyn- and 8 characters), names unnamed
  dynamic entries, moves colliding lease names to the new column
  lease_hostname, extends the unique FQDN index to dynamic entries and
  drops dhcp_pools. Down rebuilds pools from runs of dynamic entries.
- Networks no longer take pools; Network.pools is derived from the
  dynamic entries, also for the generator input.
- Dynamic entries without host name get a random one (create, update,
  CSV import, bulk type change, range assign), drawn again on an FQDN
  collision.
- Leases only update existing entries (lease fields, lease_hostname, the
  MAC of dynamic entries) and never create entries or change host names.
- GET /networks/{id}/ips, POST /networks/{id}/range (assign/clear, dry
  run, one audit entry) and POST /addresses/check (unprivileged ICMP echo,
  forward and reverse lookup at the zone's servers).
- Bulk changes: set_description, regenerate_hostnames and dry runs with
  summarized effects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:24 +02:00
sir_melonchenandClaude Opus 5.5 372a364ab6 feat(configgen): source version 3 with DNS records for dynamic entries
derive.BuildDNS creates A/AAAA and PTR records for dynamic entries only for
sources of version 3 or later, so that stored older sources render byte
for byte as before (rollbacks; TestM4Compatibility now covers versions 0
and 2). Dynamic entries in networks without domain produce no warning,
which would otherwise repeat for every address of a large DHCP range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:16 +02:00
sir_melonchenandClaude Opus 5.5 6473ab7b1d feat(ipam): DHCP range from dynamic entries, DNS for dynamic entries
ADR 0025: dynamic entries publish A/PTR records and form the DHCP range of
their network; pools are no longer part of the network. Dynamic entries
need DHCP, IPv4 and must not be the gateway. Ranges() merges addresses into
runs of consecutive addresses, utilization counts dynamic entries, the
address map loses the pool state, previews report entering and leaving the
DHCP range by type. Helpers for the IP list and range operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:11 +02:00
sir_melonchenandClaude Opus 5.5 7b0f096f87 feat: contracts for DHCP per address, range and bulk changes, checks
ADR 0025 and the REST API: per-address DHCP instead of pools, DNS for
dynamic entries, all IPs of a network, range operations, bulk dry runs
and ping/DNS checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:21:37 +02:00
sir_melonchenandClaude Opus 5.5 533082dae2 docs: mark milestone M6 as complete, update project log
ci / Web (lint, test, audit, build) (push) Successful in 2m5s
ci / Shell and licence headers (push) Successful in 22s
ci / Release build (dry run) (push) Successful in 3m53s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m38s
ci / Development environment (compose) (push) Successful in 18s
ci / Integration (real BIND and Kea) (push) Successful in 2m27s
install / Installation (debian:12) (push) Successful in 6m1s
install / Installation (debian:13) (push) Successful in 6m58s
install / Installation (ubuntu:22.04) (push) Successful in 6m47s
ci / Protobuf (push) Successful in 1m19s
ci / Go (lint, test, vuln) (push) Successful in 6m1s
install / Installation (rocky:9) (push) Successful in 10m12s
install / Installation (ubuntu:24.04) (push) Successful in 6m50s
release / release (push) Successful in 3m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:14:17 +02:00
sir_melonchenandClaude Opus 5.5 a956fc84a1 docs: installer details in ADR 0024 and OPERATIONS
ci / Go (lint, test, vuln) (push) Successful in 6m8s
ci / Protobuf (push) Successful in 13s
ci / Web (lint, test, audit, build) (push) Successful in 1m56s
ci / Shell and licence headers (push) Successful in 22s
ci / Release build (dry run) (push) Successful in 3m38s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m56s
ci / Integration (real BIND and Kea) (push) Successful in 2m52s
ci / Development environment (compose) (push) Successful in 17s
install / Installation (debian:12) (push) Successful in 6m1s
install / Installation (debian:13) (push) Successful in 6m13s
install / Installation (rocky:9) (push) Successful in 10m56s
install / Installation (ubuntu:22.04) (push) Successful in 6m2s
install / Installation (ubuntu:24.04) (push) Successful in 6m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:09:23 +02:00
sir_melonchenandClaude Opus 5.5 7635dafc31 Merge branch 'worktree-agent-m6-installer'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:08:36 +02:00
sir_melonchenandClaude Opus 5.5 7910bd0ac5 ci(install): run the installation test on main and pull requests only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:07:39 +02:00
sir_melonchenandClaude Opus 5.5 b160b2dc85 test(install): check that the precheck stops on runners with little disk, then use --force
install / Installation (debian:12) (push) Successful in 6m22s
install / Installation (debian:13) (push) Successful in 6m24s
install / Installation (rocky:9) (push) Successful in 10m27s
install / Installation (ubuntu:22.04) (push) Successful in 6m27s
install / Installation (ubuntu:24.04) (push) Successful in 5m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:31:13 +02:00
sir_melonchen 5a28216291 Merge remote-tracking branch 'origin/main' into worktree-agent-m6-installer 2026-10-16 00:30:41 +02:00
sir_melonchenandClaude Opus 5.5 fad05ff1ab test(install): build the systemd images with the host network; docs: precheck table
install / Installation (debian:12) (push) Successful in 3m41s
install / Installation (debian:13) (push) Successful in 3m47s
install / Installation (rocky:9) (push) Successful in 23m4s
install / Installation (ubuntu:22.04) (push) Successful in 3m58s
install / Installation (ubuntu:24.04) (push) Failing after 2m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:30:44 +02:00
sir_melonchenandClaude Opus 5.5 63f8a62cb5 docs: name the run of the CSV import measurement
ci / Protobuf (push) Successful in 21s
ci / Web (lint, test, audit, build) (push) Successful in 1m33s
ci / Shell and licence headers (push) Successful in 23s
ci / Go (lint, test, vuln) (push) Canceled after 10m53s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m10s
ci / Integration (real BIND and Kea) (push) Successful in 1m44s
ci / Development environment (compose) (push) Successful in 12s
ci / Release build (dry run) (push) Failing after 15m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:16:49 +02:00
sir_melonchenandClaude Opus 5.5 1cddf019f3 docs: CSV import measured after the speedup
ci / Go (lint, test, vuln) (push) Canceled after 0s
ci / Protobuf (push) Canceled after 0s
ci / Web (lint, test, audit, build) (push) Canceled after 0s
ci / Shell and licence headers (push) Canceled after 0s
ci / Release build (dry run) (push) Canceled after 0s
ci / End-to-end (server, agents, BIND, Kea) (push) Canceled after 0s
ci / Integration (real BIND and Kea) (push) Canceled after 0s
ci / Development environment (compose) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:16:29 +02:00
sir_melonchenandClaude Opus 5.5 3872eb47c3 fix(systemd): allow netlink for the DHCP agent (kea-dhcp4 -t detects interfaces)
install / Installation (debian:12) (push) Failing after 7m35s
install / Installation (debian:13) (push) Successful in 4m8s
install / Installation (rocky:9) (push) Successful in 5m5s
install / Installation (ubuntu:24.04) (push) Successful in 4m2s
install / Installation (ubuntu:22.04) (push) Failing after 7m48s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:05:09 +02:00
sir_melonchenandClaude Opus 5.5 21843d1a8b feat(install): prechecks per SYSTEM-REQUIREMENTS (ports by process, versions, reachability)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:05:08 +02:00
sir_melonchenandClaude Opus 5.5 e0e114519b perf: load networks and existing entries once per CSV import
ci / Protobuf (push) Successful in 21s
ci / Shell and licence headers (push) Successful in 12s
ci / Release build (dry run) (push) Successful in 3m44s
ci / Integration (real BIND and Kea) (push) Successful in 2m44s
ci / Go (lint, test, vuln) (push) Successful in 3m48s
ci / Web (lint, test, audit, build) (push) Successful in 1m35s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m8s
ci / Development environment (compose) (push) Successful in 13s
Per-row network and address searches limited imports to ~300 rows/s, so a
file at the row limit outlasted the write timeout. The import now loads
networks and the affected entries once and gets a 10-minute deadline. The
master key mode in ADR 0024 follows the server's check (0400).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:01:07 +02:00
sir_melonchenandClaude Opus 5.5 534e347649 Merge branch 'worktree-agent-bench-m6'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:56:10 +02:00
sir_melonchenandClaude Opus 5.5 5880d32e19 ci(bench): run the benchmark on demand only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:55:27 +02:00
sir_melonchenandClaude Opus 5.5 be43792db2 docs: measurements of run 93, import size advice, project log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:55:27 +02:00
sir_melonchenandClaude Opus 5.5 13597a55ed docs: describe license set-key, retention, packages and release signing as merged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:42:35 +02:00
sir_melonchenandClaude Opus 5.5 9272d1b9ff refactor(install): keep the release public key in the names block (docs/RELEASE.md)
install / Installation (debian:12) (push) Failing after 7m36s
install / Installation (rocky:9) (push) Failing after 4m54s
install / Installation (debian:13) (push) Failing after 7m33s
install / Installation (ubuntu:22.04) (push) Failing after 4m54s
install / Installation (ubuntu:24.04) (push) Failing after 7m48s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:41:46 +02:00
sir_melonchenandClaude Opus 5.5 f5f6ceb019 test(bench): report timings with 0.1 ms resolution
bench / Benchmark (server, BIND, Kea) (push) Successful in 9m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:41:28 +02:00
sir_melonchenandClaude Opus 5.5 464e7399db test(bench): dnsperf from Debian with an HTTPS fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:41:13 +02:00
sir_melonchen 57ebcddfa6 Merge remote-tracking branch 'origin/main' into worktree-agent-m6-installer 2026-10-15 19:40:57 +02:00
sir_melonchenandClaude Opus 5.5 4f79ea0aee fix(install): accept host:port in --server; docs: Kea socket drop-in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:40:56 +02:00
sir_melonchen 720b6b1eb1 Merge branch 'main' into worktree-agent-bench-m6 2026-10-15 19:40:34 +02:00
sir_melonchenandClaude Opus 5.5 342327c33d test(bench): take dnsperf from Alpine
bench / Benchmark (server, BIND, Kea) (push) Failing after 2m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:10:57 +02:00
sir_melonchenandClaude Opus 5.5 37b173cf8c test(configgen): start the secondary after the primary in the AXFR test
ci / Protobuf (push) Successful in 20s
ci / Web (lint, test, audit, build) (push) Successful in 1m33s
ci / Go (lint, test, vuln) (push) Successful in 5m29s
ci / Shell and licence headers (push) Successful in 23s
ci / Release build (dry run) (push) Successful in 2m51s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 2m58s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 2m6s
A refresh that fails because the primary is not up yet is retried only
after the zone's retry interval, longer than the test waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:06:52 +02:00
sir_melonchenandClaude Opus 5.5 c3a96c592a fix(install): set KEA_CONTROL_SOCKET_DIR for Kea and the DHCP agent, portable tools
install / Installation (debian:12) (push) Failing after 2m34s
install / Installation (debian:13) (push) Failing after 2m45s
install / Installation (ubuntu:22.04) (push) Failing after 2m41s
install / Installation (ubuntu:24.04) (push) Failing after 2m39s
install / Installation (rocky:9) (push) Failing after 9m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:01:16 +02:00
sir_melonchenandClaude Opus 5.5 3d79c106e6 test(bench): retry package downloads
bench / Benchmark (server, BIND, Kea) (push) Failing after 5m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:52:36 +02:00
sir_melonchenandClaude Opus 5.5 73a1001d80 chore: neutral default package maintainer
ci / Go (lint, test, vuln) (push) Successful in 3m45s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m8s
ci / Protobuf (push) Successful in 8s
ci / Web (lint, test, audit, build) (push) Successful in 1m24s
ci / Shell and licence headers (push) Successful in 9s
ci / Release build (dry run) (push) Successful in 2m57s
ci / Integration (real BIND and Kea) (push) Failing after 2m10s
ci / Development environment (compose) (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:49:13 +02:00
sir_melonchenandClaude Opus 5.5 0a73192c0d fix(install): survive missing tools under pipefail, license exit codes; docs: INSTALL.md
install / Installation (debian:13) (push) Failing after 2m45s
install / Installation (ubuntu:22.04) (push) Failing after 2m48s
install / Installation (debian:12) (push) Failing after 2m28s
install / Installation (rocky:9) (push) Failing after 6m1s
install / Installation (ubuntu:24.04) (push) Failing after 2m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:48:17 +02:00
sir_melonchenandClaude Opus 5.5 2e6f8fa954 docs: system requirements with measured values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:47:10 +02:00
sir_melonchenandClaude Opus 5.5 68134fba01 Merge branch 'worktree-agent-a178521b74a333215'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:39 +02:00
sir_melonchenandClaude Opus 5.5 bd06881576 test(bench): round timings and drop ratios
bench / Benchmark (server, BIND, Kea) (push) Failing after 3m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:32 +02:00
sir_melonchenandClaude Opus 5.5 d3f0181aec test: installation test in systemd containers of all supported systems
install / Installation (debian:12) (push) Failing after 2m0s
install / Installation (ubuntu:22.04) (push) Canceled after 0s
install / Installation (ubuntu:24.04) (push) Canceled after 0s
install / Installation (debian:13) (push) Canceled after 3m42s
install / Installation (rocky:9) (push) Canceled after 1m59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:30 +02:00
sir_melonchenandClaude Opus 5.5 f36548d81d feat: add the installer scripts/install.sh (ADR 0024)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:29 +02:00
sir_melonchenandClaude Opus 5.5 aec9a90320 test(bench): rename hosts so that the second revision differs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:43:18 +02:00
sir_melonchenandClaude Opus 5.5 26d8887e2e docs: release, packages, signing and retention for M6
docs/RELEASE.md describes the release flow, the packages and how to
create the Ed25519 signing key, the Gitea secret and the installer's
public key. ADR 0024 records the implementation details; ARCHITECTURE
lists the retention options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:41:26 +02:00
sir_melonchenandClaude Opus 5.5 306efd2990 ci: build packages, sign releases and check names
The release dry run builds and inspects the packages and proves signing
and verification with a throwaway key; the release workflow signs
SHA256SUMS when RELEASE_SIGNING_KEY is set and checks the signature with
the installer's public key. The shell job runs the brand check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:41:13 +02:00
sir_melonchenandClaude Opus 5.5 927e8f62b7 feat(release): build .deb and .rpm packages with nfpm
make packages (or make release PACKAGES=1) packages the binaries from the
release tarballs with the unit rendered for /usr/bin and the license
text, and rewrites SHA256SUMS over tarballs and packages. nfpm 2.48.0 is
installed pinned with checksum by scripts/ci/install-tools.sh, which can
now install single tools. Make targets lint-brand and sign-release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:41:12 +02:00
sir_melonchenandClaude Opus 5.5 6266bbb10a feat(ci): check units, packages and installer for hard-coded names
Implements the check announced in ADR 0018. The installer may define
the names once between '# --- names (ADR 0018) ---' and
'# --- end names ---'; missing files are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:41:04 +02:00
sir_melonchenandClaude Opus 5.5 709b7a1aab feat(release): sign and verify SHA256SUMS with Ed25519
scripts/sign-release.sh signs with a key file or the PEM key from
RELEASE_SIGNING_KEY (private temporary file, never printed), verifies
with openssl pkeyutl -rawin and has a selftest with a throwaway key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:41:04 +02:00
sir_melonchenandClaude Opus 5.5 593641ee43 docs: link operations, security and system requirements
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:40:42 +02:00
sir_melonchenandClaude Opus 5.5 72824f130e test(bench): finer perfdhcp rate steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:39:54 +02:00
sir_melonchenandClaude Opus 5.5 9b310ed633 test(bench): report the stored size of each revision
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:39:48 +02:00
sir_melonchenandClaude Opus 5.5 5e2fdae75e docs: security model and operations guide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:39:47 +02:00
sir_melonchenandClaude Opus 5.5 9c61d31ef8 test(bench): install perfdhcp from the ISC repository
bench / Benchmark (server, BIND, Kea) (push) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:34:45 +02:00
sir_melonchenandClaude Opus 5.5 43bf44a8ca test: benchmark of server, BIND and Kea with generated data
bench / Benchmark (server, BIND, Kea) (push) Canceled after 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:33:24 +02:00
sir_melonchenandClaude Opus 5.5 dd77485efd feat(server): retention for audit entries and configuration revisions
retention.audit_days and retention.revisions_keep (DDI_RETENTION_*) drive
a cleanup five minutes after the start and then daily. The latest
revision, revisions agents run, fall back to or target, and revisions
with pending deployments are never deleted; deployment rows stay. A
cleanup that deletes anything records retention.cleanup in the audit log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:32:45 +02:00
sir_melonchenandClaude Opus 5.5 0848d480dd feat(release): ship the systemd unit and license texts in each tarball
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:29:12 +02:00
sir_melonchenandClaude Opus 5.5 df25705b6e feat(server): add license set-key command reading the key from stdin
Validates and activates the key like PUT /api/v1/license (same service
call, audited as the system actor) and prints the license state without
the key. Exit codes: 2 bad input, 3 key rejected, 4 license server
unreachable or untrusted (nothing stored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:28:33 +02:00
sir_melonchenandClaude Opus 5.5 31229f060d test: probe distribution facts for the installer (temporary)
install / Probe debian:12 (push) Failing after 58s
install / Probe debian:13 (push) Successful in 1m2s
install / Probe ubuntu:22.04 (push) Successful in 1m7s
install / Probe ubuntu:24.04 (push) Successful in 1m3s
install / Probe rocky:9 (push) Successful in 7m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:28:33 +02:00
sir_melonchenandClaude Opus 5.5 7c223161a1 feat: render the systemd unit templates
ci / Web (lint, test, audit, build) (push) Successful in 1m22s
ci / Shell and licence headers (push) Successful in 8s
ci / Integration (real BIND and Kea) (push) Successful in 2m6s
ci / Protobuf (push) Successful in 6s
ci / Go (lint, test, vuln) (push) Successful in 3m43s
ci / Release build (dry run) (push) Successful in 2m27s
ci / Development environment (compose) (push) Successful in 13s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:01:42 +02:00
sir_melonchenandClaude Opus 5.5 422c0d6e50 feat: M6 contracts for the installer, systemd units and releases
ADR 0024 and hardened unit templates for the server and both agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:00:54 +02:00
sir_melonchenandClaude Opus 5.5 2349d7e454 docs: mark milestone M5 as complete, update project log
ci / Protobuf (push) Successful in 18s
ci / Release build (dry run) (push) Successful in 2m11s
ci / Go (lint, test, vuln) (push) Successful in 5m28s
ci / Integration (real BIND and Kea) (push) Successful in 2m5s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m8s
ci / Web (lint, test, audit, build) (push) Successful in 1m35s
ci / Shell and licence headers (push) Successful in 19s
ci / Development environment (compose) (push) Successful in 14s
release / release (push) Successful in 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:02 +02:00
sir_melonchenandClaude Opus 5.5 34e8342e8c feat: require the HA password file name in the generator settings
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m36s
ci / Shell and licence headers (push) Successful in 21s
ci / Release build (dry run) (push) Successful in 2m13s
ci / Go (lint, test, vuln) (push) Successful in 5m29s
ci / Integration (real BIND and Kea) (push) Successful in 2m1s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 3m8s
ci / Development environment (compose) (push) Successful in 13s
The DHCP agent writes the HA credentials only as ddi-suite-ha-password and
ddi-suite-ha-user; other names are now rejected when the settings are
saved instead of when a revision is generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:32:48 +02:00
sir_melonchenandClaude Opus 5.5 9a6c3f5159 fix(kea): read the HA user from a file as Kea 3.x requires
Kea 3.x rejects clear-text users and passwords in HTTP authentication
("use of clear text 'user' is NOT SECURE"). Control socket clients now
use user-file and password-file, HA peers basic-auth-user-file and
basic-auth-password-file. The new secret artifact ddi-suite-ha-user
sits next to the password file (HAConfig.UserFile, set by derive);
keagen requires the file names ddi-suite-ha-password and
ddi-suite-ha-user. The DHCP agent handles both credential files alike:
consistent references in one directory, validation copy pointing at
the revision's files, written before the configuration, restored on a
failed reload, removed when HA goes away, compared by the health check.
Integration test and e2e read the user from the file; ADR 0023 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:18 +02:00
sir_melonchenandClaude Opus 5.5 06c3f3f7f6 test(agent): compare the state directory independently of the platform
ci / Protobuf (push) Successful in 8s
ci / Web (lint, test, audit, build) (push) Successful in 1m24s
ci / Shell and licence headers (push) Successful in 9s
ci / Go (lint, test, vuln) (push) Successful in 3m43s
ci / Release build (dry run) (push) Successful in 2m30s
ci / Integration (real BIND and Kea) (push) Failing after 2m2s
ci / Development environment (compose) (push) Successful in 13s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 3m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:07:44 +02:00
sir_melonchenandClaude Opus 5.5 d3b892ef9a test(e2e): DNS cluster, dynamic updates, Kea HA and lease adoption
A second BIND joins as secondary, a second Kea forms an HA pair. The test
checks zone transfers, records, updates by address and by TSIG key (and a
refused one), that revisions keep dynamic records, the HA state over the
authenticated control socket and the adoption of a lease as address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:02:14 +02:00
sir_melonchenandClaude Opus 5.5 2252576825 fix(keagen): convert pool bounds with encoding/binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:58:18 +02:00
sir_melonchenandClaude Opus 5.5 89c9ff806a feat(keagen): split pools between the peers in load-balancing mode
Kea's load-balancing mode needs non-overlapping pools per server: every
pool is split into a lower half for the primary (HA_primary) and an upper
half for the secondary (HA_secondary). The Kea integration test now covers
both HA modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:57:05 +02:00
sir_melonchenandClaude Opus 5.5 c51014ce4a Merge branch 'worktree-agent-a0ac42744be96fcb4'
Generator targets replace the temporary server shim. The keys file header
carries no revision number, so that diffs only show it when a key changed.
API test of cluster targets with the real generator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:53:18 +02:00
sir_melonchenandClaude Opus 5.5 e32a30cfeb chore(deploy): include ddi-suite-keys.conf before ddi-suite.conf
The development and end-to-end named.conf include the TSIG keys file, which starts empty, so named starts before the first revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:42:53 +02:00
sir_melonchenandClaude Opus 5.5 8c7f7e8bfc test(agent/dns): integration with keys file and a waiting secondary zone
named.conf includes ddi-suite-keys.conf; a revision without keys file
gets one without keys, and a revision with TSIG keys and a secondary
zone whose primary is unreachable deploys while the zone waits for its
transfer. The secondary directory gets the owner and mode of zone_dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:58 +02:00
sir_melonchenandClaude Opus 5.5 5c9f2baa0a feat(agent/dhcp): install the Kea HA password file
A DHCP revision may carry ddi-suite-ha-password next to
kea-dhcp4.conf. The role writes it to the one path the configuration
references (control socket clients and HA peers must agree, the file
name must be ddi-suite-ha-password, the directory must exist) before
the configuration, restores it with the configuration when the reload
fails and removes the previous revision's file when HA goes away.
kea-dhcp4 -t checks a temporary copy that refers to the revision's
password file, and the health check notices a changed password file
without showing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:11 +02:00
sir_melonchenandClaude Opus 5.5 643a625d1d feat(web): translate further M5 error codes, refresh addresses on lease adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:35:58 +02:00
sir_melonchenandClaude Opus 5.5 d39f29207a Merge branch 'worktree-agent-a495ca1d9381b7fed'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:32:47 +02:00
sir_melonchenandClaude Opus 5.5 c95dfda72a feat: secret flag in file diffs, per-member problem fields for clusters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:32:11 +02:00
sir_melonchenandClaude Opus 5.5 70643f8146 feat(agent/dns): keys file, secondary zones and their health
The DNS role installs ddi-suite-keys.conf (a keys file without keys for
revisions from before M5, so that named.conf can always include it),
includes it before ddi-suite.conf in the validation named.conf, accepts
secondary zone files in <zone_dir>/secondary/ and creates that
directory with the mode and owner of zone_dir without ever touching its
content. The health check queries secondary zones too; a zone still
waiting for its first transfer is reported but does not fail the
deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:30:40 +02:00
sir_melonchenandClaude Opus 5.5 b527f5b1d8 refactor(derive): report missing keys as dns.unknown_key per zone
Aligns with the warning codes the web UI translates: missing update and
transfer keys are reported as dns.unknown_key with the params zone and
key (the key ID).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:28:09 +02:00
sir_melonchen 98b2dba7b5 Merge branch 'main' into the M5 server branch 2026-10-14 14:55:44 +02:00
sir_melonchenandClaude Opus 5.5 8b1d66d57a feat: mark secret files in diffs explicitly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:15 +02:00
sir_melonchenandClaude Opus 5.5 1351d9e29e docs: M5 server details in ADR 0023, addresses event, API roles and secrets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:11 +02:00
sir_melonchenandClaude Opus 5.5 0d265c3c1a test: M5 clusters, TSIG keys, zones, records, revision targets and lease adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:54:19 +02:00
sir_melonchenandClaude Opus 5.5 d69a09aece test(configgen): integration tests for clusters, TSIG keys and Kea HA
named.conf includes the keys file before ddi-suite.conf. New cases check
a cluster primary (also-notify, allow-transfer, update keys) and its
secondary with named-checkconf/named-checkzone, run both in one
container until every zone is transferred with the TSIG key, and check
the HA configuration with kea-dhcp4 -t on Kea 3.x, start it and probe
the authenticated HTTP control socket with ha-heartbeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:52:30 +02:00
sir_melonchenandClaude Opus 5.5 d6a76e9419 Merge branch 'worktree-agent-adf19957537754d9f'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:51:58 +02:00
sir_melonchenandClaude Opus 5.5 18b6fff909 test(web): cover cluster, TSIG, zone, record and target views
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:51:26 +02:00
sir_melonchenandClaude Opus 5.5 15bdb03c14 feat(web): clusters, TSIG keys, zones and records (M5)
- Clusters page: DNS/DHCP clusters with members, transfer key, DNS
  options and Kea HA settings; member picking limited to free agents of
  the role, client-side checks of the membership rules.
- TSIG keys page: create, edit, rotate, delete; secrets only after an
  explicit, audited retrieval and shown once.
- Zones page with derived zones ("take over") and zone detail with
  type-specific record forms and hints for dynamic zones and AD.
- Networks, agents and generator settings show/assign clusters; files,
  diffs and pending changes are grouped by target, secret files are
  never shown.
- Pages are lazy-loaded per route (main bundle 600 KB -> 480 KB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:51:26 +02:00
sir_melonchenandClaude Opus 5.5 2b04661792 feat: clusters, TSIG keys, zones and records in service and REST API
Implements the M5 endpoints of the contract (ADR 0023): clusters with
member validation, transfer keys and HA credentials, TSIG keys with
audited secret access and rotation, explicit zones and typed records,
the DHCP cluster of networks, the cluster of agents and the default
clusters and HA password file of the generator settings. Revisions are
stored per target with sealed secret files and sealed source secrets
(restored for rollbacks); bundles carry an agent's own target or the
default group. Active leases are adopted as dynamic addresses.

The derivation is called through a temporary adapter until derive
implements the M5 contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:47:37 +02:00
sir_melonchenandClaude Opus 5.5 bd9bc3c75a feat(keagen): Kea HA with authenticated HTTP control socket
An HA member (Kea >= 3.0) loads libdhcp_ha.so after
libdhcp_lease_cmds.so, switches to "control-sockets" with the agent's
UNIX socket plus an HTTP socket on its own peer address that requires
basic authentication, disables the dedicated HA listener (it checks no
credentials) and sends basic-auth-user and the password file to its
peer. The password goes into the secret artifact ddi-suite-ha-password.
Output without HA is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:47:11 +02:00
sir_melonchenandClaude Opus 5.5 303c4781f8 feat(store): M5 schema for clusters, TSIG keys, zones, records and revision targets
Migration 00005: clusters with members (composite foreign keys keep the
roles of agents, clusters, zones and networks apart), TSIG keys with sealed
secrets, zones with update keys, typed DNS records, the DHCP cluster of a
network, revision files per target with a secret flag and sealed revision
secrets. Repositories for the new tables and for the lease adoption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:34 +02:00
sir_melonchenandClaude Opus 5.5 f037675596 feat(bindgen): TSIG keys file, primary/secondary zones and update keys
Render always writes the secret ddi-suite-keys.conf (only a comment
without keys). Primary zones of a cluster get "notify explicit",
"also-notify" and "allow-transfer" with the transfer key; secondary
zones transfer from their primaries with the key into
<zone_dir>/secondary/ and have no artifact. Dynamic zones accept the
update keys in their ACL. Keys are validated (name, algorithm
whitelist, canonical base64) and every referenced key must exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:43:36 +02:00
sir_melonchenandClaude Opus 5.5 8f09d3aed2 feat(configgen): derive DNS and DHCP targets for clusters and zones
BuildDNS and BuildDHCP return one configuration per target (ADR 0023):
the default group first, then every cluster member by agent ID. DNS
gains explicit zones and records, longest-suffix placement of address
records, primary/secondary zones with NOTIFY and transfer keys, dynamic
zones with update keys and CNAME conflict resolution; DHCP gains the
Kea HA setup of cluster pairs. DerivedZones lists the zones derived
from networks. An M4 source still renders byte-identical files
(testdata/m4 holds the output of the M4 generator).

The service renders only the default target until the server part of
M5 follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:40:11 +02:00
sir_melonchenandClaude Opus 5.5 d26153c2cc chore(web): regenerate API types for the M5 contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:25:38 +02:00
sir_melonchenandClaude Opus 5.5 8608506891 docs: Active Directory with dynamic zones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:24:04 +02:00
sir_melonchenandClaude Opus 5.5 99c94f1305 feat: HA password file path in the M5 contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:21:53 +02:00
sir_melonchenandClaude Opus 5.5 2466b81f04 style: gofmt model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:20:59 +02:00
sir_melonchenandClaude Opus 5.5 b6775e1dcd feat: M5 contracts for clusters, zones, records, TSIG keys and HA
ADR 0023, generator input and output types per target, and the REST API
for clusters, TSIG keys, zones and records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:20:54 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 29f723612a docs: mark milestone M4 as complete, update project log
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 1m7s
ci / Shell and licence headers (push) Successful in 8s
ci / Go (lint, test, vuln) (push) Successful in 3m16s
ci / Release build (dry run) (push) Successful in 2m26s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 2m6s
ci / Development environment (compose) (push) Successful in 13s
ci / Integration (real BIND and Kea) (push) Successful in 1m55s
release / release (push) Successful in 2m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:52:23 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 7b01f2f2f8 test(e2e): reuse the API session instead of logging in for every step
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m19s
ci / Release build (dry run) (push) Successful in 2m9s
ci / Integration (real BIND and Kea) (push) Successful in 11m26s
ci / Shell and licence headers (push) Successful in 19s
ci / Go (lint, test, vuln) (push) Successful in 4m46s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 2m7s
ci / Development environment (compose) (push) Successful in 12s
Every api.sh step logged in again and the run hit the login rate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:35:32 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 d1f4eb999f feat: translate M4 problem codes, record implementation details in ADR 0022
ci / Protobuf (push) Successful in 7s
ci / Web (lint, test, audit, build) (push) Successful in 1m7s
ci / Shell and licence headers (push) Successful in 7s
ci / Go (lint, test, vuln) (push) Successful in 3m17s
ci / Release build (dry run) (push) Successful in 2m29s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 2m2s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:26:52 +02:00
beug.k@beugsiud.de 9e4887601e Merge branch 'worktree-agent-a60dbb92836336cb2' 2026-10-13 20:24:34 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 b8a714fb5b feat(server): in-process event bus for live updates
Fan-out to subscribers with bounded buffers; slow subscribers miss
events instead of blocking publishers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:46 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 28dc177310 feat(store): migration 00004 for push deployments, target revisions and revision sources
Agents get a target revision (manual mode), revisions store their
generator input (jsonb) for rollbacks, deployments gain the outcomes
pending and superseded, a nullable end and the requesting user. The
duplicate protection now covers agent reports only. Transactions can
register hooks that run after the commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:45 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 3879c27f35 fix(web): do not preselect agents that already run a newer revision
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:16:23 +02:00
beug.k@beugsiud.de 519d3a1ff4 Merge branch 'worktree-agent-ace13af6aa36eb177' 2026-10-13 20:13:18 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 70884c9653 test(web): pending changes, diffs, deploy, rollback, mode and live events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:12:52 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 751fecddd9 feat(web): diff view, pending changes, deploy now and rollback
Revisions page lists the pending audit entries and file diffs above
"Neue Revision". The revision page compares with the previous revision
(or another one, or empty), pushes the revision to selected agents with
a per-agent delivery result, and rolls back by creating a new revision
with the old content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:12:52 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 2fa3f4db21 feat(web): live updates, pending-changes notice, agent mode switch
Server-sent events from /api/v1/events invalidate the affected queries
(agents, deployments, revisions, pending changes) and reconnect with
backoff; while connected, polling drops to a 60 s fallback. The footer
shows the live status, the header notes how many changes wait to be
deployed. Agents get a mode switch (auto_pull/manual) with their target
revision; deployments show the pending and superseded outcomes and who
requested a push. Translations for all M4 texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:12:51 +02:00
beug.k@beugsiud.de 92b9e6e015 Merge branch 'worktree-agent-a8916aaf27575ea1e' 2026-10-13 20:12:13 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 9e5cffe964 docs(adr): pushes never go below an agent's active revision
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:12:13 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 e7a0fbb123 test(e2e): pending changes, manual mode with push, diff, rollback, events
New steps before the revoke step: /config/pending reports nothing after
revision 2 and the new address afterwards; the DHCP agent switched to
manual mode stays on revision 2 while the DNS agent reaches 3, and an
explicit push of revision 3 brings it there with a succeeded push
deployment; the diff of revision 3 contains the new host; rolling back
to revision 1 creates revision 4 with SOA serial 4 and without the later
names; /events announces a revision created while listening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:11:27 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 9a4046e3e2 feat(agent): push deployments by polling, apply each deployment once
- GetDesiredStateResponse.deployment_id set: the desired revision is
  applied as a push with that ID, also in manual mode (ADR 0022).
- The reports of the last 20 push deployments are kept in state.json.
  A resent push with a handled deployment ID is not applied again; its
  report is delivered again unless it is still queued.
- A push already queued with the same deployment ID is not queued twice.
- Reports the server refuses for good (InvalidArgument, NotFound,
  AlreadyExists, FailedPrecondition, OutOfRange) are dropped instead of
  blocking the report queue.
- Tests for polling pushes, resent pushes, mode changes via a new
  Welcome, persistence and bounds of handled deployments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:07:35 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 d5511b3286 feat: M4 contracts for push deployments, diff, rollback and live events
ADR 0022, OpenAPI additions (agent mode, deploy, rollback, diff, pending
changes, server-sent events), deployment_id in GetDesiredStateResponse
for pushes picked up by polling, and the textdiff package (Myers,
unified format).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:53:21 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 866c0cbdcb docs: mark milestone M3 as complete, update project log
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m15s
ci / Shell and licence headers (push) Successful in 19s
ci / Release build (dry run) (push) Successful in 2m8s
release / release (push) Successful in 2m11s
ci / Go (lint, test, vuln) (push) Successful in 4m16s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 1m48s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 2m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:09 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 d3e5807a09 test: retry image pulls, executable e2e scripts, fix truncation test
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m16s
ci / Shell and licence headers (push) Successful in 20s
ci / Release build (dry run) (push) Successful in 2m9s
ci / Go (lint, test, vuln) (push) Successful in 4m19s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 1m49s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 5m12s
Docker Hub timed out on the shared runner; pulls now retry and fall back
to a local image. The e2e scripts were committed without the executable
bit. The truncation test checked letters that the marker itself contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:37:53 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 56347ec5ff test: end-to-end test of the agent path, fix first CI findings
ci / Web (lint, test, audit, build) (push) Successful in 1m1s
ci / Shell and licence headers (push) Successful in 7s
ci / Go (lint, test, vuln) (push) Failing after 2m40s
ci / Protobuf (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 2m27s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 1m26s
ci / Development environment (compose) (push) Successful in 13s
ci / Integration (real BIND and Kea) (push) Failing after 1m58s
test/e2e runs server, PostgreSQL, BIND and Kea with both agents in
containers (make test-e2e, CI job End-to-end): enrollment with the
command from the API, revision 1 and 2 applied and served by BIND and
Kea, unchanged data creates no revision, revoking disconnects the agent
while BIND keeps serving.

Fixes from the first CI run: quote a spec description containing a
comma, exempt the agent lock-out routes in the route protection test,
whole-second certificate lifetimes, a push replacing queued pulls, the
truncation marker containing the letter checked, and curl|grep -q under
pipefail in the server smoke test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:29:58 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 56892631be feat: wire BIND and Kea roles into the agents, allow revoking agents in read-only mode
ci / Protobuf (push) Successful in 2m18s
ci / Go (lint, test, vuln) (push) Failing after 3m11s
ci / Shell and licence headers (push) Successful in 8s
ci / Web (lint, test, audit, build) (push) Successful in 1m14s
ci / Integration (real BIND and Kea) (push) Successful in 1m42s
ci / Release build (dry run) (push) Failing after 2m24s
ci / Development environment (compose) (push) Successful in 38s
Revoking or deleting an agent and deleting enrollment tokens lock agents
out, a security function that must stay available when the license is
read-only. Translations for the new problem codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:19:31 +02:00
beug.k@beugsiud.de 31fb8285eb Merge branch 'worktree-agent-a423f65773305d370' 2026-10-13 19:16:01 +02:00
beug.k@beugsiud.de 1a88112ce3 Merge branch 'worktree-agent-af35c8bdd4b0bcf64' 2026-10-13 19:15:16 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 7bbcd28aa6 test(agent/role): run the DNS and DHCP roles against real BIND and Kea
A driver is cross-compiled for the Docker daemon's architecture and
copied with fixture revisions into BIND 9.18/9.20 and Kea 2.6.5/3.2.1
containers (docker cp, no bind mounts or published ports). It runs the
core's pipeline: two revisions apply, broken revisions are rejected,
a failed live check and a failed health check roll back, a failed Kea
reload restores the previous file, and leases added with lease4-add
are listed page by page. make test-integration runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:35 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 562f35c10c feat(agent/dhcp): Kea DHCPv4 role over the UNIX control socket
Validate requires exactly kea-dhcp4.conf with this agent's control
socket and runs kea-dhcp4 -t. Activate writes the file atomically and
sends config-reload; on failure the previous file is restored and
reloaded. Health uses status-get (pid, uptime, last reload, socket
state) and checks that the installed file is the expected revision.
Leases pages through lease4-get-page and maps Kea lease states
(default, declined, expired-reclaimed, released) to core lease states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:34 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 d61b738a8c feat(agent/dns): BIND role with validation, activation and SOA health check
Validate maps ddi-suite*.conf to conf_dir and zones/db.<zone> to
zone_dir, rejects other files and a zone directory that differs from
dns.zone_dir, and runs named-checkconf on a temporary named.conf plus
strict named-checkzone per zone. Activate backs up all touched files,
writes zone files then includes atomically (unchanged files are kept),
never touches existing create-only files, removes files dropped since
the previous revision and the stale journal of a zone that became
managed, runs named-checkconf -z on the live named.conf and then
rndc reconfig and rndc reload; failures restore the backup (and reload
again). Health runs rndc status and compares the SOA serial of every
managed zone (UDP with TCP fallback, authoritative answers only).

golang.org/x/net becomes a direct dependency (dnsmessage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:34 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 bb13723c2b feat(app): start the agent API next to the HTTP listener
Loads or creates the agent PKI at startup, serves the gRPC agent API on
agent_api.listen with graceful shutdown, exports ddi_agents_connected
and removes old enrollment tokens periodically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:30 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 53e4cb75d6 fix(agentapi): align keepalive, message size and deployment reports with the agent core
Keepalive MinTime 20 s (agents ping every 30 s, also without a stream),
send limit above bundle.MaxTotalSize, and resent deployment reports
(same agent, revision and start time) are dropped without error and
without touching the agent's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:30 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 b5e57c3cb7 feat(agent/role): command runner and atomic file helpers for the roles
Shared, role-internal helpers: programs run without a shell, with a
timeout and captured, truncated output quoted in errors; configuration
files are replaced atomically (temp file, fsync, rename, directory sync)
with explicit mode and the owner of the existing file or its directory;
an in-memory backup restores touched files after a failed activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:23 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 4b02c06adf chore: ignore local agent worktrees
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:12:07 +02:00
beug.k@beugsiud.de c6162e00d2 Merge branch 'worktree-agent-a2c569cc905081672' 2026-10-13 19:10:13 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 258919fd12 feat(agentapi): gRPC agent service with mTLS and certificate checks
TLS 1.3 listener that requests client certificates and checks them in
interceptors: Enroll needs a valid one-time token (rate limited per
source IP), RenewCertificate accepts certificates expired up to 30 days,
every other call needs the agent's current, unrevoked certificate.
Connect registers the stream in the hub (pushes, revocation, shutdown);
FetchBundle signs the role's artifacts per agent; lease batches are
deduplicated by sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:09:11 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 3065175673 feat(agent): enrollment, server connection and apply pipeline
- CLI: enroll, run, status, version, help (core.Main with a role
  factory); the agent binaries use a placeholder factory until the
  BIND and Kea roles are wired in.
- Enrollment: local ECDSA P-256 key and CSR, TLS 1.3 to the agent API
  with the server chain pinned to the CA fingerprint.
- ServerSource: mTLS gRPC with keepalive, Connect stream (Hello,
  Welcome, heartbeats, apply and renew commands), reconnect with
  exponential backoff and jitter, GetDesiredState polling as fallback,
  certificate renewal after half of the lifetime.
- Apply pipeline: fetch, bundle verification with replay protection,
  store, Validate, Activate, Health with retries, rollback to the last
  good revision; outcomes succeeded/rejected/rolled_back/failed are
  queued in state.json and reported; failed revisions are retried only
  by a newer revision or an explicit push.
- Lease reporting with change detection, persistent sequence and full
  snapshots at start, per connection and periodically.
- Tests with fake roles, a fake source and an in-process fake
  AgentService over real mTLS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:06:05 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 c96eb33e58 feat(agent): configuration file and file-based agent state
Agent configuration (agent-<role>.yaml, strict decoding, role section
passed undecoded to the role factory) and the state directory of ADR
0011: identity with interrupted-renewal recovery, verified revisions
under revisions/<n>/, state.json with apply results, lease sequence and
queued deployment reports, atomic writes, pruning and a lock file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:05:16 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 92d1d9ec3d feat(api): agents, enrollment tokens, revisions, deployments and leases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:02:50 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 1bdd877051 feat(service): agents, enrollment tokens, revisions, deployments and leases
Use cases for the REST API (agent list/revoke/delete, one-time tokens
with the ready enrollment command, immutable revisions with unchanged
detection, deployment and lease lists) and for the agent data plane
(enrollment, renewal, status, bundles, deployment and lease reports).
A hub of open agent streams pushes new revisions to auto_pull agents and
closes the stream of revoked agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:02:49 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 89d7949e18 feat(config): agent_api listener settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:14 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 c60cd4a21e feat(pki): agent CA, bundle signing key and certificate issuance
The CA (ECDSA P-256, 10 years) and the Ed25519 bundle signing key are
created on first start, sealed with the master key and stored once
(concurrent starts read back the winner). The agent-port server
certificate is issued in memory; agent CSRs are signed for 14 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:13 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 8e76c0cf32 feat(store): agents, enrollment tokens, revisions, deployments and leases
Migration 00003 adds the PKI key table, agents, enrollment tokens,
immutable configuration revisions with their files, deployment reports
and DHCP leases, each with database constraints. Repositories and DB
tests for all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:12 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 6c1f7249b0 feat(web): agents, enrollment tokens, revisions, deployments and leases
Agent overview with connection, health and revision state (polled every
five seconds until the live stream of M4), agent details with checks and
deployment history, enrollment token dialog showing the ready-to-run
command, revision list and creation, revision details with deployments
and files, and a lease list with network, state and text filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:05:01 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 e843502cbf feat: M3 contracts for agents, revisions and bundles
Shared Apache-2.0 packages for the signed bundle format and the agent
certificate conventions, the agent role interface, REST API spec for
agents, enrollment tokens, revisions, deployments and leases, and ADR
0021. Generator settings now hold the target paths on the managed hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:30 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 44de779fbf docs: mark milestone M2 as complete, update project log
ci / Shell and licence headers (push) Successful in 6s
release / release (push) Successful in 1m53s
ci / Go (lint, test, vuln) (push) Successful in 3m36s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 56s
ci / Release build (dry run) (push) Failing after 1m55s
ci / Integration (real BIND and Kea) (push) Successful in 43s
ci / Development environment (compose) (push) Successful in 13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:36:57 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 279bb92c74 test(integration): pull images first and keep docker stderr apart
ci / Go (lint, test, vuln) (push) Successful in 3m43s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 57s
ci / Shell and licence headers (push) Successful in 7s
ci / Release build (dry run) (push) Successful in 1m58s
ci / Integration (real BIND and Kea) (push) Successful in 41s
ci / Development environment (compose) (push) Successful in 14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:17 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 b94b6c8dae test(api): reset preview before decoding the second response
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:24:08 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 0a74d4b3ba feat: generator settings and configuration preview (API and UI)
ci / Go (lint, test, vuln) (push) Failing after 3m31s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 57s
ci / Shell and licence headers (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 1m59s
ci / Integration (real BIND and Kea) (push) Failing after 43s
ci / Development environment (compose) (push) Successful in 13s
- stable Kea subnet IDs per network (migration 00002)
- generator settings (SOA, name servers, TTLs, recursion, DHCP defaults)
- GET /config/preview renders the configuration for the current data
- UI: configuration preview page and DNS/DHCP settings page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:19:26 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 3564686db0 feat(configgen): BIND and Kea configuration generator with integration tests
- configgen: input model shared by derivation and renderers
- derive: forward zones per domain, reverse zones (IPv4 octet, IPv6 nibble
  boundaries), A/AAAA/PTR from static and manual_dhcp entries, Kea subnets
  with effective pools and reservations; pure and deterministic
- bindgen: zone-statement include, options include, zone files with strict
  validation, canonical ordering and RFC 1035 TXT escaping
- keagen: typed Kea DHCPv4 JSON with strict validation
- golden-file, validation, determinism and fuzz tests
- integration tests check generated files with real named-checkconf,
  named-checkzone (BIND 9.18, 9.20) and kea-dhcp4 -t (Kea 2.6, 3.2);
  new CI job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:19:25 +02:00
beug.k@beugsiud.deandClaude Opus 5.5 e91d1d63c0 docs: add project log (handover notes) and CLAUDE.md pointer
ci / Go (lint, test, vuln) (push) Successful in 3m27s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 53s
ci / Shell and licence headers (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 1m53s
ci / Development environment (compose) (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:56:38 +02:00
380 changed files with 65148 additions and 2360 deletions
+34
View File
@@ -0,0 +1,34 @@
# SPDX-License-Identifier: Apache-2.0
#
# Benchmarks of server, BIND and Kea (test/bench, docs/SYSTEM-REQUIREMENTS.md).
# Started by hand; the results are printed as a Markdown table in the log
# (and in the job summary where the runner supports it).
name: bench
on:
workflow_dispatch:
concurrency:
group: bench-${{ github.ref }}
cancel-in-progress: true
jobs:
bench:
name: Benchmark (server, BIND, Kea)
runs-on: ubuntu-latest
timeout-minutes: 90
env:
# The Docker daemon may be shared: unique compose project.
COMPOSE_PROJECT_NAME: ddi-bench-${{ 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
- name: Tools
run: command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq --no-install-recommends jq; }
- name: Build
run: make build-go
- name: Benchmark
run: make bench
+61 -2
View File
@@ -99,6 +99,8 @@ jobs:
run: make lint-shell
- name: SPDX headers
run: make lint-spdx
- name: No hard-coded product names (ADR 0018)
run: make lint-brand
release-dry-run:
name: Release build (dry run)
@@ -126,15 +128,72 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: Build release artefacts
run: make release
- name: Install nfpm
run: scripts/ci/install-tools.sh "$HOME/.local/bin" nfpm
- name: Build release artefacts and packages
run: make release PACKAGES=1
- name: Verify checksums
run: cd dist && sha256sum -c SHA256SUMS
- name: Inspect packages
run: |
ls -l dist/*.deb dist/*.rpm
# 3 binaries x 2 architectures x (deb, rpm)
test "$(ls dist/*.deb dist/*.rpm | wc -l)" -eq 12
for deb in dist/*_amd64.deb; do
dpkg-deb --info "$deb" >/dev/null
dpkg-deb --contents "$deb" >contents.txt
grep -q ' ./usr/bin/' contents.txt
grep -q ' ./usr/lib/systemd/system/.*\.service$' contents.txt
grep -q ' ./usr/share/doc/.*/LICENSE$' contents.txt
done
- name: Release signature (throwaway key)
run: |
command -v openssl >/dev/null || (apt-get update -qq && apt-get install -y -qq openssl)
scripts/sign-release.sh selftest
umask 077
key="$(mktemp)"
openssl genpkey -algorithm ed25519 -out "$key"
RELEASE_SIGNING_KEY="$(cat "$key")" scripts/sign-release.sh sign dist/SHA256SUMS
scripts/sign-release.sh public-key "$key" >"$key.pub"
scripts/sign-release.sh verify dist/SHA256SUMS dist/SHA256SUMS.sig "$key.pub"
rm -f "$key" "$key.pub" dist/SHA256SUMS.sig
- name: Smoke test of the released server binary
env:
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)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- name: Generated configuration and agent roles against real BIND and Kea
run: make test-integration
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
dev-environment:
name: Development environment (compose)
+46
View File
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: Apache-2.0
#
# Installation test (ADR 0024): the release artifacts and scripts/install.sh
# in privileged systemd containers of every supported system. Needs a
# runner with access to a Docker daemon that allows privileged containers
# with cgroup v2 (see docs/adr/0003).
name: install
on:
push:
branches: [main]
pull_request:
concurrency:
group: install-${{ github.ref }}
cancel-in-progress: true
jobs:
install:
name: Installation (${{ matrix.os }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: ["debian:12", "debian:13", "ubuntu:22.04", "ubuntu:24.04", "rocky:9"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- 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: Build release artefacts
run: make release PLATFORMS="linux/$(go env GOARCH)"
- name: Build a newer release for the upgrade
run: |
make -o web -o web-deps release PLATFORMS="linux/$(go env GOARCH)" DIST_DIR=dist-next \
VERSION="$(git describe --tags --always)-next"
- name: Install, enroll, deploy, rerun, upgrade, uninstall
env:
RUN_ID: ${{ github.run_id }}
run: test/install/run.sh "${{ matrix.os }}" dist dist-next
+31 -3
View File
@@ -23,12 +23,40 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: Ensure jq
run: command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
- name: Ensure jq and openssl
run: |
command -v jq >/dev/null && command -v openssl >/dev/null ||
(apt-get update -qq && apt-get install -y -qq jq openssl)
- name: Install nfpm
run: scripts/ci/install-tools.sh "$HOME/.local/bin" nfpm
- name: Build
run: make release VERSION="${{ github.ref_name }}"
run: make release PACKAGES=1 VERSION="${{ github.ref_name }}"
- name: Verify checksums
run: cd dist && sha256sum -c SHA256SUMS
# Ed25519 signature over SHA256SUMS (ADR 0024, docs/RELEASE.md) if the
# secret RELEASE_SIGNING_KEY (PEM private key) is set. The key only
# exists in a private temporary file during signing.
- name: Sign checksums
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
if [[ -z "${RELEASE_SIGNING_KEY:-}" ]]; then
echo "::warning::RELEASE_SIGNING_KEY is not set: the release is not signed"
exit 0
fi
scripts/sign-release.sh sign dist/SHA256SUMS
# The installer must accept the signature: verify with its public
# key if one is configured there.
pub="$(mktemp)"
sed -n -e 's/^[[:space:]]*//' -e 's/.*\(-----BEGIN PUBLIC KEY-----\)/\1/' \
-e 's/\(-----END PUBLIC KEY-----\).*/\1/' \
-e '/-----BEGIN PUBLIC KEY-----/,/-----END PUBLIC KEY-----/p' scripts/install.sh >"$pub" 2>/dev/null || true
if [[ -s "$pub" ]]; then
scripts/sign-release.sh verify dist/SHA256SUMS dist/SHA256SUMS.sig "$pub"
else
echo "::warning::no public key in scripts/install.sh: the installer will not check the signature"
fi
rm -f "$pub"
- name: Upload to release
env:
GITEA_API_URL: ${{ github.server_url }}/api/v1
+4
View File
@@ -35,3 +35,7 @@ master.key
*.swp
.DS_Store
Thumbs.db
# Local worktrees of Claude Code agents
/.claude/worktrees/
/test/e2e/bin/
+2
View File
@@ -3,6 +3,8 @@ version: "2"
run:
timeout: 5m
build-tags:
- integration
linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
+6
View File
@@ -0,0 +1,6 @@
# Hinweise für die Arbeit an diesem Repository
- Auftrag: [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md). Architektur: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Entscheidungen: [docs/adr/](docs/adr/README.md).
- **Stand, offene Punkte, nächster Schritt und Erfahrungen aus früheren Sitzungen: [docs/LOGBUCH.md](docs/LOGBUCH.md) – zuerst lesen und nach jeder Arbeitssitzung fortschreiben.**
- Arbeitsweise nach Brief §0: in Meilensteinen, Conventional Commits, Tag erst bei grüner CI, Code/Logs Englisch (US), Doku/UI Deutsch.
- Vor jedem Commit: `make lint` (bzw. `golangci-lint run ./...`, `scripts/check-spdx.sh`, `shellcheck`, `cd web && npm run lint && npm run typecheck`) und Tests; API-Änderungen immer zusammen mit `server/api/openapi.yaml` und `npm run gen:api`.
+40 -2
View File
@@ -25,6 +25,10 @@ BUILD_DATE ?= $(shell git log -1 --format=%cI 2>/dev/null)
PLATFORMS ?= linux/amd64 linux/arm64
BIN_DIR ?= bin
DIST_DIR ?= dist
# nfpm for make packages / PACKAGES=1; scripts/ci/install-tools.sh installs
# the pinned version (NFPM_VERSION there).
NFPM ?= nfpm
export NFPM
COMPOSE_DEV := docker compose -f deploy/compose/compose.dev.yaml
@@ -103,7 +107,7 @@ fmt:
## lint: run all linters
.PHONY: lint
lint: lint-go lint-proto lint-web lint-shell lint-spdx
lint: lint-go lint-proto lint-web lint-shell lint-spdx lint-brand
.PHONY: lint-go
lint-go:
@@ -127,6 +131,11 @@ lint-shell:
lint-spdx:
scripts/check-spdx.sh
## lint-brand: no hard-coded product names in units, packages, installer (ADR 0018)
.PHONY: lint-brand
lint-brand:
scripts/check-brand.sh
## test: run Go and frontend unit tests
.PHONY: test
test: test-go test-web
@@ -139,6 +148,22 @@ 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:
go test -tags integration -count=1 -v -timeout 20m \
./server/internal/configgen/integration/ ./agent/role/integration/
## bench: measure server, BIND and Kea with generated data (needs docker and bin/ from build-go)
.PHONY: bench
bench:
test/bench/run.sh
## vuln: scan dependencies for known vulnerabilities
.PHONY: vuln
vuln:
@@ -151,10 +176,23 @@ ci: lint check-generated test vuln build
# --------------------------------------------------------------------------- release
## release: cross-compile tarballs and SHA256SUMS into dist/
## release: cross-compile tarballs and SHA256SUMS into dist/ (PACKAGES=1: also .deb/.rpm)
.PHONY: release
release: web
scripts/build-release.sh -o $(DIST_DIR) -p "$(PLATFORMS)"
ifeq ($(PACKAGES),1)
scripts/build-packages.sh -d $(DIST_DIR) -p "$(PLATFORMS)"
endif
## packages: .deb and .rpm from the tarballs in dist/ (after make release; needs nfpm)
.PHONY: packages
packages:
scripts/build-packages.sh -d $(DIST_DIR) -p "$(PLATFORMS)"
## sign-release: sign dist/SHA256SUMS with the Ed25519 key in RELEASE_SIGNING_KEY_FILE
.PHONY: sign-release
sign-release:
scripts/sign-release.sh sign $(DIST_DIR)/SHA256SUMS "$(RELEASE_SIGNING_KEY_FILE)"
# --------------------------------------------------------------------------- development
+12 -3
View File
@@ -10,9 +10,13 @@ Selbst gehostete DDI-Lösung (DNS, DHCP, IP-Adressverwaltung) im Stil von VitalQ
| --- | --- |
| M0 Fundament (Repo, Build, Lint, CI, Entwicklungsumgebung, Architektur, ADRs) | abgeschlossen (`v0.1.0`) |
| M1 Server-Kern (Schema, Anmeldung lokal/OIDC/TOTP, Rollen, Audit, Lizenz, REST-API, Netze/Adressen, Oberfläche) | abgeschlossen (`v0.2.0`) |
| M2–M6 | geplant, siehe [ARCHITECTURE.md](docs/ARCHITECTURE.md#11-meilensteine) |
| M2 Config-Generator (BIND/Kea aus dem Datenmodell, geprüft mit echtem BIND 9.18/9.20 und Kea 2.6/3.2) | abgeschlossen (`v0.3.0`) |
| M3 Agent verwaltet (Registrierung, mTLS, signierte Revisionen, Anwenden mit Prüfung/Health-Check/Rollback, Leases; End-to-End-Test mit BIND und Kea) | abgeschlossen (`v0.4.0`) |
| M4 Pull und Push (Betriebsmodi, „Jetzt ausrollen“, offene Änderungen, Diff, Rollback, Live-Status) | abgeschlossen (`v0.5.0`) |
| M5 Cluster und Zonen (DNS-Primary/Secondary mit TSIG, Kea-HA, Zonen und Records, dynamische Zonen, Lease-Übernahme) | abgeschlossen (`v0.6.0`) |
| M6 Auslieferung (Installer für Debian/Ubuntu/EL9, gehärtete systemd-Units, Pakete, Release-Signatur, Messungen, Betriebs- und Sicherheitsdoku) | abgeschlossen (`v0.7.0`) |
Der Management-Server verwaltet Netze und Adressen (QIP-artige Tabelle), Benutzer, Lizenz und Audit-Log. Das Erzeugen und Ausrollen von DNS-/DHCP-Konfigurationen folgt ab M2; die Agents bieten bisher nur den Programmrahmen.
Installation: [docs/INSTALL.md](docs/INSTALL.md). Der Management-Server verwaltet Netze und Adressen (QIP-artige Tabelle), Zonen und Records, DNS- und DHCP-Cluster, Benutzer, Lizenz und Audit-Log. Aus den Daten erzeugt er die BIND- und Kea-Konfiguration als unveränderliche Revisionen (**Ausrollen**). Die Agents (`ddi-agent-dns`, `ddi-agent-dhcp`) registrieren sich mit einem Einmal-Token, holen die signierten Revisionen über mTLS, prüfen und aktivieren sie, rollen bei Fehlern zurück und melden Zustand und Leases (**Agents**, **Leases**).
## Dokumentation
@@ -21,6 +25,11 @@ Der Management-Server verwaltet Netze und Adressen (QIP-artige Tabelle), Benutze
- [Architekturentscheidungen (ADRs)](docs/adr/README.md)
- [REST-API](docs/API.md)
- [Lizenzierung](docs/LICENSING.md)
- [Systemvoraussetzungen](docs/SYSTEM-REQUIREMENTS.md) (mit Messwerten)
- [Installation](docs/INSTALL.md)
- [Betrieb](docs/OPERATIONS.md) (Sicherung, Upgrade, Schlüssel, Fehlersuche)
- [Sicherheit](docs/SECURITY.md) (Bedrohungsmodell, Schlüsselverwaltung, Härtung)
- [Active Directory](docs/ACTIVE-DIRECTORY.md)
## Entwicklung
@@ -69,7 +78,7 @@ make dev-down # stoppen (Daten bleiben erhalten)
### CI
Gitea Actions unter [.gitea/workflows/](.gitea/workflows/): Go (Lint, Tests mit Race-Detector, `govulncheck`), Protobuf, Web (ESLint, Typecheck, Tests, `npm audit`, Build), Shell/SPDX, Release-Probebau und ein Compose-Smoke-Test. Tags `v*` erzeugen ein Release mit Artefakten. Anforderungen an den Runner stehen in [ADR 0003](docs/adr/0003-git-host-gitea-actions.md).
Gitea Actions unter [.gitea/workflows/](.gitea/workflows/): Go (Lint, Tests mit Race-Detector, `govulncheck`), Protobuf, Web (ESLint, Typecheck, Tests, `npm audit`, Build), Shell/SPDX, Release-Probebau und ein Compose-Smoke-Test. Messungen von Server, BIND und Kea laufen auf Anforderung im Workflow `bench` (`make bench`, [test/bench/](test/bench/)); die Ergebnisse stehen in [SYSTEM-REQUIREMENTS.md](docs/SYSTEM-REQUIREMENTS.md). Tags `v*` erzeugen ein Release mit Artefakten. Anforderungen an den Runner stehen in [ADR 0003](docs/adr/0003-git-host-gitea-actions.md).
## Lizenz
+2 -1
View File
@@ -7,9 +7,10 @@ import (
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/dhcp"
"git.beugsiud.de/beug.k/DDI/internal/brand"
)
func main() {
os.Exit(core.RunCLI(brand.AgentDHCPBinary, core.RoleDHCP, os.Args[1:], os.Stdout, os.Stderr))
os.Exit(core.Main(brand.AgentDHCPBinary, core.KindDHCP, dhcp.New, os.Args[1:], os.Stdout, os.Stderr))
}
+2 -1
View File
@@ -7,9 +7,10 @@ import (
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/dns"
"git.beugsiud.de/beug.k/DDI/internal/brand"
)
func main() {
os.Exit(core.RunCLI(brand.AgentDNSBinary, core.RoleDNS, os.Args[1:], os.Stdout, os.Stderr))
os.Exit(core.Main(brand.AgentDNSBinary, core.KindDNS, dns.New, os.Args[1:], os.Stdout, os.Stderr))
}
+664
View File
@@ -0,0 +1,664 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log/slog"
"reflect"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Timing defaults of the agent that are not configurable.
const (
healthRetryInterval = time.Second
reportRetryInterval = 30 * time.Second
reportTimeout = 30 * time.Second
statusTimeout = 10 * time.Second
serviceVersionMaxAge = 10 * time.Minute
// maxMessageLen bounds the message of a deployment report.
maxMessageLen = 4096
)
// Agent drives a role: it applies the revisions announced by a Source and
// reports outcomes, health and leases.
type Agent struct {
kind Kind
cfg *Config
role Role
store *Store
agentID string
bundleKey ed25519.PublicKey
source Source
log *slog.Logger
// Intervals, shortened by tests.
healthRetry time.Duration
reportRetry time.Duration
// roleMu serializes calls into the role: only one apply at a time, and
// health checks for heartbeats are skipped while an apply runs.
roleMu sync.Mutex
mu sync.Mutex
st *State
current *Revision // applied revision, nil if none or not loadable
health Health
healthAt time.Time
version string
versionAt time.Time
qmu sync.Mutex
queue []applyRequest
wake chan struct{}
flushCh chan struct{}
leaseCh chan struct{}
}
// applyRequest asks for an apply of a revision.
type applyRequest struct {
revision uint64
trigger Trigger
deploymentID string
}
// NewAgent prepares an agent. The identity provides the agent ID and the
// bundle signing key.
func NewAgent(kind Kind, cfg *Config, role Role, store *Store, id *Identity, src Source, log *slog.Logger) (*Agent, error) {
if role.Kind() != kind {
return nil, fmt.Errorf("role %q does not match agent role %q", role.Kind(), kind)
}
st, err := store.LoadState()
if err != nil {
return nil, err
}
a := &Agent{
kind: kind, cfg: cfg, role: role, store: store, agentID: id.AgentID,
bundleKey: id.BundleKey, source: src, log: log, st: st,
healthRetry: healthRetryInterval, reportRetry: reportRetryInterval,
wake: make(chan struct{}, 1), flushCh: make(chan struct{}, 1), leaseCh: make(chan struct{}, 1),
}
if st.Applied != 0 {
rev, err := store.LoadRevision(st.Applied)
if err != nil {
log.Warn("applied revision is not available locally", "revision", st.Applied, "error", err)
} else {
a.current = &rev
}
}
return a, nil
}
// Run works until ctx is canceled. The managed service keeps running with
// its current configuration afterwards.
func (a *Agent) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
a.prune()
var wg sync.WaitGroup
wg.Go(func() { a.applyLoop(ctx) })
wg.Go(func() { a.reportLoop(ctx) })
if ls, ok := a.role.(LeaseSource); ok {
wg.Go(func() { a.leaseLoop(ctx, ls) })
}
err := a.source.Run(ctx, a)
stopped := ctx.Err() != nil
cancel()
wg.Wait()
if stopped {
return nil
}
return err
}
// state returns a copy of the state (without the report queue and the
// handled deployments).
func (a *Agent) state() State {
a.mu.Lock()
defer a.mu.Unlock()
st := *a.st
st.PendingReports = nil
st.HandledDeployments = nil
return st
}
// Connected implements SourceHandler.
func (a *Agent) Connected() {
notify(a.flushCh)
notify(a.leaseCh)
}
// Desired implements SourceHandler.
func (a *Agent) Desired(revision uint64, autoApply bool) {
if revision == 0 {
return
}
if !autoApply {
a.log.Debug("desired revision not applied in manual mode", "revision", revision)
return
}
a.enqueue(applyRequest{revision: revision, trigger: TriggerPull})
}
// Push implements SourceHandler.
func (a *Agent) Push(revision uint64, deploymentID string) {
a.enqueue(applyRequest{revision: revision, trigger: TriggerPush, deploymentID: deploymentID})
}
// Status implements SourceHandler. It runs a health check unless an apply
// is in progress, in which case the last result is returned.
func (a *Agent) Status(ctx context.Context) AgentStatus {
if a.roleMu.TryLock() {
a.refreshStatus(ctx)
a.roleMu.Unlock()
}
a.mu.Lock()
defer a.mu.Unlock()
return AgentStatus{
Applied: a.st.Applied, LastGood: a.st.LastGood,
Health: a.health, CheckedAt: a.healthAt, ServiceVersion: a.version,
}
}
// refreshStatus runs a health check and, if stale, reads the service
// version. The caller holds roleMu.
func (a *Agent) refreshStatus(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
a.mu.Lock()
cur := a.current
needVersion := a.version == "" || time.Since(a.versionAt) > serviceVersionMaxAge
a.mu.Unlock()
h := a.role.Health(ctx, cur)
var version string
if needVersion {
v, err := a.role.ServiceVersion(ctx)
if err != nil {
a.log.Warn("cannot determine the service version", "error", err)
}
version = v
}
a.mu.Lock()
a.health, a.healthAt = h, time.Now()
if needVersion && version != "" {
a.version, a.versionAt = version, time.Now()
}
a.mu.Unlock()
}
// enqueue adds a request. A pull is dropped if an equal or newer revision
// is already queued, a push if the same deployment is already queued; a
// push replaces queued pulls up to its revision.
func (a *Agent) enqueue(r applyRequest) {
a.qmu.Lock()
if r.trigger == TriggerPull {
for _, q := range a.queue {
if q.revision >= r.revision {
a.qmu.Unlock()
return
}
}
} else {
if r.deploymentID != "" && slices.ContainsFunc(a.queue, func(q applyRequest) bool {
return q.trigger == TriggerPush && q.deploymentID == r.deploymentID
}) {
a.qmu.Unlock()
return
}
a.queue = slices.DeleteFunc(a.queue, func(q applyRequest) bool {
return q.trigger == TriggerPull && q.revision <= r.revision
})
}
a.queue = append(a.queue, r)
a.qmu.Unlock()
notify(a.wake)
}
func (a *Agent) dequeue() (applyRequest, bool) {
a.qmu.Lock()
defer a.qmu.Unlock()
if len(a.queue) == 0 {
return applyRequest{}, false
}
r := a.queue[0]
a.queue = a.queue[1:]
return r, true
}
// applyLoop processes requests one at a time.
func (a *Agent) applyLoop(ctx context.Context) {
for {
r, ok := a.dequeue()
if !ok {
select {
case <-ctx.Done():
return
case <-a.wake:
continue
}
}
if ctx.Err() != nil {
return
}
a.handle(ctx, r)
}
}
// handle decides whether a request leads to an apply.
func (a *Agent) handle(ctx context.Context, r applyRequest) {
log := a.log.With("revision", r.revision, "trigger", r.trigger, "deployment_id", r.deploymentID)
if r.trigger == TriggerPush && a.skipHandled(log, r) {
return
}
st := a.state()
switch {
case r.trigger == TriggerPull && r.revision <= st.Applied:
log.Debug("revision already applied or older", "applied", st.Applied)
return
case r.trigger == TriggerPull && r.revision <= st.LastUnsuccessful:
log.Debug("revision failed before; waiting for a newer revision or an explicit push",
"last_unsuccessful", st.LastUnsuccessful)
return
case r.revision == 0 || r.revision < st.Applied:
now := time.Now()
log.Warn("refusing to apply a revision older than the applied one", "applied", st.Applied)
a.finish(log, DeploymentReport{
DeploymentID: r.deploymentID, Revision: r.revision, Trigger: r.trigger, Outcome: OutcomeRejected,
Message: fmt.Sprintf("revision %d is older than the applied revision %d", r.revision, st.Applied),
ActiveRevision: st.Applied, StartedAt: now, FinishedAt: now,
}, nil)
return
}
a.apply(ctx, r, st, log)
}
// skipHandled reports whether a push belongs to a deployment that was
// handled before, which the server resends after a reconnect or announces
// while polling as long as it has no report. The revision is not applied
// again: if the report is still queued, the report loop delivers it;
// otherwise the remembered report is queued again so that the server can
// close the deployment.
func (a *Agent) skipHandled(log *slog.Logger, r applyRequest) bool {
a.mu.Lock()
prev, ok := a.st.handledDeployment(r.deploymentID)
if !ok {
a.mu.Unlock()
return false
}
queued := slices.ContainsFunc(a.st.PendingReports, func(p DeploymentReport) bool {
return p.DeploymentID == r.deploymentID
})
var err error
if !queued {
a.st.PendingReports = append(a.st.PendingReports, prev)
a.st.UpdatedAt = time.Now().UTC()
err = a.store.SaveState(a.st)
}
a.mu.Unlock()
if err != nil {
log.Error("cannot save state", "error", err)
}
log.Info("deployment already handled; not applying it again",
"outcome", prev.Outcome, "handled_revision", prev.Revision, "report_queued", queued)
notify(a.flushCh)
return true
}
// apply runs the pipeline of docs/adr/0021: fetch, verify, store, Validate,
// Activate, Health, rollback. Shutdown aborts it until Activate starts; from
// then on it is completed (bounded by the configured timeouts) so that the
// service is never left between two revisions.
func (a *Agent) apply(ctx context.Context, r applyRequest, st State, log *slog.Logger) {
started := time.Now()
report := DeploymentReport{
DeploymentID: r.deploymentID, Revision: r.revision, Trigger: r.trigger,
ActiveRevision: st.Applied, StartedAt: started,
}
log.Info("applying revision", "applied", st.Applied, "last_good", st.LastGood)
reject := func(msg string, err error) {
report.Outcome = OutcomeRejected
report.Message = fmt.Sprintf("%s: %v", msg, err)
log.Error("revision rejected", "error", report.Message)
a.finish(log, report, func(s *State) { s.LastUnsuccessful = max(s.LastUnsuccessful, r.revision) })
}
fctx, cancel := context.WithTimeout(ctx, a.cfg.ApplyTimeout)
b, err := a.source.FetchBundle(fctx, r.revision)
cancel()
if err != nil {
if ctx.Err() != nil {
return
}
log.Warn("cannot fetch bundle", "error", err)
if r.trigger == TriggerPush {
// Transport problems do not block later pulls of this revision.
report.Outcome = OutcomeRejected
report.Message = "fetch bundle: " + err.Error()
a.finish(log, report, nil)
}
return
}
minRev := st.Applied + 1
if r.trigger == TriggerPush {
minRev = st.Applied // an explicit push may re-apply the current revision
}
m, contents, err := a.verify(b, r.revision, minRev)
if err != nil {
reject("bundle verification failed", err)
return
}
rev, err := a.store.StoreRevision(m, b.Manifest, b.Signature, contents)
if err != nil {
reject("store revision", err)
return
}
if ctx.Err() != nil {
log.Info("shutdown before validation; revision not applied")
return
}
a.roleMu.Lock()
defer a.roleMu.Unlock()
rctx := context.WithoutCancel(ctx)
vctx, cancel := context.WithTimeout(rctx, a.cfg.ApplyTimeout)
err = a.role.Validate(vctx, rev)
cancel()
if err != nil {
reject("validation failed", err)
return
}
if ctx.Err() != nil {
log.Info("shutdown before activation; revision not applied")
return
}
a.mu.Lock()
prev := a.current
a.mu.Unlock()
actx, cancel := context.WithTimeout(rctx, a.cfg.ApplyTimeout)
err = a.role.Activate(actx, rev, prev)
cancel()
activated := err == nil
var reason string
if activated {
h := a.waitHealthy(rctx, &rev)
if h.State == HealthOK {
report.Outcome = OutcomeSucceeded
report.ActiveRevision = rev.Number
log.Info("revision applied")
a.finish(log, report, func(s *State) {
s.Applied, s.LastGood = rev.Number, rev.Number
a.current = &rev
a.health, a.healthAt = h, time.Now()
})
return
}
reason = "health check failed: " + describeHealth(h)
} else {
reason = "activation failed: " + err.Error()
}
log.Error("revision unhealthy, rolling back", "error", reason, "last_good", st.LastGood)
a.rollback(rctx, log, report, rev, activated, reason, st)
}
// rollback restores the last good revision after a failed activation.
func (a *Agent) rollback(ctx context.Context, log *slog.Logger, report DeploymentReport, rev Revision, activated bool, reason string, st State) {
// What is installed if the rollback cannot restore anything: the new
// revision if it was activated, otherwise (the role undid its own
// changes) the previous one.
installed := st.Applied
if activated {
installed = rev.Number
}
fail := func(msg string) {
report.Outcome = OutcomeFailed
report.Message = reason + "; " + msg
report.ActiveRevision = installed
log.Error("deployment failed", "error", report.Message)
a.finish(log, report, func(s *State) {
s.LastUnsuccessful = max(s.LastUnsuccessful, rev.Number)
if installed != s.Applied {
s.Applied = installed
a.current = &rev
}
})
}
if st.LastGood == 0 || st.LastGood == rev.Number {
fail("no last good revision to roll back to")
return
}
good, err := a.store.LoadRevision(st.LastGood)
if err != nil {
fail(fmt.Sprintf("rollback to revision %d impossible: %v", st.LastGood, err))
return
}
actx, cancel := context.WithTimeout(ctx, a.cfg.ApplyTimeout)
err = a.role.Activate(actx, good, &rev)
cancel()
if err != nil {
fail(fmt.Sprintf("rollback to revision %d failed: %v", good.Number, err))
return
}
installed = good.Number
h := a.waitHealthy(ctx, &good)
if h.State != HealthOK {
report.Outcome = OutcomeFailed
report.Message = fmt.Sprintf("%s; revision %d restored but unhealthy: %s", reason, good.Number, describeHealth(h))
} else {
report.Outcome = OutcomeRolledBack
report.Message = fmt.Sprintf("%s; rolled back to revision %d", reason, good.Number)
}
report.ActiveRevision = good.Number
log.Warn("deployment rolled back", "outcome", report.Outcome, "active_revision", good.Number)
a.finish(log, report, func(s *State) {
s.LastUnsuccessful = max(s.LastUnsuccessful, rev.Number)
s.Applied = good.Number
a.current = &good
a.health, a.healthAt = h, time.Now()
})
}
// verify checks a fetched bundle against the bundle signing key.
func (a *Agent) verify(b *Bundle, revision, minRev uint64) (*bundle.Manifest, map[string][]byte, error) {
contents := make(map[string][]byte, len(b.Artifacts))
for _, art := range b.Artifacts {
if _, dup := contents[art.Path]; dup {
return nil, nil, fmt.Errorf("duplicate artifact %s", art.Path)
}
contents[art.Path] = art.Content
}
m, err := bundle.Verify(a.bundleKey, b.Manifest, b.Signature, contents, bundle.Expect{
AgentID: a.agentID, Role: string(a.kind), MinRevision: minRev,
})
if err != nil {
return nil, nil, err
}
if m.Revision != revision {
return nil, nil, fmt.Errorf("bundle holds revision %d instead of %d", m.Revision, revision)
}
for _, f := range m.Files {
if reservedArtifact(f.Path) {
return nil, nil, fmt.Errorf("artifact path %s is reserved", f.Path)
}
}
return m, contents, nil
}
// waitHealthy calls Health every healthRetry until it reports OK or the
// health timeout has passed, and returns the last result.
func (a *Agent) waitHealthy(ctx context.Context, rev *Revision) Health {
ctx, cancel := context.WithTimeout(ctx, a.cfg.HealthTimeout)
defer cancel()
t := time.NewTimer(0)
defer t.Stop()
for {
h := a.role.Health(ctx, rev)
if h.State == HealthOK {
return h
}
t.Reset(a.healthRetry)
select {
case <-ctx.Done():
return h
case <-t.C:
}
}
}
// finish records the outcome: it updates the state (update runs under the
// state lock), queues the report, persists both and triggers the report
// delivery.
func (a *Agent) finish(log *slog.Logger, r DeploymentReport, update func(*State)) {
r.Message = truncate(r.Message, maxMessageLen)
if r.FinishedAt.IsZero() {
r.FinishedAt = time.Now()
}
a.mu.Lock()
if update != nil {
update(a.st)
}
last := r
a.st.LastResult = &last
a.st.PendingReports = append(a.st.PendingReports, r)
if r.Trigger == TriggerPush {
a.st.rememberDeployment(r)
}
a.st.UpdatedAt = time.Now().UTC()
err := a.store.SaveState(a.st)
a.mu.Unlock()
if err != nil {
log.Error("cannot save state", "error", err)
}
a.prune()
notify(a.flushCh)
}
// prune removes old revisions, keeping the applied and last good one.
func (a *Agent) prune() {
st := a.state()
if err := a.store.Prune(KeepRevisions, st.Applied, st.LastGood); err != nil {
a.log.Warn("cannot prune old revisions", "error", err)
}
}
// reportLoop delivers queued deployment reports.
func (a *Agent) reportLoop(ctx context.Context) {
t := time.NewTicker(a.reportRetry)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-a.flushCh:
case <-t.C:
}
a.flushReports(ctx)
}
}
// flushReports sends queued reports in order until one fails.
func (a *Agent) flushReports(ctx context.Context) {
for ctx.Err() == nil {
a.mu.Lock()
if len(a.st.PendingReports) == 0 {
a.mu.Unlock()
return
}
r := a.st.PendingReports[0]
a.mu.Unlock()
rctx, cancel := context.WithTimeout(ctx, reportTimeout)
err := a.source.ReportDeployment(rctx, r)
cancel()
switch {
case errors.Is(err, ErrReportRefused):
a.log.Warn("deployment report refused; dropping it", "revision", r.Revision,
"deployment_id", r.DeploymentID, "error", err)
case err != nil:
a.log.Debug("cannot deliver deployment report; will retry", "revision", r.Revision,
"deployment_id", r.DeploymentID, "error", err)
return
default:
a.log.Info("deployment reported", "revision", r.Revision, "deployment_id", r.DeploymentID, "outcome", r.Outcome)
}
a.mu.Lock()
if i := slices.IndexFunc(a.st.PendingReports, func(p DeploymentReport) bool { return reflect.DeepEqual(p, r) }); i >= 0 {
a.st.PendingReports = slices.Delete(a.st.PendingReports, i, i+1)
}
err = a.store.SaveState(a.st)
a.mu.Unlock()
if err != nil {
a.log.Error("cannot save state", "error", err)
}
}
}
// describeHealth renders a non-OK health result for reports.
func describeHealth(h Health) string {
var b strings.Builder
b.WriteString(h.State.String())
if h.Message != "" {
b.WriteString(": " + h.Message)
}
for _, c := range h.Checks {
if c.State == HealthOK {
continue
}
fmt.Fprintf(&b, "; %s: %s", c.Name, c.State)
if c.Message != "" {
b.WriteString(" (" + c.Message + ")")
}
}
return b.String()
}
// String returns the lowercase name of the state.
func (s HealthState) String() string {
switch s {
case HealthOK:
return "ok"
case HealthDegraded:
return "degraded"
case HealthFailed:
return "failed"
default:
return "unknown"
}
}
// truncate shortens s to at most n bytes without splitting a UTF-8
// sequence.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
const marker = " [truncated]"
cut := n - len(marker)
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + marker
}
// notify performs a non-blocking send on a 1-buffered channel.
func notify(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
// errRoleNotAvailable is returned by the placeholder role factory.
var errRoleNotAvailable = errors.New("role not available in this build")
+556
View File
@@ -0,0 +1,556 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
)
func TestApplySucceeded(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeSucceeded || r.Revision != 1 || r.ActiveRevision != 1 || r.Trigger != TriggerPull {
t.Fatalf("result = %+v", r)
}
if r.StartedAt.IsZero() || r.FinishedAt.Before(r.StartedAt) {
t.Errorf("timestamps = %v .. %v", r.StartedAt, r.FinishedAt)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 0 {
t.Errorf("state = %+v", st)
}
if got, want := f.role.callLog(), []string{"validate 1", "activate 1 prev none"}; !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
// Stored revision: manifest, signature and artifacts.
dir := filepath.Join(f.store.Dir(), "revisions", "1")
for _, name := range []string{"manifest.json", "manifest.sig", "ddi-suite.conf", filepath.Join("zones", "db.example.com")} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("stored revision lacks %s: %v", name, err)
}
}
// The report is persisted until it is delivered.
saved, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if len(saved.PendingReports) != 1 || saved.Applied != 1 {
t.Fatalf("saved state = %+v", saved)
}
f.agent.flushReports(context.Background())
if rep := f.src.reported(); len(rep) != 1 || rep[0].Outcome != OutcomeSucceeded {
t.Fatalf("reports = %+v", rep)
}
if saved, _ := f.store.LoadState(); len(saved.PendingReports) != 0 {
t.Errorf("pending reports after delivery = %d", len(saved.PendingReports))
}
// Next revision: Activate gets the previous one.
f.addBundle(t, 2)
f.role.resetCalls()
f.pull(2)
if got, want := f.role.callLog(), []string{"validate 2", "activate 2 prev 1"}; !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
if st := f.agent.state(); st.Applied != 2 || st.LastGood != 2 {
t.Errorf("state = %+v", st)
}
}
func TestApplyRejectedOnValidate(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.validateErr[2] = errors.New("zone example.com: bad SOA")
f.role.resetCalls()
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || r.ActiveRevision != 1 || !strings.Contains(r.Message, "bad SOA") {
t.Fatalf("result = %+v", r)
}
if got := f.role.callLog(); !slices.Equal(got, []string{"validate 2"}) {
t.Errorf("calls = %q", got)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
// No automatic retry of the same revision.
f.role.resetCalls()
f.src.fetches = 0
f.pull(2)
if len(f.role.callLog()) != 0 || f.src.fetches != 0 {
t.Errorf("rejected revision was retried: calls %q, fetches %d", f.role.callLog(), f.src.fetches)
}
// An explicit push retries it.
delete(f.role.validateErr, 2)
f.push(2, "dep-7")
r = f.lastResult(t)
if r.Outcome != OutcomeSucceeded || r.DeploymentID != "dep-7" || r.Trigger != TriggerPush {
t.Fatalf("push result = %+v", r)
}
// A newer revision is pulled again.
f.addBundle(t, 3)
f.pull(3)
if st := f.agent.state(); st.Applied != 3 {
t.Errorf("state = %+v", st)
}
}
func TestRollbackOnActivateError(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.activateErr[2] = errors.New("rndc reconfig failed")
f.role.resetCalls()
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRolledBack || r.ActiveRevision != 1 || !strings.Contains(r.Message, "rndc reconfig failed") {
t.Fatalf("result = %+v", r)
}
want := []string{"validate 2", "activate 2 prev 1", "activate 1 prev 2"}
if got := f.role.callLog(); !slices.Equal(got, want) {
t.Errorf("calls = %q, want %q", got, want)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
// Not retried by a later poll.
f.role.resetCalls()
f.pull(2)
if len(f.role.callLog()) != 0 {
t.Errorf("rolled back revision retried: %q", f.role.callLog())
}
}
func TestRollbackOnHealthFailure(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[2] = true
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeRolledBack || r.ActiveRevision != 1 {
t.Fatalf("result = %+v", r)
}
if !strings.Contains(r.Message, "serial mismatch") || !strings.Contains(r.Message, "rolled back to revision 1") {
t.Errorf("message = %q", r.Message)
}
if n := f.role.healthCalls[2]; n < 2 {
t.Errorf("health of revision 2 checked %d times, want retries", n)
}
if f.role.active != 1 {
t.Errorf("active = %d", f.role.active)
}
if st := f.agent.state(); st.Applied != 1 || st.LastGood != 1 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWithoutLastGood(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.role.unhealthy[1] = true
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 1 || !strings.Contains(r.Message, "no last good revision") {
t.Fatalf("result = %+v", r)
}
st := f.agent.state()
if st.Applied != 1 || st.LastGood != 0 || st.LastUnsuccessful != 1 {
t.Errorf("state = %+v", st)
}
// Activation error without last good: the previous state stays.
g := newFixture(t, KindDNS)
g.addBundle(t, 1)
g.role.activateErr[1] = errors.New("boom")
g.pull(1)
if r := g.lastResult(t); r.Outcome != OutcomeFailed || r.ActiveRevision != 0 {
t.Fatalf("result = %+v", r)
}
if st := g.agent.state(); st.Applied != 0 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWhenRollbackFails(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[2] = true
f.role.activateErr[1] = errors.New("restore failed")
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 2 || !strings.Contains(r.Message, "rollback to revision 1 failed: restore failed") {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 2 || st.LastGood != 1 {
t.Errorf("state = %+v", st)
}
}
func TestFailedWhenRollbackUnhealthy(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.unhealthy[1] = true
f.role.unhealthy[2] = true
f.pull(2)
r := f.lastResult(t)
if r.Outcome != OutcomeFailed || r.ActiveRevision != 1 || !strings.Contains(r.Message, "restored but unhealthy") {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 1 || st.LastGood != 1 || st.LastUnsuccessful != 2 {
t.Errorf("state = %+v", st)
}
}
func TestReplayProtection(t *testing.T) {
f := newFixture(t, KindDNS)
for rev := uint64(1); rev <= 3; rev++ {
f.addBundle(t, rev)
}
f.pull(3)
if st := f.agent.state(); st.Applied != 3 {
t.Fatalf("state = %+v", st)
}
// Older revisions: a pull is ignored, a push is rejected.
f.role.resetCalls()
f.pull(2)
if len(f.role.callLog()) != 0 || len(f.agent.state().PendingReports) != 0 {
t.Errorf("older pull was processed")
}
f.push(2, "dep-old")
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || r.Revision != 2 || !strings.Contains(r.Message, "older") || r.ActiveRevision != 3 {
t.Fatalf("result = %+v", r)
}
if len(f.role.callLog()) != 0 {
t.Errorf("role called for an older revision: %q", f.role.callLog())
}
// The server answers revision 4 with the (validly signed) bundle of 2.
f.src.bundles[4] = f.src.bundles[2]
f.pull(4)
if r := f.lastResult(t); r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "older than 4") {
t.Fatalf("result = %+v", r)
}
// An explicit push of the applied revision re-applies it.
f.push(3, "dep-again")
if r := f.lastResult(t); r.Outcome != OutcomeSucceeded || r.Revision != 3 {
t.Fatalf("result = %+v", r)
}
if st := f.agent.state(); st.Applied != 3 {
t.Errorf("state = %+v", st)
}
}
func TestRejectedBundles(t *testing.T) {
cases := map[string]func(f *fixture) *Bundle{
"bad signature": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Signature[0] ^= 1
return b
},
"other agent": func(f *fixture) *Bundle {
return signBundle(t, f.signer, "11111111-2222-4333-8444-555555555555", KindDNS, 1)
},
"other role": func(f *fixture) *Bundle { return signBundle(t, f.signer, testAgentID, KindDHCP, 1) },
"other key": func(*fixture) *Bundle {
_, other := newSigningKey(t)
return signBundle(t, other, testAgentID, KindDNS, 1)
},
"tampered content": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Artifacts[0].Content = []byte("evil")
return b
},
"duplicate artifact": func(f *fixture) *Bundle {
b := signBundle(t, f.signer, testAgentID, KindDNS, 1)
b.Artifacts = append(b.Artifacts, b.Artifacts[0])
return b
},
}
for name, mk := range cases {
t.Run(name, func(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.bundles[1] = mk(f)
f.pull(1)
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "verification failed") {
t.Fatalf("result = %+v", r)
}
if len(f.role.callLog()) != 0 {
t.Errorf("role called: %q", f.role.callLog())
}
if revs, _ := f.store.ListRevisions(); len(revs) != 0 {
t.Errorf("rejected bundle stored: %v", revs)
}
})
}
}
func TestFetchError(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.fetchErr = errors.New("unavailable")
f.pull(1)
if st := f.agent.state(); st.LastResult != nil || st.LastUnsuccessful != 0 {
t.Fatalf("pull fetch error recorded: %+v", st)
}
f.push(1, "dep-1")
r := f.lastResult(t)
if r.Outcome != OutcomeRejected || !strings.Contains(r.Message, "fetch bundle: unavailable") {
t.Fatalf("result = %+v", r)
}
// A transport problem does not block a later pull.
f.src.fetchErr = nil
f.addBundle(t, 1)
f.pull(1)
if st := f.agent.state(); st.Applied != 1 {
t.Errorf("state = %+v", st)
}
}
func TestShutdownBeforeValidation(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
ctx, cancel := context.WithCancel(context.Background())
f.src.onFetch = cancel
f.agent.handle(ctx, applyRequest{revision: 1, trigger: TriggerPull})
if len(f.role.callLog()) != 0 {
t.Errorf("role called after shutdown: %q", f.role.callLog())
}
if st := f.agent.state(); st.LastResult != nil || st.Applied != 0 {
t.Errorf("state = %+v", st)
}
}
func TestReportQueueWhileServerDown(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.setReportErr(errors.New("server down"))
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.role.validateErr[2] = errors.New("bad")
f.pull(2)
f.agent.flushReports(context.Background())
if n := len(f.src.reported()); n != 0 {
t.Fatalf("%d reports delivered while down", n)
}
// After a restart the queue is still there and is sent in order.
g := newFixtureWithStore(t, KindDNS, f.store, f.signer)
if st, _ := g.store.LoadState(); len(st.PendingReports) != 2 {
t.Fatalf("pending reports after restart = %d", len(st.PendingReports))
}
g.agent.flushReports(context.Background())
rep := g.src.reported()
if len(rep) != 2 || rep[0].Revision != 1 || rep[1].Revision != 2 || rep[1].Outcome != OutcomeRejected {
t.Fatalf("reports = %+v", rep)
}
if st, _ := g.store.LoadState(); len(st.PendingReports) != 0 {
t.Errorf("pending after delivery = %d", len(st.PendingReports))
}
}
func TestPendingReportsBounded(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.setReportErr(errors.New("down"))
for i := 0; i < maxPendingReports+5; i++ {
f.push(1, "dep-"+strconv.Itoa(i)) // fetch fails: rejected report each time
}
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if len(st.PendingReports) != maxPendingReports {
t.Errorf("pending = %d, want %d", len(st.PendingReports), maxPendingReports)
}
}
func TestPruneKeepsAppliedAndLastGood(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
for rev := uint64(2); rev <= 14; rev++ {
f.addBundle(t, rev)
f.role.validateErr[rev] = errors.New("bad")
f.push(rev, "")
}
revs, err := f.store.ListRevisions()
if err != nil {
t.Fatal(err)
}
if !slices.Contains(revs, 1) || len(revs) != KeepRevisions+1 || revs[len(revs)-1] != 14 {
t.Errorf("revisions = %v", revs)
}
}
func TestEnqueue(t *testing.T) {
f := newFixture(t, KindDNS)
a := f.agent
a.enqueue(applyRequest{revision: 5, trigger: TriggerPull})
a.enqueue(applyRequest{revision: 4, trigger: TriggerPull}) // dropped: 5 queued
a.enqueue(applyRequest{revision: 5, trigger: TriggerPull}) // dropped: duplicate
a.enqueue(applyRequest{revision: 6, trigger: TriggerPush, deploymentID: "d"})
a.enqueue(applyRequest{revision: 7, trigger: TriggerPull})
var got []applyRequest
for {
r, ok := a.dequeue()
if !ok {
break
}
got = append(got, r)
}
want := []applyRequest{{revision: 6, trigger: TriggerPush, deploymentID: "d"}, {revision: 7, trigger: TriggerPull}}
if !slices.Equal(got, want) {
t.Errorf("queue = %+v, want %+v", got, want)
}
}
func TestDesiredAndPush(t *testing.T) {
f := newFixture(t, KindDNS)
f.agent.Desired(0, true)
f.agent.Desired(3, false) // manual mode
if _, ok := f.agent.dequeue(); ok {
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")
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)
}
}
func TestRunAppliesAndReports(t *testing.T) {
f := newFixture(t, KindDHCP)
f.addBundle(t, 1)
f.role.setLeases([]Lease{{IP: mustAddr("10.0.0.5"), MAC: "aa:bb:cc:dd:ee:ff", State: LeaseActive}})
f.src.run = func(ctx context.Context, h SourceHandler) error {
h.Connected()
h.Desired(1, true)
<-ctx.Done()
return ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- f.agent.Run(ctx) }()
waitFor(t, "deployment report", func() bool { return len(f.src.reported()) == 1 })
waitFor(t, "lease snapshot", func() bool { return len(f.src.leaseBatches()) > 0 })
if b := f.src.leaseBatches()[0]; !b.FullSync || len(b.Leases) != 1 {
t.Errorf("first lease batch = %+v", b)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Run = %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return")
}
}
func TestRunSourceError(t *testing.T) {
f := newFixture(t, KindDNS)
f.src.run = func(context.Context, SourceHandler) error { return errors.New("broken source") }
if err := f.agent.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "broken source") {
t.Errorf("Run = %v", err)
}
}
func TestStatus(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
st := f.agent.Status(context.Background())
if st.Applied != 1 || st.LastGood != 1 || st.Health.State != HealthOK || st.ServiceVersion != "Fake 1.0" || st.CheckedAt.IsZero() {
t.Errorf("status = %+v", st)
}
// While the role is busy the cached result is returned.
f.role.unhealthy[1] = true
f.agent.roleMu.Lock()
cached := f.agent.Status(context.Background())
f.agent.roleMu.Unlock()
if cached.Health.State != HealthOK {
t.Errorf("cached status = %+v", cached)
}
if fresh := f.agent.Status(context.Background()); fresh.Health.State != HealthFailed {
t.Errorf("fresh status = %+v", fresh)
}
}
func TestNewAgentLoadsAppliedRevision(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
g := newFixtureWithStore(t, KindDNS, f.store, f.signer)
if g.agent.current == nil || g.agent.current.Number != 1 || len(g.agent.current.Files) != 2 {
t.Fatalf("current = %+v", g.agent.current)
}
// Role mismatch.
id := &Identity{AgentID: testAgentID}
if _, err := NewAgent(KindDHCP, testConfig(f.store.Dir()), newFakeRole(KindDNS), f.store, id, f.src, testLogger()); err == nil {
t.Error("role mismatch accepted")
}
}
func TestDescribeHealthAndTruncate(t *testing.T) {
h := Health{State: HealthDegraded, Message: "slow", Checks: []HealthCheck{
{Name: "a", State: HealthOK}, {Name: "b", State: HealthFailed, Message: "down"}, {Name: "c", State: HealthUnknown},
}}
if got, want := describeHealth(h), "degraded: slow; b: failed (down); c: unknown"; got != want {
t.Errorf("describeHealth = %q, want %q", got, want)
}
if got := truncate("short", 10); got != "short" {
t.Errorf("truncate = %q", got)
}
long := strings.Repeat("ä", 100) // 200 bytes
got := truncate(long, 50)
if len(got) > 50 || !strings.HasSuffix(got, "[truncated]") || !strings.HasPrefix(got, "ää") {
t.Errorf("truncate = %q (%d bytes)", got, len(got))
}
for _, r := range strings.TrimSuffix(got, " [truncated]") {
if r != 'ä' {
t.Fatalf("truncate split a rune: %q", got)
}
}
}
+318 -21
View File
@@ -1,33 +1,39 @@
// SPDX-License-Identifier: Apache-2.0
// Package core is the role-independent part of the agents: command line,
// enrollment, configuration source, verification and apply pipeline
// (see docs/ARCHITECTURE.md, section 4). M0 provides the command line frame;
// enrollment and the apply pipeline follow in M3.
// configuration, local state, enrollment, the connection to the management
// server and the apply pipeline (see docs/ARCHITECTURE.md, section 4, and
// docs/adr/0021-agent-strecke-m3.md). The roles (BIND, Kea) implement Role.
package core
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/internal/logging"
)
// Role identifies an agent role.
type Role string
// Supported roles.
const (
RoleDNS Role = "dns"
RoleDHCP Role = "dhcp"
)
// RunCLI dispatches the agent command line and returns the process exit code.
func RunCLI(binary string, role Role, args []string, stdout, stderr io.Writer) int {
// Main dispatches the agent command line and returns the process exit code.
// factory creates the role for the run command.
func Main(binary string, kind Kind, factory RoleFactory, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr, binary, role)
usage(stderr, binary, kind)
return 2
}
c := &cli{binary: binary, kind: kind, factory: factory, stdout: stdout, stderr: stderr}
switch args[0] {
case "version", "--version":
if err := buildinfo.Print(stdout, binary); err != nil {
@@ -35,22 +41,313 @@ func RunCLI(binary string, role Role, args []string, stdout, stderr io.Writer) i
}
return 0
case "help", "-h", "--help":
usage(stdout, binary, role)
usage(stdout, binary, kind)
return 0
case "enroll":
return c.enroll(args[1:])
case "run":
return c.run(args[1:])
case "status":
return c.status(args[1:])
default:
_, _ = fmt.Fprintf(stderr, "%s: unknown command %q\n\n", binary, args[0])
usage(stderr, binary, role)
usage(stderr, binary, kind)
return 2
}
}
func usage(w io.Writer, binary string, role Role) {
_, _ = fmt.Fprintf(w, `Usage: %s <command>
func usage(w io.Writer, binary string, kind Kind) {
_, _ = fmt.Fprintf(w, `Usage: %[1]s <command> [flags]
Agent role: %s
Agent role: %[2]s
Commands:
enroll register with the management server
--server <host:port> --token <token> --ca-hash sha256:<hex>
[--server-name <name>] [--force]
run run the agent (until SIGINT/SIGTERM)
status show identity, revisions and the last result
version print version information
help show this help
`, binary, role)
Common flags:
--config <file> configuration file (default %[3]s)
`, binary, kind, DefaultConfigPath(kind))
}
type cli struct {
binary string
kind Kind
factory RoleFactory
stdout io.Writer
stderr io.Writer
// systemctl runs systemctl (tests replace it).
systemctl func(args ...string) ([]byte, error)
}
func (c *cli) flags(name string) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(c.binary+" "+name, flag.ContinueOnError)
fs.SetOutput(c.stderr)
cfg := fs.String("config", DefaultConfigPath(c.kind), "configuration file")
return fs, cfg
}
func (c *cli) fail(err error) int {
_, _ = fmt.Fprintf(c.stderr, "%s: %v\n", c.binary, err)
return 1
}
// parse parses flags and rejects positional arguments.
func parse(fs *flag.FlagSet, args []string) int {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(fs.Output(), "unexpected argument %q\n", fs.Arg(0))
return 2
}
return -1
}
func (c *cli) enroll(args []string) int {
fs, cfgPath := c.flags("enroll")
var opts EnrollOptions
fs.StringVar(&opts.Server, "server", "", "agent API address of the management server (host:port)")
fs.StringVar(&opts.Token, "token", "", "one-time enrollment token")
fs.StringVar(&opts.CAHash, "ca-hash", "", "fingerprint of the agent CA (sha256:<hex>)")
fs.StringVar(&opts.ServerName, "server-name", "", "TLS server name (default: host of --server)")
fs.BoolVar(&opts.Force, "force", false, "replace an existing identity")
noStart := fs.Bool("no-start", false, "do not enable and start the systemd service afterwards")
if code := parse(fs, args); code >= 0 {
return code
}
if opts.Server == "" || opts.Token == "" || opts.CAHash == "" {
_, _ = fmt.Fprintln(c.stderr, "enroll needs --server, --token and --ca-hash")
return 2
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, release, err := openLocked(cfg)
if err != nil {
return c.fail(err)
}
unlock := sync.OnceFunc(release) // released early before starting the service
defer unlock()
opts.Kind = c.kind
opts.Hostname, _ = os.Hostname()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
id, err := Enroll(ctx, store, opts)
if err != nil {
return c.fail(err)
}
_, _ = fmt.Fprintf(c.stdout, "Enrolled as agent %s (%s) with %s.\nIdentity stored in %s.\n",
id.AgentID, c.kind, id.Server, store.Dir())
if uid, err := handOverStateDir(store.Dir()); err != nil {
_, _ = fmt.Fprintf(c.stderr, "warning: could not hand %s over to its owner: %v\n", store.Dir(), err)
} else if uid >= 0 {
_, _ = fmt.Fprintf(c.stdout, "Files in %s now belong to the owner of the directory (UID %d).\n", store.Dir(), uid)
}
// The service takes the state lock itself.
unlock()
c.startService(*noStart)
return 0
}
// startService enables and starts the agent's systemd service after an
// enrollment as root on a systemd host, so that a manual enrollment ends
// with a running agent (the installer leaves the unit waiting for the
// identity). Otherwise, or with --no-start, it says what to run.
func (c *cli) startService(noStart bool) {
unit := c.binary + ".service"
hint := func() {
_, _ = fmt.Fprintf(c.stdout, "Start the agent with: sudo systemctl enable --now %s\n", unit)
}
if noStart || os.Geteuid() != 0 || !systemdRunning() {
hint()
return
}
run := c.systemctl
if run == nil {
run = func(args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() //nolint:gosec // fixed program; arguments are "enable --now <own unit>"
}
}
if out, err := run("enable", "--now", unit); err != nil {
_, _ = fmt.Fprintf(c.stderr, "warning: systemctl enable --now %s failed: %v\n%s", unit, err, out)
hint()
return
}
_, _ = fmt.Fprintf(c.stdout, "Service %s enabled and started.\n", unit)
}
func openLocked(cfg *Config) (*Store, func(), error) {
store, err := OpenStore(cfg.StateDir)
if err != nil {
return nil, nil, err
}
unlock, err := store.Lock()
if err != nil {
return nil, nil, err
}
return store, unlock, nil
}
func (c *cli) run(args []string) int {
fs, cfgPath := c.flags("run")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
log, err := logging.New(c.stderr, cfg.Log)
if err != nil {
return c.fail(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := runAgent(ctx, c.kind, c.factory, cfg, log); err != nil {
log.Error("agent stopped", "error", err)
return 1
}
return 0
}
// runAgent runs the agent until ctx is canceled.
func runAgent(ctx context.Context, kind Kind, factory RoleFactory, cfg *Config, log *slog.Logger) error {
if factory == nil {
return errRoleNotAvailable
}
store, unlock, err := openLocked(cfg)
if err != nil {
return err
}
defer unlock()
id, err := store.LoadIdentity()
if err != nil {
return err
}
if id.Role != kind {
return fmt.Errorf("identity in %s belongs to a %s agent", store.Dir(), id.Role)
}
role, err := factory(cfg.DecodeRole)
if err != nil {
return fmt.Errorf("%s role: %w", kind, err)
}
log = log.With("agent_id", id.AgentID, "role", string(kind))
if ls, ok := role.(LogSetter); ok {
ls.SetLogger(log)
}
hostname, _ := os.Hostname()
src := NewServerSource(store, id, hostname, log)
agent, err := NewAgent(kind, cfg, role, store, id, src, log)
if err != nil {
return err
}
log.Info("agent started", "version", buildinfo.Get().Version, "server", id.Server, "state_dir", store.Dir())
err = agent.Run(ctx)
log.Info("agent stopped; the managed service keeps its configuration")
return err
}
func (c *cli) status(args []string) int {
fs, cfgPath := c.flags("status")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, err := OpenStore(cfg.StateDir)
if err != nil {
return c.fail(err)
}
if err := writeStatus(c.stdout, store); err != nil {
return c.fail(err)
}
return 0
}
// writeStatus prints identity, revisions and the last result.
func writeStatus(w io.Writer, store *Store) error {
var b strings.Builder
line := func(k, format string, a ...any) { fmt.Fprintf(&b, "%-20s %s\n", k+":", fmt.Sprintf(format, a...)) }
line("State directory", "%s", store.Dir())
id, err := store.LoadIdentity()
switch {
case errors.Is(err, ErrNotEnrolled):
line("Identity", "not enrolled")
case err != nil:
return err
default:
leaf := id.Certificate.Leaf
line("Agent ID", "%s", id.AgentID)
line("Role", "%s", id.Role)
line("Server", "%s (TLS name %s)", id.Server, id.ServerName)
line("Enrolled", "%s", id.EnrolledAt.Format(time.RFC3339))
line("Certificate", "valid until %s, renewal from %s", leaf.NotAfter.UTC().Format(time.RFC3339),
renewalDue(leaf).UTC().Format(time.RFC3339))
line("CA fingerprint", "%s", agentpki.Fingerprint(id.CA))
}
st, err := store.LoadState()
if err != nil {
return err
}
revs, err := store.ListRevisions()
if err != nil {
return err
}
line("Applied revision", "%s", revOrNone(st.Applied))
line("Last good revision", "%s", revOrNone(st.LastGood))
if st.LastUnsuccessful != 0 {
line("Last unsuccessful", "%d (retried only by a newer revision or an explicit push)", st.LastUnsuccessful)
}
nums := make([]string, len(revs))
for i, n := range revs {
nums[i] = fmt.Sprint(n)
}
line("Stored revisions", "%s", orNone(strings.Join(nums, ", ")))
if r := st.LastResult; r != nil {
dep := ""
if r.DeploymentID != "" {
dep = ", deployment " + r.DeploymentID
}
line("Last result", "revision %d %s at %s (%s%s)", r.Revision, r.Outcome,
r.FinishedAt.UTC().Format(time.RFC3339), r.Trigger, dep)
if r.Message != "" {
line("Message", "%s", r.Message)
}
} else {
line("Last result", "none")
}
line("Pending reports", "%d", len(st.PendingReports))
if st.LeaseSequence != 0 {
line("Lease sequence", "%d", st.LeaseSequence)
}
_, err = io.WriteString(w, b.String())
return err
}
func revOrNone(n uint64) string {
if n == 0 {
return "none"
}
return fmt.Sprint(n)
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
+169 -3
View File
@@ -4,11 +4,16 @@ package core
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRunCLI(t *testing.T) {
func TestMainBasics(t *testing.T) {
tests := []struct {
name string
args []string
@@ -20,14 +25,19 @@ func TestRunCLI(t *testing.T) {
{name: "version", args: []string{"version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "--version", args: []string{"--version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "help", args: []string{"help"}, wantCode: 0, wantStdout: "Agent role: dns"},
{name: "help lists enroll", args: []string{"--help"}, wantCode: 0, wantStdout: "--ca-hash sha256:<hex>"},
{name: "unknown", args: []string{"explode"}, wantCode: 2, wantStderr: `unknown command "explode"`},
{name: "enroll without flags", args: []string{"enroll"}, wantCode: 2, wantStderr: "enroll needs --server"},
{name: "bad flag", args: []string{"run", "--bogus"}, wantCode: 2, wantStderr: "flag provided but not defined"},
{name: "extra argument", args: []string{"status", "extra"}, wantCode: 2, wantStderr: `unexpected argument "extra"`},
{name: "flag help", args: []string{"status", "-h"}, wantCode: 0, wantStderr: "-config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out, errOut bytes.Buffer
code := RunCLI("ddi-agent-test", RoleDNS, tt.args, &out, &errOut)
code := Main("ddi-agent-test", KindDNS, nil, tt.args, &out, &errOut)
if code != tt.wantCode {
t.Errorf("code = %d, want %d", code, tt.wantCode)
t.Errorf("code = %d, want %d (stderr %q)", code, tt.wantCode, errOut.String())
}
if tt.wantStdout != "" && !strings.Contains(out.String(), tt.wantStdout) {
t.Errorf("stdout %q does not contain %q", out.String(), tt.wantStdout)
@@ -38,3 +48,159 @@ func TestRunCLI(t *testing.T) {
})
}
}
// writeConfig writes a configuration with its own state directory.
func writeConfig(t *testing.T, extra string) (cfgPath, stateDir string) {
t.Helper()
dir := t.TempDir()
stateDir = filepath.Join(dir, "state")
cfgPath = filepath.Join(dir, "agent.yaml")
data := "state_dir: " + filepath.ToSlash(stateDir) + "\nlog:\n format: text\n" + extra
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return cfgPath, stateDir
}
func TestMainEnrollStatusRun(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
cfgPath, stateDir := writeConfig(t, "dns:\n zone_dir: /var/lib/bind/x\n")
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status before enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), "not enrolled") {
t.Errorf("status = %q", out.String())
}
// run without identity fails.
out.Reset()
errOut.Reset()
factory := func(decode func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if code := Main("ddi-agent-dns", KindDNS, factory, []string{"run", "--config", cfgPath}, &out, &errOut); code != 1 {
t.Fatalf("run before enroll = %d", code)
}
if !strings.Contains(errOut.String(), "not enrolled") {
t.Errorf("stderr = %q", errOut.String())
}
args := []string{"enroll", "--config", cfgPath, "--server", addr, "--token", srv.token,
"--ca-hash", srv.fingerprint(), "--server-name", "localhost"}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 0 {
t.Fatalf("enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), srv.agentID) {
t.Errorf("enroll output = %q", out.String())
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "--force") {
t.Errorf("second enroll = %d: %s", code, errOut.String())
}
// Run the agent until revision 1 is reported.
srv.with(func(f *fakeServer) { f.desired = 1 })
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
var gotSection struct {
ZoneDir string `yaml:"zone_dir"`
}
role := newFakeRole(KindDNS)
factory = func(decode func(any) error) (Role, error) {
if err := decode(&gotSection); err != nil {
return nil, err
}
return role, nil
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runAgent(ctx, KindDNS, factory, cfg, testLogger()) }()
waitFor(t, "deployment report", func() bool { return len(serverReports(srv)) == 1 })
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runAgent = %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("runAgent did not return")
}
if gotSection.ZoneDir != "/var/lib/bind/x" {
t.Errorf("role section = %+v", gotSection)
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status = %d: %s", code, errOut.String())
}
for _, want := range []string{srv.agentID, "Applied revision:", "revision 1 succeeded", "Stored revisions: 1", filepath.ToSlash(stateDir)} {
if !strings.Contains(out.String(), want) {
t.Errorf("status lacks %q:\n%s", want, out.String())
}
}
}
func TestRunAgentErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "")
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
if err := runAgent(context.Background(), KindDNS, nil, cfg, testLogger()); !errors.Is(err, errRoleNotAvailable) {
t.Errorf("nil factory: %v", err)
}
// Enrolled as a DHCP agent, run as DNS agent.
store, err := OpenStore(cfg.StateDir)
if err != nil {
t.Fatal(err)
}
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
id.Role = KindDHCP
if err := store.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
factory := func(func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if err := runAgent(context.Background(), KindDNS, factory, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "dhcp agent") {
t.Errorf("role mismatch: %v", err)
}
if err := runAgent(context.Background(), KindDHCP, func(func(any) error) (Role, error) {
return nil, errors.New("kea missing")
}, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "kea missing") {
t.Errorf("factory error: %v", err)
}
}
func TestMainConfigErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "bogus: 1\n")
for _, args := range [][]string{
{"run", "--config", cfgPath},
{"status", "--config", cfgPath},
{"enroll", "--config", cfgPath, "--server", "x:1", "--token", "a.b", "--ca-hash", "sha256:00"},
} {
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "bogus") {
t.Errorf("%v: code %d, stderr %q", args[0], code, errOut.String())
}
}
}
// TestStartServiceHint: with --no-start (or without root/systemd) enroll
// only says how to start the service and never calls systemctl.
func TestStartServiceHint(t *testing.T) {
var out bytes.Buffer
called := false
c := &cli{binary: "ddi-agent-dns", stdout: &out, stderr: &out,
systemctl: func(...string) ([]byte, error) { called = true; return nil, nil }}
c.startService(true)
if called || !strings.Contains(out.String(), "systemctl enable --now ddi-agent-dns.service") {
t.Errorf("called=%v output %q", called, out.String())
}
}
+178
View File
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/internal/logging"
)
// Configuration defaults (docs/adr/0021-agent-strecke-m3.md).
const (
DefaultApplyTimeout = 5 * time.Minute
DefaultHealthTimeout = 30 * time.Second
DefaultLeaseInterval = 30 * time.Second
DefaultLeaseFullSync = time.Hour
)
// Config is the agent configuration file (<ConfigDir>/agent-<role>.yaml).
// Server address, server name and the identity are not configured here; the
// enroll command stores them in the state directory.
type Config struct {
// StateDir holds identity, revisions and state.json (ADR 0011).
StateDir string `yaml:"state_dir"`
// Log configures the structured logger (written to stderr).
Log logging.Options `yaml:"log"`
// ApplyTimeout bounds each role call (Validate, Activate) of an apply.
ApplyTimeout time.Duration `yaml:"apply_timeout"`
// HealthTimeout is how long the health check of a freshly activated
// revision may take until it reports OK.
HealthTimeout time.Duration `yaml:"health_timeout"`
// LeaseInterval is the lease polling interval (DHCP).
LeaseInterval time.Duration `yaml:"lease_interval"`
// LeaseFullSync is the interval of full lease snapshots.
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
// role holds the undecoded section of the agent's role.
role yaml.Node
}
// fileConfig mirrors Config for strict decoding, including the role
// sections that are handed to the role factory undecoded.
type fileConfig struct {
StateDir string `yaml:"state_dir"`
Log logging.Options `yaml:"log"`
ApplyTimeout time.Duration `yaml:"apply_timeout"`
HealthTimeout time.Duration `yaml:"health_timeout"`
LeaseInterval time.Duration `yaml:"lease_interval"`
LeaseFullSync time.Duration `yaml:"lease_full_sync"`
DNS yaml.Node `yaml:"dns"`
DHCP yaml.Node `yaml:"dhcp"`
}
// DefaultConfigPath returns <brand.ConfigDir>/agent-<role>.yaml.
func DefaultConfigPath(kind Kind) string {
return filepath.Join(brand.ConfigDir, "agent-"+string(kind)+".yaml")
}
// DefaultStateDir returns <brand.StateDir>/agent-<role>.
func DefaultStateDir(kind Kind) string {
return filepath.Join(brand.StateDir, "agent-"+string(kind))
}
// LoadConfig reads the configuration file of the role. A missing file yields
// the defaults.
func LoadConfig(path string, kind Kind) (*Config, error) {
data, err := os.ReadFile(path) //nolint:gosec // path is given by the operator
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("read configuration: %w", err)
}
cfg, err := ParseConfig(data, kind)
if err != nil {
return nil, fmt.Errorf("configuration %s: %w", path, err)
}
return cfg, nil
}
// ParseConfig decodes a configuration (strictly: unknown keys are errors),
// applies defaults and validates it.
func ParseConfig(data []byte, kind Kind) (*Config, error) {
var fc fileConfig
if len(bytes.TrimSpace(data)) > 0 {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&fc); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
}
cfg := &Config{
StateDir: fc.StateDir, Log: fc.Log, ApplyTimeout: fc.ApplyTimeout,
HealthTimeout: fc.HealthTimeout, LeaseInterval: fc.LeaseInterval, LeaseFullSync: fc.LeaseFullSync,
}
switch kind {
case KindDNS:
cfg.role = fc.DNS
if !isEmptyNode(&fc.DHCP) {
return nil, errors.New("section dhcp is not used by the DNS agent")
}
case KindDHCP:
cfg.role = fc.DHCP
if !isEmptyNode(&fc.DNS) {
return nil, errors.New("section dns is not used by the DHCP agent")
}
default:
return nil, fmt.Errorf("unknown role %q", kind)
}
if cfg.StateDir == "" {
cfg.StateDir = DefaultStateDir(kind)
}
setDefault(&cfg.ApplyTimeout, DefaultApplyTimeout)
setDefault(&cfg.HealthTimeout, DefaultHealthTimeout)
setDefault(&cfg.LeaseInterval, DefaultLeaseInterval)
setDefault(&cfg.LeaseFullSync, DefaultLeaseFullSync)
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 {
*d = def
}
}
func (c *Config) validate() error {
for _, d := range []struct {
name string
v time.Duration
}{
{"apply_timeout", c.ApplyTimeout}, {"health_timeout", c.HealthTimeout},
{"lease_interval", c.LeaseInterval}, {"lease_full_sync", c.LeaseFullSync},
} {
if d.v < time.Second {
return fmt.Errorf("%s must be at least 1s", d.name)
}
}
if c.LeaseFullSync < c.LeaseInterval {
return errors.New("lease_full_sync must not be shorter than lease_interval")
}
if _, err := logging.New(io.Discard, c.Log); err != nil {
return err
}
return nil
}
// isEmptyNode reports whether a section is absent or null.
func isEmptyNode(n *yaml.Node) bool {
return n.Kind == 0 || (n.Kind == yaml.ScalarNode && n.Tag == "!!null")
}
// DecodeRole decodes the role's section into v (strictly). An absent section
// leaves v unchanged so that roles keep their defaults.
func (c *Config) DecodeRole(v any) error {
if isEmptyNode(&c.role) {
return nil
}
data, err := yaml.Marshal(&c.role)
if err != nil {
return fmt.Errorf("encode role section: %w", err)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("role section: %w", err)
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseConfigDefaults(t *testing.T) {
for _, data := range []string{"", " \n", "# only a comment\n"} {
cfg, err := ParseConfig([]byte(data), KindDNS)
if err != nil {
t.Fatalf("%q: %v", data, err)
}
if cfg.StateDir != DefaultStateDir(KindDNS) || cfg.ApplyTimeout != DefaultApplyTimeout ||
cfg.HealthTimeout != 30*time.Second || cfg.LeaseInterval != 30*time.Second || cfg.LeaseFullSync != time.Hour {
t.Errorf("defaults = %+v", cfg)
}
var role struct{ X int }
role.X = 7
if err := cfg.DecodeRole(&role); err != nil || role.X != 7 {
t.Errorf("absent role section: %v, %+v", err, role)
}
}
if !strings.HasSuffix(filepath.ToSlash(DefaultConfigPath(KindDHCP)), "/agent-dhcp.yaml") {
t.Errorf("DefaultConfigPath = %s", DefaultConfigPath(KindDHCP))
}
}
func TestParseConfigFull(t *testing.T) {
data := `
state_dir: /tmp/agent
log:
level: debug
format: text
apply_timeout: 2m
health_timeout: 45s
lease_interval: 10s
lease_full_sync: 30m
dhcp:
config_file: /etc/kea/kea-dhcp4.conf
control_socket: /run/kea/ctrl.sock
`
cfg, err := ParseConfig([]byte(data), KindDHCP)
if err != nil {
t.Fatal(err)
}
if cfg.StateDir != "/tmp/agent" || cfg.Log.Level != "debug" || cfg.Log.Format != "text" ||
cfg.ApplyTimeout != 2*time.Minute || cfg.HealthTimeout != 45*time.Second ||
cfg.LeaseInterval != 10*time.Second || cfg.LeaseFullSync != 30*time.Minute {
t.Errorf("config = %+v", cfg)
}
var role struct {
ConfigFile string `yaml:"config_file"`
ControlSocket string `yaml:"control_socket"`
}
if err := cfg.DecodeRole(&role); err != nil {
t.Fatal(err)
}
if role.ConfigFile != "/etc/kea/kea-dhcp4.conf" || role.ControlSocket != "/run/kea/ctrl.sock" {
t.Errorf("role = %+v", role)
}
// Strict decoding of the role section.
var narrow struct {
ConfigFile string `yaml:"config_file"`
}
if err := cfg.DecodeRole(&narrow); err == nil {
t.Error("unknown role key accepted")
}
}
func TestParseConfigErrors(t *testing.T) {
cases := map[string]string{
"unknown key": "flavor: blue\n",
"other role": "dhcp:\n config_file: x\n",
"short timeout": "health_timeout: 10ms\n",
"full sync": "lease_interval: 2m\nlease_full_sync: 1m\n",
"bad level": "log:\n level: loud\n",
"bad duration": "apply_timeout: soon\n",
"not a mapping": "- a\n- b\n",
"unknown log key": "log:\n flavor: x\n",
"negative timeout": "apply_timeout: -5s\n",
}
for name, data := range cases {
if _, err := ParseConfig([]byte(data), KindDNS); err == nil {
t.Errorf("%s: accepted", name)
}
}
if _, err := ParseConfig(nil, Kind("ntp")); err == nil {
t.Error("unknown role accepted")
}
if _, err := ParseConfig([]byte("dns:\n zone_dir: x\n"), KindDHCP); err == nil {
t.Error("dns section accepted by the DHCP agent")
}
// A null section is fine.
if _, err := ParseConfig([]byte("dhcp:\n"), KindDNS); err != nil {
t.Errorf("null section: %v", err)
}
}
func TestLoadConfig(t *testing.T) {
dir := t.TempDir()
cfg, err := LoadConfig(filepath.Join(dir, "missing.yaml"), KindDNS)
if err != nil || cfg.StateDir != DefaultStateDir(KindDNS) {
t.Fatalf("missing file: %+v, %v", cfg, err)
}
path := filepath.Join(dir, "agent-dns.yaml")
if err := os.WriteFile(path, []byte("state_dir: "+filepath.ToSlash(dir)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg, err = LoadConfig(path, KindDNS)
if err != nil || cfg.StateDir != filepath.ToSlash(dir) {
t.Fatalf("file: %+v, %v", cfg, err)
}
if err := os.WriteFile(path, []byte("bogus: 1\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(path, KindDNS); err == nil || !strings.Contains(err.Error(), path) {
t.Errorf("error does not name the file: %v", err)
}
if _, err := LoadConfig(dir, KindDNS); err == nil {
t.Error("directory accepted as configuration file")
}
}
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/ed25519"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// EnrollOptions are the parameters of the enroll command.
type EnrollOptions struct {
Kind Kind
// Server is the agent API address (host:port; an https:// prefix is
// tolerated).
Server string
// Token is the one-time enrollment token "<id>.<secret>".
Token string
// CAHash is the fingerprint "sha256:<hex>" of the agent CA.
CAHash string
// ServerName is the TLS server name (default: host part of Server).
ServerName string
// Force replaces an existing identity.
Force bool
Hostname string
}
// normalizeServer validates host:port and returns the address and its host.
func normalizeServer(s string) (addr, host string, err error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "https://")
s = strings.TrimSuffix(s, "/")
host, port, err := net.SplitHostPort(s)
if err != nil || host == "" || port == "" {
return "", "", fmt.Errorf("server address %q must have the form host:port", s)
}
return s, host, nil
}
// Enroll exchanges the token for a client certificate and stores the
// identity. The server is authenticated by the CA fingerprint: the TLS chain
// it presents must contain a CA with that fingerprint which issued the
// server certificate for ServerName.
func Enroll(ctx context.Context, store *Store, opts EnrollOptions) (*Identity, error) {
addr, host, err := normalizeServer(opts.Server)
if err != nil {
return nil, err
}
if opts.Token == "" || !strings.Contains(opts.Token, ".") {
return nil, errors.New("enrollment token must have the form <id>.<secret>")
}
fingerprint, err := agentpki.ParseFingerprint(opts.CAHash)
if err != nil {
return nil, err
}
serverName := opts.ServerName
if serverName == "" {
serverName = host
}
if store.Enrolled() && !opts.Force {
return nil, errAlreadyEnrolled
}
role, err := roleToProto(opts.Kind)
if err != nil {
return nil, err
}
var (
caMu sync.Mutex
pinnedCA *x509.Certificate
)
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
ServerName: serverName,
// The server is verified against the CA fingerprint below instead
// of a system trust store.
InsecureSkipVerify: true, //nolint:gosec // verified in VerifyPeerCertificate
// No session resumption, so every handshake runs the check below.
SessionTicketsDisabled: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
ca, err := agentpki.VerifyChainWithFingerprint(rawCerts, fingerprint, serverName)
if err != nil {
return err
}
caMu.Lock()
pinnedCA = ca
caMu.Unlock()
return nil
},
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", addr, err)
}
defer func() { _ = conn.Close() }()
key, csr, err := newKeyAndCSR(opts.Hostname)
if err != nil {
return nil, err
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := agentv1.NewAgentServiceClient(conn).Enroll(rctx, &agentv1.EnrollRequest{
Token: opts.Token, CsrDer: csr, Role: role, Hostname: opts.Hostname,
AgentVersion: buildinfo.Get().Version,
})
if err != nil {
return nil, fmt.Errorf("enroll: %w", err)
}
caMu.Lock()
ca := pinnedCA
caMu.Unlock()
if ca == nil {
return nil, errors.New("enroll: server CA was not verified")
}
if chain := resp.GetCaCertificatesDer(); len(chain) > 0 {
root, err := x509.ParseCertificate(chain[len(chain)-1])
if err != nil || !root.Equal(ca) {
return nil, errors.New("enroll: CA in the response differs from the verified server CA")
}
}
if !agentpki.ValidAgentID(resp.GetAgentId()) {
return nil, fmt.Errorf("enroll: server assigned an invalid agent ID %q", resp.GetAgentId())
}
cert, err := checkIssued(resp.GetCertificateDer(), ca, resp.GetAgentId(), key)
if err != nil {
return nil, fmt.Errorf("enroll: %w", err)
}
if len(resp.GetBundleSigningPublicKey()) != ed25519.PublicKeySize {
return nil, errors.New("enroll: invalid bundle signing key")
}
id := &Identity{
AgentID: resp.GetAgentId(), Role: opts.Kind, Server: addr, ServerName: serverName,
EnrolledAt: time.Now().UTC(), CA: ca, Certificate: keyPair(key, cert),
BundleKey: ed25519.PublicKey(resp.GetBundleSigningPublicKey()),
}
if err := store.SaveIdentity(id, key, opts.Force); err != nil {
return nil, err
}
if opts.Force {
// Reports of the previous identity must not be sent with the new one.
st, err := store.LoadState()
if err == nil && len(st.PendingReports) > 0 {
st.PendingReports = nil
err = store.SaveState(st)
}
if err != nil {
return nil, err
}
}
return id, nil
}
func roleToProto(k Kind) (agentv1.Role, error) {
switch k {
case KindDNS:
return agentv1.Role_ROLE_DNS, nil
case KindDHCP:
return agentv1.Role_ROLE_DHCP, nil
default:
return agentv1.Role_ROLE_UNSPECIFIED, fmt.Errorf("unknown role %q", k)
}
}
+716
View File
@@ -0,0 +1,716 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
const testAgentID = "0b6f3c1e-5d2a-4c8e-9f10-123456789abc"
func testLogger() *slog.Logger {
if testing.Verbose() {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// waitFor polls cond until it holds or the timeout expires.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// ---- PKI ----
type testPKI struct {
ca *x509.Certificate
caKey *ecdsa.PrivateKey
serverCert tls.Certificate
}
func newTestPKI(t *testing.T) *testPKI {
t.Helper()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test agent CA"},
NotBefore: time.Now().Add(-24 * time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
ca, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
p := &testPKI{ca: ca, caKey: caKey}
srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
srvDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, ca, &srvKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
p.serverCert = tls.Certificate{Certificate: [][]byte{srvDER, ca.Raw}, PrivateKey: srvKey}
return p
}
func (p *testPKI) issueClient(pub crypto.PublicKey, agentID string, notBefore, notAfter time.Time) (*x509.Certificate, error) {
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return nil, err
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: agentID},
URIs: []*url.URL{agentpki.AgentURI(agentID)},
NotBefore: notBefore, NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}, p.ca, pub, p.caKey)
if err != nil {
return nil, err
}
return x509.ParseCertificate(der)
}
// ---- bundles ----
func newSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
// testArtifacts returns the artifacts of a test revision.
func testArtifacts(rev uint64) []bundle.Artifact {
return []bundle.Artifact{
{Path: "ddi-suite.conf", Mode: 0o640, Content: fmt.Appendf(nil, "// revision %d\n", rev)},
{Path: "zones/db.example.com", Mode: 0o644, Content: fmt.Appendf(nil, "example.com. SOA ns 1 %d\n", rev)},
}
}
func signBundle(t testing.TB, key ed25519.PrivateKey, agentID string, role Kind, rev uint64) *Bundle {
t.Helper()
arts := testArtifacts(rev)
manifest, sig, err := bundle.Sign(key, agentID, string(role), rev, time.Unix(1_700_000_000, 0), arts)
if err != nil {
t.Fatal(err)
}
b := &Bundle{Manifest: manifest, Signature: sig}
for _, a := range arts {
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.Path, Content: a.Content})
}
return b
}
// ---- fake role ----
type fakeRole struct {
kind Kind
mu sync.Mutex
validateErr map[uint64]error
activateErr map[uint64]error
unhealthy map[uint64]bool
active uint64
calls []string
healthCalls map[uint64]int
leases []Lease
leasesErr error
versionErr error
}
func newFakeRole(kind Kind) *fakeRole {
return &fakeRole{
kind: kind, validateErr: map[uint64]error{}, activateErr: map[uint64]error{},
unhealthy: map[uint64]bool{}, healthCalls: map[uint64]int{},
}
}
func (r *fakeRole) Kind() Kind { return r.kind }
func (r *fakeRole) ServiceVersion(context.Context) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.versionErr != nil {
return "", r.versionErr
}
return "Fake 1.0", nil
}
// checkFiles verifies that the stored revision holds its artifacts.
func checkFiles(rev Revision) error {
for _, f := range rev.Files {
data, err := os.ReadFile(filepath.Join(rev.Dir, filepath.FromSlash(f.Path)))
if err != nil {
return err
}
if int64(len(data)) != f.Size {
return fmt.Errorf("%s: size %d, want %d", f.Path, len(data), f.Size)
}
}
return nil
}
func (r *fakeRole) Validate(_ context.Context, rev Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, fmt.Sprintf("validate %d", rev.Number))
if err := checkFiles(rev); err != nil {
return err
}
return r.validateErr[rev.Number]
}
func (r *fakeRole) Activate(_ context.Context, rev Revision, prev *Revision) error {
r.mu.Lock()
defer r.mu.Unlock()
p := "none"
if prev != nil {
p = fmt.Sprint(prev.Number)
}
r.calls = append(r.calls, fmt.Sprintf("activate %d prev %s", rev.Number, p))
if err := r.activateErr[rev.Number]; err != nil {
return err
}
r.active = rev.Number
return nil
}
func (r *fakeRole) Health(_ context.Context, expected *Revision) Health {
r.mu.Lock()
defer r.mu.Unlock()
var n uint64
if expected != nil {
n = expected.Number
}
r.healthCalls[n]++
if r.unhealthy[r.active] {
return Health{State: HealthFailed, Message: "service down",
Checks: []HealthCheck{{Name: "soa", State: HealthFailed, Message: "serial mismatch"}, {Name: "status", State: HealthOK}}}
}
return Health{State: HealthOK}
}
func (r *fakeRole) Leases(context.Context) ([]Lease, error) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.leases), r.leasesErr
}
func (r *fakeRole) setLeases(l []Lease) {
r.mu.Lock()
defer r.mu.Unlock()
r.leases = l
}
func (r *fakeRole) callLog() []string {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.calls)
}
func (r *fakeRole) activeRevision() uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
func (r *fakeRole) resetCalls() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
// fakeLeaseRole is a role with a LeaseSource; fakeRole itself has the
// method too, so wrap it to hide Leases for the DNS case.
type dnsOnlyRole struct{ *fakeRole }
func (d dnsOnlyRole) Leases() {} // shadows LeaseSource.Leases with another signature
// ---- fake source ----
type fakeSource struct {
mu sync.Mutex
bundles map[uint64]*Bundle
fetchErr error
reportErr error
leaseErr error
reports []DeploymentReport
batches []LeaseBatch
fetches int
onFetch func()
run func(ctx context.Context, h SourceHandler) error
}
func newFakeSource() *fakeSource { return &fakeSource{bundles: map[uint64]*Bundle{}} }
func (s *fakeSource) Run(ctx context.Context, h SourceHandler) error {
if s.run != nil {
return s.run(ctx, h)
}
<-ctx.Done()
return ctx.Err()
}
func (s *fakeSource) FetchBundle(_ context.Context, rev uint64) (*Bundle, error) {
s.mu.Lock()
s.fetches++
b, err, hook := s.bundles[rev], s.fetchErr, s.onFetch
s.mu.Unlock()
if hook != nil {
hook()
}
if err != nil {
return nil, err
}
if b == nil {
return nil, fmt.Errorf("revision %d not found", rev)
}
return b, nil
}
func (s *fakeSource) ReportDeployment(_ context.Context, r DeploymentReport) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.reportErr != nil {
return s.reportErr
}
s.reports = append(s.reports, r)
return nil
}
func (s *fakeSource) ReportLeases(_ context.Context, b LeaseBatch) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.leaseErr != nil {
return s.leaseErr
}
b.Leases = slices.Clone(b.Leases)
s.batches = append(s.batches, b)
return nil
}
func (s *fakeSource) setReportErr(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reportErr = err
}
func (s *fakeSource) reported() []DeploymentReport {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.reports)
}
func (s *fakeSource) leaseBatches() []LeaseBatch {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.batches)
}
// ---- agent fixture ----
type fixture struct {
agent *Agent
role *fakeRole
src *fakeSource
store *Store
signer ed25519.PrivateKey
}
func testConfig(dir string) *Config {
return &Config{
StateDir: dir, ApplyTimeout: 5 * time.Second, HealthTimeout: 300 * time.Millisecond,
LeaseInterval: time.Second, LeaseFullSync: time.Hour,
}
}
func newFixture(t *testing.T, kind Kind) *fixture {
t.Helper()
store, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return newFixtureWithStore(t, kind, store, nil)
}
func newFixtureWithStore(t *testing.T, kind Kind, store *Store, signer ed25519.PrivateKey) *fixture {
t.Helper()
if signer == nil {
_, signer = newSigningKey(t)
}
role := newFakeRole(kind)
src := newFakeSource()
id := &Identity{AgentID: testAgentID, Role: kind, BundleKey: signer.Public().(ed25519.PublicKey)}
a, err := NewAgent(kind, testConfig(store.Dir()), role, store, id, src, testLogger())
if err != nil {
t.Fatal(err)
}
a.healthRetry = 20 * time.Millisecond
a.reportRetry = 50 * time.Millisecond
return &fixture{agent: a, role: role, src: src, store: store, signer: signer}
}
// addBundle makes revision rev available from the fake source.
func (f *fixture) addBundle(t *testing.T, rev uint64) {
t.Helper()
f.src.mu.Lock()
defer f.src.mu.Unlock()
f.src.bundles[rev] = signBundle(t, f.signer, testAgentID, f.agent.kind, rev)
}
func (f *fixture) pull(rev uint64) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPull})
}
func (f *fixture) push(rev uint64, dep string) {
f.agent.handle(context.Background(), applyRequest{revision: rev, trigger: TriggerPush, deploymentID: dep})
}
func (f *fixture) lastResult(t *testing.T) DeploymentReport {
t.Helper()
st := f.agent.state()
if st.LastResult == nil {
t.Fatal("no result recorded")
}
return *st.LastResult
}
// ---- fake gRPC server ----
type fakeServer struct {
agentv1.UnimplementedAgentServiceServer
pki *testPKI
signKey ed25519.PrivateKey
role Kind
mu sync.Mutex
token string
agentID string
serial *big.Int
enrollValidity [2]time.Duration // NotBefore offset, NotAfter offset for Enroll
desired uint64
mode agentv1.OperatingMode
pullInterval time.Duration
heartbeat time.Duration
streamDisabled bool
hellos []*agentv1.Hello
heartbeats int
acks []*agentv1.CommandAck
reports []*agentv1.ReportDeploymentRequest
leaseReports []*agentv1.ReportLeasesRequest
renewals int
polls int
commands chan *agentv1.Command
enrollments int
// pushDeployment is announced by GetDesiredState until a report with
// this deployment ID arrives (open push, ADR 0022).
pushDeployment string
// reportCode makes ReportDeployment fail with this code if set.
reportCode codes.Code
// kick ends the current Connect stream (as the hub does on a mode
// change).
kick chan struct{}
}
func newFakeServer(t *testing.T, role Kind) *fakeServer {
t.Helper()
_, key := newSigningKey(t)
return &fakeServer{
pki: newTestPKI(t), signKey: key, role: role, token: "0123456789abcdef.secret",
agentID: "11111111-2222-4333-8444-555555555555",
enrollValidity: [2]time.Duration{-time.Minute, 14 * 24 * time.Hour},
mode: agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL,
pullInterval: 50 * time.Millisecond, heartbeat: 50 * time.Millisecond,
commands: make(chan *agentv1.Command, 10), kick: make(chan struct{}, 1),
}
}
// start serves the fake on 127.0.0.1 with mTLS and returns its address.
func (f *fakeServer) start(t *testing.T) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AddCert(f.pki.ca)
srv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{f.pki.serverCert},
ClientAuth: tls.VerifyClientCertIfGiven, ClientCAs: pool,
})),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 10 * time.Second, PermitWithoutStream: true}),
)
agentv1.RegisterAgentServiceServer(srv, f)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
return lis.Addr().String()
}
func (f *fakeServer) fingerprint() string { return agentpki.Fingerprint(f.pki.ca) }
func (f *fakeServer) authenticate(ctx context.Context) error {
p, ok := peer.FromContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "no peer")
}
ti, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(ti.State.PeerCertificates) == 0 {
return status.Error(codes.Unauthenticated, "client certificate required")
}
cert := ti.State.PeerCertificates[0]
id, err := agentpki.AgentID(cert)
if err != nil {
return status.Error(codes.Unauthenticated, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
if id != f.agentID || f.serial == nil || cert.SerialNumber.Cmp(f.serial) != 0 {
return status.Error(codes.Unauthenticated, "unknown certificate")
}
return nil
}
func (f *fakeServer) issue(csrDER []byte, notBefore, notAfter time.Time) (*x509.Certificate, error) {
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
cert, err := f.pki.issueClient(csr.PublicKey, id, notBefore, notAfter)
if err != nil {
return nil, err
}
f.mu.Lock()
f.serial = cert.SerialNumber
f.mu.Unlock()
return cert, nil
}
func (f *fakeServer) Enroll(_ context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
f.mu.Lock()
token, validity := f.token, f.enrollValidity
f.enrollments++
f.mu.Unlock()
if req.GetToken() != token {
return nil, status.Error(codes.PermissionDenied, "invalid enrollment token")
}
want, _ := roleToProto(f.role)
if req.GetRole() != want {
return nil, status.Error(codes.InvalidArgument, "wrong role")
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(validity[0]), now.Add(validity[1]))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
defer f.mu.Unlock()
return &agentv1.EnrollResponse{
AgentId: f.agentID, CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw},
BundleSigningPublicKey: f.signKey.Public().(ed25519.PublicKey),
}, nil
}
func (f *fakeServer) RenewCertificate(ctx context.Context, req *agentv1.RenewCertificateRequest) (*agentv1.RenewCertificateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
now := time.Now()
cert, err := f.issue(req.GetCsrDer(), now.Add(-time.Minute), now.Add(14*24*time.Hour))
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
f.mu.Lock()
f.renewals++
f.mu.Unlock()
return &agentv1.RenewCertificateResponse{CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{f.pki.ca.Raw}}, nil
}
func (f *fakeServer) Connect(stream agentv1.AgentService_ConnectServer) error {
f.mu.Lock()
disabled := f.streamDisabled
f.mu.Unlock()
if disabled {
return status.Error(codes.Unimplemented, "streams disabled")
}
if err := f.authenticate(stream.Context()); err != nil {
return err
}
first, err := stream.Recv()
if err != nil {
return err
}
if first.GetHello() == nil {
return status.Error(codes.InvalidArgument, "Hello expected")
}
f.mu.Lock()
f.hellos = append(f.hellos, first.GetHello())
w := &agentv1.Welcome{
Mode: f.mode, PullInterval: durationpb.New(f.pullInterval),
HeartbeatInterval: durationpb.New(f.heartbeat), DesiredRevision: f.desired,
}
f.mu.Unlock()
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Welcome{Welcome: w}}); err != nil {
return err
}
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
f.mu.Lock()
switch {
case msg.GetHeartbeat() != nil:
f.heartbeats++
case msg.GetCommandAck() != nil:
f.acks = append(f.acks, msg.GetCommandAck())
}
f.mu.Unlock()
}
}()
for {
select {
case err := <-recvErr:
if errors.Is(err, io.EOF) {
return nil
}
return err
case <-stream.Context().Done():
return nil
case <-f.kick:
return nil
case c := <-f.commands:
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: c}}); err != nil {
return err
}
}
}
}
func (f *fakeServer) GetDesiredState(ctx context.Context, _ *agentv1.GetDesiredStateRequest) (*agentv1.GetDesiredStateResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.polls++
return &agentv1.GetDesiredStateResponse{
DesiredRevision: f.desired, Mode: f.mode, PullInterval: durationpb.New(f.pullInterval),
DeploymentId: f.pushDeployment,
}, nil
}
func (f *fakeServer) FetchBundle(ctx context.Context, req *agentv1.FetchBundleRequest) (*agentv1.FetchBundleResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
id := f.agentID
f.mu.Unlock()
arts := testArtifacts(req.GetRevision())
manifest, sig, err := bundle.Sign(f.signKey, id, string(f.role), req.GetRevision(), time.Unix(1_700_000_000, 0), arts)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
resp := &agentv1.FetchBundleResponse{Manifest: manifest, Signature: sig}
for _, a := range arts {
resp.Artifacts = append(resp.Artifacts, &agentv1.Artifact{Path: a.Path, Content: a.Content})
}
return resp, nil
}
func (f *fakeServer) ReportDeployment(ctx context.Context, req *agentv1.ReportDeploymentRequest) (*agentv1.ReportDeploymentResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
if f.reportCode != codes.OK {
return nil, status.Error(f.reportCode, "report refused")
}
f.reports = append(f.reports, req)
if req.GetDeploymentId() != "" && req.GetDeploymentId() == f.pushDeployment {
f.pushDeployment = ""
}
return &agentv1.ReportDeploymentResponse{}, nil
}
func (f *fakeServer) ReportLeases(ctx context.Context, req *agentv1.ReportLeasesRequest) (*agentv1.ReportLeasesResponse, error) {
if err := f.authenticate(ctx); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
f.leaseReports = append(f.leaseReports, req)
return &agentv1.ReportLeasesResponse{AcknowledgedSequence: req.GetSequence()}, nil
}
// snapshot returns a copy of the recorded data under the lock.
func (f *fakeServer) with(fn func(f *fakeServer)) {
f.mu.Lock()
defer f.mu.Unlock()
fn(f)
}
+320
View File
@@ -0,0 +1,320 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
)
// Files in identity/.
const (
keyFile = "key.pem"
certFile = "cert.pem"
caFile = "ca.pem"
bundleKeyFile = "bundle-signing.pub"
agentFile = "agent.json"
newSuffix = ".new"
)
// ErrNotEnrolled is returned by LoadIdentity if the agent has no identity.
var ErrNotEnrolled = errors.New("agent is not enrolled (run the enroll command first)")
// errAlreadyEnrolled is returned by SaveIdentity without force.
var errAlreadyEnrolled = errors.New("agent is already enrolled (use --force to enroll again)")
// Identity is the enrolled identity of the agent.
type Identity struct {
AgentID string
Role Kind
Server string // host:port of the agent API
ServerName string // TLS server name
EnrolledAt time.Time
CA *x509.Certificate
// Certificate holds the client certificate (Leaf set) and its key.
Certificate tls.Certificate
BundleKey ed25519.PublicKey
}
// agentInfo is identity/agent.json.
type agentInfo struct {
AgentID string `json:"agent_id"`
Role Kind `json:"role"`
Server string `json:"server"`
ServerName string `json:"server_name"`
EnrolledAt time.Time `json:"enrolled_at"`
CAFingerprint string `json:"ca_fingerprint"`
}
func (s *Store) identityPath(name string) string {
return filepath.Join(s.dir, identityDir, name)
}
// Enrolled reports whether an identity exists.
func (s *Store) Enrolled() bool {
_, err := os.Stat(s.identityPath(agentFile))
return err == nil
}
// LoadIdentity reads identity/. It completes an interrupted certificate
// renewal (see SaveCertificate).
func (s *Store) LoadIdentity() (*Identity, error) {
data, err := os.ReadFile(s.identityPath(agentFile))
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrNotEnrolled
}
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
var info agentInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, fmt.Errorf("decode %s: %w", agentFile, err)
}
if !agentpki.ValidAgentID(info.AgentID) {
return nil, fmt.Errorf("%s holds an invalid agent ID", agentFile)
}
id := &Identity{
AgentID: info.AgentID, Role: info.Role, Server: info.Server,
ServerName: info.ServerName, EnrolledAt: info.EnrolledAt,
}
if id.CA, err = readCertPEM(s.identityPath(caFile)); err != nil {
return nil, err
}
if id.BundleKey, err = readBundleKey(s.identityPath(bundleKeyFile)); err != nil {
return nil, err
}
if id.Certificate, err = s.loadKeyPair(); err != nil {
return nil, err
}
return id, nil
}
// loadKeyPair loads key.pem and cert.pem. If they do not match and a
// cert.pem.new matching the key exists, a renewal was interrupted after the
// key was replaced; the new certificate is moved into place.
func (s *Store) loadKeyPair() (tls.Certificate, error) {
keyPEM, err := os.ReadFile(s.identityPath(keyFile))
if err != nil {
return tls.Certificate{}, fmt.Errorf("read identity: %w", err)
}
certPEM, err := os.ReadFile(s.identityPath(certFile))
if err != nil {
return tls.Certificate{}, fmt.Errorf("read identity: %w", err)
}
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
s.removeStaleNew()
return pair, nil
}
newCert, nerr := os.ReadFile(s.identityPath(certFile + newSuffix))
if nerr != nil {
return tls.Certificate{}, fmt.Errorf("load client certificate: %w", err)
}
pair, nerr = tls.X509KeyPair(newCert, keyPEM)
if nerr != nil {
return tls.Certificate{}, fmt.Errorf("load client certificate: %w", err)
}
if err := os.Rename(s.identityPath(certFile+newSuffix), s.identityPath(certFile)); err != nil {
return tls.Certificate{}, fmt.Errorf("complete certificate renewal: %w", err)
}
syncDir(filepath.Join(s.dir, identityDir))
return pair, nil
}
func (s *Store) removeStaleNew() {
_ = os.Remove(s.identityPath(keyFile + newSuffix))
_ = os.Remove(s.identityPath(certFile + newSuffix))
}
// SaveIdentity writes a new identity directory. Without force it refuses to
// replace an existing identity.
func (s *Store) SaveIdentity(id *Identity, key crypto.Signer, force bool) error {
if s.Enrolled() && !force {
return errAlreadyEnrolled
}
keyPEM, err := encodeKey(key)
if err != nil {
return err
}
pubDER, err := x509.MarshalPKIXPublicKey(id.BundleKey)
if err != nil {
return fmt.Errorf("encode bundle signing key: %w", err)
}
info, err := json.MarshalIndent(agentInfo{
AgentID: id.AgentID, Role: id.Role, Server: id.Server, ServerName: id.ServerName,
EnrolledAt: id.EnrolledAt.UTC(), CAFingerprint: agentpki.Fingerprint(id.CA),
}, "", " ")
if err != nil {
return fmt.Errorf("encode identity: %w", err)
}
tmp, err := os.MkdirTemp(s.dir, tmpPrefix+identityDir+"-")
if err != nil {
return fmt.Errorf("create identity directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := os.Chmod(tmp, 0o700); err != nil { //nolint:gosec // directories need the search bit
return fmt.Errorf("create identity directory: %w", err)
}
files := []struct {
name string
data []byte
perm fs.FileMode
}{
{keyFile, keyPEM, 0o600},
{certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: id.Certificate.Leaf.Raw}), 0o644},
{caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: id.CA.Raw}), 0o644},
{bundleKeyFile, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}), 0o644},
{agentFile, append(info, '\n'), 0o644},
}
for _, f := range files {
if err := writeFileSync(filepath.Join(tmp, f.name), f.data, f.perm); err != nil {
return err
}
}
syncDir(tmp)
if err := replaceDir(tmp, filepath.Join(s.dir, identityDir)); err != nil {
return err
}
syncDir(s.dir)
return nil
}
// SaveCertificate stores a renewed key and certificate. Both are written as
// *.new first; key.pem is replaced before cert.pem, and LoadIdentity
// completes the second rename if the process died in between.
func (s *Store) SaveCertificate(key crypto.Signer, cert *x509.Certificate) error {
keyPEM, err := encodeKey(key)
if err != nil {
return err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyNew, certNew := s.identityPath(keyFile+newSuffix), s.identityPath(certFile+newSuffix)
if err := writeFileAtomic(keyNew, keyPEM, 0o600); err != nil {
return err
}
if err := writeFileAtomic(certNew, certPEM, 0o644); err != nil {
return err
}
if err := os.Rename(keyNew, s.identityPath(keyFile)); err != nil {
return fmt.Errorf("replace key: %w", err)
}
if err := os.Rename(certNew, s.identityPath(certFile)); err != nil {
return fmt.Errorf("replace certificate: %w", err)
}
syncDir(filepath.Join(s.dir, identityDir))
return nil
}
func readCertPEM(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("%s holds no certificate", filepath.Base(path))
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Base(path), err)
}
return cert, nil
}
func readBundleKey(path string) (ed25519.PublicKey, error) {
data, err := os.ReadFile(path) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("read identity: %w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("%s holds no public key", filepath.Base(path))
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Base(path), err)
}
key, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("%s is not an Ed25519 key", filepath.Base(path))
}
return key, nil
}
func encodeKey(key crypto.Signer) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("encode private key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}
// newKeyAndCSR generates an ECDSA P-256 key and a CSR for it. The server
// sets subject and SANs itself; commonName is informational.
func newKeyAndCSR(commonName string) (*ecdsa.PrivateKey, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: commonName},
}, key)
if err != nil {
return nil, nil, fmt.Errorf("create certificate request: %w", err)
}
return key, csr, nil
}
// checkIssued verifies a certificate issued by the server: it must chain to
// ca for client authentication, carry agentID and certify key.
func checkIssued(der []byte, ca *x509.Certificate, agentID string, key *ecdsa.PrivateKey) (*x509.Certificate, error) {
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse issued certificate: %w", err)
}
roots := x509.NewCertPool()
roots.AddCert(ca)
// Tolerate a server clock that is slightly ahead of ours.
at := time.Now()
if at.Before(cert.NotBefore) {
at = cert.NotBefore
}
if _, err := cert.Verify(x509.VerifyOptions{
Roots: roots, CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}); err != nil {
return nil, fmt.Errorf("verify issued certificate: %w", err)
}
id, err := agentpki.AgentID(cert)
if err != nil {
return nil, err
}
if id != agentID {
return nil, fmt.Errorf("issued certificate is for agent %s, not %s", id, agentID)
}
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !pub.Equal(&key.PublicKey) {
return nil, errors.New("issued certificate does not certify the generated key")
}
return cert, nil
}
// keyPair combines a key and its certificate.
func keyPair(key *ecdsa.PrivateKey, cert *x509.Certificate) tls.Certificate {
return tls.Certificate{Certificate: [][]byte{cert.Raw}, PrivateKey: key, Leaf: cert}
}
+162
View File
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"fmt"
"net/netip"
"slices"
"time"
)
// maxLeasesPerBatch bounds one ReportLeases request (about 100 bytes per
// lease, far below the default gRPC message limit of 4 MiB).
const maxLeasesPerBatch = 5000
// leasesTimeout bounds one LeaseSource.Leases call.
const leasesTimeout = 30 * time.Second
// leaseTracker remembers what the source was last told.
type leaseTracker struct {
// known holds the leases as last reported, by address.
known map[netip.Addr]Lease
// needFull forces the next report to be a full snapshot (at start,
// after a new connection and after a failed report).
needFull bool
lastFull time.Time
}
// leaseLoop polls the role for leases and reports changes. A full snapshot
// is sent at start, after each new connection, after a failed report and
// every LeaseFullSync.
func (a *Agent) leaseLoop(ctx context.Context, ls LeaseSource) {
t := time.NewTicker(a.cfg.LeaseInterval)
defer t.Stop()
tr := &leaseTracker{needFull: true}
for {
a.pollLeases(ctx, ls, tr)
select {
case <-ctx.Done():
return
case <-t.C:
case <-a.leaseCh:
tr.needFull = true
}
}
}
// pollLeases runs one polling round.
func (a *Agent) pollLeases(ctx context.Context, ls LeaseSource, tr *leaseTracker) {
if !a.roleMu.TryLock() {
return // an apply is running; try again next round
}
lctx, cancel := context.WithTimeout(ctx, leasesTimeout)
cur, err := ls.Leases(lctx)
cancel()
a.roleMu.Unlock()
if err != nil {
if ctx.Err() == nil {
a.log.Warn("cannot read leases", "error", err)
}
return
}
full := tr.needFull || time.Since(tr.lastFull) >= a.cfg.LeaseFullSync
changes, next := diffLeases(tr.known, cur)
toSend := changes
if full {
toSend = sortedLeases(next)
} else if len(changes) == 0 {
return
}
if err := a.sendLeases(ctx, toSend, full); err != nil {
if ctx.Err() == nil {
a.log.Warn("cannot report leases; next report is a full snapshot", "error", err)
}
tr.needFull = true
return
}
tr.known = next
tr.needFull = false
if full {
tr.lastFull = time.Now()
}
}
// sendLeases sends leases in batches of maxLeasesPerBatch. For a full
// snapshot only the first batch carries FullSync; the server replaces its
// view with it and applies the following batches as changes, so a snapshot
// split into several requests ends in the same state. If any batch fails,
// the caller sends a new full snapshot later.
func (a *Agent) sendLeases(ctx context.Context, leases []Lease, full bool) error {
first := true
for len(leases) > 0 || first {
n := min(len(leases), maxLeasesPerBatch)
seq, err := a.nextLeaseSequence()
if err != nil {
return err
}
b := LeaseBatch{Sequence: seq, FullSync: full && first, Leases: leases[:n]}
if err := a.source.ReportLeases(ctx, b); err != nil {
return err
}
a.log.Debug("leases reported", "sequence", seq, "full_sync", b.FullSync, "count", n)
leases = leases[n:]
first = false
}
return nil
}
// nextLeaseSequence increments and persists the lease sequence before it is
// used, so that a restart never reuses a number.
func (a *Agent) nextLeaseSequence() (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.st.LeaseSequence++
a.st.UpdatedAt = time.Now().UTC()
if err := a.store.SaveState(a.st); err != nil {
return 0, fmt.Errorf("persist lease sequence: %w", err)
}
return a.st.LeaseSequence, nil
}
// diffLeases compares the current leases with the known ones. It returns the
// changes (new or modified leases, and known leases that disappeared, marked
// expired) and the new known set.
func diffLeases(known map[netip.Addr]Lease, cur []Lease) ([]Lease, map[netip.Addr]Lease) {
next := make(map[netip.Addr]Lease, len(cur))
for _, l := range cur {
if l.IP.IsValid() {
next[l.IP] = l
}
}
var changes []Lease
for ip, l := range next {
if old, ok := known[ip]; !ok || !leaseEqual(old, l) {
changes = append(changes, l)
}
}
for ip, l := range known {
if _, ok := next[ip]; !ok {
l.State = LeaseExpired
changes = append(changes, l)
}
}
slices.SortFunc(changes, func(x, y Lease) int { return x.IP.Compare(y.IP) })
return changes, next
}
func sortedLeases(m map[netip.Addr]Lease) []Lease {
out := make([]Lease, 0, len(m))
for _, l := range m {
out = append(out, l)
}
slices.SortFunc(out, func(x, y Lease) int { return x.IP.Compare(y.IP) })
return out
}
func leaseEqual(x, y Lease) bool {
return x.IP == y.IP && x.MAC == y.MAC && x.Hostname == y.Hostname && x.State == y.State &&
x.Expires.Equal(y.Expires) && x.Updated.Equal(y.Updated) && x.SubnetID == y.SubnetID &&
x.ClientID == y.ClientID
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"net/netip"
"testing"
"time"
)
func mustAddr(s string) netip.Addr { return netip.MustParseAddr(s) }
func lease(ip, mac string) Lease {
return Lease{
IP: mustAddr(ip), MAC: mac, Hostname: "host-" + ip, State: LeaseActive,
Expires: time.Unix(1_800_000_000, 0), Updated: time.Unix(1_700_000_000, 0), SubnetID: 1,
}
}
func TestDiffLeases(t *testing.T) {
a, b, c := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02"), lease("10.0.0.3", "aa:00:00:00:00:03")
changes, known := diffLeases(nil, []Lease{b, a, {}})
if len(changes) != 2 || changes[0].IP != a.IP || changes[1].IP != b.IP || len(known) != 2 {
t.Fatalf("initial changes = %+v", changes)
}
if changes, _ := diffLeases(known, []Lease{a, b}); len(changes) != 0 {
t.Errorf("unchanged leases reported: %+v", changes)
}
b2 := b
b2.Expires = b.Expires.Add(time.Hour)
changes, next := diffLeases(known, []Lease{b2, c})
if len(changes) != 3 {
t.Fatalf("changes = %+v", changes)
}
if changes[0].IP != a.IP || changes[0].State != LeaseExpired {
t.Errorf("disappeared lease = %+v", changes[0])
}
if !changes[1].Expires.Equal(b2.Expires) || changes[2].IP != c.IP {
t.Errorf("changes = %+v", changes)
}
if len(next) != 2 {
t.Errorf("next = %v", next)
}
// Equal instants in different locations are not a change.
b3 := b2
b3.Expires = b2.Expires.In(time.FixedZone("x", 3600))
if changes, _ := diffLeases(next, []Lease{b3, c}); len(changes) != 0 {
t.Errorf("time zone change reported: %+v", changes)
}
}
func TestPollLeases(t *testing.T) {
f := newFixture(t, KindDHCP)
ctx := context.Background()
tr := &leaseTracker{needFull: true}
a, b := lease("10.0.0.1", "aa:00:00:00:00:01"), lease("10.0.0.2", "aa:00:00:00:00:02")
f.role.setLeases([]Lease{a, b})
f.agent.pollLeases(ctx, f.role, tr)
batches := f.src.leaseBatches()
if len(batches) != 1 || !batches[0].FullSync || batches[0].Sequence != 1 || len(batches[0].Leases) != 2 {
t.Fatalf("batches = %+v", batches)
}
// No change: nothing is sent.
f.agent.pollLeases(ctx, f.role, tr)
if n := len(f.src.leaseBatches()); n != 1 {
t.Fatalf("%d batches after an unchanged poll", n)
}
// b released, a gone, c new: one delta.
b.State = LeaseReleased
c := lease("10.0.0.3", "aa:00:00:00:00:03")
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if len(batches) != 2 {
t.Fatalf("batches = %+v", batches)
}
d := batches[1]
if d.FullSync || d.Sequence != 2 || len(d.Leases) != 3 || d.Leases[0].State != LeaseExpired || d.Leases[1].State != LeaseReleased {
t.Fatalf("delta = %+v", d)
}
// A failed report forces a full snapshot next time.
f.src.mu.Lock()
f.src.leaseErr = errors.New("down")
f.src.mu.Unlock()
c.Hostname = "renamed"
f.role.setLeases([]Lease{b, c})
f.agent.pollLeases(ctx, f.role, tr)
if !tr.needFull {
t.Fatal("failure did not request a full snapshot")
}
f.src.mu.Lock()
f.src.leaseErr = nil
f.src.mu.Unlock()
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
last := batches[len(batches)-1]
if !last.FullSync || len(last.Leases) != 2 || last.Sequence != 4 {
t.Fatalf("snapshot after failure = %+v", last)
}
// The sequence survives a restart.
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
if st.LeaseSequence != 4 {
t.Errorf("persisted sequence = %d", st.LeaseSequence)
}
// The full sync interval triggers a snapshot.
tr.lastFull = time.Now().Add(-2 * time.Hour)
f.agent.pollLeases(ctx, f.role, tr)
batches = f.src.leaseBatches()
if last := batches[len(batches)-1]; !last.FullSync || last.Sequence != 5 {
t.Errorf("periodic snapshot = %+v", last)
}
// Errors of the role and a busy role skip the round.
f.role.mu.Lock()
f.role.leasesErr = errors.New("socket gone")
f.role.mu.Unlock()
n := len(f.src.leaseBatches())
tr.needFull = true
f.agent.pollLeases(ctx, f.role, tr)
f.role.mu.Lock()
f.role.leasesErr = nil
f.role.mu.Unlock()
f.agent.roleMu.Lock()
f.agent.pollLeases(ctx, f.role, tr)
f.agent.roleMu.Unlock()
if len(f.src.leaseBatches()) != n {
t.Error("leases reported despite an error or a running apply")
}
}
func TestPollLeasesEmptySnapshot(t *testing.T) {
f := newFixture(t, KindDHCP)
tr := &leaseTracker{needFull: true}
f.agent.pollLeases(context.Background(), f.role, tr)
b := f.src.leaseBatches()
if len(b) != 1 || !b[0].FullSync || len(b[0].Leases) != 0 {
t.Fatalf("batches = %+v", b)
}
}
func TestLeaseBatching(t *testing.T) {
f := newFixture(t, KindDHCP)
var leases []Lease
for i := 0; i < 2*maxLeasesPerBatch+1; i++ {
ip := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)})
leases = append(leases, Lease{IP: ip, MAC: fmt.Sprintf("aa:00:00:%02x:%02x:%02x", byte(i>>16), byte(i>>8), byte(i)), State: LeaseActive})
}
f.role.setLeases(leases)
f.agent.pollLeases(context.Background(), f.role, &leaseTracker{needFull: true})
b := f.src.leaseBatches()
if len(b) != 3 {
t.Fatalf("%d batches", len(b))
}
if !b[0].FullSync || b[1].FullSync || b[2].FullSync {
t.Errorf("full_sync flags = %v %v %v", b[0].FullSync, b[1].FullSync, b[2].FullSync)
}
if len(b[0].Leases) != maxLeasesPerBatch || len(b[2].Leases) != 1 || b[0].Sequence != 1 || b[2].Sequence != 3 {
t.Errorf("batches: %d/%d/%d leases, sequences %d..%d", len(b[0].Leases), len(b[1].Leases), len(b[2].Leases), b[0].Sequence, b[2].Sequence)
}
}
func TestLeaseLoopNotStartedForDNS(t *testing.T) {
f := newFixture(t, KindDNS)
role := dnsOnlyRole{f.role}
a, err := NewAgent(KindDNS, f.agent.cfg, role, f.store, &Identity{AgentID: testAgentID, BundleKey: f.agent.bundleKey}, f.src, testLogger())
if err != nil {
t.Fatal(err)
}
if _, ok := a.role.(LeaseSource); ok {
t.Fatal("DNS test role implements LeaseSource")
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if err := a.Run(ctx); err != nil {
t.Fatal(err)
}
if n := len(f.src.leaseBatches()); n != 0 {
t.Errorf("%d lease batches from a DNS agent", n)
}
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
package core
import (
"errors"
"fmt"
"os"
"syscall"
)
// lockPath takes an exclusive, non-blocking flock on path. The lock is
// released by the returned function or when the process exits.
func lockPath(path string) (func(), error) {
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec // path built from the state directory
if err != nil {
return nil, fmt.Errorf("open lock file: %w", err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { //nolint:gosec // file descriptors fit into int
_ = f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, fmt.Errorf("state directory is locked by another agent process (%s)", path)
}
return nil, fmt.Errorf("lock state directory: %w", err)
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:gosec // file descriptors fit into int
_ = f.Close()
}, nil
}
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: Apache-2.0
//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly)
package core
// lockPath is a no-op on platforms without flock. The agents only run on
// Linux; this keeps the package buildable elsewhere (e.g. for development).
func lockPath(string) (func(), error) {
return func() {}, nil
}
+366
View File
@@ -0,0 +1,366 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
"google.golang.org/grpc/codes"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// Tests for push deployments of docs/adr/0022-ausrollen-pull-push-m4.md.
// countCalls counts the role calls that start with prefix.
func (r *fakeRole) countCalls(prefix string) int {
n := 0
for _, c := range r.callLog() {
if strings.HasPrefix(c, prefix) {
n++
}
}
return n
}
// activations counts the activations of a revision.
func (r *fakeRole) activations(rev uint64) int {
return r.countCalls(fmt.Sprintf("activate %d ", rev))
}
// validations counts the validations of a revision.
func (r *fakeRole) validations(rev uint64) int {
return r.countCalls(fmt.Sprintf("validate %d", rev))
}
func (f *fixture) pending() []DeploymentReport {
f.agent.mu.Lock()
defer f.agent.mu.Unlock()
return slices.Clone(f.agent.st.PendingReports)
}
func TestPushAppliedOncePerDeployment(t *testing.T) {
ctx := context.Background()
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.push(1, "dep-1")
if n := f.role.activations(1); n != 1 {
t.Fatalf("activations of revision 1 = %d", n)
}
// The server resends the open push (reconnect, polling) while the
// report is still queued: not applied again, the queued report is
// delivered once.
f.push(1, "dep-1")
if n := f.role.activations(1); n != 1 {
t.Errorf("resent push applied again (%d activations)", n)
}
if p := f.pending(); len(p) != 1 || p[0].DeploymentID != "dep-1" || p[0].Outcome != OutcomeSucceeded {
t.Fatalf("pending reports = %+v", p)
}
f.agent.flushReports(ctx)
if r := f.src.reported(); len(r) != 1 {
t.Fatalf("reports = %+v", r)
}
// Resent after the report was delivered (the server did not record
// it): the remembered report is delivered again.
f.push(1, "dep-1")
if n := f.role.activations(1); n != 1 {
t.Errorf("resent push applied again (%d activations)", n)
}
f.agent.flushReports(ctx)
r := f.src.reported()
if len(r) != 2 || r[1].DeploymentID != "dep-1" || r[1].Outcome != OutcomeSucceeded ||
!r[1].StartedAt.Equal(r[0].StartedAt) || r[1].Trigger != TriggerPush {
t.Fatalf("reports = %+v", r)
}
// A new deployment of the same revision is applied again.
f.push(1, "dep-2")
if n := f.role.activations(1); n != 2 {
t.Errorf("activations of revision 1 = %d, want 2", n)
}
// Pushes without a deployment ID are never deduplicated (M3 behavior).
f.push(1, "")
f.push(1, "")
if n := f.role.activations(1); n != 4 {
t.Errorf("activations of revision 1 = %d, want 4", n)
}
}
func TestRejectedPushNotRetriedForSameDeployment(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.pull(1)
f.addBundle(t, 2)
f.role.mu.Lock()
f.role.validateErr[2] = errors.New("bad zone")
f.role.mu.Unlock()
f.push(2, "dep-3")
if r := f.lastResult(t); r.Outcome != OutcomeRejected || r.DeploymentID != "dep-3" {
t.Fatalf("result = %+v", r)
}
f.role.mu.Lock()
delete(f.role.validateErr, 2)
f.role.mu.Unlock()
f.push(2, "dep-3")
if n := f.role.validations(2); n != 1 {
t.Errorf("validations of revision 2 = %d, want 1", n)
}
if st := f.agent.state(); st.Applied != 1 {
t.Errorf("applied = %d", st.Applied)
}
// A new push of the same revision retries it.
f.push(2, "dep-4")
if r := f.lastResult(t); r.Outcome != OutcomeSucceeded || r.DeploymentID != "dep-4" {
t.Errorf("result = %+v", r)
}
}
func TestHandledDeploymentsPersistedAndBounded(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
for i := range maxHandledDeployments + 5 {
f.push(1, fmt.Sprintf("dep-%d", i))
}
st, err := f.store.LoadState()
if err != nil {
t.Fatal(err)
}
h := st.HandledDeployments
if len(h) != maxHandledDeployments || h[0].DeploymentID != "dep-5" ||
h[len(h)-1].DeploymentID != fmt.Sprintf("dep-%d", maxHandledDeployments+4) {
t.Fatalf("handled deployments = %+v", h)
}
// After a restart, resent pushes are still recognized; forgotten ones
// are applied again.
g := newFixtureWithStore(t, KindDNS, f.store, f.signer)
g.addBundle(t, 1)
g.push(1, fmt.Sprintf("dep-%d", maxHandledDeployments+4))
if n := g.role.activations(1); n != 0 {
t.Errorf("remembered deployment applied again after restart (%d activations)", n)
}
g.push(1, "dep-0")
if n := g.role.activations(1); n != 1 {
t.Errorf("forgotten deployment: %d activations, want 1", n)
}
}
func TestRememberDeployment(t *testing.T) {
var st State
st.rememberDeployment(DeploymentReport{Revision: 1}) // pulls are not remembered
st.rememberDeployment(DeploymentReport{DeploymentID: "a", Revision: 1, Message: strings.Repeat("x", 5000)})
st.rememberDeployment(DeploymentReport{DeploymentID: "b", Revision: 2})
st.rememberDeployment(DeploymentReport{DeploymentID: "a", Revision: 3})
if len(st.HandledDeployments) != 2 || st.HandledDeployments[0].DeploymentID != "b" ||
st.HandledDeployments[1].Revision != 3 {
t.Fatalf("handled = %+v", st.HandledDeployments)
}
st.rememberDeployment(DeploymentReport{DeploymentID: "c", Message: strings.Repeat("y", 5000)})
if r, ok := st.handledDeployment("c"); !ok || len(r.Message) > handledMessageLen {
t.Errorf("handled c = %v, message length %d", ok, len(r.Message))
}
if _, ok := st.handledDeployment(""); ok {
t.Error("empty deployment ID found")
}
if _, ok := st.handledDeployment("x"); ok {
t.Error("unknown deployment ID found")
}
}
func TestEnqueueDeduplicatesPushes(t *testing.T) {
f := newFixture(t, KindDNS)
a := f.agent
a.enqueue(applyRequest{revision: 3, trigger: TriggerPush, deploymentID: "d"})
a.enqueue(applyRequest{revision: 3, trigger: TriggerPush, deploymentID: "d"})
a.enqueue(applyRequest{revision: 3, trigger: TriggerPush, deploymentID: "e"})
a.enqueue(applyRequest{revision: 3, trigger: TriggerPush})
a.enqueue(applyRequest{revision: 3, trigger: TriggerPush})
var got []string
for {
r, ok := a.dequeue()
if !ok {
break
}
got = append(got, r.deploymentID)
}
if want := []string{"d", "e", "", ""}; !slices.Equal(got, want) {
t.Errorf("queued deployments = %q, want %q", got, want)
}
}
func TestRefusedReportIsDropped(t *testing.T) {
f := newFixture(t, KindDNS)
f.addBundle(t, 1)
f.addBundle(t, 2)
f.pull(1)
f.push(2, "dep-gone")
f.src.setReportErr(fmt.Errorf("%w: deployment closed", ErrReportRefused))
f.agent.flushReports(context.Background())
if p := f.pending(); len(p) != 0 {
t.Errorf("refused reports still queued: %+v", p)
}
}
func TestServerSourceReportRefused(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
src := NewServerSource(store, id, "h", testLogger())
defer src.closeConn()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rep := DeploymentReport{DeploymentID: "d", Revision: 1, Trigger: TriggerPush, Outcome: OutcomeSucceeded,
ActiveRevision: 1, StartedAt: time.Now(), FinishedAt: time.Now()}
if err := src.ReportDeployment(ctx, rep); err != nil {
t.Fatalf("report = %v", err)
}
srv.with(func(f *fakeServer) { f.reportCode = codes.FailedPrecondition })
if err := src.ReportDeployment(ctx, rep); !errors.Is(err, ErrReportRefused) {
t.Errorf("refused report = %v", err)
}
srv.with(func(f *fakeServer) { f.reportCode = codes.Unavailable })
if err := src.ReportDeployment(ctx, rep); err == nil || errors.Is(err, ErrReportRefused) {
t.Errorf("unavailable server = %v", err)
}
}
func TestServerSourcePollingPush(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
srv.with(func(f *fakeServer) {
f.desired = 1
f.mode = agentv1.OperatingMode_OPERATING_MODE_MANUAL
f.streamDisabled = true
})
role, _ := runAgentAgainst(t, store, id, KindDNS)
polls := func() int {
n := 0
srv.with(func(f *fakeServer) { n = f.polls })
return n
}
waitFor(t, "polls", func() bool { return polls() >= 3 })
if n := len(serverReports(srv)); n != 0 {
t.Fatalf("manual mode applied the desired revision (%d reports)", n)
}
// An open push is announced by polling and applied in manual mode.
srv.with(func(f *fakeServer) {
f.desired = 2
f.pushDeployment = "dep-poll"
})
waitFor(t, "report of the push", func() bool { return len(serverReports(srv)) >= 1 })
r := serverReports(srv)[0]
if r.GetRevision() != 2 || r.GetDeploymentId() != "dep-poll" ||
r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH ||
r.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED {
t.Fatalf("report = %v", r)
}
after := polls()
waitFor(t, "more polls", func() bool { return polls() >= after+3 })
if n := role.activations(2); n != 1 {
t.Errorf("activations of revision 2 = %d, want 1", n)
}
// A poll racing with the report may cause the same report to be sent
// again, never a second apply.
for _, rr := range serverReports(srv) {
if rr.GetDeploymentId() != "dep-poll" || !rr.GetStartedAt().AsTime().Equal(r.GetStartedAt().AsTime()) {
t.Errorf("unexpected report %v", rr)
}
}
}
func TestServerSourceModeChangeAndResentPush(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
srv.with(func(f *fakeServer) {
f.desired = 1
f.mode = agentv1.OperatingMode_OPERATING_MODE_MANUAL
})
role, _ := runAgentAgainst(t, store, id, KindDNS)
counts := func() (hellos, heartbeats, acks int) {
srv.with(func(f *fakeServer) { hellos, heartbeats, acks = len(f.hellos), f.heartbeats, len(f.acks) })
return
}
waitFor(t, "heartbeats in manual mode", func() bool { _, hb, _ := counts(); return hb >= 3 })
if n := len(serverReports(srv)); n != 0 {
t.Fatalf("manual mode applied the desired revision (%d reports)", n)
}
// Switching to auto_pull: the server closes the stream and the new
// Welcome carries the mode.
srv.with(func(f *fakeServer) { f.mode = agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL })
srv.kick <- struct{}{}
waitFor(t, "report of revision 1", func() bool { return len(serverReports(srv)) == 1 })
if r := serverReports(srv)[0]; r.GetRevision() != 1 || r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL {
t.Fatalf("report = %v", r)
}
if h, _, _ := counts(); h < 2 {
t.Errorf("hellos = %d, want a reconnect", h)
}
// Back to manual with a newer revision: it is not applied.
srv.with(func(f *fakeServer) {
f.mode = agentv1.OperatingMode_OPERATING_MODE_MANUAL
f.desired = 2
})
srv.kick <- struct{}{}
waitFor(t, "third hello", func() bool { h, _, _ := counts(); return h >= 3 })
_, hb, _ := counts()
waitFor(t, "heartbeats after the mode change", func() bool { _, n, _ := counts(); return n >= hb+3 })
if n := role.activations(2); n != 0 || len(serverReports(srv)) != 1 {
t.Fatalf("manual mode applied revision 2 (%d activations)", n)
}
// The explicit push applies it.
push := func(cmdID string) *agentv1.Command {
return &agentv1.Command{CommandId: cmdID, Action: &agentv1.Command_ApplyRevision{
ApplyRevision: &agentv1.ApplyRevisionCommand{Revision: 2, DeploymentId: "dep-2"},
}}
}
srv.commands <- push("c1")
waitFor(t, "report of the push", func() bool { return len(serverReports(srv)) == 2 })
first := serverReports(srv)[1]
if first.GetRevision() != 2 || first.GetDeploymentId() != "dep-2" ||
first.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH ||
first.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED {
t.Fatalf("report = %v", first)
}
// The server resends the push (as after a reconnect) after the agent
// has dropped the delivered report from its queue: acknowledged, not
// applied again, the remembered report is delivered again.
waitFor(t, "empty report queue", func() bool {
st, err := store.LoadState()
return err == nil && len(st.PendingReports) == 0
})
srv.commands <- push("c2")
waitFor(t, "second ack and report", func() bool {
_, _, acks := counts()
return acks == 2 && len(serverReports(srv)) == 3
})
if n := role.activations(2); n != 1 {
t.Errorf("activations of revision 2 = %d, want 1", n)
}
again := serverReports(srv)[2]
if again.GetDeploymentId() != "dep-2" || !again.GetStartedAt().AsTime().Equal(first.GetStartedAt().AsTime()) {
t.Errorf("repeated report = %v", again)
}
srv.with(func(f *fakeServer) {
if !f.acks[1].GetAccepted() || f.acks[1].GetCommandId() != "c2" {
t.Errorf("acks = %v", f.acks)
}
})
}
+127
View File
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"log/slog"
"net/netip"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Kind identifies an agent role.
type Kind string
// Supported roles. The values equal the role in bundle manifests.
const (
KindDNS Kind = "dns"
KindDHCP Kind = "dhcp"
)
// Revision is a verified configuration revision stored by the core under
// <state_dir>/revisions/<Number>/. Roles must treat it as read-only.
type Revision struct {
Number uint64
// Dir holds the artifacts at their manifest paths (bundle.CheckPath
// guarantees they stay inside Dir).
Dir string
Files []bundle.File
}
// Role applies revisions to one managed service (BIND or Kea). The core
// drives it as follows (docs/ARCHITECTURE.md, section 4.2):
//
// 1. Validate(rev). An error means the revision is rejected; nothing on the
// host may have changed.
// 2. Activate(rev, prev). Makes rev the live configuration and reloads the
// service. An error triggers a rollback.
// 3. Health(&rev), retried for a while. A non-OK state triggers a rollback.
// 4. Rollback: Activate(lastGood, &rev) followed by Health(&lastGood).
//
// Files marked CreateOnly are written only if they do not exist and are never
// overwritten or removed. Activate removes files that prev had and rev does
// not (except CreateOnly ones).
type Role interface {
// Kind returns the role.
Kind() Kind
// ServiceVersion returns the managed service's version, e.g.
// "BIND 9.20.3" or "Kea 3.2.1".
ServiceVersion(ctx context.Context) (string, error)
// Validate checks rev without changing the live configuration.
Validate(ctx context.Context, rev Revision) error
// Activate installs rev and reloads the service. prev is the revision
// that was active before (nil if none).
Activate(ctx context.Context, rev Revision, prev *Revision) error
// Health checks the running service. expected is the revision that
// should be active (nil if none); the DNS role compares SOA serials.
Health(ctx context.Context, expected *Revision) Health
}
// HealthState summarizes a health check.
type HealthState int
// Health states (mirroring the protocol's HealthState).
const (
HealthUnknown HealthState = iota
HealthOK
HealthDegraded
HealthFailed
)
// Health is the result of Role.Health.
type Health struct {
State HealthState
Message string
Checks []HealthCheck
}
// HealthCheck is one named check within a Health result.
type HealthCheck struct {
Name string
State HealthState
Message string
}
// LeaseState mirrors the protocol's LeaseState.
type LeaseState int
// Lease states.
const (
LeaseUnknown LeaseState = iota
LeaseActive
LeaseExpired
LeaseReleased
LeaseDeclined
)
// Lease is one DHCP lease as seen by the managed service.
type Lease struct {
IP netip.Addr
MAC string // lowercase, colon separated
Hostname string
State LeaseState
Expires time.Time
Updated time.Time // last client transaction
SubnetID uint32
ClientID string
}
// LeaseSource is implemented by roles that can list leases (DHCP). The core
// polls it, computes changes and reports them to the server.
type LeaseSource interface {
Leases(ctx context.Context) ([]Lease, error)
}
// LogSetter is implemented by roles that log on their own (e.g. the DNS
// role's dynamic updates). The core passes its logger, which carries the
// agent ID and role, before it uses the role.
type LogSetter interface {
SetLogger(log *slog.Logger)
}
// RoleFactory creates a role from its section of the agent configuration
// file. decode unmarshals that section (YAML) into the role's own struct; an
// absent section decodes nothing, so roles must start from defaults.
type RoleFactory func(decode func(v any) error) (Role, error)
+622
View File
@@ -0,0 +1,622 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// Defaults of the server connection (docs/adr/0021-agent-strecke-m3.md).
const (
defaultPullInterval = 60 * time.Second
defaultHeartbeatInterval = 30 * time.Second
keepaliveInterval = 30 * time.Second
keepaliveTimeout = 10 * time.Second
backoffMin = time.Second
backoffMax = 60 * time.Second
welcomeTimeout = 30 * time.Second
rpcTimeout = 30 * time.Second
// renewRetryInterval spaces failed renewal attempts (also with an
// expired certificate, which the server accepts for 30 days).
renewRetryInterval = 15 * time.Minute
// oldConnGrace keeps the connection of a replaced certificate open for
// calls in flight.
oldConnGrace = 30 * time.Second
// maxBundleMessage allows bundles up to the bundle size limit.
maxBundleMessage = bundle.MaxTotalSize + 16<<20
)
// errReconnect ends a session after the certificate was renewed.
var errReconnect = errors.New("reconnecting with the renewed certificate")
// ServerSource is the Source backed by the management server: an mTLS gRPC
// connection with the Connect control stream and GetDesiredState polling as
// fallback (docs/adr/0007-server-agent-grpc-mtls.md). It also renews the
// client certificate.
type ServerSource struct {
store *Store
agentID string
ca *x509.Certificate
server string
serverName string
hostname string
log *slog.Logger
// Timing, shortened by tests.
backoffMin, backoffMax time.Duration
welcomeTimeout time.Duration
renewRetry time.Duration
// dialOptions are appended to the defaults (tests).
dialOptions []grpc.DialOption
mu sync.Mutex
cert tls.Certificate
conn *grpc.ClientConn
pullInterval time.Duration
heartbeatInterval time.Duration
lastRenewTry time.Time
renewMu sync.Mutex
}
// NewServerSource creates the source for an enrolled identity.
func NewServerSource(store *Store, id *Identity, hostname string, log *slog.Logger) *ServerSource {
return &ServerSource{
store: store, agentID: id.AgentID, ca: id.CA, server: id.Server, serverName: id.ServerName,
hostname: hostname, log: log, cert: id.Certificate,
backoffMin: backoffMin, backoffMax: backoffMax, welcomeTimeout: welcomeTimeout,
renewRetry: renewRetryInterval, pullInterval: defaultPullInterval,
heartbeatInterval: defaultHeartbeatInterval,
}
}
// client returns the gRPC client, creating the connection on first use.
// Connections are established lazily by gRPC.
func (s *ServerSource) client() (agentv1.AgentServiceClient, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
opts := append([]grpc.DialOption{
grpc.WithTransportCredentials(credentials.NewTLS(agentpki.ClientTLSConfig(s.ca, s.cert, s.serverName))),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: keepaliveInterval, Timeout: keepaliveTimeout, PermitWithoutStream: true,
}),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxBundleMessage)),
}, s.dialOptions...)
conn, err := grpc.NewClient(s.server, opts...)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", s.server, err)
}
s.conn = conn
}
return agentv1.NewAgentServiceClient(s.conn), nil
}
// replaceConn drops the current connection (after a certificate renewal);
// the next call dials with the new certificate.
func (s *ServerSource) replaceConn() {
s.mu.Lock()
old := s.conn
s.conn = nil
s.mu.Unlock()
if old != nil {
time.AfterFunc(oldConnGrace, func() { _ = old.Close() })
}
}
func (s *ServerSource) closeConn() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
_ = s.conn.Close()
s.conn = nil
}
}
func (s *ServerSource) intervals() (pull, heartbeat time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
return s.pullInterval, s.heartbeatInterval
}
func (s *ServerSource) setIntervals(pull, heartbeat time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if pull > 0 {
s.pullInterval = pull
}
if heartbeat > 0 {
s.heartbeatInterval = heartbeat
}
}
// Run implements Source: it keeps a Connect stream open, reconnecting with
// exponential backoff and jitter, and polls GetDesiredState every pull
// interval while no stream can be established.
func (s *ServerSource) Run(ctx context.Context, h SourceHandler) error {
defer s.closeConn()
backoff := s.backoffMin
var lastPoll time.Time
reachable := false
for ctx.Err() == nil {
s.maybeRenew(ctx)
established, err := s.session(ctx, h)
if ctx.Err() != nil {
break
}
wait := true
switch {
case errors.Is(err, errReconnect):
wait = false
backoff = s.backoffMin
case established:
s.log.Warn("control stream to the server closed", "error", err)
backoff = s.backoffMin
reachable = false
default:
s.log.Warn("cannot open control stream", "server", s.server, "error", err)
pull, _ := s.intervals()
if time.Since(lastPoll) >= pull {
lastPoll = time.Now()
if err := s.poll(ctx, h); err != nil {
s.log.Warn("cannot poll the desired state", "error", err)
reachable = false
} else if !reachable {
reachable = true
h.Connected()
}
}
}
if wait {
if !sleepCtx(ctx, jitter(backoff)) {
break
}
backoff = min(backoff*2, s.backoffMax)
}
}
return ctx.Err()
}
// session runs one Connect stream. established reports whether the server
// answered the Hello with a Welcome.
func (s *ServerSource) session(ctx context.Context, h SourceHandler) (established bool, err error) {
cl, err := s.client()
if err != nil {
return false, err
}
sctx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := cl.Connect(sctx)
if err != nil {
return false, err
}
defer func() { _ = stream.CloseSend() }()
st := h.Status(sctx)
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{
AgentVersion: buildinfo.Get().Version, AppliedRevision: st.Applied, LastGoodRevision: st.LastGood,
Health: healthToProto(st.Health, st.CheckedAt), ServiceVersion: st.ServiceVersion, Hostname: s.hostname,
}}}); err != nil {
return false, err
}
msgs := make(chan *agentv1.ConnectResponse)
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
select {
case msgs <- msg:
case <-sctx.Done():
return
}
}
}()
timer := time.NewTimer(s.welcomeTimeout)
defer timer.Stop()
select {
case <-ctx.Done():
return false, ctx.Err()
case err := <-recvErr:
return false, err
case <-timer.C:
return false, errors.New("server did not answer the Hello in time")
case msg := <-msgs:
w := msg.GetWelcome()
if w == nil {
return false, errors.New("server did not answer the Hello with a Welcome")
}
s.log.Info("connected to the server", "server", s.server, "mode", w.GetMode().String(),
"desired_revision", w.GetDesiredRevision())
s.welcome(w, h, true)
}
_, hbInterval := s.intervals()
heartbeat := time.NewTicker(hbInterval)
defer heartbeat.Stop()
renew := time.NewTimer(s.untilRenewal())
defer renew.Stop()
for {
select {
case <-ctx.Done():
return true, ctx.Err()
case err := <-recvErr:
return true, err
case msg := <-msgs:
switch m := msg.GetMessage().(type) {
case *agentv1.ConnectResponse_Welcome:
s.welcome(m.Welcome, h, false)
_, hb := s.intervals()
heartbeat.Reset(hb)
case *agentv1.ConnectResponse_Command:
if err := s.command(ctx, stream, m.Command, h); err != nil {
return true, err
}
}
case <-heartbeat.C:
st := h.Status(sctx)
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Heartbeat{Heartbeat: &agentv1.Heartbeat{
AppliedRevision: st.Applied, LastGoodRevision: st.LastGood, Health: healthToProto(st.Health, st.CheckedAt),
}}}); err != nil {
return true, err
}
case <-renew.C:
if s.maybeRenew(ctx) {
return true, errReconnect
}
renew.Reset(s.untilRenewal())
}
}
}
// welcome applies the settings of a Welcome and announces its desired
// revision.
func (s *ServerSource) welcome(w *agentv1.Welcome, h SourceHandler, first bool) {
s.setIntervals(w.GetPullInterval().AsDuration(), w.GetHeartbeatInterval().AsDuration())
if first {
h.Connected()
}
h.Desired(w.GetDesiredRevision(), w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
}
// command acknowledges and executes a server command.
func (s *ServerSource) command(ctx context.Context, stream agentv1.AgentService_ConnectClient, c *agentv1.Command, h SourceHandler) error {
ack := func(accepted bool, msg string) error {
return stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_CommandAck{CommandAck: &agentv1.CommandAck{
CommandId: c.GetCommandId(), Accepted: accepted, Message: msg,
}}})
}
log := s.log.With("command_id", c.GetCommandId())
switch a := c.GetAction().(type) {
case *agentv1.Command_ApplyRevision:
rev, dep := a.ApplyRevision.GetRevision(), a.ApplyRevision.GetDeploymentId()
if rev == 0 {
log.Warn("apply command without revision")
return ack(false, "revision is missing")
}
log.Info("apply command received", "revision", rev, "deployment_id", dep)
if err := ack(true, ""); err != nil {
return err
}
h.Push(rev, dep)
return nil
case *agentv1.Command_RenewCertificate:
log.Info("certificate renewal requested by the server")
if err := ack(true, ""); err != nil {
return err
}
if err := s.renew(ctx); err != nil {
log.Error("certificate renewal failed", "error", err)
return nil
}
return errReconnect
default:
log.Warn("unknown command")
return ack(false, "unknown command")
}
}
// poll asks for the desired state once.
func (s *ServerSource) poll(ctx context.Context, h SourceHandler) error {
cl, err := s.client()
if err != nil {
return err
}
st := h.Status(ctx)
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.GetDesiredState(rctx, &agentv1.GetDesiredStateRequest{AppliedRevision: st.Applied})
if err != nil {
return err
}
s.setIntervals(resp.GetPullInterval().AsDuration(), 0)
rev, dep := resp.GetDesiredRevision(), resp.GetDeploymentId()
s.log.Debug("desired state polled", "desired_revision", rev, "mode", resp.GetMode().String(), "deployment_id", dep)
if dep != "" && rev != 0 {
// An open push: applied in every mode (docs/adr/0022-ausrollen-pull-push-m4.md).
s.log.Info("open push received by polling", "revision", rev, "deployment_id", dep)
h.Push(rev, dep)
return nil
}
h.Desired(rev, resp.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
return nil
}
// leaf returns the current client certificate.
func (s *ServerSource) leaf() *x509.Certificate {
s.mu.Lock()
defer s.mu.Unlock()
return s.cert.Leaf
}
// renewalDue returns when the certificate should be renewed: when half of
// its lifetime has passed.
func renewalDue(c *x509.Certificate) time.Time {
return c.NotBefore.Add(c.NotAfter.Sub(c.NotBefore) / 2)
}
// untilRenewal returns the time until the next renewal attempt.
func (s *ServerSource) untilRenewal() time.Duration {
due := renewalDue(s.leaf())
s.mu.Lock()
last := s.lastRenewTry
s.mu.Unlock()
if !last.IsZero() && time.Now().After(due) {
due = last.Add(s.renewRetry)
}
return max(time.Until(due), 0)
}
// maybeRenew renews the certificate if half of its lifetime has passed
// (or it expired), spacing failed attempts by renewRetry.
func (s *ServerSource) maybeRenew(ctx context.Context) bool {
leaf := s.leaf()
now := time.Now()
if now.Before(renewalDue(leaf)) {
return false
}
s.mu.Lock()
if !s.lastRenewTry.IsZero() && now.Sub(s.lastRenewTry) < s.renewRetry {
s.mu.Unlock()
return false
}
s.lastRenewTry = now
s.mu.Unlock()
if err := s.renew(ctx); err != nil {
if now.After(leaf.NotAfter) {
s.log.Error("client certificate expired and renewal failed; the agent must be enrolled again if this persists",
"not_after", leaf.NotAfter, "error", err)
} else {
s.log.Warn("certificate renewal failed", "not_after", leaf.NotAfter, "error", err)
}
return false
}
return true
}
// renew obtains a certificate for a new key and stores it.
func (s *ServerSource) renew(ctx context.Context) error {
s.renewMu.Lock()
defer s.renewMu.Unlock()
key, csr, err := newKeyAndCSR(s.agentID)
if err != nil {
return err
}
cl, err := s.client()
if err != nil {
return err
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.RenewCertificate(rctx, &agentv1.RenewCertificateRequest{CsrDer: csr})
if err != nil {
return err
}
if chain := resp.GetCaCertificatesDer(); len(chain) > 0 {
if root, err := x509.ParseCertificate(chain[len(chain)-1]); err != nil || !root.Equal(s.ca) {
s.log.Warn("server sent a different CA certificate; keeping the enrolled CA")
}
}
cert, err := checkIssued(resp.GetCertificateDer(), s.ca, s.agentID, key)
if err != nil {
return err
}
if err := s.store.SaveCertificate(key, cert); err != nil {
return err
}
s.mu.Lock()
s.cert = keyPair(key, cert)
s.lastRenewTry = time.Time{}
s.mu.Unlock()
s.replaceConn()
s.log.Info("client certificate renewed", "not_after", cert.NotAfter)
return nil
}
// FetchBundle implements Source.
func (s *ServerSource) FetchBundle(ctx context.Context, revision uint64) (*Bundle, error) {
cl, err := s.client()
if err != nil {
return nil, err
}
resp, err := cl.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: revision})
if err != nil {
return nil, err
}
b := &Bundle{Manifest: resp.GetManifest(), Signature: resp.GetSignature()}
for _, a := range resp.GetArtifacts() {
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.GetPath(), Content: a.GetContent()})
}
return b, nil
}
// ReportDeployment implements Source.
func (s *ServerSource) ReportDeployment(ctx context.Context, r DeploymentReport) error {
cl, err := s.client()
if err != nil {
return err
}
_, err = cl.ReportDeployment(ctx, &agentv1.ReportDeploymentRequest{
DeploymentId: r.DeploymentID, Revision: r.Revision, Trigger: triggerToProto(r.Trigger),
Outcome: outcomeToProto(r.Outcome), Message: r.Message, ActiveRevision: r.ActiveRevision,
StartedAt: timestamppb.New(r.StartedAt), FinishedAt: timestamppb.New(r.FinishedAt),
})
if refusedReportCodes[status.Code(err)] {
return fmt.Errorf("%w: %w", ErrReportRefused, err)
}
return err
}
// refusedReportCodes are the answers to ReportDeployment that a retry
// cannot change: the server considers the report invalid or does not
// expect it (any more).
var refusedReportCodes = map[codes.Code]bool{
codes.InvalidArgument: true,
codes.NotFound: true,
codes.AlreadyExists: true,
codes.FailedPrecondition: true,
codes.OutOfRange: true,
}
// ReportLeases implements Source.
func (s *ServerSource) ReportLeases(ctx context.Context, b LeaseBatch) error {
cl, err := s.client()
if err != nil {
return err
}
req := &agentv1.ReportLeasesRequest{Sequence: b.Sequence, FullSync: b.FullSync, Leases: make([]*agentv1.Lease, len(b.Leases))}
for i, l := range b.Leases {
req.Leases[i] = leaseToProto(l)
}
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
defer cancel()
resp, err := cl.ReportLeases(rctx, req)
if err != nil {
return err
}
if ack := resp.GetAcknowledgedSequence(); ack != b.Sequence {
s.log.Debug("lease report acknowledged with another sequence", "sequence", b.Sequence, "acknowledged", ack)
}
return nil
}
func healthToProto(h Health, at time.Time) *agentv1.HealthStatus {
out := &agentv1.HealthStatus{State: healthStateToProto(h.State), Message: h.Message}
if !at.IsZero() {
out.CheckedAt = timestamppb.New(at)
}
for _, c := range h.Checks {
out.Checks = append(out.Checks, &agentv1.HealthCheck{Name: c.Name, State: healthStateToProto(c.State), Message: c.Message})
}
return out
}
func healthStateToProto(s HealthState) agentv1.HealthState {
switch s {
case HealthOK:
return agentv1.HealthState_HEALTH_STATE_OK
case HealthDegraded:
return agentv1.HealthState_HEALTH_STATE_DEGRADED
case HealthFailed:
return agentv1.HealthState_HEALTH_STATE_FAILED
default:
return agentv1.HealthState_HEALTH_STATE_UNSPECIFIED
}
}
func triggerToProto(t Trigger) agentv1.DeploymentTrigger {
switch t {
case TriggerPull:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL
case TriggerPush:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH
default:
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_UNSPECIFIED
}
}
func outcomeToProto(o Outcome) agentv1.DeploymentOutcome {
switch o {
case OutcomeSucceeded:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED
case OutcomeRejected:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED
case OutcomeRolledBack:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK
case OutcomeFailed:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED
default:
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_UNSPECIFIED
}
}
func leaseToProto(l Lease) *agentv1.Lease {
out := &agentv1.Lease{
Ip: l.IP.String(), Mac: l.MAC, Hostname: l.Hostname, State: leaseStateToProto(l.State),
SubnetId: l.SubnetID, ClientId: l.ClientID,
}
if !l.Expires.IsZero() {
out.ExpiresAt = timestamppb.New(l.Expires)
}
if !l.Updated.IsZero() {
out.UpdatedAt = timestamppb.New(l.Updated)
}
return out
}
func leaseStateToProto(s LeaseState) agentv1.LeaseState {
switch s {
case LeaseActive:
return agentv1.LeaseState_LEASE_STATE_ACTIVE
case LeaseExpired:
return agentv1.LeaseState_LEASE_STATE_EXPIRED
case LeaseReleased:
return agentv1.LeaseState_LEASE_STATE_RELEASED
case LeaseDeclined:
return agentv1.LeaseState_LEASE_STATE_DECLINED
default:
return agentv1.LeaseState_LEASE_STATE_UNSPECIFIED
}
}
// jitter returns d randomized by ±20 %.
func jitter(d time.Duration) time.Duration {
return time.Duration(float64(d) * (0.8 + 0.4*rand.Float64())) //nolint:gosec // jitter needs no cryptographic randomness
}
// sleepCtx waits for d; it returns false if ctx ended first.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
+412
View File
@@ -0,0 +1,412 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
)
// enrollWith enrolls a new store against the fake server.
func enrollWith(t *testing.T, srv *fakeServer, addr string) (*Store, *Identity) {
t.Helper()
store := openTestStore(t)
id, err := Enroll(context.Background(), store, EnrollOptions{
Kind: srv.role, Server: addr, Token: srv.token, CAHash: srv.fingerprint(),
ServerName: "localhost", Hostname: "test-host",
})
if err != nil {
t.Fatalf("Enroll: %v", err)
}
return store, id
}
func TestEnroll(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
if id.AgentID != srv.agentID || id.Server != addr || id.ServerName != "localhost" || !id.CA.Equal(srv.pki.ca) {
t.Fatalf("identity = %+v", id)
}
loaded, err := store.LoadIdentity()
if err != nil {
t.Fatal(err)
}
if loaded.AgentID != srv.agentID || !loaded.BundleKey.Equal(srv.signKey.Public()) || loaded.Role != KindDNS {
t.Errorf("stored identity = %+v", loaded)
}
if got, err := agentpki.AgentID(loaded.Certificate.Leaf); err != nil || got != srv.agentID {
t.Errorf("certificate agent ID = %q, %v", got, err)
}
// Enrolling again needs --force.
opts := EnrollOptions{Kind: KindDNS, Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "localhost"}
if _, err := Enroll(context.Background(), store, opts); !errors.Is(err, errAlreadyEnrolled) {
t.Fatalf("second Enroll = %v", err)
}
srv.with(func(f *fakeServer) { f.agentID = "22222222-2222-4333-8444-555555555555" })
st := &State{Applied: 3, PendingReports: []DeploymentReport{{Revision: 3}}}
if err := store.SaveState(st); err != nil {
t.Fatal(err)
}
opts.Force = true
id2, err := Enroll(context.Background(), store, opts)
if err != nil || id2.AgentID != "22222222-2222-4333-8444-555555555555" {
t.Fatalf("forced Enroll = %+v, %v", id2, err)
}
if st, _ := store.LoadState(); st.Applied != 3 || len(st.PendingReports) != 0 {
t.Errorf("state after forced enroll = %+v", st)
}
}
func TestEnrollDefaultsServerNameToHost(t *testing.T) {
srv := newFakeServer(t, KindDHCP)
addr := srv.start(t) // 127.0.0.1:port; the server certificate has the IP SAN
store := openTestStore(t)
id, err := Enroll(context.Background(), store, EnrollOptions{
Kind: KindDHCP, Server: "https://" + addr + "/", Token: srv.token, CAHash: strings.ToUpper(srv.fingerprint()[7:]),
})
if err == nil {
t.Fatalf("fingerprint without prefix accepted: %+v", id)
}
id, err = Enroll(context.Background(), store, EnrollOptions{
Kind: KindDHCP, Server: "https://" + addr + "/", Token: srv.token, CAHash: "sha256:" + strings.ToUpper(srv.fingerprint()[7:]),
})
if err != nil {
t.Fatal(err)
}
if id.ServerName != "127.0.0.1" || id.Server != addr {
t.Errorf("identity = %+v", id)
}
}
func TestEnrollFailures(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
other := newTestPKI(t)
cases := map[string]EnrollOptions{
"wrong fingerprint": {Server: addr, Token: srv.token, CAHash: agentpki.Fingerprint(other.ca), ServerName: "localhost"},
"wrong server name": {Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "evil.example.com"},
"wrong token": {Server: addr, Token: "0123456789abcdef.wrong", CAHash: srv.fingerprint(), ServerName: "localhost"},
"bad token": {Server: addr, Token: "nodot", CAHash: srv.fingerprint()},
"bad address": {Server: "localhost", Token: srv.token, CAHash: srv.fingerprint()},
"bad fingerprint": {Server: addr, Token: srv.token, CAHash: "sha256:abc"},
}
for name, opts := range cases {
t.Run(name, func(t *testing.T) {
opts.Kind = KindDNS
store := openTestStore(t)
if _, err := Enroll(context.Background(), store, opts); err == nil {
t.Fatal("accepted")
}
if store.Enrolled() {
t.Error("identity stored after a failed enrollment")
}
})
}
// Role mismatch: the server refuses.
store := openTestStore(t)
if _, err := Enroll(context.Background(), store, EnrollOptions{
Kind: KindDHCP, Server: addr, Token: srv.token, CAHash: srv.fingerprint(), ServerName: "localhost",
}); err == nil {
t.Error("wrong role accepted")
}
if _, err := Enroll(context.Background(), store, EnrollOptions{
Kind: Kind("ntp"), Server: addr, Token: srv.token, CAHash: srv.fingerprint(),
}); err == nil {
t.Error("unknown role accepted")
}
srv.with(func(f *fakeServer) {
if f.enrollments == 0 {
t.Error("server never reached")
}
})
}
// runAgentAgainst runs an agent with a ServerSource until the test ends.
func runAgentAgainst(t *testing.T, store *Store, id *Identity, kind Kind) (*fakeRole, *ServerSource) {
t.Helper()
role := newFakeRole(kind)
src := NewServerSource(store, id, "test-host", testLogger())
src.backoffMin, src.backoffMax = 10*time.Millisecond, 100*time.Millisecond
a, err := NewAgent(kind, testConfig(store.Dir()), role, store, id, src, testLogger())
if err != nil {
t.Fatal(err)
}
a.healthRetry = 20 * time.Millisecond
a.reportRetry = 50 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Run = %v", err)
}
case <-time.After(10 * time.Second):
t.Error("Run did not return after cancel")
}
})
return role, src
}
func serverReports(srv *fakeServer) []*agentv1.ReportDeploymentRequest {
var out []*agentv1.ReportDeploymentRequest
srv.with(func(f *fakeServer) { out = append(out, f.reports...) })
return out
}
func TestServerSourceStreamAndPush(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
srv.with(func(f *fakeServer) { f.desired = 1 })
role, _ := runAgentAgainst(t, store, id, KindDNS)
waitFor(t, "report of revision 1", func() bool { return len(serverReports(srv)) == 1 })
r := serverReports(srv)[0]
if r.GetRevision() != 1 || r.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED ||
r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL || r.GetActiveRevision() != 1 ||
r.GetStartedAt() == nil || r.GetFinishedAt() == nil {
t.Fatalf("report = %v", r)
}
srv.with(func(f *fakeServer) {
if len(f.hellos) == 0 || f.hellos[0].GetHostname() != "test-host" || f.hellos[0].GetAgentVersion() == "" {
t.Errorf("hellos = %v", f.hellos)
}
})
waitFor(t, "heartbeats", func() bool {
n := 0
srv.with(func(f *fakeServer) { n = f.heartbeats })
return n >= 2
})
// Push over the stream.
srv.commands <- &agentv1.Command{CommandId: "c1", Action: &agentv1.Command_ApplyRevision{
ApplyRevision: &agentv1.ApplyRevisionCommand{Revision: 2, DeploymentId: "dep-2"},
}}
waitFor(t, "report of revision 2", func() bool { return len(serverReports(srv)) == 2 })
r = serverReports(srv)[1]
if r.GetRevision() != 2 || r.GetDeploymentId() != "dep-2" || r.GetTrigger() != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH ||
r.GetOutcome() != agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED {
t.Fatalf("report = %v", r)
}
srv.with(func(f *fakeServer) {
if len(f.acks) != 1 || f.acks[0].GetCommandId() != "c1" || !f.acks[0].GetAccepted() {
t.Errorf("acks = %v", f.acks)
}
})
if n := role.activeRevision(); n != 2 {
t.Errorf("active = %d", n)
}
// Invalid and unknown commands are refused.
srv.commands <- &agentv1.Command{CommandId: "c2", Action: &agentv1.Command_ApplyRevision{ApplyRevision: &agentv1.ApplyRevisionCommand{}}}
srv.commands <- &agentv1.Command{CommandId: "c3"}
waitFor(t, "refusals", func() bool {
n := 0
srv.with(func(f *fakeServer) { n = len(f.acks) })
return n == 3
})
srv.with(func(f *fakeServer) {
if f.acks[1].GetAccepted() || f.acks[2].GetAccepted() {
t.Errorf("acks = %v", f.acks)
}
})
}
func TestServerSourceManualMode(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
srv.with(func(f *fakeServer) {
f.desired = 1
f.mode = agentv1.OperatingMode_OPERATING_MODE_MANUAL
})
runAgentAgainst(t, store, id, KindDNS)
waitFor(t, "hello", func() bool {
n := 0
srv.with(func(f *fakeServer) { n = f.heartbeats })
return n >= 3
})
if n := len(serverReports(srv)); n != 0 {
t.Errorf("manual mode applied the desired revision (%d reports)", n)
}
}
func TestServerSourcePollingFallback(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
srv.with(func(f *fakeServer) {
f.desired = 1
f.streamDisabled = true
})
runAgentAgainst(t, store, id, KindDNS)
waitFor(t, "report via polling", func() bool { return len(serverReports(srv)) == 1 })
srv.with(func(f *fakeServer) {
if f.polls == 0 {
t.Error("GetDesiredState never called")
}
})
// A newer revision is picked up by a later poll.
srv.with(func(f *fakeServer) { f.desired = 2 })
waitFor(t, "report of revision 2", func() bool { return len(serverReports(srv)) == 2 })
if st, _ := store.LoadState(); st.Applied != 2 {
t.Errorf("state = %+v", st)
}
}
func TestServerSourceRenewsCertificate(t *testing.T) {
srv := newFakeServer(t, KindDNS)
// More than half of the certificate's lifetime has passed.
srv.enrollValidity = [2]time.Duration{-3 * time.Hour, time.Hour}
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
oldCert, _ := os.ReadFile(filepath.Join(store.Dir(), "identity", "cert.pem"))
runAgentAgainst(t, store, id, KindDNS)
waitFor(t, "renewal and hello", func() bool {
ok := false
srv.with(func(f *fakeServer) { ok = f.renewals == 1 && len(f.hellos) >= 1 })
return ok
})
newCert, _ := os.ReadFile(filepath.Join(store.Dir(), "identity", "cert.pem"))
if string(newCert) == string(oldCert) {
t.Error("cert.pem unchanged after renewal")
}
loaded, err := store.LoadIdentity()
if err != nil {
t.Fatal(err)
}
if time.Until(loaded.Certificate.Leaf.NotAfter) < 24*time.Hour {
t.Errorf("renewed certificate expires %v", loaded.Certificate.Leaf.NotAfter)
}
}
func TestServerSourceRenewCommand(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
runAgentAgainst(t, store, id, KindDNS)
waitFor(t, "first hello", func() bool {
n := 0
srv.with(func(f *fakeServer) { n = len(f.hellos) })
return n == 1
})
srv.commands <- &agentv1.Command{CommandId: "r1", Action: &agentv1.Command_RenewCertificate{RenewCertificate: &agentv1.RenewCertificateCommand{}}}
// The old certificate is no longer accepted, so the second Hello proves
// that the agent reconnected with the new one.
waitFor(t, "reconnect with the renewed certificate", func() bool {
ok := false
srv.with(func(f *fakeServer) { ok = f.renewals == 1 && len(f.hellos) >= 2 })
return ok
})
}
func TestServerSourceReportsLeases(t *testing.T) {
srv := newFakeServer(t, KindDHCP)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
role, _ := runAgentAgainst(t, store, id, KindDHCP)
_ = role
waitFor(t, "full lease sync", func() bool {
ok := false
srv.with(func(f *fakeServer) {
for _, r := range f.leaseReports {
if r.GetFullSync() {
ok = true
}
}
})
return ok
})
}
func TestLeaseToProto(t *testing.T) {
l := lease("10.0.0.9", "aa:bb:cc:dd:ee:ff")
l.ClientID = "01:aa"
p := leaseToProto(l)
if p.GetIp() != "10.0.0.9" || p.GetMac() != l.MAC || p.GetState() != agentv1.LeaseState_LEASE_STATE_ACTIVE ||
!p.GetExpiresAt().AsTime().Equal(l.Expires) || p.GetSubnetId() != 1 || p.GetClientId() != "01:aa" {
t.Errorf("lease = %v", p)
}
if p := leaseToProto(Lease{IP: mustAddr("10.0.0.1")}); p.ExpiresAt != nil || p.UpdatedAt != nil {
t.Errorf("zero times encoded: %v", p)
}
for s, want := range map[LeaseState]agentv1.LeaseState{
LeaseUnknown: agentv1.LeaseState_LEASE_STATE_UNSPECIFIED, LeaseExpired: agentv1.LeaseState_LEASE_STATE_EXPIRED,
LeaseReleased: agentv1.LeaseState_LEASE_STATE_RELEASED, LeaseDeclined: agentv1.LeaseState_LEASE_STATE_DECLINED,
} {
if got := leaseStateToProto(s); got != want {
t.Errorf("%v -> %v, want %v", s, got, want)
}
}
for o, want := range map[Outcome]agentv1.DeploymentOutcome{
OutcomeRejected: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED, OutcomeRolledBack: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK,
OutcomeFailed: agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED, Outcome("x"): agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_UNSPECIFIED,
} {
if got := outcomeToProto(o); got != want {
t.Errorf("%v -> %v, want %v", o, got, want)
}
}
if triggerToProto(Trigger("x")) != agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_UNSPECIFIED {
t.Error("unknown trigger mapped")
}
h := healthToProto(Health{State: HealthDegraded, Checks: []HealthCheck{{Name: "a", State: HealthFailed}}}, time.Time{})
if h.GetState() != agentv1.HealthState_HEALTH_STATE_DEGRADED || h.CheckedAt != nil || h.GetChecks()[0].GetState() != agentv1.HealthState_HEALTH_STATE_FAILED {
t.Errorf("health = %v", h)
}
}
func TestServerSourceUnreachable(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
store, id := enrollWith(t, srv, addr)
id.Server = "127.0.0.1:1" // nothing listens there
src := NewServerSource(store, id, "h", testLogger())
defer src.closeConn()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := src.ReportDeployment(ctx, DeploymentReport{Revision: 1}); err == nil {
t.Error("report to an unreachable server succeeded")
}
if _, err := src.FetchBundle(ctx, 1); err == nil {
t.Error("fetch from an unreachable server succeeded")
}
if code := status.Code(src.ReportLeases(ctx, LeaseBatch{Sequence: 1})); code == codes.OK {
t.Error("lease report to an unreachable server succeeded")
}
}
func TestJitterAndRenewalDue(t *testing.T) {
for i := 0; i < 100; i++ {
if d := jitter(time.Second); d < 800*time.Millisecond || d > 1200*time.Millisecond {
t.Fatalf("jitter = %v", d)
}
}
pki := newTestPKI(t)
id, _ := testIdentity(t, pki)
leaf := id.Certificate.Leaf
if due := renewalDue(leaf); !due.Equal(leaf.NotBefore.Add(leaf.NotAfter.Sub(leaf.NotBefore) / 2)) {
t.Errorf("renewalDue = %v", due)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if sleepCtx(ctx, time.Hour) {
t.Error("sleepCtx ignored the context")
}
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
//go:build !(linux || darwin || freebsd || netbsd || openbsd || dragonfly)
package core
// handOverStateDir is a no-op where file ownership works differently; the
// agents only run on Linux.
func handOverStateDir(string) (int, error) { return -1, nil }
// systemdRunning is false outside Unix.
func systemdRunning() bool { return false }
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: Apache-2.0
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
package core
import (
"io/fs"
"os"
"syscall"
)
// handOverStateDir gives everything below the state directory to the owner
// of the directory. The installer creates the directory for the service
// user of the agent (e.g. bind); an enrollment run as root would otherwise
// leave files the service cannot read. The walk is confined to the
// directory (os.Root), so a symlink placed inside cannot redirect the
// ownership change. It returns the new owner's UID, or -1 if nothing was
// to do (not root, or the directory belongs to root).
func handOverStateDir(dir string) (int, error) {
if os.Geteuid() != 0 {
return -1, nil
}
info, err := os.Stat(dir)
if err != nil {
return -1, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || st.Uid == 0 {
return -1, nil
}
uid, gid := int(st.Uid), int(st.Gid)
root, err := os.OpenRoot(dir)
if err != nil {
return -1, err
}
defer root.Close() //nolint:errcheck // read-only handle
err = fs.WalkDir(root.FS(), ".", func(path string, _ fs.DirEntry, err error) error {
if err != nil || path == "." {
return err
}
return root.Lchown(path, uid, gid)
})
return uid, err
}
// systemdRunning reports whether systemd manages this host.
func systemdRunning() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"time"
)
// ErrReportRefused marks a deployment report that the source refused for
// good (invalid, or for a deployment it no longer expects). Retrying it
// would block the reports queued behind it.
var ErrReportRefused = errors.New("deployment report refused")
// Source is where the agent learns its desired revision, fetches bundles and
// reports results (docs/adr/0010-agent-binaries-pro-rolle.md). M3 provides
// ServerSource (gRPC); a FileSource for standalone operation (M7) implements
// the same interface without changes to the apply pipeline.
type Source interface {
// Run delivers events to h until ctx is canceled. It returns ctx.Err()
// on shutdown or an error if the source cannot work at all.
Run(ctx context.Context, h SourceHandler) error
// FetchBundle returns the signed bundle of a revision. The caller
// verifies it.
FetchBundle(ctx context.Context, revision uint64) (*Bundle, error)
// ReportDeployment delivers an apply outcome. An error wrapping
// ErrReportRefused means that the report will never be accepted; the
// agent then drops it instead of retrying.
ReportDeployment(ctx context.Context, r DeploymentReport) error
// ReportLeases delivers a batch of lease observations.
ReportLeases(ctx context.Context, b LeaseBatch) error
}
// SourceHandler receives the events of a Source. Its methods must not
// block for long.
type SourceHandler interface {
// Connected is called whenever the source (re)established contact;
// the agent then sends queued reports and a full lease snapshot.
Connected()
// Desired announces the revision the agent should run. autoApply is
// false in manual mode, where only explicit pushes are applied.
Desired(revision uint64, autoApply bool)
// Push asks for an explicit apply of a revision. Unlike Desired, it
// also retries a revision that failed before. A push whose deployment
// ID was handled before is not applied again; its report is delivered
// (again) instead.
Push(revision uint64, deploymentID string)
// Status returns what the agent reports in Hello and heartbeats.
Status(ctx context.Context) AgentStatus
}
// AgentStatus is the agent's view of itself.
type AgentStatus struct {
Applied uint64
LastGood uint64
Health Health
CheckedAt time.Time
ServiceVersion string
}
// Bundle is a signed bundle as delivered by a Source.
type Bundle struct {
Manifest []byte
Signature []byte
Artifacts []BundleArtifact
}
// BundleArtifact is one artifact of a Bundle.
type BundleArtifact struct {
Path string
Content []byte
}
// LeaseBatch is one lease report. Sequence is monotonic per agent. A batch
// with FullSync replaces the server's view of all leases of this agent; the
// following batches (higher sequence) are changes on top of it.
type LeaseBatch struct {
Sequence uint64
FullSync bool
Leases []Lease
}
+406
View File
@@ -0,0 +1,406 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Layout of the state directory (docs/adr/0011-agent-zustand-in-dateien.md).
const (
identityDir = "identity"
revisionsDir = "revisions"
stateFile = "state.json"
lockFile = "agent.lock"
manifestFile = "manifest.json"
signatureFile = "manifest.sig"
tmpPrefix = ".tmp-"
oldPrefix = ".old-"
)
// KeepRevisions is the number of stored revisions kept by Prune (the applied
// and the last good revision are kept in addition).
const KeepRevisions = 10
// maxPendingReports bounds the queue of unsent deployment reports.
const maxPendingReports = 100
// maxHandledDeployments bounds the remembered push deployments, and
// handledMessageLen the message kept with each of them.
const (
maxHandledDeployments = 20
handledMessageLen = 1024
)
// Store manages the agent's state directory. Writes are atomic: files are
// written to a temporary file, synced and renamed; directories are built
// under a temporary name and renamed into place.
type Store struct {
dir string
}
// OpenStore creates the state directory if necessary.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("state directory is not set")
}
if err := os.MkdirAll(filepath.Join(dir, revisionsDir), 0o750); err != nil {
return nil, fmt.Errorf("create state directory: %w", err)
}
return &Store{dir: dir}, nil
}
// Dir returns the state directory.
func (s *Store) Dir() string { return s.dir }
// Lock takes the exclusive lock of the state directory. The returned
// function releases it.
func (s *Store) Lock() (func(), error) {
return lockPath(filepath.Join(s.dir, lockFile))
}
// Trigger says why a revision was applied.
type Trigger string
// Triggers.
const (
TriggerPull Trigger = "pull"
TriggerPush Trigger = "push"
)
// Outcome is the result of applying a revision.
type Outcome string
// Outcomes (see DeploymentOutcome in the protocol).
const (
OutcomeSucceeded Outcome = "succeeded"
OutcomeRejected Outcome = "rejected"
OutcomeRolledBack Outcome = "rolled_back"
OutcomeFailed Outcome = "failed"
)
// DeploymentReport is the outcome of one apply, queued in state.json until
// the source accepted it.
type DeploymentReport struct {
DeploymentID string `json:"deployment_id,omitempty"`
Revision uint64 `json:"revision"`
Trigger Trigger `json:"trigger"`
Outcome Outcome `json:"outcome"`
Message string `json:"message,omitempty"`
ActiveRevision uint64 `json:"active_revision"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// State is persisted in state.json.
type State struct {
// Applied is the revision whose configuration is installed (0 = none).
Applied uint64 `json:"applied_revision"`
// LastGood is the last revision that passed its health check.
LastGood uint64 `json:"last_good_revision"`
// LastUnsuccessful is the highest revision that was rejected, rolled
// back or failed. Pulls never retry it; only a newer revision or an
// explicit push does.
LastUnsuccessful uint64 `json:"last_unsuccessful_revision,omitempty"`
// LastResult is the most recent apply outcome.
LastResult *DeploymentReport `json:"last_result,omitempty"`
// LeaseSequence is the sequence number of the last lease report.
LeaseSequence uint64 `json:"lease_sequence,omitempty"`
// PendingReports are deployment reports not yet accepted by the server.
PendingReports []DeploymentReport `json:"pending_reports,omitempty"`
// HandledDeployments are the reports of the most recent push
// deployments (oldest first, at most maxHandledDeployments). A push
// with one of these deployment IDs is not applied again: the server
// resends open pushes after a reconnect and announces them while
// polling until it has the report (docs/adr/0022-ausrollen-pull-push-m4.md).
HandledDeployments []DeploymentReport `json:"handled_deployments,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// handledDeployment returns the remembered report of a push deployment.
func (st *State) handledDeployment(id string) (DeploymentReport, bool) {
if id == "" {
return DeploymentReport{}, false
}
i := slices.IndexFunc(st.HandledDeployments, func(d DeploymentReport) bool { return d.DeploymentID == id })
if i < 0 {
return DeploymentReport{}, false
}
return st.HandledDeployments[i], true
}
// rememberDeployment records the report of a finished push deployment.
func (st *State) rememberDeployment(r DeploymentReport) {
if r.DeploymentID == "" {
return
}
r.Message = truncate(r.Message, handledMessageLen)
handled := slices.DeleteFunc(slices.Clone(st.HandledDeployments), func(d DeploymentReport) bool {
return d.DeploymentID == r.DeploymentID
})
handled = append(handled, r)
if n := len(handled); n > maxHandledDeployments {
handled = slices.Clone(handled[n-maxHandledDeployments:])
}
st.HandledDeployments = handled
}
// LoadState reads state.json; a missing file yields the zero state.
func (s *Store) LoadState() (*State, error) {
var st State
data, err := os.ReadFile(filepath.Join(s.dir, stateFile))
if errors.Is(err, fs.ErrNotExist) {
return &st, nil
}
if err != nil {
return nil, fmt.Errorf("read state: %w", err)
}
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("decode %s: %w", stateFile, err)
}
return &st, nil
}
// SaveState writes state.json atomically.
func (s *Store) SaveState(st *State) error {
if n := len(st.PendingReports); n > maxPendingReports {
st.PendingReports = slices.Clone(st.PendingReports[n-maxPendingReports:])
}
if n := len(st.HandledDeployments); n > maxHandledDeployments {
st.HandledDeployments = slices.Clone(st.HandledDeployments[n-maxHandledDeployments:])
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
return writeFileAtomic(filepath.Join(s.dir, stateFile), append(data, '\n'), 0o600)
}
// revisionPath returns the directory of revision n.
func (s *Store) revisionPath(n uint64) string {
return filepath.Join(s.dir, revisionsDir, strconv.FormatUint(n, 10))
}
// reservedArtifact reports whether an artifact path would collide with the
// files the store keeps next to the artifacts.
func reservedArtifact(p string) bool {
return p == manifestFile || p == signatureFile
}
// StoreRevision writes a verified bundle to revisions/<n>/ (manifest,
// signature and the artifacts at their manifest paths) and returns it. An
// existing directory for the same number is replaced.
func (s *Store) StoreRevision(m *bundle.Manifest, manifest, signature []byte, contents map[string][]byte) (Revision, error) {
parent := filepath.Join(s.dir, revisionsDir)
tmp, err := os.MkdirTemp(parent, tmpPrefix)
if err != nil {
return Revision{}, fmt.Errorf("create revision directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := writeFileSync(filepath.Join(tmp, manifestFile), manifest, 0o640); err != nil {
return Revision{}, err
}
if err := writeFileSync(filepath.Join(tmp, signatureFile), signature, 0o640); err != nil {
return Revision{}, err
}
dirs := map[string]bool{tmp: true}
for _, f := range m.Files {
if err := bundle.CheckPath(f.Path); err != nil {
return Revision{}, err
}
if reservedArtifact(f.Path) {
return Revision{}, fmt.Errorf("artifact path %s is reserved", f.Path)
}
target := filepath.Join(tmp, filepath.FromSlash(f.Path))
dir := filepath.Dir(target)
if !dirs[dir] {
if err := os.MkdirAll(dir, 0o750); err != nil {
return Revision{}, fmt.Errorf("create directory for %s: %w", f.Path, err)
}
dirs[dir] = true
}
if err := writeFileSync(target, contents[f.Path], fs.FileMode(f.Mode&0o777)); err != nil {
return Revision{}, err
}
}
for d := range dirs {
syncDir(d)
}
final := s.revisionPath(m.Revision)
if err := replaceDir(tmp, final); err != nil {
return Revision{}, err
}
syncDir(parent)
return Revision{Number: m.Revision, Dir: final, Files: slices.Clone(m.Files)}, nil
}
// LoadRevision reads a stored revision. Stored revisions were verified
// before they were written (ADR 0009), so only the manifest is parsed.
func (s *Store) LoadRevision(n uint64) (Revision, error) {
dir := s.revisionPath(n)
data, err := os.ReadFile(filepath.Join(dir, manifestFile)) //nolint:gosec // path built from the state directory
if err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
var m bundle.Manifest
if err := json.Unmarshal(data, &m); err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
if m.Revision != n {
return Revision{}, fmt.Errorf("load revision %d: manifest is for revision %d", n, m.Revision)
}
return Revision{Number: n, Dir: dir, Files: m.Files}, nil
}
// ListRevisions returns the stored revision numbers in ascending order.
func (s *Store) ListRevisions() ([]uint64, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, revisionsDir))
if err != nil {
return nil, fmt.Errorf("list revisions: %w", err)
}
var out []uint64
for _, e := range entries {
if !e.IsDir() {
continue
}
n, err := strconv.ParseUint(e.Name(), 10, 64)
if err != nil || n == 0 || strconv.FormatUint(n, 10) != e.Name() {
continue
}
out = append(out, n)
}
slices.Sort(out)
return out, nil
}
// Prune keeps the newest keep revisions plus the protected ones, removes the
// others and cleans up leftovers of interrupted writes.
func (s *Store) Prune(keep int, protect ...uint64) error {
parent := filepath.Join(s.dir, revisionsDir)
entries, err := os.ReadDir(parent)
if err != nil {
return fmt.Errorf("prune revisions: %w", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), tmpPrefix) || strings.HasPrefix(e.Name(), oldPrefix) {
_ = os.RemoveAll(filepath.Join(parent, e.Name()))
}
}
revs, err := s.ListRevisions()
if err != nil {
return err
}
var errs []error
for i, n := range revs {
if len(revs)-i <= keep || slices.Contains(protect, n) {
continue
}
if err := os.RemoveAll(s.revisionPath(n)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// writeFileSync creates path with data and syncs it.
func writeFileSync(path string, data []byte, perm fs.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) //nolint:gosec // path built from the state directory
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
if err := finishFile(f, data, perm); err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
return nil
}
func finishFile(f *os.File, data []byte, perm fs.FileMode) error {
_, err := f.Write(data)
if err == nil {
err = f.Chmod(perm) // not subject to the umask
if errors.Is(err, errors.ErrUnsupported) {
err = nil
}
}
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// writeFileAtomic replaces path with data: temporary file, fsync, rename,
// fsync of the directory.
func writeFileAtomic(target string, data []byte, perm fs.FileMode) error {
dir := filepath.Dir(target)
f, err := os.CreateTemp(dir, tmpPrefix+filepath.Base(target)+"-")
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
tmp := f.Name()
if err := finishFile(f, data, perm); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
syncDir(dir)
return nil
}
// replaceDir moves the fully written directory tmp to final. An existing
// final directory is moved aside first and removed afterwards.
func replaceDir(tmp, final string) error {
parent := filepath.Dir(final)
var old string
if _, err := os.Stat(final); err == nil {
old = filepath.Join(parent, oldPrefix+filepath.Base(final)+"-"+randomSuffix())
if err := os.Rename(final, old); err != nil {
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
}
if err := os.Rename(tmp, final); err != nil {
if old != "" {
_ = os.Rename(old, final)
}
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
if old != "" {
_ = os.RemoveAll(old)
}
return nil
}
func randomSuffix() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// syncDir flushes directory metadata (renames). Errors are ignored because
// some platforms cannot sync directories; the data itself was synced.
func syncDir(dir string) {
d, err := os.Open(dir) //nolint:gosec // path built from the state directory
if err != nil {
return
}
_ = d.Sync()
_ = d.Close()
}
+382
View File
@@ -0,0 +1,382 @@
// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := OpenStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
}
// storeTestRevision verifies and stores a signed test bundle.
func storeTestRevision(t *testing.T, s *Store, key ed25519.PrivateKey, rev uint64) Revision {
t.Helper()
b := signBundle(t, key, testAgentID, KindDNS, rev)
contents := map[string][]byte{}
for _, a := range b.Artifacts {
contents[a.Path] = a.Content
}
m, err := bundle.Verify(key.Public().(ed25519.PublicKey), b.Manifest, b.Signature, contents,
bundle.Expect{AgentID: testAgentID, Role: string(KindDNS)})
if err != nil {
t.Fatal(err)
}
r, err := s.StoreRevision(m, b.Manifest, b.Signature, contents)
if err != nil {
t.Fatal(err)
}
return r
}
func TestOpenStoreEmpty(t *testing.T) {
if _, err := OpenStore(""); err == nil {
t.Error("empty state directory accepted")
}
}
func TestStoreRevision(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
r := storeTestRevision(t, s, key, 7)
if r.Number != 7 || r.Dir != filepath.Join(s.Dir(), "revisions", "7") || len(r.Files) != 2 {
t.Fatalf("revision = %+v", r)
}
data, err := os.ReadFile(filepath.Join(r.Dir, "zones", "db.example.com"))
if err != nil || !strings.Contains(string(data), "SOA ns 1 7") {
t.Fatalf("artifact = %q, %v", data, err)
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(r.Dir, "ddi-suite.conf"))
if err != nil || fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %v, %v", fi.Mode(), err)
}
}
loaded, err := s.LoadRevision(7)
if err != nil || loaded.Number != 7 || loaded.Dir != r.Dir || len(loaded.Files) != 2 {
t.Fatalf("LoadRevision = %+v, %v", loaded, err)
}
// Storing the same number again replaces the directory.
if err := os.WriteFile(filepath.Join(r.Dir, "stale"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
storeTestRevision(t, s, key, 7)
if _, err := os.Stat(filepath.Join(r.Dir, "stale")); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("old directory content survived: %v", err)
}
if _, err := s.LoadRevision(8); err == nil {
t.Error("missing revision loaded")
}
entries, _ := os.ReadDir(filepath.Join(s.Dir(), "revisions"))
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
t.Errorf("leftover %s", e.Name())
}
}
}
func TestStoreRevisionReservedPath(t *testing.T) {
s := openTestStore(t)
m := &bundle.Manifest{Revision: 1, Files: []bundle.File{{Path: "manifest.json", Mode: 0o644}}}
if _, err := s.StoreRevision(m, []byte("{}"), []byte("sig"), map[string][]byte{"manifest.json": nil}); err == nil {
t.Error("reserved artifact path accepted")
}
if revs, _ := s.ListRevisions(); len(revs) != 0 {
t.Errorf("revisions = %v", revs)
}
}
func TestLoadRevisionMismatch(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
r := storeTestRevision(t, s, key, 3)
if err := os.Rename(r.Dir, filepath.Join(s.Dir(), "revisions", "4")); err != nil {
t.Fatal(err)
}
if _, err := s.LoadRevision(4); err == nil {
t.Error("manifest of another revision accepted")
}
}
func TestPrune(t *testing.T) {
s := openTestStore(t)
_, key := newSigningKey(t)
for rev := uint64(1); rev <= 15; rev++ {
storeTestRevision(t, s, key, rev)
}
for _, name := range []string{".tmp-123", ".old-5-abc", "notanumber", "007"} {
if err := os.Mkdir(filepath.Join(s.Dir(), "revisions", name), 0o750); err != nil {
t.Fatal(err)
}
}
if err := s.Prune(KeepRevisions, 2, 3); err != nil {
t.Fatal(err)
}
revs, err := s.ListRevisions()
if err != nil {
t.Fatal(err)
}
want := []uint64{2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
if !slices.Equal(revs, want) {
t.Errorf("revisions = %v, want %v", revs, want)
}
for _, name := range []string{".tmp-123", ".old-5-abc"} {
if _, err := os.Stat(filepath.Join(s.Dir(), "revisions", name)); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("%s not removed", name)
}
}
if _, err := os.Stat(filepath.Join(s.Dir(), "revisions", "notanumber")); err != nil {
t.Error("foreign directory removed")
}
}
func TestStateRoundTrip(t *testing.T) {
s := openTestStore(t)
st, err := s.LoadState()
if err != nil || st.Applied != 0 {
t.Fatalf("empty state = %+v, %v", st, err)
}
now := time.Now().UTC().Truncate(time.Second)
st = &State{
Applied: 5, LastGood: 4, LastUnsuccessful: 5, LeaseSequence: 9,
LastResult: &DeploymentReport{Revision: 5, Outcome: OutcomeFailed, Trigger: TriggerPush, StartedAt: now, FinishedAt: now},
PendingReports: []DeploymentReport{{Revision: 5, Outcome: OutcomeFailed, DeploymentID: "d"}},
}
if err := s.SaveState(st); err != nil {
t.Fatal(err)
}
got, err := s.LoadState()
if err != nil {
t.Fatal(err)
}
if got.Applied != 5 || got.LastGood != 4 || got.LeaseSequence != 9 || got.LastResult.Outcome != OutcomeFailed ||
len(got.PendingReports) != 1 || got.PendingReports[0].DeploymentID != "d" {
t.Errorf("state = %+v", got)
}
// Human-readable JSON with the documented keys.
raw, _ := os.ReadFile(filepath.Join(s.Dir(), "state.json"))
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, k := range []string{"applied_revision", "last_good_revision", "pending_reports", "lease_sequence"} {
if _, ok := m[k]; !ok {
t.Errorf("state.json lacks %s", k)
}
}
entries, _ := os.ReadDir(s.Dir())
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".tmp-") {
t.Errorf("leftover temporary file %s", e.Name())
}
}
if err := os.WriteFile(filepath.Join(s.Dir(), "state.json"), []byte("{broken"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadState(); err == nil {
t.Error("broken state.json accepted")
}
}
func TestLock(t *testing.T) {
s := openTestStore(t)
unlock, err := s.Lock()
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "linux" {
if _, err := s.Lock(); err == nil || !strings.Contains(err.Error(), "locked") {
t.Errorf("second lock = %v", err)
}
}
unlock()
unlock2, err := s.Lock()
if err != nil {
t.Fatalf("lock after unlock: %v", err)
}
unlock2()
}
// testIdentity creates an identity signed by pki.
func testIdentity(t *testing.T, pki *testPKI) (*Identity, *ecdsa.PrivateKey) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
cert, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
pub, _ := newSigningKey(t)
return &Identity{
AgentID: testAgentID, Role: KindDNS, Server: "ddi.example.com:8444", ServerName: "ddi.example.com",
EnrolledAt: time.Now().UTC().Truncate(time.Second), CA: pki.ca, Certificate: keyPair(key, cert), BundleKey: pub,
}, key
}
func TestIdentityRoundTrip(t *testing.T) {
s := openTestStore(t)
if _, err := s.LoadIdentity(); !errors.Is(err, ErrNotEnrolled) {
t.Fatalf("LoadIdentity = %v", err)
}
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
if err := s.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
if !s.Enrolled() {
t.Error("not enrolled after SaveIdentity")
}
got, err := s.LoadIdentity()
if err != nil {
t.Fatal(err)
}
if got.AgentID != id.AgentID || got.Server != id.Server || got.ServerName != id.ServerName || got.Role != KindDNS ||
!got.CA.Equal(pki.ca) || !got.BundleKey.Equal(id.BundleKey) || !got.Certificate.Leaf.Equal(id.Certificate.Leaf) ||
!got.EnrolledAt.Equal(id.EnrolledAt) {
t.Errorf("identity = %+v", got)
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(s.Dir(), "identity", "key.pem"))
if err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("key.pem mode = %v, %v", fi.Mode(), err)
}
di, err := os.Stat(filepath.Join(s.Dir(), "identity"))
if err != nil || di.Mode().Perm() != 0o700 {
t.Errorf("identity dir mode = %v, %v", di.Mode(), err)
}
}
// Already enrolled.
if err := s.SaveIdentity(id, key, false); !errors.Is(err, errAlreadyEnrolled) {
t.Errorf("second SaveIdentity = %v", err)
}
id2, key2 := testIdentity(t, pki)
id2.Server = "other.example.com:8444"
if err := s.SaveIdentity(id2, key2, true); err != nil {
t.Fatal(err)
}
if got, err := s.LoadIdentity(); err != nil || got.Server != "other.example.com:8444" {
t.Errorf("forced identity = %+v, %v", got, err)
}
entries, _ := os.ReadDir(s.Dir())
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
t.Errorf("leftover %s", e.Name())
}
}
}
func TestSaveCertificateAndRecovery(t *testing.T) {
s := openTestStore(t)
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
if err := s.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
newKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
newCert, err := pki.issueClient(&newKey.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if err := s.SaveCertificate(newKey, newCert); err != nil {
t.Fatal(err)
}
got, err := s.LoadIdentity()
if err != nil || !got.Certificate.Leaf.Equal(newCert) {
t.Fatalf("renewed identity = %v", err)
}
// Simulate a crash between the two renames: key.pem is new, cert.pem
// old, cert.pem.new holds the matching certificate.
key3, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cert3, err := pki.issueClient(&key3.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(3*time.Hour))
if err != nil {
t.Fatal(err)
}
keyPEM, _ := encodeKey(key3)
dir := filepath.Join(s.Dir(), "identity")
if err := os.WriteFile(filepath.Join(dir, "key.pem"), keyPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cert.pem.new"), pemCert(cert3), 0o600); err != nil {
t.Fatal(err)
}
got, err = s.LoadIdentity()
if err != nil || !got.Certificate.Leaf.Equal(cert3) {
t.Fatalf("recovered identity: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "cert.pem.new")); !errors.Is(err, fs.ErrNotExist) {
t.Error("cert.pem.new left behind")
}
// A mismatch without a recoverable certificate is an error.
key4, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyPEM, _ = encodeKey(key4)
if err := os.WriteFile(filepath.Join(dir, "key.pem"), keyPEM, 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadIdentity(); err == nil {
t.Error("mismatched key pair accepted")
}
}
func pemCert(c *x509.Certificate) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
}
func TestCheckIssued(t *testing.T) {
pki := newTestPKI(t)
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cert, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := checkIssued(cert.Raw, pki.ca, testAgentID, key); err != nil {
t.Fatalf("checkIssued = %v", err)
}
if _, err := checkIssued(cert.Raw, pki.ca, "11111111-2222-4333-8444-555555555555", key); err == nil {
t.Error("other agent ID accepted")
}
other, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if _, err := checkIssued(cert.Raw, pki.ca, testAgentID, other); err == nil {
t.Error("certificate for another key accepted")
}
if _, err := checkIssued(cert.Raw, newTestPKI(t).ca, testAgentID, key); err == nil {
t.Error("certificate of another CA accepted")
}
if _, err := checkIssued([]byte("junk"), pki.ca, testAgentID, key); err == nil {
t.Error("junk accepted")
}
// A server clock slightly ahead is tolerated.
future, err := pki.issueClient(&key.PublicKey, testAgentID, time.Now().Add(2*time.Minute), time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := checkIssued(future.Raw, pki.ca, testAgentID, key); err != nil {
t.Errorf("future NotBefore: %v", err)
}
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"errors"
"fmt"
"path"
"strings"
"time"
)
// Config is the "dhcp:" section of the agent configuration file.
type Config struct {
// ConfigFile is the file kea-dhcp4 was started with (-c); config-reload
// re-reads exactly this file.
ConfigFile string `yaml:"config_file"`
// KeaDHCP4 is the program name (looked up in PATH) or an absolute path.
KeaDHCP4 string `yaml:"kea_dhcp4"`
// ControlSocket is the UNIX control socket of kea-dhcp4. It must equal
// the control socket of the server's generator settings.
ControlSocket string `yaml:"control_socket"`
// LeasePageSize is the limit of one lease4-get-page command.
LeasePageSize int `yaml:"lease_page_size"`
// CommandTimeout bounds every program run and every control command.
CommandTimeout time.Duration `yaml:"command_timeout"`
}
// DefaultConfig returns the defaults of ADR 0021.
func DefaultConfig() Config {
return Config{
ConfigFile: "/etc/kea/kea-dhcp4.conf",
KeaDHCP4: "kea-dhcp4",
ControlSocket: "/run/kea/kea4-ctrl-socket",
LeasePageSize: 1000,
CommandTimeout: 60 * time.Second,
}
}
// maxSocketPath is the usable length of sun_path on Linux (108 bytes
// including the terminating NUL).
const maxSocketPath = 107
// Validate checks the configuration.
func (c *Config) Validate() error {
var errs []error
if err := checkAbsPath(c.ConfigFile); err != nil {
errs = append(errs, fmt.Errorf("dhcp.config_file: %w", err))
}
if err := checkAbsPath(c.ControlSocket); err != nil {
errs = append(errs, fmt.Errorf("dhcp.control_socket: %w", err))
} else if len(c.ControlSocket) > maxSocketPath {
errs = append(errs, fmt.Errorf("dhcp.control_socket: longer than %d bytes", maxSocketPath))
}
if strings.TrimSpace(c.KeaDHCP4) == "" || strings.ContainsAny(c.KeaDHCP4, "\x00\n") {
errs = append(errs, errors.New("dhcp.kea_dhcp4: program name is empty or invalid"))
}
if c.LeasePageSize < 1 || c.LeasePageSize > 100_000 {
errs = append(errs, fmt.Errorf("dhcp.lease_page_size: %d is out of range [1, 100000]", c.LeasePageSize))
}
if c.CommandTimeout <= 0 || c.CommandTimeout > time.Hour {
errs = append(errs, fmt.Errorf("dhcp.command_timeout: %s is out of range (0, 1h]", c.CommandTimeout))
}
return errors.Join(errs...)
}
func checkAbsPath(p string) error {
if p == "" || !path.IsAbs(p) || path.Clean(p) != p || p == "/" {
return fmt.Errorf("%q must be a clean absolute path", p)
}
if strings.ContainsFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
return fmt.Errorf("%q contains a control character", p)
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"time"
)
// Kea control channel result codes (Kea ARM 2.6 and 3.x, "Management API",
// "Data Syntax").
const (
ResultSuccess = 0
ResultError = 1
ResultUnsupported = 2
ResultEmpty = 3
ResultConflict = 4
)
// maxResponse bounds a control channel response (lease pages are the
// largest ones).
const maxResponse = 256 << 20
// Response is the answer to a control command.
type Response struct {
Result int `json:"result"`
Text string `json:"text"`
Arguments json.RawMessage `json:"arguments"`
}
// Err returns nil for result 0 and an error otherwise.
func (r *Response) Err(command string) error {
if r.Result == ResultSuccess {
return nil
}
return &CommandError{Command: command, Result: r.Result, Text: r.Text}
}
// CommandError is a non-zero result of a control command.
type CommandError struct {
Command string
Result int
Text string
}
func (e *CommandError) Error() string {
return fmt.Sprintf("kea %s: result %d: %s", e.Command, e.Result, e.Text)
}
// Command sends one command over the UNIX control socket of kea-dhcp4 and
// returns the response. It is used by the role and by tests.
//
// Protocol (Kea ARM 2.6 and 3.x, "Management API"): the client writes a JSON
// object {"command": ..., "arguments": ...}; the server answers with one
// JSON object {"result", "text", "arguments"} and closes the connection.
// Answers relayed by the (deprecated) Control Agent are JSON lists of such
// objects, so a list is accepted as well. The response is read as one JSON
// value, which does not depend on the server closing the connection. Kea
// itself aborts a command that is not received within 10 seconds.
func (r *Role) Command(ctx context.Context, command string, args any) (*Response, error) {
ctx, cancel := context.WithTimeout(ctx, r.cfg.CommandTimeout)
defer cancel()
req := struct {
Command string `json:"command"`
Arguments any `json:"arguments,omitempty"`
}{command, args}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("kea %s: %w", command, err)
}
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", r.cfg.ControlSocket)
if err != nil {
return nil, fmt.Errorf("kea %s: connect to control socket: %w", command, err)
}
defer func() { _ = conn.Close() }()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
// Close the connection when ctx ends, so a cancellation interrupts reads.
stop := context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Unix(1, 0)) })
defer stop()
if _, err := conn.Write(body); err != nil {
return nil, fmt.Errorf("kea %s: send: %w", command, err)
}
var raw json.RawMessage
dec := json.NewDecoder(io.LimitReader(conn, maxResponse))
if err := dec.Decode(&raw); err != nil {
if ctx.Err() != nil {
err = errors.Join(ctx.Err(), err)
}
return nil, fmt.Errorf("kea %s: read response: %w", command, err)
}
return decodeResponse(command, raw)
}
func decodeResponse(command string, raw json.RawMessage) (*Response, error) {
raw = bytes.TrimSpace(raw)
if len(raw) > 0 && raw[0] == '[' {
var list []json.RawMessage
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("kea %s: invalid response: %w", command, err)
}
if len(list) == 0 {
return nil, fmt.Errorf("kea %s: empty response list", command)
}
raw = list[0]
}
var resp Response
// Decode result separately so that a missing result is an error rather
// than "success".
var probe struct {
Result *int `json:"result"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, fmt.Errorf("kea %s: invalid response: %w", command, err)
}
if probe.Result == nil {
return nil, fmt.Errorf("kea %s: response without result", command)
}
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("kea %s: invalid response: %w", command, err)
}
return &resp, nil
}
+249
View File
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
)
// UserFileName is the HA user file of a DHCP revision; it is also the only
// file name the configuration may reference as user file.
const UserFileName = "ddi-suite-ha-user"
// credentialNames are the HA credential files (ADR 0023). Kea 3.x refuses
// clear-text users and passwords in HTTP authentication, so both come from
// files, which the revision carries as secret artifacts.
var credentialNames = []string{PasswordFileName, UserFileName}
// revFile is a file inside a revision.
type revFile struct {
src string
mode fs.FileMode
}
// revFiles are the files of a revision.
type revFiles struct {
conf string // path inside the revision
confMode fs.FileMode
// creds are the HA credential files of the revision by file name.
creds map[string]revFile
}
// source returns the files of rev.
func source(rev core.Revision) (revFiles, error) {
out := revFiles{creds: map[string]revFile{}}
bad := false
for _, f := range rev.Files {
_, dup := out.creds[f.Path]
switch {
case f.CreateOnly:
bad = true
case f.Path == FileName && out.conf == "":
out.conf, out.confMode = filepath.Join(rev.Dir, FileName), fs.FileMode(f.Mode)
case slices.Contains(credentialNames, f.Path) && !dup:
out.creds[f.Path] = revFile{src: filepath.Join(rev.Dir, f.Path), mode: fs.FileMode(f.Mode)}
default:
bad = true
}
}
if bad || out.conf == "" {
paths := make([]string, len(rev.Files))
for i, f := range rev.Files {
paths[i] = f.Path
}
return revFiles{}, fmt.Errorf("DHCP revision must contain exactly %s (and %s for HA), got %v",
FileName, strings.Join(credentialNames, ", "), paths)
}
return out, nil
}
// plan reads the configuration of rev and finds the live paths of its HA
// credential files by file name (empty without HA). The revision has a
// credential file exactly if the configuration references one.
func (r *Role) plan(rev core.Revision) (files revFiles, data []byte, live map[string]string, err error) {
if files, err = source(rev); err != nil {
return files, nil, nil, err
}
if data, err = fsutil.ReadFile(files.conf); err != nil {
return files, nil, nil, err
}
if live, err = haCredentialPaths(data); err != nil {
return files, nil, nil, err
}
for _, name := range credentialNames {
_, inRev := files.creds[name]
switch p, ok := live[name]; {
case inRev && !ok:
return files, nil, nil, fmt.Errorf("the revision has %s, but %s does not reference it", name, FileName)
case !inRev && ok:
return files, nil, nil, fmt.Errorf("%s references the HA credential file %q, but the revision has none", FileName, p)
}
}
return files, data, live, nil
}
// Validate checks that the configuration uses this agent's control socket
// and valid HA credential file paths, and runs "kea-dhcp4 -t" on it. With
// HA, Kea reads the credential files while checking, so the check runs on a
// temporary copy of the configuration that refers to the revision's files
// instead of the live ones.
func (r *Role) Validate(ctx context.Context, rev core.Revision) error {
files, data, live, err := r.plan(rev)
if err != nil {
return err
}
if err := r.checkControlSocket(data); err != nil {
return err
}
check := files.conf
if len(live) > 0 {
replace := map[string]string{}
for name, p := range live {
if err := fsutil.RequireDir(filepath.Dir(p)); err != nil {
return fmt.Errorf("HA credential file directory: %w", err)
}
replace[p] = files.creds[name].src
}
tmp, err := os.MkdirTemp("", "ddi-dhcp-validate-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmp) }()
test, err := replaceStrings(data, replace)
if err != nil {
return err
}
check = filepath.Join(tmp, FileName)
if err := os.WriteFile(check, test, 0o600); err != nil {
return err
}
}
if _, err := r.run.Run(ctx, r.cfg.KeaDHCP4, "-t", check); err != nil {
return fmt.Errorf("configuration check failed: %w", err)
}
return nil
}
// credentialRefs is the part of the configuration that names HA credential
// files: the clients of control socket authentication (Kea ARM 3.x,
// "HTTP/HTTPS Control Socket": user-file, password-file) and the HA peers
// (basic-auth-user-file, basic-auth-password-file).
type credentialRefs struct {
Dhcp4 *struct {
ControlSocket *authSocket `json:"control-socket"`
ControlSockets []authSocket `json:"control-sockets"`
HooksLibraries []struct {
Parameters *struct {
HighAvailability []struct {
Peers []struct {
UserFile string `json:"basic-auth-user-file"`
PasswordFile string `json:"basic-auth-password-file"`
} `json:"peers"`
} `json:"high-availability"`
} `json:"parameters"`
} `json:"hooks-libraries"`
} `json:"Dhcp4"`
}
type authSocket struct {
Authentication *struct {
Directory string `json:"directory"`
Clients []struct {
UserFile string `json:"user-file"`
PasswordFile string `json:"password-file"`
} `json:"clients"`
} `json:"authentication"`
}
// haCredentialPaths returns the HA credential files the configuration
// references, by file name. All references to one kind must name the same
// clean absolute path whose file name is PasswordFileName or UserFileName,
// and both files must share a directory: the agent writes the files there,
// so it must not be able to replace anything else.
func haCredentialPaths(data []byte) (map[string]string, error) {
var doc credentialRefs
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("%s is not valid JSON: %w", FileName, err)
}
refs := map[string][]string{}
add := func(name, p string) {
if p != "" {
refs[name] = append(refs[name], p)
}
}
if doc.Dhcp4 != nil {
socks := doc.Dhcp4.ControlSockets
if doc.Dhcp4.ControlSocket != nil {
socks = append(socks, *doc.Dhcp4.ControlSocket)
}
for _, s := range socks {
if s.Authentication == nil {
continue
}
for _, c := range s.Authentication.Clients {
if c.UserFile != "" {
add(UserFileName, keaCredentialPath(s.Authentication.Directory, c.UserFile))
}
if c.PasswordFile != "" {
add(PasswordFileName, keaCredentialPath(s.Authentication.Directory, c.PasswordFile))
}
}
}
for _, h := range doc.Dhcp4.HooksLibraries {
if h.Parameters == nil {
continue
}
for _, rel := range h.Parameters.HighAvailability {
for _, p := range rel.Peers {
add(UserFileName, p.UserFile)
add(PasswordFileName, p.PasswordFile)
}
}
}
}
out := map[string]string{}
for _, name := range credentialNames {
rs := refs[name]
if len(rs) == 0 {
continue
}
p := rs[0]
for _, o := range rs[1:] {
if o != p {
return nil, fmt.Errorf("%s references different HA credential files (%q and %q)", FileName, p, o)
}
}
if err := checkAbsPath(p); err != nil {
return nil, fmt.Errorf("HA credential file: %w", err)
}
if path.Base(p) != name {
return nil, fmt.Errorf("HA credential file %q: the file name must be %s", p, name)
}
out[name] = p
}
if pw, user := out[PasswordFileName], out[UserFileName]; pw != "" && user != "" && path.Dir(pw) != path.Dir(user) {
return nil, fmt.Errorf("HA credential files %q and %q must be in the same directory", pw, user)
}
return out, nil
}
// keaCredentialPath joins the directory and file name of a credential file
// the way Kea does (BasicHttpAuthConfig::getFileContent): a '/' between
// them, a leading '/' of the file name dropped.
func keaCredentialPath(dir, file string) string {
if dir == "" || !strings.HasSuffix(dir, "/") {
dir += "/"
}
return dir + strings.TrimPrefix(file, "/")
}
+369
View File
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: Apache-2.0
// Package dhcp is the agent role for the Kea DHCPv4 server (ADR 0005,
// ADR 0021). A DHCP revision consists of kea-dhcp4.conf and, for a member
// of a Kea HA pair (ADR 0023), the secret credential files
// ddi-suite-ha-password and ddi-suite-ha-user (Kea 3.x accepts basic-auth
// credentials only from files). The role installs the configuration as
// Config.ConfigFile, writes the credential files to the paths the
// configuration references (file names as above, one existing directory)
// and makes kea-dhcp4 re-read the configuration with config-reload over its
// UNIX control socket. The Kea Control Agent is not used (it is deprecated
// since Kea 2.7.2/3.0).
package dhcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
)
// FileName is the configuration file of a DHCP revision.
const FileName = "kea-dhcp4.conf"
// PasswordFileName is the HA password file of a DHCP revision; it is also
// the only file name the configuration may reference as password file.
const PasswordFileName = "ddi-suite-ha-password" //nolint:gosec // a file name, not a credential
// Role manages kea-dhcp4.
type Role struct {
cfg Config
run command.Runner
now func() time.Time
}
var (
_ core.Role = (*Role)(nil)
_ core.LeaseSource = (*Role)(nil)
)
// New creates the role from the "dhcp:" section of the agent configuration;
// it matches core.RoleFactory. decode may be nil (no section).
func New(decode func(v any) error) (core.Role, error) {
cfg := DefaultConfig()
if decode != nil {
if err := decode(&cfg); err != nil {
return nil, fmt.Errorf("dhcp configuration: %w", err)
}
}
return newRole(cfg, command.Exec{Timeout: cfg.CommandTimeout})
}
func newRole(cfg Config, run command.Runner) (*Role, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Role{cfg: cfg, run: run, now: time.Now}, nil
}
// Kind implements core.Role.
func (r *Role) Kind() core.Kind { return core.KindDHCP }
// ServiceVersion runs "kea-dhcp4 -v", which prints the bare version
// (e.g. "3.2.1"), and returns "Kea 3.2.1".
func (r *Role) ServiceVersion(ctx context.Context) (string, error) {
out, err := r.run.Run(ctx, r.cfg.KeaDHCP4, "-v")
if err != nil {
return "", err
}
line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
f := strings.Fields(line)
if len(f) == 0 {
return "", errors.New("kea-dhcp4 -v printed nothing")
}
v := f[0]
if strings.EqualFold(v, "kea") && len(f) > 1 {
v = f[1]
}
if v[0] < '0' || v[0] > '9' {
return "", fmt.Errorf("unexpected output of kea-dhcp4 -v: %q", command.Excerpt([]byte(line)))
}
return "Kea " + v, nil
}
// replaceStrings returns the JSON document data with every string value
// that is a key of replace replaced by its value (numbers are kept as
// written).
func replaceStrings(data []byte, replace map[string]string) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var doc any
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("%s: %w", FileName, err)
}
var walk func(v any) any
walk = func(v any) any {
switch x := v.(type) {
case string:
if to, ok := replace[x]; ok {
return to
}
case map[string]any:
for k, e := range x {
x[k] = walk(e)
}
case []any:
for i, e := range x {
x[i] = walk(e)
}
}
return v
}
return json.Marshal(walk(doc))
}
type socketConfig struct {
SocketType string `json:"socket-type"`
SocketName string `json:"socket-name"`
}
// checkControlSocket requires a UNIX control socket equal to the configured
// one: the agent could not reach Kea after a reload otherwise. Kea 2.x uses
// "control-socket"; Kea 3.x additionally accepts a "control-sockets" list.
func (r *Role) checkControlSocket(data []byte) error {
var doc struct {
Dhcp4 *struct {
ControlSocket *socketConfig `json:"control-socket"`
ControlSockets []socketConfig `json:"control-sockets"`
} `json:"Dhcp4"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return fmt.Errorf("%s is not valid JSON: %w", FileName, err)
}
if doc.Dhcp4 == nil {
return fmt.Errorf("%s has no Dhcp4 object", FileName)
}
socks := doc.Dhcp4.ControlSockets
if doc.Dhcp4.ControlSocket != nil {
socks = append(socks, *doc.Dhcp4.ControlSocket)
}
var names []string
for _, s := range socks {
if s.SocketType != "unix" {
continue
}
if path.Clean(s.SocketName) == r.cfg.ControlSocket {
return nil
}
names = append(names, s.SocketName)
}
if len(names) == 0 {
return fmt.Errorf("%s has no UNIX control socket; this agent needs %q", FileName, r.cfg.ControlSocket)
}
return fmt.Errorf("%s uses control socket %q, but this agent's dhcp.control_socket is %q: "+
"set the same control socket in the server's generator settings and the agent configuration",
FileName, strings.Join(names, ", "), r.cfg.ControlSocket)
}
// Activate installs the HA credential files and the configuration and
// sends config-reload. If the reload fails, the previous files are restored
// and reloaded again. Credential files of prev that rev no longer uses are
// removed.
func (r *Role) Activate(ctx context.Context, rev core.Revision, prev *core.Revision) error {
files, data, live, err := r.plan(rev)
if err != nil {
return err
}
used := map[string]bool{}
for _, p := range live {
used[p] = true
}
var stale []string
if prev != nil {
if _, _, old, err := r.plan(*prev); err == nil {
for _, name := range credentialNames {
if p := old[name]; p != "" && !used[p] {
stale = append(stale, p)
}
}
}
}
var backup fsutil.Backup
toSave := append([]string(nil), stale...)
for _, name := range credentialNames {
if p := live[name]; p != "" {
toSave = append(toSave, p)
}
}
for _, p := range append(toSave, r.cfg.ConfigFile) {
if err := backup.Save(p); err != nil {
return err
}
}
restore := func(cause error) error {
errs := []error{cause}
if err := backup.Restore(); err != nil {
errs = append(errs, fmt.Errorf("restoring the previous configuration failed: %w", err))
}
return errors.Join(errs...)
}
// The credentials first: Kea reads them while loading the configuration.
for _, name := range credentialNames {
p := live[name]
if p == "" {
continue
}
f := files.creds[name]
content, err := fsutil.ReadFile(f.src)
if err != nil {
return restore(err)
}
if err := fsutil.WriteFile(p, content, f.mode, fsutil.OwnerFor(p)); err != nil {
return restore(err)
}
}
if err := fsutil.WriteFile(r.cfg.ConfigFile, data, files.confMode, fsutil.OwnerFor(r.cfg.ConfigFile)); err != nil {
return restore(err)
}
for _, p := range stale {
if err := fsutil.Remove(p); err != nil {
return restore(fmt.Errorf("remove %s: %w", p, err))
}
}
reloadErr := r.reload(ctx)
if reloadErr == nil {
return nil
}
errs := []error{reloadErr}
if err := backup.Restore(); err != nil {
errs = append(errs, fmt.Errorf("restoring the previous configuration failed: %w", err))
} else if err := r.reload(context.WithoutCancel(ctx)); err != nil {
errs = append(errs, fmt.Errorf("reload of the restored configuration failed: %w", err))
}
return errors.Join(errs...)
}
// reload sends config-reload. On failure Kea keeps its previous
// configuration (Kea source, loadConfigFile rolls back the staging
// configuration), but the file on disk must be restored as well.
func (r *Role) reload(ctx context.Context) error {
resp, err := r.Command(ctx, "config-reload", nil)
if err != nil {
return err
}
return resp.Err("config-reload")
}
// statusArgs are the arguments of status-get (Kea ARM, "status-get"; the
// "sockets" map exists since Kea 2.3).
type statusArgs struct {
PID *int `json:"pid"`
Uptime *int64 `json:"uptime"`
Reload *int64 `json:"reload"`
Sockets *struct {
Status string `json:"status"`
Errors []string `json:"errors"`
} `json:"sockets"`
}
// Health sends status-get. Failed if Kea does not answer; Degraded if Kea
// reports that it is still retrying to open its DHCP sockets, Failed if it
// gave up. If expected is given, the installed configuration file must
// equal the revision's file (Degraded otherwise, e.g. after a manual edit).
func (r *Role) Health(ctx context.Context, expected *core.Revision) core.Health {
var h core.Health
st := core.HealthCheck{Name: "status-get", State: core.HealthOK}
resp, err := r.Command(ctx, "status-get", nil)
if err == nil {
err = resp.Err("status-get")
}
var args statusArgs
if err == nil && len(resp.Arguments) > 0 {
err = json.Unmarshal(resp.Arguments, &args)
}
if err != nil {
st.State = core.HealthFailed
st.Message = err.Error()
h.Checks = append(h.Checks, st)
h.State = core.HealthFailed
h.Message = "kea-dhcp4 does not answer status-get"
return h
}
st.Message = describeStatus(args)
h.Checks = append(h.Checks, st)
if s := args.Sockets; s != nil && s.Status != "" && s.Status != "ready" {
c := core.HealthCheck{Name: "sockets", State: core.HealthDegraded,
Message: s.Status + ": " + strings.Join(s.Errors, "; ")}
if s.Status == "failed" {
c.State = core.HealthFailed
}
h.Checks = append(h.Checks, c)
}
if expected != nil {
h.Checks = append(h.Checks, r.checkInstalled(*expected))
}
h.State = core.HealthOK
for _, c := range h.Checks {
if c.State > h.State {
h.State = c.State
}
}
h.Message = "kea-dhcp4 " + st.Message
return h
}
func describeStatus(a statusArgs) string {
var parts []string
if a.PID != nil {
parts = append(parts, fmt.Sprintf("pid %d", *a.PID))
}
if a.Uptime != nil {
parts = append(parts, "up "+(time.Duration(*a.Uptime)*time.Second).String())
}
if a.Reload != nil {
parts = append(parts, "configured "+(time.Duration(*a.Reload)*time.Second).String()+" ago")
}
if len(parts) == 0 {
return "is running"
}
return strings.Join(parts, ", ")
}
func (r *Role) checkInstalled(rev core.Revision) core.HealthCheck {
c := core.HealthCheck{Name: "configuration file", State: core.HealthOK}
files, want, live, err := r.plan(rev)
var have []byte
if err == nil {
have, err = fsutil.ReadFile(r.cfg.ConfigFile)
}
// The first credential file that differs; messages never show content.
var differs string
for _, name := range credentialNames {
p := live[name]
if err != nil || p == "" || differs != "" {
continue
}
var w, h []byte
if w, err = fsutil.ReadFile(files.creds[name].src); err == nil {
h, err = fsutil.ReadFile(p)
}
if err == nil && !bytes.Equal(w, h) {
differs = p
}
}
switch {
case err != nil:
c.State = core.HealthDegraded
c.Message = err.Error()
case !bytes.Equal(want, have):
c.State = core.HealthDegraded
c.Message = fmt.Sprintf("%s differs from revision %d", r.cfg.ConfigFile, rev.Number)
case differs != "":
c.State = core.HealthDegraded
c.Message = fmt.Sprintf("HA credential file %s differs from revision %d", differs, rev.Number)
default:
c.Message = fmt.Sprintf("%s is revision %d", r.cfg.ConfigFile, rev.Number)
}
return c
}
+509
View File
@@ -0,0 +1,509 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"sync"
"testing"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
type fakeRunner struct {
mu sync.Mutex
calls []string
fn func(name string, args []string) ([]byte, error)
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, strings.Join(append([]string{name}, args...), " "))
fn := f.fn
f.mu.Unlock()
if fn == nil {
return nil, nil
}
out, err := fn(name, args)
if err != nil {
// Like command.Exec: the error quotes the output.
return out, &command.Error{Name: name, Args: args, Err: err, Output: out}
}
return out, nil
}
// request is a command received by the fake Kea server.
type request struct {
Command string `json:"command"`
Arguments json.RawMessage `json:"arguments"`
}
// fakeKea serves a UNIX control socket like kea-dhcp4: one JSON command per
// connection, one JSON response, then the connection is closed.
type fakeKea struct {
path string
mu sync.Mutex
handler func(req request) any
got []request
}
func startFakeKea(t *testing.T, dir string) *fakeKea {
t.Helper()
f := &fakeKea{path: filepath.Join(dir, "kea4.sock")}
ln, err := net.Listen("unix", f.path)
if err != nil {
t.Skipf("UNIX sockets unavailable: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = c.Close() }()
var req request
if err := json.NewDecoder(c).Decode(&req); err != nil {
return
}
f.mu.Lock()
f.got = append(f.got, req)
h := f.handler
f.mu.Unlock()
var resp any = map[string]any{"result": 2, "text": "'" + req.Command + "' command not supported."}
if h != nil {
resp = h(req)
}
_ = json.NewEncoder(c).Encode(resp)
}()
}
}()
return f
}
func (f *fakeKea) setHandler(h func(req request) any) {
f.mu.Lock()
defer f.mu.Unlock()
f.handler = h
}
func (f *fakeKea) commands() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.got))
for i, r := range f.got {
out[i] = r.Command
}
return out
}
type env struct {
role *Role
run *fakeRunner
kea *fakeKea
conf string
}
func newEnv(t *testing.T) *env {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("DHCP role tests need Unix paths")
}
dir := t.TempDir()
kea := startFakeKea(t, dir)
cfg := DefaultConfig()
cfg.ConfigFile = filepath.Join(dir, "kea-dhcp4.conf")
cfg.ControlSocket = kea.path
cfg.CommandTimeout = 5 * time.Second
if len(cfg.ControlSocket) > maxSocketPath {
t.Skip("temporary directory too long for a UNIX socket path")
}
run := &fakeRunner{}
r, err := newRole(cfg, run)
if err != nil {
t.Fatal(err)
}
return &env{role: r, run: run, kea: kea, conf: cfg.ConfigFile}
}
func keaConfig(socket string, subnets ...string) string {
return fmt.Sprintf(`{
"Dhcp4": {
"control-socket": {"socket-type": "unix", "socket-name": %q},
"subnet4": [%s]
}
}
`, socket, strings.Join(subnets, ","))
}
func revision(t *testing.T, n uint64, files map[string]string) core.Revision {
t.Helper()
rev := core.Revision{Number: n, Dir: t.TempDir()}
names := make([]string, 0, len(files))
for p := range files {
names = append(names, p)
}
sort.Strings(names)
for _, p := range names {
if err := os.WriteFile(filepath.Join(rev.Dir, p), []byte(files[p]), 0o600); err != nil {
t.Fatal(err)
}
rev.Files = append(rev.Files, bundle.File{Path: p, Mode: 0o640, Size: int64(len(files[p]))})
}
return rev
}
func ok(args any) map[string]any {
m := map[string]any{"result": 0, "text": "ok"}
if args != nil {
m["arguments"] = args
}
return m
}
func TestNewDecodesConfig(t *testing.T) {
src := "config_file: /etc/kea/dhcp4.json\nlease_page_size: 50\ncommand_timeout: 2m\n"
r, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err != nil {
t.Fatal(err)
}
cfg := r.(*Role).cfg
if cfg.ConfigFile != "/etc/kea/dhcp4.json" || cfg.LeasePageSize != 50 || cfg.CommandTimeout != 2*time.Minute ||
cfg.ControlSocket != "/run/kea/kea4-ctrl-socket" || cfg.KeaDHCP4 != "kea-dhcp4" {
t.Errorf("config = %+v", cfg)
}
if r.Kind() != core.KindDHCP {
t.Error("wrong kind")
}
for name, src := range map[string]string{
"relative": "config_file: kea.conf",
"page size": "lease_page_size: 0",
"long socket": "control_socket: /" + strings.Repeat("s", 120),
"timeout": "command_timeout: -1s",
"unknown": "socket: /x",
"empty binary": "kea_dhcp4: ' '",
} {
_, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestServiceVersion(t *testing.T) {
e := newEnv(t)
for out, want := range map[string]string{"3.2.1\n": "Kea 3.2.1", "2.6.5": "Kea 2.6.5", "Kea 3.0.0": "Kea 3.0.0", "usage": ""} {
e.run.fn = func(string, []string) ([]byte, error) { return []byte(out), nil }
got, err := e.role.ServiceVersion(context.Background())
if got != want || (want == "") != (err != nil) {
t.Errorf("%q: got %q, %v", out, got, err)
}
}
}
func TestValidate(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
good := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Validate(ctx, good); err != nil {
t.Fatal(err)
}
if want := "kea-dhcp4 -t " + filepath.Join(good.Dir, FileName); len(e.run.calls) != 1 || e.run.calls[0] != want {
t.Errorf("calls = %v", e.run.calls)
}
list := revision(t, 1, map[string]string{FileName: fmt.Sprintf(
`{"Dhcp4": {"control-sockets": [{"socket-type": "http", "socket-port": 8000}, {"socket-type": "unix", "socket-name": %q}]}}`, e.kea.path)})
if err := e.role.Validate(ctx, list); err != nil {
t.Errorf("control-sockets list: %v", err)
}
cases := map[string]struct {
rev core.Revision
fail bool
want string
}{
"other socket": {rev: revision(t, 1, map[string]string{FileName: keaConfig("/var/run/kea/other")}), want: "dhcp.control_socket"},
"no socket": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp4": {}}`}), want: "no UNIX control socket"},
"no Dhcp4": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp6": {}}`}), want: "no Dhcp4"},
"not JSON": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp4": `}), want: "not valid JSON"},
"two files": {rev: revision(t, 1, map[string]string{FileName: "{}", "extra.conf": "{}"}), want: "exactly"},
"wrong name": {rev: revision(t, 1, map[string]string{"kea.conf": "{}"}), want: "exactly"},
"kea -t rejects": {rev: good, fail: true, want: "configuration check failed"},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
e.run.fn = nil
if c.fail {
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("DHCP4_CONFIG_LOAD_FAIL configuration error"), errors.New("exit status 1")
}
}
err := e.role.Validate(ctx, c.rev)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want %q", err, c.want)
}
})
}
}
func TestActivate(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if err := os.WriteFile(e.conf, []byte("bootstrap"), 0o600); err != nil {
t.Fatal(err)
}
e.kea.setHandler(func(req request) any { return ok(nil) })
rev := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Activate(ctx, rev, nil); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(e.conf)
if string(got) != keaConfig(e.kea.path) {
t.Errorf("config = %q", got)
}
if fi, _ := os.Stat(e.conf); fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %o", fi.Mode().Perm())
}
if cmds := e.kea.commands(); !slices.Equal(cmds, []string{"config-reload"}) {
t.Errorf("commands = %v", cmds)
}
}
func TestActivateRestoresOnReloadFailure(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if err := os.WriteFile(e.conf, []byte("previous"), 0o600); err != nil {
t.Fatal(err)
}
n := 0
var mu sync.Mutex
e.kea.setHandler(func(req request) any {
mu.Lock()
defer mu.Unlock()
n++
if n == 1 {
return map[string]any{"result": 1, "text": "Config reload failed: subnet pool out of range"}
}
return ok(nil)
})
rev := revision(t, 2, map[string]string{FileName: keaConfig(e.kea.path)})
err := e.role.Activate(ctx, rev, nil)
if err == nil || !strings.Contains(err.Error(), "pool out of range") {
t.Fatalf("err = %v", err)
}
got, _ := os.ReadFile(e.conf)
if string(got) != "previous" {
t.Errorf("config not restored: %q", got)
}
if fi, _ := os.Stat(e.conf); fi.Mode().Perm() != 0o600 {
t.Errorf("mode not restored: %o", fi.Mode().Perm())
}
if cmds := e.kea.commands(); !slices.Equal(cmds, []string{"config-reload", "config-reload"}) {
t.Errorf("commands = %v", cmds)
}
}
func TestActivateWithoutKea(t *testing.T) {
e := newEnv(t)
e.role.cfg.ControlSocket = filepath.Join(filepath.Dir(e.kea.path), "missing.sock")
if err := os.WriteFile(e.conf, []byte("previous"), 0o600); err != nil {
t.Fatal(err)
}
rev := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Activate(context.Background(), rev, nil); err == nil || !strings.Contains(err.Error(), "control socket") {
t.Fatalf("err = %v", err)
}
if got, _ := os.ReadFile(e.conf); string(got) != "previous" {
t.Errorf("config not restored: %q", got)
}
}
func TestHealth(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
rev := revision(t, 3, map[string]string{FileName: keaConfig(e.kea.path)})
if err := os.WriteFile(e.conf, []byte(keaConfig(e.kea.path)), 0o640); err != nil {
t.Fatal(err)
}
sockets := map[string]any{"status": "ready"}
e.kea.setHandler(func(req request) any {
if req.Command != "status-get" {
return map[string]any{"result": 2, "text": "unsupported"}
}
// Kea replies with a list when relayed through the Control Agent.
return []any{ok(map[string]any{"pid": 42, "uptime": 3600, "reload": 5, "sockets": sockets,
"multi-threading-enabled": true})}
})
h := e.role.Health(ctx, &rev)
if h.State != core.HealthOK || !strings.Contains(h.Message, "pid 42") || !strings.Contains(h.Message, "configured 5s ago") {
t.Fatalf("health = %+v", h)
}
sockets = map[string]any{"status": "retrying", "errors": []string{"eth1: no such interface"}}
if h := e.role.Health(ctx, &rev); h.State != core.HealthDegraded {
t.Errorf("retrying sockets: %+v", h)
}
sockets = map[string]any{"status": "failed", "errors": []string{"eth1: no such interface"}}
if h := e.role.Health(ctx, &rev); h.State != core.HealthFailed {
t.Errorf("failed sockets: %+v", h)
}
sockets = map[string]any{"status": "ready"}
if err := os.WriteFile(e.conf, []byte("edited by hand"), 0o640); err != nil {
t.Fatal(err)
}
if h := e.role.Health(ctx, &rev); h.State != core.HealthDegraded {
t.Errorf("modified file: %+v", h)
}
if h := e.role.Health(ctx, nil); h.State != core.HealthOK {
t.Errorf("without revision: %+v", h)
}
e.kea.setHandler(func(request) any { return map[string]any{"text": "no result"} })
if h := e.role.Health(ctx, nil); h.State != core.HealthFailed {
t.Errorf("response without result: %+v", h)
}
e.role.cfg.ControlSocket = filepath.Join(filepath.Dir(e.kea.path), "missing.sock")
if h := e.role.Health(ctx, nil); h.State != core.HealthFailed {
t.Errorf("no socket: %+v", h)
}
}
func TestLeasesPaging(t *testing.T) {
e := newEnv(t)
e.role.cfg.LeasePageSize = 2
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
e.role.now = func() time.Time { return now }
cltt := now.Add(-time.Hour).Unix()
all := []map[string]any{
{"ip-address": "192.0.2.10", "hw-address": "1A:1B:1C:1D:1E:1F", "hostname": "pc1.office.test.", "state": 0,
"cltt": cltt, "valid-lft": 7200, "subnet-id": 1, "client-id": "01:1A:1B:1C:1D:1E:1F", "fqdn-fwd": true},
{"ip-address": "192.0.2.11", "hw-address": "00:11:22:33:44:55", "state": 0, "cltt": cltt, "valid-lft": 1800, "subnet-id": 1},
{"ip-address": "192.0.2.12", "hw-address": "00:11:22:33:44:56", "state": 1, "cltt": cltt, "valid-lft": 86400, "subnet-id": 1},
{"ip-address": "192.0.2.13", "hw-address": "00:11:22:33:44:57", "state": 2, "cltt": cltt, "valid-lft": 60, "subnet-id": 2},
{"ip-address": "192.0.2.14", "hw-address": "", "state": 3, "cltt": cltt, "valid-lft": 4294967295, "subnet-id": 2},
}
var pages []string
var mu sync.Mutex
e.kea.setHandler(func(req request) any {
var args struct {
From string `json:"from"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(req.Arguments, &args)
mu.Lock()
pages = append(pages, args.From)
mu.Unlock()
var page []map[string]any
for _, l := range all {
ip := netip.MustParseAddr(l["ip-address"].(string))
if args.From == "start" || netip.MustParseAddr(args.From).Less(ip) {
page = append(page, l)
}
if len(page) == args.Limit {
break
}
}
if len(page) == 0 {
return map[string]any{"result": 3, "text": "0 IPv4 lease(s) found.", "arguments": map[string]any{"leases": []any{}, "count": 0}}
}
return ok(map[string]any{"leases": page, "count": len(page)})
})
leases, err := e.role.Leases(context.Background())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(pages, []string{"start", "192.0.2.11", "192.0.2.13"}) {
t.Errorf("pages requested from %v", pages)
}
if len(leases) != 5 {
t.Fatalf("got %d leases", len(leases))
}
updated := time.Unix(cltt, 0).UTC()
want0 := core.Lease{IP: netip.MustParseAddr("192.0.2.10"), MAC: "1a:1b:1c:1d:1e:1f", Hostname: "pc1.office.test",
State: core.LeaseActive, Expires: updated.Add(2 * time.Hour), Updated: updated, SubnetID: 1, ClientID: "01:1a:1b:1c:1d:1e:1f"}
if leases[0] != want0 {
t.Errorf("lease 0 = %+v\nwant %+v", leases[0], want0)
}
states := []core.LeaseState{core.LeaseActive, core.LeaseExpired, core.LeaseDeclined, core.LeaseExpired, core.LeaseReleased}
for i, l := range leases {
if l.State != states[i] {
t.Errorf("lease %s: state %d, want %d", l.IP, l.State, states[i])
}
}
if !leases[4].Expires.IsZero() || leases[4].MAC != "" {
t.Errorf("infinite lease = %+v", leases[4])
}
}
func TestLeasesEmptyAndUnsupported(t *testing.T) {
e := newEnv(t)
e.kea.setHandler(func(request) any { return map[string]any{"result": 3, "text": "0 IPv4 lease(s) found."} })
if leases, err := e.role.Leases(context.Background()); err != nil || len(leases) != 0 {
t.Errorf("empty: %v, %v", leases, err)
}
e.kea.setHandler(nil) // result 2: unsupported
if _, err := e.role.Leases(context.Background()); err == nil || !strings.Contains(err.Error(), "lease_cmds") {
t.Errorf("unsupported: %v", err)
}
e.role.cfg.LeasePageSize = 1
e.kea.setHandler(func(request) any {
return ok(map[string]any{"leases": []any{map[string]any{"ip-address": "192.0.2.1", "state": 0}}, "count": 1})
})
if _, err := e.role.Leases(context.Background()); err == nil || !strings.Contains(err.Error(), "did not advance") {
t.Errorf("stuck paging: %v", err)
}
}
func TestNormalizeMAC(t *testing.T) {
for in, want := range map[string]string{
"1A:1B:1C:1D:1E:1F": "1a:1b:1c:1d:1e:1f",
"1a-1b-1c-1d-1e-1f": "1a:1b:1c:1d:1e:1f",
"1a1b.1c1d.1e1f": "1a:1b:1c:1d:1e:1f",
"1A1B1C1D1E1F": "1a:1b:1c:1d:1e:1f",
"1:2:3:a:b:c": "01:02:03:0a:0b:0c",
"": "",
"ZZ:11": "zz:11",
"abc": "abc",
} {
if got := normalizeMAC(in); got != want {
t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeResponse(t *testing.T) {
if _, err := decodeResponse("x", json.RawMessage(`[]`)); err == nil {
t.Error("empty list accepted")
}
r, err := decodeResponse("x", json.RawMessage(` [{"result": 1, "text": "boom"}]`))
if err != nil || r.Result != 1 || r.Err("x") == nil || !strings.Contains(r.Err("x").Error(), "boom") {
t.Errorf("list: %+v, %v", r, err)
}
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
// haKeaConfig is an HA member's configuration as the server's keagen writes
// it, with the user file at user and the password file at pw.
func haKeaConfig(socket, user, pw string) string {
return fmt.Sprintf(`{
"Dhcp4": {
"control-sockets": [
{"socket-type": "unix", "socket-name": %[1]q},
{"socket-type": "http", "socket-address": "192.0.2.2", "socket-port": 8001,
"authentication": {"type": "basic", "realm": "ddi-suite-ha",
"clients": [{"user-file": %[2]q, "password-file": %[3]q}]}}
],
"hooks-libraries": [
{"library": "libdhcp_lease_cmds.so"},
{"library": "libdhcp_ha.so", "parameters": {"high-availability": [{
"this-server-name": "primary", "mode": "hot-standby",
"multi-threading": {"enable-multi-threading": true, "http-dedicated-listener": false},
"peers": [
{"name": "primary", "url": "http://192.0.2.2:8001/", "role": "primary", "auto-failover": true,
"basic-auth-user-file": %[2]q, "basic-auth-password-file": %[3]q},
{"name": "secondary", "url": "http://192.0.2.3:8001/", "role": "standby", "auto-failover": true,
"basic-auth-user-file": %[2]q, "basic-auth-password-file": %[3]q}
]}]}}
],
"valid-lifetime": 4294967295,
"subnet4": []
}
}
`, socket, user, pw)
}
// haFiles is an HA revision's files.
func haFiles(conf, user, pw string) map[string]string {
return map[string]string{FileName: conf, UserFileName: user, PasswordFileName: pw}
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
func TestValidateHA(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
dir := filepath.Dir(e.conf)
pw := filepath.Join(dir, PasswordFileName)
user := filepath.Join(dir, UserFileName)
var checked, checkedPath string
e.run.fn = func(name string, args []string) ([]byte, error) {
checkedPath = args[len(args)-1]
checked = readFile(t, checkedPath)
return nil, nil
}
good := revision(t, 1, haFiles(haKeaConfig(e.kea.path, user, pw), "ha\n", "secret\n"))
if err := e.role.Validate(ctx, good); err != nil {
t.Fatal(err)
}
// kea-dhcp4 -t reads the revision's credential files, not the live
// ones, and sees the configuration otherwise unchanged.
var doc map[string]any
if err := json.Unmarshal([]byte(checked), &doc); err != nil {
t.Fatalf("checked configuration: %v\n%s", err, checked)
}
for _, f := range []struct{ live, rev string }{
{pw, filepath.Join(good.Dir, PasswordFileName)},
{user, filepath.Join(good.Dir, UserFileName)},
} {
if strings.Contains(checked, `"`+f.live+`"`) || strings.Count(checked, `"`+f.rev+`"`) != 3 {
t.Errorf("checked configuration does not use %s:\n%s", f.rev, checked)
}
}
if !strings.Contains(checked, "4294967295") || !strings.Contains(checked, e.kea.path) {
t.Errorf("checked configuration changed:\n%s", checked)
}
if exists(pw) || exists(user) || exists(checkedPath) {
t.Error("Validate wrote live credential files or left the temporary configuration behind")
}
cases := map[string]struct {
files map[string]string
want string
}{
"credentials without reference": {
files: haFiles(keaConfig(e.kea.path), "ha\n", "secret\n"),
want: "does not reference it",
},
"reference without password": {
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), UserFileName: "ha\n"},
want: "the revision has none",
},
"reference without user": {
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), PasswordFileName: "x\n"},
want: "the revision has none",
},
"other password file name": {
files: haFiles(haKeaConfig(e.kea.path, user, filepath.Join(dir, "shadow")), "ha\n", "x\n"),
want: "file name must be " + PasswordFileName,
},
"other user file name": {
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "passwd"), pw), "ha\n", "x\n"),
want: "file name must be " + UserFileName,
},
"relative path": {
files: haFiles(haKeaConfig(e.kea.path, user, "etc/"+PasswordFileName), "ha\n", "x\n"),
want: "HA credential file",
},
"different directories": {
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "a", UserFileName), pw), "ha\n", "x\n"),
want: "same directory",
},
"missing directory": {
files: haFiles(haKeaConfig(e.kea.path, filepath.Join(dir, "nope", UserFileName), filepath.Join(dir, "nope", PasswordFileName)), "ha\n", "x\n"),
want: "HA credential file directory",
},
"different references": {
files: haFiles(strings.Replace(haKeaConfig(e.kea.path, user, pw), pw, filepath.Join(dir, "a", PasswordFileName), 1), "ha\n", "x\n"),
want: "different HA credential files",
},
"unexpected file": {
files: map[string]string{FileName: haKeaConfig(e.kea.path, user, pw), PasswordFileName: "x\n", UserFileName: "ha\n", "other": ""},
want: "exactly",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
err := e.role.Validate(ctx, revision(t, 1, c.files))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want %q", err, c.want)
}
})
}
}
func TestActivateHA(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
dir := filepath.Dir(e.conf)
pw := filepath.Join(dir, PasswordFileName)
user := filepath.Join(dir, UserFileName)
conf := haKeaConfig(e.kea.path, user, pw)
e.kea.setHandler(func(req request) any { return ok(nil) })
rev1 := revision(t, 1, haFiles(conf, "ha-1\n", "secret-1\n"))
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
for p, want := range map[string]string{pw: "secret-1\n", user: "ha-1\n", e.conf: conf} {
if got := readFile(t, p); got != want {
t.Errorf("%s = %q", p, got)
}
}
for _, p := range []string{pw, user} {
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
t.Errorf("%s: mode %o", p, fi.Mode().Perm())
}
}
if c := e.role.checkInstalled(rev1); c.State != core.HealthOK {
t.Errorf("installed check: %+v", c)
}
// Changed credentials on disk are noticed, without showing them.
for _, p := range []string{user, pw} {
old := readFile(t, p)
if err := os.WriteFile(p, []byte("edited\n"), 0o640); err != nil {
t.Fatal(err)
}
c := e.role.checkInstalled(rev1)
if c.State != core.HealthDegraded || !strings.Contains(c.Message, p) || strings.Contains(c.Message, "edited") {
t.Errorf("modified %s: %+v", p, c)
}
if err := os.WriteFile(p, []byte(old), 0o640); err != nil {
t.Fatal(err)
}
}
// A failed reload restores configuration and credential files.
if err := os.WriteFile(pw, []byte("edited\n"), 0o640); err != nil {
t.Fatal(err)
}
rev2 := revision(t, 2, haFiles(conf, "ha-2\n", "secret-2\n"))
n := 0
var mu sync.Mutex
e.kea.setHandler(func(req request) any {
mu.Lock()
defer mu.Unlock()
n++
if n == 1 {
return map[string]any{"result": 1, "text": "HA configuration error"}
}
return ok(nil)
})
if err := e.role.Activate(ctx, rev2, &rev1); err == nil {
t.Fatal("reload failure not reported")
}
if got := readFile(t, pw); got != "edited\n" {
t.Errorf("password file not restored: %q", got)
}
if got := readFile(t, user); got != "ha-1\n" {
t.Errorf("user file not restored: %q", got)
}
// Without HA the credential files of the previous revision go.
e.kea.setHandler(func(req request) any { return ok(nil) })
rev3 := revision(t, 3, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Activate(ctx, rev3, &rev1); err != nil {
t.Fatal(err)
}
if exists(pw) || exists(user) {
t.Error("credential files of the previous revision still exist")
}
}
func TestKeaCredentialPath(t *testing.T) {
for _, c := range []struct{ dir, file, want string }{
{"", "/etc/kea/pw", "/etc/kea/pw"},
{"", "pw", "/pw"},
{"/etc/kea", "pw", "/etc/kea/pw"},
{"/etc/kea/", "/pw", "/etc/kea/pw"},
} {
if got := keaCredentialPath(c.dir, c.file); got != c.want {
t.Errorf("keaCredentialPath(%q, %q) = %q, want %q", c.dir, c.file, got, c.want)
}
}
got, err := haCredentialPaths([]byte(`{"Dhcp4": {"control-socket": {"socket-type": "http",
"authentication": {"directory": "/etc/kea", "clients": [{"user-file": "` + UserFileName + `", "password-file": "` + PasswordFileName + `"}]}}}}`))
if err != nil || got[PasswordFileName] != "/etc/kea/"+PasswordFileName || got[UserFileName] != "/etc/kea/"+UserFileName {
t.Errorf("directory and file: %v, %v", got, err)
}
if got, err := haCredentialPaths([]byte(`{"Dhcp4": {"hooks-libraries": [{"library": "x", "parameters": {}}]}}`)); len(got) != 0 || err != nil {
t.Errorf("no references: %v, %v", got, err)
}
if _, err := haCredentialPaths([]byte(`{"Dhcp4": `)); err == nil || !strings.Contains(err.Error(), "not valid JSON") {
t.Errorf("invalid JSON: %v", err)
}
}
func TestReplaceStrings(t *testing.T) {
out, err := replaceStrings([]byte(`{"a": "x", "b": ["x", "y", {"c": "x"}], "n": 18446744073709551615, "d": "xx"}`),
map[string]string{"x": "z", "y": "w"})
if err != nil {
t.Fatal(err)
}
if want := `{"a":"z","b":["z","w",{"c":"z"}],"d":"xx","n":18446744073709551615}`; string(out) != want {
t.Errorf("got %s, want %s", out, want)
}
if _, err := replaceStrings([]byte("{"), nil); err == nil {
t.Error("invalid JSON accepted")
}
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
// Kea lease states (Kea source src/lib/dhcpsrv/lease.cc; Kea ARM
// "lease_cmds": state 0 default, 1 declined, 2 expired-reclaimed).
// "released" (3, leases kept for lease affinity) exists since Kea 2.7 /
// 3.0, "registered" (4) only for DHCPv6.
const (
keaStateDefault = 0
keaStateDeclined = 1
keaStateExpiredReclaimed = 2
keaStateReleased = 3
)
// infiniteLifetime is Kea's "infinite" valid lifetime (0xffffffff).
const infiniteLifetime = 0xffffffff
// keaLease is one element of "leases" in a lease4-get-page response
// (Lease4::toElement). Unknown fields are ignored for compatibility with
// newer Kea versions.
type keaLease struct {
IPAddress string `json:"ip-address"`
HWAddress string `json:"hw-address"`
Hostname string `json:"hostname"`
State int64 `json:"state"`
CLTT int64 `json:"cltt"`
ValidLft int64 `json:"valid-lft"`
SubnetID uint32 `json:"subnet-id"`
ClientID string `json:"client-id"`
}
type leasePage struct {
Leases []keaLease `json:"leases"`
Count int `json:"count"`
}
// Leases implements core.LeaseSource. It pages through lease4-get-page
// (lease_cmds hook): the first page starts at "start", every further page
// at the last address of the previous one. The last page is the one with
// fewer leases than the limit; Kea answers result 3 (empty) when nothing
// is left (Kea ARM, "lease4-get-page").
func (r *Role) Leases(ctx context.Context) ([]core.Lease, error) {
var out []core.Lease
from := "start"
var last netip.Addr
for {
resp, err := r.Command(ctx, "lease4-get-page", map[string]any{"from": from, "limit": r.cfg.LeasePageSize})
if err != nil {
return nil, err
}
if resp.Result == ResultEmpty {
return out, nil
}
if resp.Result == ResultUnsupported {
return nil, fmt.Errorf("kea does not support lease4-get-page (is the lease_cmds hook loaded?): %s", resp.Text)
}
if err := resp.Err("lease4-get-page"); err != nil {
return nil, err
}
var page leasePage
if err := json.Unmarshal(resp.Arguments, &page); err != nil {
return nil, fmt.Errorf("kea lease4-get-page: invalid arguments: %w", err)
}
now := r.now()
for _, kl := range page.Leases {
l, err := convertLease(kl, now)
if err != nil {
return nil, err
}
out = append(out, l)
}
if len(page.Leases) == 0 || len(page.Leases) < r.cfg.LeasePageSize {
return out, nil
}
next := out[len(out)-1].IP
// Pages are ordered by address; refuse to loop if Kea does not
// make progress.
if last.IsValid() && !last.Less(next) {
return nil, fmt.Errorf("kea lease4-get-page did not advance beyond %s", last)
}
last = next
from = next.String()
}
}
func convertLease(kl keaLease, now time.Time) (core.Lease, error) {
ip, err := netip.ParseAddr(kl.IPAddress)
if err != nil || !ip.Is4() {
return core.Lease{}, fmt.Errorf("kea returned an invalid lease address %q", kl.IPAddress)
}
l := core.Lease{
IP: ip,
MAC: normalizeMAC(kl.HWAddress),
Hostname: strings.TrimSuffix(kl.Hostname, "."),
SubnetID: kl.SubnetID,
ClientID: strings.ToLower(kl.ClientID),
}
if kl.CLTT > 0 {
l.Updated = time.Unix(kl.CLTT, 0).UTC()
// An infinite lifetime never expires; Expires stays zero.
if kl.ValidLft >= 0 && kl.ValidLft != infiniteLifetime {
l.Expires = l.Updated.Add(time.Duration(kl.ValidLft) * time.Second)
}
}
switch kl.State {
case keaStateDefault:
l.State = core.LeaseActive
if !l.Expires.IsZero() && !l.Expires.After(now) {
// Not reclaimed yet, but expired.
l.State = core.LeaseExpired
}
case keaStateDeclined:
l.State = core.LeaseDeclined
case keaStateExpiredReclaimed:
l.State = core.LeaseExpired
case keaStateReleased:
l.State = core.LeaseReleased
default:
l.State = core.LeaseUnknown
}
return l, nil
}
// normalizeMAC returns a hardware address as lowercase, colon separated
// hex. Kea already prints it that way; other spellings ("-" or "."
// separators, no separators) are converted. Unparsable input is returned
// in lower case.
func normalizeMAC(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return s
}
var parts []string
if strings.ContainsAny(s, ":-") {
// Separated single digits ("1:2:3") are padded per byte.
parts = strings.FieldsFunc(s, func(r rune) bool { return r == ':' || r == '-' })
for i, p := range parts {
if len(p) == 1 {
parts[i] = "0" + p
}
}
} else {
hex := strings.ReplaceAll(s, ".", "")
if len(hex)%2 != 0 {
return s
}
for i := 0; i < len(hex); i += 2 {
parts = append(parts, hex[i:i+2])
}
}
for _, p := range parts {
if len(p) != 2 || !isHex(p[0]) || !isHex(p[1]) {
return s
}
}
return strings.Join(parts, ":")
}
func isHex(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') }
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"errors"
"fmt"
"net/netip"
"path"
"strings"
"time"
)
// Config is the "dns:" section of the agent configuration file.
type Config struct {
// ConfDir receives ddi-suite.conf and ddi-suite-options.conf; the
// distribution's named.conf includes them.
ConfDir string `yaml:"conf_dir"`
// ZoneDir receives the zone files. It must equal the zone directory of
// the server's generator settings because ddi-suite.conf names the zone
// files with absolute paths.
ZoneDir string `yaml:"zone_dir"`
// NamedConf is the named.conf the running server uses.
NamedConf string `yaml:"named_conf"`
// Program names (looked up in PATH) or absolute paths.
Named string `yaml:"named"`
NamedCheckconf string `yaml:"named_checkconf"`
NamedCheckzone string `yaml:"named_checkzone"`
Rndc string `yaml:"rndc"`
// Nsupdate applies the DDI-managed records of dynamic zones (ADR 0026)
// as RFC 2136 updates sent to DNSAddress.
Nsupdate string `yaml:"nsupdate"`
// RndcArgs come before every rndc command, e.g. ["-k", "/etc/bind/rndc.key"].
RndcArgs []string `yaml:"rndc_args"`
// DNSAddress is where the health check sends SOA queries and nsupdate
// sends dynamic updates (ip:port).
DNSAddress string `yaml:"dns_address"`
// CommandTimeout bounds every program run.
CommandTimeout time.Duration `yaml:"command_timeout"`
}
// DefaultConfig returns the defaults of ADR 0021.
func DefaultConfig() Config {
return Config{
ConfDir: "/etc/bind/ddi-suite",
ZoneDir: "/var/lib/bind/ddi-suite",
NamedConf: "/etc/bind/named.conf",
Named: "named",
NamedCheckconf: "named-checkconf",
NamedCheckzone: "named-checkzone",
Rndc: "rndc",
Nsupdate: "nsupdate",
DNSAddress: "127.0.0.1:53",
CommandTimeout: 60 * time.Second,
}
}
// Validate checks the configuration.
func (c *Config) Validate() error {
var errs []error
for _, d := range []struct{ name, value string }{
{"conf_dir", c.ConfDir}, {"zone_dir", c.ZoneDir}, {"named_conf", c.NamedConf},
} {
if err := checkAbsPath(d.value); err != nil {
errs = append(errs, fmt.Errorf("dns.%s: %w", d.name, err))
}
}
for _, p := range []struct{ name, value string }{
{"named", c.Named}, {"named_checkconf", c.NamedCheckconf},
{"named_checkzone", c.NamedCheckzone}, {"rndc", c.Rndc}, {"nsupdate", c.Nsupdate},
} {
if strings.TrimSpace(p.value) == "" || strings.ContainsAny(p.value, "\x00\n") {
errs = append(errs, fmt.Errorf("dns.%s: program name is empty or invalid", p.name))
}
}
for i, a := range c.RndcArgs {
if strings.ContainsRune(a, 0) {
errs = append(errs, fmt.Errorf("dns.rndc_args[%d]: invalid argument", i))
}
}
if ap, err := netip.ParseAddrPort(c.DNSAddress); err != nil || ap.Port() == 0 {
errs = append(errs, fmt.Errorf("dns.dns_address: %q is not ip:port", c.DNSAddress))
}
if c.CommandTimeout <= 0 || c.CommandTimeout > time.Hour {
errs = append(errs, fmt.Errorf("dns.command_timeout: %s is out of range (0, 1h]", c.CommandTimeout))
}
return errors.Join(errs...)
}
// checkAbsPath accepts clean absolute slash paths that can be written into
// a named.conf string (no quotes, backslashes or control characters).
func checkAbsPath(p string) error {
if p == "" || !path.IsAbs(p) || path.Clean(p) != p || p == "/" {
return fmt.Errorf("%q must be a clean absolute path", p)
}
for _, r := range p {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r == ';' {
return fmt.Errorf("%q contains an invalid character", p)
}
}
return nil
}
+508
View File
@@ -0,0 +1,508 @@
// SPDX-License-Identifier: Apache-2.0
// Package dns is the agent role for BIND 9 (ADR 0004, ADR 0021).
//
// A DNS revision consists of ddi-suite.conf (top-level statements: ACLs and
// zones), ddi-suite-options.conf (statements for the options block),
// ddi-suite-keys.conf (TSIG keys, since M5) and one zone file per primary
// zone under zones/. The distribution's named.conf includes the .conf files
// from Config.ConfDir (the keys before ddi-suite.conf); zone files go to
// Config.ZoneDir. Initial files of dynamic zones are CreateOnly: BIND owns
// them once they exist.
//
// Secondary zones have no file in the revision: BIND transfers them from
// their primaries and writes them to <zone_dir>/secondary/, which the role
// creates (with the mode and owner of zone_dir) and whose content it never
// touches. Revisions without keys file (before M5) get a keys file without
// keys, so that named.conf can always include it.
//
// A target that serves dynamic zones as primary also gets
// ddi-suite-update.key and dynamic-records.json (ADR 0026). They stay in
// the revision directory; after the reload the role applies the managed
// records of those zones with nsupdate (see dynamic.go).
package dns
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Bundle layout produced by the server's BIND generator.
const (
confFile = "ddi-suite.conf"
optionsFile = "ddi-suite-options.conf"
keysFile = "ddi-suite-keys.conf"
zonesPrefix = "zones/"
zonePrefix = "db."
// secondaryDir below the zone directory receives the files of
// secondary zones, written by BIND.
secondaryDir = "secondary"
// keysMode is the mode of the keys file written for revisions without
// one.
keysMode = 0o640
// journalSuffix is appended to a zone file name by BIND for the journal
// of a dynamic zone (BIND 9 ARM, "journal" zone option: default is the
// zone file name with ".jnl" appended).
journalSuffix = ".jnl"
)
// Role manages BIND.
type Role struct {
cfg Config
run command.InputRunner
// dial is used for SOA queries (replaced in tests).
dial dialFunc
log *slog.Logger
// updateWait bounds the retries of dynamic updates while zones load,
// updateRetry is the pause between two attempts (replaced in tests).
updateWait, updateRetry time.Duration
}
var (
_ core.Role = (*Role)(nil)
_ core.LogSetter = (*Role)(nil)
)
// New creates the role from the "dns:" section of the agent configuration;
// it matches core.RoleFactory. decode may be nil (no section).
func New(decode func(v any) error) (core.Role, error) {
cfg := DefaultConfig()
if decode != nil {
if err := decode(&cfg); err != nil {
return nil, fmt.Errorf("dns configuration: %w", err)
}
}
return newRole(cfg, command.Exec{Timeout: cfg.CommandTimeout})
}
func newRole(cfg Config, run command.InputRunner) (*Role, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Role{
cfg: cfg, run: run, dial: defaultDial, log: slog.New(slog.DiscardHandler),
updateWait: defaultUpdateWait, updateRetry: defaultUpdateRetry,
}, nil
}
// SetLogger implements core.LogSetter.
func (r *Role) SetLogger(log *slog.Logger) {
if log != nil {
r.log = log
}
}
// Kind implements core.Role.
func (r *Role) Kind() core.Kind { return core.KindDNS }
// ServiceVersion runs "named -v", which prints e.g.
// "BIND 9.20.3 (Stable Release) <id:...>", and returns "BIND 9.20.3".
func (r *Role) ServiceVersion(ctx context.Context) (string, error) {
out, err := r.run.Run(ctx, r.cfg.Named, "-v")
if err != nil {
return "", err
}
return parseNamedVersion(string(out))
}
func parseNamedVersion(out string) (string, error) {
line, _, _ := strings.Cut(strings.TrimSpace(out), "\n")
f := strings.Fields(line)
if len(f) < 2 || f[0] != "BIND" {
return "", fmt.Errorf("unexpected output of named -v: %q", command.Excerpt([]byte(line)))
}
return f[0] + " " + f[1], nil
}
// noKeys is the keys file written for revisions without one.
const noKeys = "// No TSIG keys: written by the DDI Suite DNS agent for a revision without\n" +
"// ddi-suite-keys.conf, so that named.conf can include it.\n"
// target is one revision file and where it goes on the host.
type target struct {
file bundle.File
src string // inside the revision directory; "" if data is set
data []byte // content of files the role adds (keys file of old revisions)
dst string // live path
zone string // zone name for zone files, "" for .conf files
}
// content returns the file content of t.
func (t target) content() ([]byte, error) {
if t.data != nil {
return t.data, nil
}
return fsutil.ReadFile(t.src)
}
// plan maps the files of rev to their live paths. Only top-level *.conf
// files and zones/db.<zone> are allowed. A revision without keys file gets
// one without keys.
func (r *Role) plan(rev core.Revision) ([]target, error) {
out := make([]target, 0, len(rev.Files)+1)
var haveConf, haveOptions, haveKeys bool
for _, f := range rev.Files {
if err := bundle.CheckPath(f.Path); err != nil {
return nil, err
}
if isUpdateFile(f.Path) {
// Revision data for dynamic updates, used from the revision
// directory (see loadDynamic).
continue
}
t := target{file: f, src: filepath.Join(rev.Dir, filepath.FromSlash(f.Path))}
switch {
case !strings.Contains(f.Path, "/") && strings.HasSuffix(f.Path, ".conf"):
t.dst = filepath.Join(r.cfg.ConfDir, f.Path)
haveConf = haveConf || f.Path == confFile
haveOptions = haveOptions || f.Path == optionsFile
haveKeys = haveKeys || f.Path == keysFile
case strings.HasPrefix(f.Path, zonesPrefix):
name := strings.TrimPrefix(f.Path, zonesPrefix)
zone, ok := strings.CutPrefix(name, zonePrefix)
if !ok || strings.Contains(name, "/") || !validZoneName(zone) {
return nil, fmt.Errorf("unexpected zone file %q in DNS revision (want zones/db.<zone>)", f.Path)
}
t.dst = filepath.Join(r.cfg.ZoneDir, name)
t.zone = zone
default:
return nil, fmt.Errorf("unexpected file %q in DNS revision", f.Path)
}
if f.CreateOnly && t.zone == "" {
return nil, fmt.Errorf("file %q: only zone files may be create-only", f.Path)
}
out = append(out, t)
}
if !haveConf || !haveOptions {
return nil, fmt.Errorf("DNS revision must contain %s and %s", confFile, optionsFile)
}
if !haveKeys {
out = append(out, target{
file: bundle.File{Path: keysFile, Mode: keysMode},
data: []byte(noKeys),
dst: filepath.Join(r.cfg.ConfDir, keysFile),
})
}
return out, nil
}
// validZoneName accepts dot-separated, non-empty labels without a trailing
// dot (bundle.CheckPath has already restricted the characters).
func validZoneName(z string) bool {
if z == "" || len(z) > 253 {
return false
}
for _, l := range strings.Split(z, ".") {
if l == "" || len(l) > 63 {
return false
}
}
return true
}
// fileStmt finds the zone file statements in ddi-suite.conf.
var fileStmt = regexp.MustCompile(`(?m)^\s*file\s+"([^"]*)"\s*;`)
// secondaryPath is the directory of the secondary zone files.
func (r *Role) secondaryPath() string { return path.Join(r.cfg.ZoneDir, secondaryDir) }
// checkZoneDir verifies that ddi-suite.conf names the files of primary
// zones inside the configured zone_dir and only files of this revision, and
// the files of secondary zones inside zone_dir/secondary.
func (r *Role) checkZoneDir(conf []byte, targets []target) error {
zoneFiles := map[string]bool{}
for _, t := range targets {
if t.zone != "" {
zoneFiles[path.Base(t.file.Path)] = true
}
}
for _, m := range fileStmt.FindAllSubmatch(conf, -1) {
p := string(m[1])
switch dir := path.Dir(p); dir {
case r.cfg.ZoneDir:
if !zoneFiles[path.Base(p)] {
return fmt.Errorf("%s references %q, which is not part of the revision", confFile, p)
}
case r.secondaryPath():
// Written by BIND after a zone transfer.
if z, ok := strings.CutPrefix(path.Base(p), zonePrefix); !ok || !validZoneName(z) {
return fmt.Errorf("%s references the secondary zone file %q (want db.<zone>)", confFile, p)
}
default:
return fmt.Errorf("%s places zone files in %q, but this agent's dns.zone_dir is %q: "+
"set the same zone directory in the server's generator settings and the agent configuration",
confFile, strings.TrimSuffix(dir, "/"+secondaryDir), r.cfg.ZoneDir)
}
}
return nil
}
// secondaryZones returns the names of the secondary zones of the revision
// in Dir (from the file statements of its ddi-suite.conf).
func (r *Role) secondaryZones(rev core.Revision) []string {
conf, err := fsutil.ReadFile(filepath.Join(rev.Dir, confFile))
if err != nil {
return nil
}
var zones []string
for _, m := range fileStmt.FindAllSubmatch(conf, -1) {
p := string(m[1])
if path.Dir(p) != r.secondaryPath() {
continue
}
if z, ok := strings.CutPrefix(path.Base(p), zonePrefix); ok && validZoneName(z) {
zones = append(zones, z)
}
}
return zones
}
// Validate checks rev with named-checkconf (on a temporary named.conf that
// includes the revision's files) and named-checkzone. Nothing live changes.
func (r *Role) Validate(ctx context.Context, rev core.Revision) error {
targets, err := r.plan(rev)
if err != nil {
return err
}
for _, d := range []string{r.cfg.ConfDir, r.cfg.ZoneDir} {
if err := fsutil.RequireDir(d); err != nil {
return fmt.Errorf("target directory: %w", err)
}
}
conf, err := fsutil.ReadFile(filepath.Join(rev.Dir, confFile))
if err != nil {
return err
}
if err := r.checkZoneDir(conf, targets); err != nil {
return err
}
dyn, err := r.loadDynamic(rev, targets)
if err != nil {
return err
}
if dyn != nil {
if err := checkUpdateKeyKnown(rev, dyn.key); err != nil {
return err
}
}
if err := r.checkConf(ctx, rev); err != nil {
return err
}
var errs []error
for _, t := range targets {
if t.zone == "" {
continue
}
// Strict like the server's integration tests: fail on bad names and
// on MX/SRV records that point to addresses or CNAMEs.
if _, err := r.run.Run(ctx, r.cfg.NamedCheckzone, "-k", "fail", "-m", "fail", "-M", "fail",
"-S", "fail", "-i", "local", t.zone, t.src); err != nil {
errs = append(errs, fmt.Errorf("zone %s: %w", t.zone, err))
if len(errs) >= 10 {
errs = append(errs, errors.New("further zones not checked"))
break
}
}
}
return errors.Join(errs...)
}
// checkConf writes a temporary named.conf that includes the revision's
// files the way the distribution's named.conf does (the keys file, if the
// revision has one, before ddi-suite.conf) and checks it.
func (r *Role) checkConf(ctx context.Context, rev core.Revision) error {
revDir := rev.Dir
if strings.ContainsAny(revDir, "\"\\\n;") {
return fmt.Errorf("revision directory %q cannot be used in named.conf", revDir)
}
tmp, err := os.MkdirTemp("", "ddi-dns-validate-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmp) }()
slash := filepath.ToSlash
keys := ""
if slices.ContainsFunc(rev.Files, func(f bundle.File) bool { return f.Path == keysFile }) {
keys = fmt.Sprintf("include %q;\n", slash(filepath.Join(revDir, keysFile)))
}
named := fmt.Sprintf("options {\n\tdirectory %q;\n\tinclude %q;\n};\n%sinclude %q;\n",
slash(tmp), slash(filepath.Join(revDir, optionsFile)), keys, slash(filepath.Join(revDir, confFile)))
nc := filepath.Join(tmp, "named.conf")
if err := os.WriteFile(nc, []byte(named), 0o600); err != nil {
return err
}
if _, err := r.run.Run(ctx, r.cfg.NamedCheckconf, nc); err != nil {
return fmt.Errorf("configuration check failed: %w", err)
}
return nil
}
// Activate installs rev and reloads BIND, then applies the managed records
// of dynamic zones (ADR 0026). See core.Role for the rules on CreateOnly
// files and removals. On failure the previous files are restored (and BIND
// is reloaded again if it had been reloaded); records already updated are
// restored by the core's rollback, which activates the previous revision.
func (r *Role) Activate(ctx context.Context, rev core.Revision, prev *core.Revision) (err error) {
targets, err := r.plan(rev)
if err != nil {
return err
}
dyn, err := r.loadDynamic(rev, targets)
if err != nil {
return err
}
var old []target
var oldDyn *dynSet
if prev != nil {
// A previous revision that no longer fits the configuration only
// loses the removal of its stale files and of its managed records.
var perr error
if old, perr = r.plan(*prev); perr == nil {
if oldDyn, perr = r.loadDynamic(*prev, old); perr != nil {
r.log.Warn("previous revision has unusable dynamic records; none are deleted",
"revision", prev.Number, "error", perr)
}
}
}
// BIND writes transferred secondary zones there; it stays when the
// activation fails (it is empty or BIND's own).
if err := fsutil.MkdirLike(r.secondaryPath(), r.cfg.ZoneDir); err != nil {
return fmt.Errorf("secondary zone directory: %w", err)
}
var backup fsutil.Backup
writes, removes, err := r.changes(targets, old)
if err != nil {
return err
}
for _, w := range writes {
if err := backup.Save(w.dst); err != nil {
return err
}
}
for _, p := range removes {
if err := backup.Save(p); err != nil {
return err
}
}
reloaded := false
defer func() {
if err == nil {
return
}
if rerr := backup.Restore(); rerr != nil {
err = errors.Join(err, fmt.Errorf("restoring the previous files failed: %w", rerr))
return
}
if reloaded {
if rerr := r.reload(context.WithoutCancel(ctx)); rerr != nil {
err = errors.Join(err, fmt.Errorf("reload after restoring the previous files failed: %w", rerr))
}
}
}()
for _, w := range writes {
data, rerr := w.content()
if rerr != nil {
return rerr
}
if fsutil.Same(w.dst, data, fs.FileMode(w.file.Mode)) {
// Unchanged files keep their modification time, so that
// "rndc reload" does not reload zones that did not change.
continue
}
if werr := fsutil.WriteFile(w.dst, data, fs.FileMode(w.file.Mode), fsutil.OwnerFor(w.dst)); werr != nil {
return werr
}
}
for _, p := range removes {
if rerr := fsutil.Remove(p); rerr != nil {
return fmt.Errorf("remove %s: %w", p, rerr)
}
}
if _, cerr := r.run.Run(ctx, r.cfg.NamedCheckconf, "-z", r.cfg.NamedConf); cerr != nil {
return fmt.Errorf("live configuration check failed, previous files restored: %w", cerr)
}
reloaded = true
if err := r.reload(ctx); err != nil {
return err
}
return r.applyDynamic(ctx, rev, dyn, oldDyn, dynamicZones(targets))
}
// changes computes the files to write (zone files first, so that the
// configuration never names a missing zone file) and the files to remove.
func (r *Role) changes(targets, old []target) (writes []target, removes []string, err error) {
inRev := map[string]bool{}
for _, t := range targets {
inRev[t.dst] = true
}
oldCreateOnly := map[string]bool{}
for _, t := range old {
oldCreateOnly[t.dst] = t.file.CreateOnly
}
var confs []target
for _, t := range targets {
if t.file.CreateOnly {
exists, err := fsutil.Exists(t.dst)
if err != nil {
return nil, nil, err
}
if exists {
continue // BIND owns it
}
} else if oldCreateOnly[t.dst] {
// The zone was dynamic and is now managed: its journal no
// longer matches the new file and would be replayed on load.
removes = append(removes, t.dst+journalSuffix)
}
if t.zone == "" {
confs = append(confs, t)
} else {
writes = append(writes, t)
}
}
writes = append(writes, confs...)
for _, t := range old {
if !inRev[t.dst] && !t.file.CreateOnly {
removes = append(removes, t.dst)
}
}
return writes, removes, nil
}
// reload makes BIND read the new configuration and zone files.
//
// "rndc reconfig" loads the configuration and new zones; "rndc reload"
// (without a zone) reloads the configuration and all zones (BIND 9 ARM,
// rndc manual page, 9.18 and 9.20). Dynamic zones do not fail this
// command: named skips them when they are loaded already (lib/dns/zt.c
// maps DNS_R_DYNAMIC to success), whereas "rndc reload <zone>" would fail
// with "dynamic zone". Zone loading after "rndc reload" is asynchronous;
// the health check confirms the serials.
func (r *Role) reload(ctx context.Context) error {
var errs []error
for _, cmd := range []string{"reconfig", "reload"} {
if _, err := r.run.Run(ctx, r.cfg.Rndc, append(append([]string(nil), r.cfg.RndcArgs...), cmd)...); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+788
View File
@@ -0,0 +1,788 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// fakeRunner records program runs and answers them with fn.
type fakeRunner struct {
mu sync.Mutex
calls []string
// inputs holds the stdin of every RunInput call, in order.
inputs []string
fn func(name string, args []string) ([]byte, error)
// fnInput answers RunInput calls if set (otherwise fn does).
fnInput func(input string, name string, args []string) ([]byte, error)
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
return f.run(nil, name, args)
}
func (f *fakeRunner) RunInput(_ context.Context, input []byte, name string, args ...string) ([]byte, error) {
return f.run(input, name, args)
}
func (f *fakeRunner) Inputs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.inputs)
}
func (f *fakeRunner) run(input []byte, name string, args []string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, strings.Join(append([]string{name}, args...), " "))
if input != nil {
f.inputs = append(f.inputs, string(input))
}
fn, fnInput := f.fn, f.fnInput
f.mu.Unlock()
var out []byte
var err error
switch {
case input != nil && fnInput != nil:
out, err = fnInput(string(input), name, args)
case fn != nil:
out, err = fn(name, args)
default:
return nil, nil
}
if err != nil {
// Like command.Exec: the error quotes the output.
return out, &command.Error{Name: name, Args: args, Err: err, Output: out}
}
return out, nil
}
func (f *fakeRunner) Calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.calls)
}
func (f *fakeRunner) Reset() {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = nil
f.inputs = nil
}
type env struct {
t *testing.T
role *Role
run *fakeRunner
confDir string
zoneDir string
}
func newEnv(t *testing.T) *env {
t.Helper()
base := t.TempDir()
cfg := DefaultConfig()
cfg.ConfDir = filepath.ToSlash(filepath.Join(base, "etc"))
cfg.ZoneDir = filepath.ToSlash(filepath.Join(base, "zones"))
cfg.NamedConf = filepath.ToSlash(filepath.Join(base, "named.conf"))
cfg.RndcArgs = []string{"-k", "/etc/bind/rndc.key"}
if runtime.GOOS == "windows" {
// Validation wants slash paths; Windows only compiles these tests.
t.Skip("DNS role tests need Unix paths")
}
for _, d := range []string{cfg.ConfDir, cfg.ZoneDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
t.Fatal(err)
}
}
run := &fakeRunner{}
r, err := newRole(cfg, run)
if err != nil {
t.Fatal(err)
}
return &env{t: t, role: r, run: run, confDir: cfg.ConfDir, zoneDir: cfg.ZoneDir}
}
// zone describes one zone of a fixture revision.
type zone struct {
name string
dynamic bool
secondary bool // no zone file; BIND transfers it
extra string
}
func confFor(zoneDir string, zones []zone) string {
var b strings.Builder
b.WriteString("// Generated by DDI Suite.\n")
for _, z := range zones {
if z.secondary {
fmt.Fprintf(&b, "\nzone %q {\n type secondary;\n file \"%s/secondary/db.%s\";\n"+
" primaries { 192.0.2.1 key \"xfer\"; };\n notify no;\n};\n", z.name, zoneDir, z.name)
continue
}
update := "none"
if z.dynamic {
update = "any"
}
fmt.Fprintf(&b, "\nzone %q {\n type primary;\n file \"%s/db.%s\";\n notify no;\n allow-update { %s; };\n};\n",
z.name, zoneDir, z.name, update)
}
return b.String()
}
func zoneFile(name string, serial uint64, extra string) string {
return fmt.Sprintf("$ORIGIN %s.\n$TTL 3600\n@ IN SOA ns1.%s. hostmaster.%s. %d 3600 600 1209600 300\n@ IN NS ns1.%s.\nns1 IN A 192.0.2.53\n%s",
name, name, name, serial, name, extra)
}
// revision writes a generator-like revision (as before M5: without keys
// file) into a temporary directory.
func (e *env) revision(n uint64, zones []zone) core.Revision {
e.t.Helper()
return e.revisionWithKeys(n, zones, "")
}
const testKeys = "key \"xfer\" {\n algorithm hmac-sha256;\n secret \"eGZlcg==\";\n};\n"
// revisionWithKeys writes a revision with keys file (if keys is not empty).
func (e *env) revisionWithKeys(n uint64, zones []zone, keys string) core.Revision {
e.t.Helper()
files := map[string]string{
confFile: confFor(e.zoneDir, zones),
optionsFile: "recursion no;\nallow-transfer { none; };\n",
}
if keys != "" {
files[keysFile] = keys
}
createOnly := map[string]bool{}
for _, z := range zones {
if z.secondary {
continue
}
p := zonesPrefix + zonePrefix + z.name
files[p] = zoneFile(z.name, n, z.extra)
createOnly[p] = z.dynamic
}
return writeRevision(e.t, n, files, createOnly)
}
func writeRevision(t *testing.T, n uint64, files map[string]string, createOnly map[string]bool) core.Revision {
t.Helper()
rev := core.Revision{Number: n, Dir: t.TempDir()}
paths := make([]string, 0, len(files))
for p := range files {
paths = append(paths, p)
}
slices.Sort(paths)
for _, p := range paths {
full := filepath.Join(rev.Dir, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(files[p]), 0o600); err != nil {
t.Fatal(err)
}
rev.Files = append(rev.Files, bundle.File{Path: p, Mode: 0o640, Size: int64(len(files[p])), CreateOnly: createOnly[p]})
}
return rev
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
func TestNewDecodesConfig(t *testing.T) {
src := "conf_dir: /etc/named/ddi\nrndc_args: [-k, /etc/named/rndc.key, -p, \"9953\"]\ncommand_timeout: 5s\ndns_address: \"[::1]:5353\"\n"
decode := func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
}
r, err := New(decode)
if err != nil {
t.Fatal(err)
}
cfg := r.(*Role).cfg
if cfg.ConfDir != "/etc/named/ddi" || cfg.ZoneDir != "/var/lib/bind/ddi-suite" || cfg.CommandTimeout != 5*time.Second ||
!slices.Equal(cfg.RndcArgs, []string{"-k", "/etc/named/rndc.key", "-p", "9953"}) || cfg.DNSAddress != "[::1]:5353" {
t.Errorf("unexpected config %+v", cfg)
}
if r.Kind() != core.KindDNS {
t.Error("wrong kind")
}
if r, err := New(nil); err != nil || r.(*Role).cfg.NamedConf != "/etc/bind/named.conf" {
t.Errorf("defaults: %v", err)
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
for name, src := range map[string]string{
"relative dir": "zone_dir: var/lib/bind",
"quote in path": "conf_dir: '/etc/bind/x\"y'",
"host name": "dns_address: localhost:53",
"no port": "dns_address: 127.0.0.1",
"zero timeout": "command_timeout: 0s",
"empty program": "rndc: ''",
"unknown field": "zonedir: /x",
} {
t.Run(name, func(t *testing.T) {
_, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err == nil {
t.Fatal("expected an error")
}
})
}
}
func TestServiceVersion(t *testing.T) {
e := newEnv(t)
e.run.fn = func(name string, args []string) ([]byte, error) {
return []byte("BIND 9.20.3 (Stable Release) <id:abc>\n"), nil
}
v, err := e.role.ServiceVersion(context.Background())
if err != nil || v != "BIND 9.20.3" {
t.Fatalf("version = %q, %v", v, err)
}
if got := e.run.Calls(); !slices.Equal(got, []string{"named -v"}) {
t.Errorf("calls = %v", got)
}
for in, want := range map[string]string{
"BIND 9.18.28-0ubuntu0.22.04.1-Ubuntu (Extended Support Version) <id:>": "BIND 9.18.28-0ubuntu0.22.04.1-Ubuntu",
"named: unknown option": "",
} {
got, err := parseNamedVersion(in)
if got != want || (want == "") != (err != nil) {
t.Errorf("parseNamedVersion(%q) = %q, %v", in, got, err)
}
}
}
var sampleZones = []zone{
{name: "office.test"},
{name: "2.0.192.in-addr.arpa", extra: "10 IN PTR www.office.test.\n"},
{name: "dyn.test", dynamic: true},
}
func TestValidateRunsCheckers(t *testing.T) {
e := newEnv(t)
rev := e.revision(1, sampleZones)
var namedConf string
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
namedConf = readFile(t, args[len(args)-1])
}
return nil, nil
}
if err := e.role.Validate(context.Background(), rev); err != nil {
t.Fatal(err)
}
calls := e.run.Calls()
if len(calls) != 4 || !strings.HasPrefix(calls[0], "named-checkconf ") || strings.Contains(calls[0], "-z") {
t.Fatalf("calls = %v", calls)
}
for _, want := range []string{
"include \"" + filepath.ToSlash(filepath.Join(rev.Dir, optionsFile)) + "\";",
"include \"" + filepath.ToSlash(filepath.Join(rev.Dir, confFile)) + "\";",
"options {",
} {
if !strings.Contains(namedConf, want) {
t.Errorf("temporary named.conf lacks %q:\n%s", want, namedConf)
}
}
// The options include must be inside the options block.
if strings.Index(namedConf, optionsFile) > strings.Index(namedConf, "};") {
t.Errorf("options include outside the options block:\n%s", namedConf)
}
for _, z := range sampleZones {
want := "named-checkzone -k fail -m fail -M fail -S fail -i local " + z.name + " " +
filepath.Join(rev.Dir, "zones", "db."+z.name)
if !slices.Contains(calls, want) {
t.Errorf("missing call %q in %v", want, calls)
}
}
if exists(filepath.Join(e.confDir, confFile)) {
t.Error("Validate changed live files")
}
}
func TestValidateRejects(t *testing.T) {
e := newEnv(t)
good := e.revision(1, sampleZones)
cases := map[string]struct {
rev func() core.Revision
fn func(name string, args []string) ([]byte, error)
want string
}{
"unexpected file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: "", "other/x": ""}, nil)
},
want: "unexpected file",
},
"nested zone file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: "", "zones/a/db.x": ""}, nil)
},
want: "unexpected zone file",
},
"missing options": {
rev: func() core.Revision { return writeRevision(t, 1, map[string]string{confFile: ""}, nil) },
want: optionsFile,
},
"create-only conf": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{confFile: "", optionsFile: ""}, map[string]bool{confFile: true})
},
want: "create-only",
},
"zone dir mismatch": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{
confFile: confFor("/etc/bind/ddi-suite/zones", []zone{{name: "a.test"}}),
optionsFile: "",
"zones/db.a.test": zoneFile("a.test", 1, ""),
}, nil)
},
want: "dns.zone_dir",
},
"unknown zone file": {
rev: func() core.Revision {
return writeRevision(t, 1, map[string]string{
confFile: confFor(e.zoneDir, []zone{{name: "a.test"}}), optionsFile: "",
}, nil)
},
want: "not part of the revision",
},
"checkconf fails": {
rev: func() core.Revision { return good },
fn: func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
return []byte("ddi-suite.conf:3: unknown option 'bogus'"), errors.New("exit status 1")
}
return nil, nil
},
want: "configuration check failed",
},
"checkzone fails": {
rev: func() core.Revision { return good },
fn: func(name string, args []string) ([]byte, error) {
if name == "named-checkzone" && slices.Contains(args, "office.test") {
return []byte("db.office.test:7: syntax error"), errors.New("exit status 1")
}
return nil, nil
},
want: "zone office.test",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
e.run.fn = c.fn
err := e.role.Validate(context.Background(), c.rev())
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want it to contain %q", err, c.want)
}
})
}
}
func TestValidateRequiresTargetDirs(t *testing.T) {
e := newEnv(t)
rev := e.revision(1, sampleZones)
if err := os.Remove(e.zoneDir); err != nil {
t.Fatal(err)
}
if err := e.role.Validate(context.Background(), rev); err == nil || !strings.Contains(err.Error(), "target directory") {
t.Fatalf("err = %v", err)
}
}
func TestActivateWritesRemovesAndReloads(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
want := []string{
"named-checkconf -z " + e.role.cfg.NamedConf,
"rndc -k /etc/bind/rndc.key reconfig",
"rndc -k /etc/bind/rndc.key reload",
}
if got := e.run.Calls(); !slices.Equal(got, want) {
t.Fatalf("calls = %v, want %v", got, want)
}
for _, z := range sampleZones {
p := filepath.Join(e.zoneDir, "db."+z.name)
if got := readFile(t, p); got != zoneFile(z.name, 1, z.extra) {
t.Errorf("%s = %q", p, got)
}
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
t.Errorf("%s: mode %o", p, fi.Mode().Perm())
}
}
if got := readFile(t, filepath.Join(e.confDir, confFile)); got != confFor(e.zoneDir, sampleZones) {
t.Errorf("ddi-suite.conf = %q", got)
}
// BIND changes the dynamic zone; revision 2 drops the reverse zone.
dynFile := filepath.Join(e.zoneDir, "db.dyn.test")
if err := os.WriteFile(dynFile, []byte("owned by BIND"), 0o644); err != nil {
t.Fatal(err)
}
zones2 := []zone{{name: "office.test", extra: "www IN A 192.0.2.10\n"}, {name: "dyn.test", dynamic: true}}
rev2 := e.revision(2, zones2)
e.run.Reset()
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if got := readFile(t, dynFile); got != "owned by BIND" {
t.Errorf("create-only file was overwritten: %q", got)
}
if exists(filepath.Join(e.zoneDir, "db.2.0.192.in-addr.arpa")) {
t.Error("zone file of the removed zone still exists")
}
if got := readFile(t, filepath.Join(e.zoneDir, "db.office.test")); got != zoneFile("office.test", 2, zones2[0].extra) {
t.Errorf("office.test not updated: %q", got)
}
if len(e.run.Calls()) != 3 {
t.Errorf("calls = %v", e.run.Calls())
}
// Revision 3 drops the dynamic zone: its file stays.
rev3 := e.revision(3, zones2[:1])
if err := e.role.Activate(ctx, rev3, &rev2); err != nil {
t.Fatal(err)
}
if !exists(dynFile) {
t.Error("create-only file was removed")
}
}
func TestActivateKeepsUnchangedFiles(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
p := filepath.Join(e.confDir, optionsFile)
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
rev2 := e.revision(2, sampleZones)
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(p); !fi.ModTime().Equal(old) {
t.Error("unchanged file was rewritten")
}
}
func TestActivateRestoresWhenLiveCheckFails(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
rev2 := e.revision(2, []zone{{name: "office.test"}, {name: "new.test"}, {name: "new-dyn.test", dynamic: true}})
e.run.Reset()
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
return []byte("options: 'notify' redefined"), errors.New("exit status 1")
}
return nil, nil
}
err := e.role.Activate(ctx, rev2, &rev1)
if err == nil || !strings.Contains(err.Error(), "redefined") {
t.Fatalf("err = %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored:\n got %v\nwant %v", keys(got), keys(before))
}
for _, c := range e.run.Calls() {
if strings.HasPrefix(c, "rndc") {
t.Errorf("rndc called although the check failed: %v", e.run.Calls())
}
}
}
func TestActivateRestoresAndReloadsWhenReloadFails(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
rev2 := e.revision(2, sampleZones[:1])
e.run.Reset()
failed := false
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "rndc" && args[len(args)-1] == "reload" && !failed {
failed = true
return []byte("rndc: 'reload' failed: failure"), errors.New("exit status 1")
}
return nil, nil
}
err := e.role.Activate(ctx, rev2, &rev1)
if err == nil || !strings.Contains(err.Error(), "'reload' failed") {
t.Fatalf("err = %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored")
}
calls := e.run.Calls()
if n := len(calls); n != 5 || calls[3] != "rndc -k /etc/bind/rndc.key reconfig" || calls[4] != "rndc -k /etc/bind/rndc.key reload" {
t.Errorf("expected a second reconfig/reload after restoring, calls = %v", calls)
}
}
func TestActivateRemovesJournalWhenZoneBecomesManaged(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.revision(1, []zone{{name: "z.test", dynamic: true}})
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
jnl := filepath.Join(e.zoneDir, "db.z.test.jnl")
if err := os.WriteFile(jnl, []byte("journal"), 0o644); err != nil {
t.Fatal(err)
}
rev2 := e.revision(2, []zone{{name: "z.test"}})
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if exists(jnl) {
t.Error("journal of the formerly dynamic zone still exists")
}
if got := readFile(t, filepath.Join(e.zoneDir, "db.z.test")); got != zoneFile("z.test", 2, "") {
t.Errorf("managed zone file not written: %q", got)
}
}
var clusterZones = []zone{
{name: "office.test"},
{name: "remote.test", secondary: true},
{name: "2.0.192.in-addr.arpa", secondary: true},
}
func TestValidateIncludesKeysBeforeConf(t *testing.T) {
e := newEnv(t)
var namedConf string
e.run.fn = func(name string, args []string) ([]byte, error) {
if name == "named-checkconf" {
namedConf = readFile(t, args[len(args)-1])
}
return nil, nil
}
rev := e.revisionWithKeys(1, clusterZones, testKeys)
if err := e.role.Validate(context.Background(), rev); err != nil {
t.Fatal(err)
}
keysInc := "include \"" + filepath.ToSlash(filepath.Join(rev.Dir, keysFile)) + "\";"
confInc := "include \"" + filepath.ToSlash(filepath.Join(rev.Dir, confFile)) + "\";"
ki, ci := strings.Index(namedConf, keysInc), strings.Index(namedConf, confInc)
if ki < 0 || ci < 0 || ki > ci || ki < strings.Index(namedConf, "};") {
t.Errorf("keys must be included at the top level before ddi-suite.conf:\n%s", namedConf)
}
// Only the primary zone has a zone file to check.
if calls := e.run.Calls(); len(calls) != 2 || !strings.Contains(calls[1], "office.test") {
t.Errorf("calls = %v", calls)
}
// A revision without keys file (before M5) includes none.
rev = e.revision(2, sampleZones)
if err := e.role.Validate(context.Background(), rev); err != nil {
t.Fatal(err)
}
if strings.Contains(namedConf, keysFile) {
t.Errorf("keys file included although the revision has none:\n%s", namedConf)
}
}
func TestValidateSecondaryZoneFiles(t *testing.T) {
e := newEnv(t)
for name, c := range map[string]struct {
conf string
want string // "" means accepted
}{
"secondary file in zone_dir/secondary": {
conf: confFor(e.zoneDir, clusterZones),
},
"secondary file of another zone dir": {
conf: confFor("/srv/zones", []zone{{name: "remote.test", secondary: true}}),
want: "dns.zone_dir",
},
"secondary file with a bad name": {
conf: "zone \"x.test\" {\n type secondary;\n file \"" + e.zoneDir + "/secondary/x.test\";\n};\n",
want: "want db.<zone>",
},
"secondary directory below another directory": {
conf: "zone \"x.test\" {\n file \"" + e.zoneDir + "/secondary/a/db.x.test\";\n};\n",
want: "dns.zone_dir",
},
} {
t.Run(name, func(t *testing.T) {
files := map[string]string{confFile: c.conf, optionsFile: "", keysFile: testKeys}
if strings.Contains(c.conf, "db.office.test") {
files["zones/db.office.test"] = zoneFile("office.test", 1, "")
}
err := e.role.Validate(context.Background(), writeRevision(t, 1, files, nil))
switch {
case c.want == "" && err != nil:
t.Fatalf("rejected: %v", err)
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Fatalf("err = %v, want it to contain %q", err, c.want)
}
})
}
}
func TestActivateKeysAndSecondaryDirectory(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
if err := os.Chmod(e.zoneDir, 0o770); err != nil {
t.Fatal(err)
}
keysPath := filepath.Join(e.confDir, keysFile)
secDir := filepath.Join(e.zoneDir, "secondary")
// Before M5: a keys file without keys.
rev1 := e.revision(1, sampleZones)
if err := e.role.Activate(ctx, rev1, nil); err != nil {
t.Fatal(err)
}
if got := readFile(t, keysPath); got != noKeys {
t.Errorf("keys file = %q", got)
}
fi, err := os.Stat(secDir)
if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o770 {
t.Fatalf("secondary directory: %v %v", fi, err)
}
// BIND writes a transferred zone; the revision with keys replaces the
// placeholder and never touches the secondary directory.
transferred := filepath.Join(secDir, "db.remote.test")
if err := os.WriteFile(transferred, []byte("raw zone"), 0o644); err != nil {
t.Fatal(err)
}
rev2 := e.revisionWithKeys(2, clusterZones, testKeys)
if err := e.role.Activate(ctx, rev2, &rev1); err != nil {
t.Fatal(err)
}
if got := readFile(t, keysPath); got != testKeys {
t.Errorf("keys file = %q", got)
}
if fi, _ := os.Stat(keysPath); fi.Mode().Perm() != 0o640 {
t.Errorf("keys file mode %o", fi.Mode().Perm())
}
for _, z := range []string{"2.0.192.in-addr.arpa", "dyn.test"} {
if z == "dyn.test" {
if !exists(filepath.Join(e.zoneDir, "db."+z)) {
t.Error("create-only file removed")
}
continue
}
if exists(filepath.Join(e.zoneDir, "db."+z)) {
t.Errorf("zone file of %s (now secondary) still in zone_dir", z)
}
}
// Back to a revision without keys (local rollback to an old revision):
// the keys file stays, without keys.
if err := e.role.Activate(ctx, rev1, &rev2); err != nil {
t.Fatal(err)
}
if got := readFile(t, keysPath); got != noKeys {
t.Errorf("keys file after rollback = %q", got)
}
if got := readFile(t, transferred); got != "raw zone" {
t.Errorf("secondary zone file changed: %q", got)
}
// Something else in the way of the secondary directory fails the
// activation before anything is written.
e2 := newEnv(t)
if err := os.WriteFile(filepath.Join(e2.zoneDir, "secondary"), nil, 0o600); err != nil {
t.Fatal(err)
}
if err := e2.role.Activate(ctx, e2.revision(1, sampleZones), nil); err == nil || !strings.Contains(err.Error(), "secondary zone directory") {
t.Fatalf("err = %v", err)
}
if exists(filepath.Join(e2.confDir, confFile)) {
t.Error("files written although the activation failed")
}
}
func snapshot(t *testing.T, e *env) map[string]string {
t.Helper()
out := map[string]string{}
for _, d := range []string{e.confDir, e.zoneDir} {
entries, err := os.ReadDir(d)
if err != nil {
t.Fatal(err)
}
for _, en := range entries {
if en.IsDir() {
continue // the secondary zone directory
}
out[filepath.Join(d, en.Name())] = readFile(t, filepath.Join(d, en.Name()))
}
}
return out
}
func mapsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if w, ok := b[k]; !ok || w != v {
return false
}
}
return true
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, filepath.Base(k))
}
slices.Sort(out)
return out
}
+651
View File
@@ -0,0 +1,651 @@
// SPDX-License-Identifier: Apache-2.0
package dns
// Records of dynamic zones (ADR 0026). BIND owns the content of a dynamic
// zone once its initial file exists, so the records DDI manages there are
// applied as RFC 2136 updates: a revision for a target that serves dynamic
// zones as primary carries dynamic-records.json (the managed records per
// zone) and ddi-suite-update.key (the TSIG key nsupdate signs with). Both
// stay in the revision directory; nothing is installed into conf_dir.
//
// After the reload, Activate sends per zone: deletes of exactly the records
// the previous revision managed and the new one does not, then adds of all
// records of the new revision (adding an existing record changes nothing).
// Records DDI never managed (domain controllers, other clients, manual
// updates) are never touched.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/fsutil"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Revision files of ADR 0026 (top level, never installed).
const (
updateKeyFile = "ddi-suite-update.key"
dynRecordsFile = "dynamic-records.json"
// dynRecordsVersion is the only format version understood.
dynRecordsVersion = 1
)
// Limits of the update files and of one update message.
const (
maxKeyFileSize = 16 << 10
maxRecordsSize = 64 << 20
// maxDataLen bounds the presentation form of one record's data.
maxDataLen = 8 << 10
// Updates are sent in batches of at most this many records or bytes of
// script text, so that one message stays well below the 64 KiB limit
// of DNS over TCP.
maxBatchRecords = 500
maxBatchBytes = 32 << 10
// nsupdateTimeout is passed to nsupdate -t (seconds per update).
nsupdateTimeout = "30"
)
// Retry while a zone is not loaded yet right after rndc reconfig (BIND
// answers NOTAUTH or SERVFAIL until then).
const (
defaultUpdateWait = 10 * time.Second
defaultUpdateRetry = time.Second
)
// dynRecord is one managed record in presentation form.
type dynRecord struct {
Name string `json:"name"`
TTL int64 `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
// dynZone holds the managed records of one dynamic zone.
type dynZone struct {
Zone string `json:"zone"`
Records []dynRecord `json:"records"`
}
// dynFile is the content of dynamic-records.json.
type dynFile struct {
Version int `json:"version"`
Key string `json:"key"`
Zones []dynZone `json:"zones"`
}
// dynSet is the validated update data of one revision.
type dynSet struct {
key string // TSIG key name
keyFile string // path of the revision's key file
// zones maps the lowercase zone name to its records (validated, without
// duplicates, in file order).
zones map[string][]dynRecord
}
// identity returns a comparison key of r: names are case-insensitive, and
// so are the names in the data of PTR, CNAME, NS, MX and SRV records;
// addresses compare by value.
func (r dynRecord) identity() string {
data := r.Data
switch r.Type {
case "A", "AAAA":
if a, err := netip.ParseAddr(data); err == nil {
data = a.String()
}
case "PTR", "CNAME", "NS", "MX", "SRV":
data = strings.ToLower(data)
}
return strings.ToLower(r.Name) + " " + r.Type + " " + data
}
// dynamicZones returns the dynamic primary zones of a revision: zones whose
// file is create-only (the generator's initial file of a dynamic zone).
func dynamicZones(targets []target) map[string]bool {
zones := map[string]bool{}
for _, t := range targets {
if t.zone != "" && t.file.CreateOnly {
zones[strings.ToLower(t.zone)] = true
}
}
return zones
}
// isUpdateFile reports whether p is one of the revision files of ADR 0026.
func isUpdateFile(p string) bool { return p == updateKeyFile || p == dynRecordsFile }
// loadDynamic reads and validates the update files of rev. targets is the
// revision's plan. It returns nil if the revision has neither file.
func (r *Role) loadDynamic(rev core.Revision, targets []target) (*dynSet, error) {
var keyEntry, recEntry *bundle.File
for i, f := range rev.Files {
switch f.Path {
case updateKeyFile:
keyEntry = &rev.Files[i]
case dynRecordsFile:
recEntry = &rev.Files[i]
}
}
switch {
case keyEntry == nil && recEntry == nil:
return nil, nil
case keyEntry == nil || recEntry == nil:
return nil, fmt.Errorf("DNS revision must contain both %s and %s or neither", updateKeyFile, dynRecordsFile)
case keyEntry.CreateOnly || recEntry.CreateOnly:
return nil, fmt.Errorf("%s and %s must not be create-only", updateKeyFile, dynRecordsFile)
}
keyPath := filepath.Join(rev.Dir, updateKeyFile)
if strings.ContainsAny(keyPath, "\x00\n") {
return nil, fmt.Errorf("revision directory %q cannot be passed to nsupdate", rev.Dir)
}
keyData, err := readLimited(keyPath, maxKeyFileSize)
if err != nil {
return nil, fmt.Errorf("%s: %w", updateKeyFile, err)
}
keyName, err := parseUpdateKey(keyData)
if err != nil {
return nil, fmt.Errorf("%s: %w", updateKeyFile, err)
}
recData, err := readLimited(filepath.Join(rev.Dir, dynRecordsFile), maxRecordsSize)
if err != nil {
return nil, fmt.Errorf("%s: %w", dynRecordsFile, err)
}
set, err := parseDynamicRecords(recData, dynamicZones(targets))
if err != nil {
return nil, fmt.Errorf("%s: %w", dynRecordsFile, err)
}
if set.key != keyName {
return nil, fmt.Errorf("%s names the key %q, but %s holds the key %q", dynRecordsFile, set.key, updateKeyFile, keyName)
}
set.keyFile = keyPath
return set, nil
}
// checkUpdateKeyKnown verifies that the revision's keys file defines the
// update key, so that named can verify the updates.
func checkUpdateKeyKnown(rev core.Revision, key string) error {
if !slices.ContainsFunc(rev.Files, func(f bundle.File) bool { return f.Path == keysFile }) {
return fmt.Errorf("%s requires the key %q in %s, which the revision lacks", dynRecordsFile, key, keysFile)
}
keys, err := fsutil.ReadFile(filepath.Join(rev.Dir, keysFile))
if err != nil {
return err
}
stmt := regexp.MustCompile(`(?m)^\s*key\s+"` + regexp.QuoteMeta(key) + `"\s*\{`)
if !stmt.Match(keys) {
return fmt.Errorf("%s does not define the update key %q", keysFile, key)
}
return nil
}
func readLimited(p string, limit int64) ([]byte, error) {
data, err := fsutil.ReadFile(p)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("larger than %d bytes", limit)
}
return data, nil
}
// keyStmt matches the key statement of the update key file (named.conf
// syntax as written by the generator and tsig-keygen) after comments are
// removed. Group 1 is the name, the body is checked by parseKeyBody.
var keyStmt = regexp.MustCompile(`^\s*key\s+"([^"]*)"\s*\{([^{}"]*(?:"[^"]*"[^{}"]*)*)\}\s*;\s*$`)
// keyNameRE restricts TSIG key names to what the server allows plus dots.
var keyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$`)
var tsigAlgorithms = []string{"hmac-md5", "hmac-sha1", "hmac-sha224", "hmac-sha256", "hmac-sha384", "hmac-sha512"}
// parseUpdateKey checks that data holds exactly one key statement with an
// HMAC algorithm and a base64 secret and returns the key name. Errors never
// quote the content (it is secret).
func parseUpdateKey(data []byte) (string, error) {
if bytes.ContainsAny(data, "\x00") {
return "", errors.New("invalid content")
}
var b strings.Builder
for _, line := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "#") {
continue
}
b.WriteString(t)
b.WriteByte('\n')
}
m := keyStmt.FindStringSubmatch(b.String())
if m == nil {
return "", errors.New("must contain exactly one key statement (key \"<name>\" { algorithm ...; secret \"...\"; };) and only comments besides")
}
name := m[1]
if !keyNameRE.MatchString(name) {
return "", fmt.Errorf("invalid key name %q", name)
}
var alg, secret string
for _, s := range strings.Split(m[2], ";") {
f := strings.Fields(s)
switch {
case len(f) == 0:
continue
case len(f) == 2 && f[0] == "algorithm" && alg == "":
alg = f[1]
case len(f) == 2 && f[0] == "secret" && secret == "":
secret = f[1]
default:
return "", errors.New("the key statement must hold exactly one algorithm and one secret")
}
}
if !slices.Contains(tsigAlgorithms, strings.ToLower(alg)) {
return "", errors.New("the key statement needs an HMAC algorithm")
}
raw, quoted := strings.CutPrefix(secret, `"`)
raw, closed := strings.CutSuffix(raw, `"`)
if !quoted || !closed || raw == "" {
return "", errors.New("the secret must be a quoted base64 string")
}
if _, err := base64.StdEncoding.DecodeString(raw); err != nil {
return "", errors.New("the secret is not valid base64")
}
return name, nil
}
// parseDynamicRecords decodes dynamic-records.json strictly and validates
// every field before it can reach an nsupdate script. dynamic holds the
// lowercase names of the revision's dynamic primary zones.
func parseDynamicRecords(data []byte, dynamic map[string]bool) (*dynSet, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
var f dynFile
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid JSON: data after the object")
}
if f.Version != dynRecordsVersion {
return nil, fmt.Errorf("unsupported version %d (want %d)", f.Version, dynRecordsVersion)
}
if !keyNameRE.MatchString(f.Key) {
return nil, fmt.Errorf("invalid key name %q", f.Key)
}
set := &dynSet{key: f.Key, zones: map[string][]dynRecord{}}
for i, z := range f.Zones {
zone := strings.ToLower(strings.TrimSuffix(z.Zone, "."))
if !validZoneName(zone) || !validLabels(zone, false) {
return nil, fmt.Errorf("zones[%d]: invalid zone name %q", i, z.Zone)
}
if _, dup := set.zones[zone]; dup {
return nil, fmt.Errorf("zones[%d]: zone %s is listed twice", i, zone)
}
if !dynamic[zone] {
return nil, fmt.Errorf("zones[%d]: %s is not a dynamic primary zone of this revision", i, zone)
}
seen := map[string]bool{}
records := make([]dynRecord, 0, len(z.Records))
for j, rec := range z.Records {
if err := validateRecord(rec, zone); err != nil {
return nil, fmt.Errorf("zone %s, records[%d]: %w", zone, j, err)
}
if id := rec.identity(); !seen[id] {
seen[id] = true
records = append(records, rec)
}
}
set.zones[zone] = records
}
return set, nil
}
// validateRecord checks one record of zone (lowercase, without trailing
// dot): an absolute owner name inside the zone, a TTL within RFC 2181, a
// supported type and data that is valid presentation form of that type on
// one line.
func validateRecord(r dynRecord, zone string) error {
if !validName(r.Name, true) {
return fmt.Errorf("invalid name %q (want an absolute name with trailing dot)", r.Name)
}
name := strings.ToLower(r.Name)
if name != zone+"." && !strings.HasSuffix(name, "."+zone+".") {
return fmt.Errorf("name %q is outside the zone %s", r.Name, zone)
}
if r.TTL < 0 || r.TTL > 1<<31-1 {
return fmt.Errorf("TTL %d is out of range", r.TTL)
}
if len(r.Data) == 0 || len(r.Data) > maxDataLen {
return fmt.Errorf("%s data is empty or too long", r.Type)
}
for i := 0; i < len(r.Data); i++ {
if c := r.Data[i]; c < 0x20 || c > 0x7e {
return fmt.Errorf("%s data contains a control or non-ASCII character", r.Type)
}
}
var err error
switch r.Type {
case "A":
err = checkAddr(r.Data, true)
case "AAAA":
err = checkAddr(r.Data, false)
case "PTR", "CNAME", "NS":
if !validName(r.Data, false) {
err = errors.New("want an absolute name with trailing dot")
}
case "MX":
err = checkFields(r.Data, 1, true)
case "SRV":
err = checkFields(r.Data, 3, true)
case "TXT":
err = checkTXT(r.Data)
default:
return fmt.Errorf("unsupported type %q (want A, AAAA, PTR, CNAME, MX, TXT, SRV or NS)", r.Type)
}
if err != nil {
return fmt.Errorf("invalid %s data %q: %w", r.Type, r.Data, err)
}
return nil
}
func checkAddr(s string, v4 bool) error {
a, err := netip.ParseAddr(s)
switch {
case err != nil:
return errors.New("not an IP address")
case a.Zone() != "":
return errors.New("address with zone")
case v4 != a.Is4():
return errors.New("wrong address family")
}
return nil
}
// checkFields checks "<n1> ... <nk> <target>" with single spaces: k
// numbers 0-65535 and an absolute target name ("." if rootOK).
func checkFields(s string, numbers int, rootOK bool) error {
f := strings.Split(s, " ")
if len(f) != numbers+1 {
return fmt.Errorf("want %d numbers and a target, separated by single spaces", numbers)
}
for _, n := range f[:numbers] {
if n == "" || strings.Trim(n, "0123456789") != "" {
return fmt.Errorf("%q is not a number", n)
}
if _, err := strconv.ParseUint(n, 10, 16); err != nil {
return fmt.Errorf("%q is out of range", n)
}
}
if t := f[numbers]; (!rootOK || t != ".") && !validName(t, false) {
return errors.New("the target must be an absolute name with trailing dot")
}
return nil
}
// checkTXT accepts one or more quoted character-strings separated by single
// spaces, each at most 255 bytes long. Inside the quotes only printable
// ASCII is allowed, '"' and '\' only escaped, other escapes only as \DDD.
func checkTXT(s string) error {
i := 0
for {
if i >= len(s) || s[i] != '"' {
return errors.New("want quoted strings separated by single spaces")
}
i++
n := 0
for {
if i >= len(s) {
return errors.New("unterminated string")
}
c := s[i]
if c == '"' {
i++
break
}
if c == '\\' {
switch {
case i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\'):
i += 2
case i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]):
if v, _ := strconv.Atoi(s[i+1 : i+4]); v > 255 {
return errors.New("escape out of range")
}
i += 4
default:
return errors.New(`only \", \\ and \DDD escapes are allowed`)
}
} else {
i++
}
n++
}
if n > 255 {
return errors.New("string longer than 255 bytes")
}
if i == len(s) {
return nil
}
if s[i] != ' ' {
return errors.New("want quoted strings separated by single spaces")
}
i++
}
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// isNameChar reports whether c may appear in a label: letters, digits,
// '-' and '_' (service labels such as _ldap._tcp).
func isNameChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c) || c == '-' || c == '_'
}
// validName accepts an absolute domain name with trailing dot made of
// labels of letters, digits, '-' and '_' ("*" as the first label only for
// owner names).
func validName(s string, owner bool) bool {
n, ok := strings.CutSuffix(s, ".")
if !ok || n == "" || len(s) > 254 {
return false
}
return validLabels(n, owner)
}
// validLabels checks the labels of a name without trailing dot.
func validLabels(n string, wildcard bool) bool {
labels := strings.Split(n, ".")
for i, l := range labels {
if l == "" || len(l) > 63 {
return false
}
if l == "*" && wildcard && i == 0 {
continue
}
for j := 0; j < len(l); j++ {
if !isNameChar(l[j]) {
return false
}
}
}
return true
}
// zoneUpdate is the work for one zone.
type zoneUpdate struct {
zone string
deletes []dynRecord
adds []dynRecord
}
// planUpdates computes the updates from the previous set (may be nil) to
// cur. Zones that only the previous set lists lose its records if they are
// still dynamic primary zones of the new revision; otherwise BIND no longer
// serves them and they are skipped.
func planUpdates(cur, prev *dynSet, dynamic map[string]bool) []zoneUpdate {
zones := map[string]bool{}
if cur != nil {
for z := range cur.zones {
zones[z] = true
}
}
if prev != nil {
for z := range prev.zones {
if dynamic[z] {
zones[z] = true
}
}
}
names := make([]string, 0, len(zones))
for z := range zones {
names = append(names, z)
}
slices.Sort(names)
var out []zoneUpdate
for _, z := range names {
u := zoneUpdate{zone: z}
keep := map[string]bool{}
if cur != nil {
u.adds = cur.zones[z]
for _, rec := range u.adds {
keep[rec.identity()] = true
}
}
if prev != nil {
for _, rec := range prev.zones[z] {
if !keep[rec.identity()] {
u.deletes = append(u.deletes, rec)
}
}
}
if len(u.adds) > 0 || len(u.deletes) > 0 {
out = append(out, u)
}
}
return out
}
// updateScript renders the nsupdate input for u: deletes first, then adds,
// in batches (each with its own server, zone and send lines). Every field
// has been validated; a line break would still be refused here.
func updateScript(server netip.AddrPort, u zoneUpdate) (string, error) {
header := fmt.Sprintf("server %s %d\nzone %s.\n", server.Addr().String(), server.Port(), u.zone)
var b, batch strings.Builder
count := 0
flush := func() {
if count == 0 {
return
}
b.WriteString(header)
b.WriteString(batch.String())
b.WriteString("send\n")
batch.Reset()
count = 0
}
add := func(line string) error {
if strings.ContainsAny(line, "\n\r\x00") {
return errors.New("record contains a line break")
}
if count >= maxBatchRecords || (count > 0 && batch.Len()+len(line) > maxBatchBytes) {
flush()
}
batch.WriteString(line)
batch.WriteByte('\n')
count++
return nil
}
for _, r := range u.deletes {
if err := add(fmt.Sprintf("update delete %s %s %s", r.Name, r.Type, r.Data)); err != nil {
return "", err
}
}
for _, r := range u.adds {
if err := add(fmt.Sprintf("update add %s %d %s %s", r.Name, r.TTL, r.Type, r.Data)); err != nil {
return "", err
}
}
flush()
return b.String(), nil
}
// retryableUpdateError reports whether nsupdate failed because the zone is
// not (yet) loaded or the server did not answer.
func retryableUpdateError(out []byte) bool {
s := strings.ToLower(string(out))
for _, m := range []string{"notauth", "servfail", "communication with", "timed out", "connection refused"} {
if strings.Contains(s, m) {
return true
}
}
return false
}
// applyDynamic sends the updates from prev to cur, one nsupdate run per
// zone. A failure is returned (Activate then restores the previous files;
// the core's rollback re-applies the previous revision's records).
func (r *Role) applyDynamic(ctx context.Context, rev core.Revision, cur, prev *dynSet, dynamic map[string]bool) error {
if cur == nil {
if prev != nil {
for z := range prev.zones {
if dynamic[z] {
r.log.Warn("revision has no update key: managed records of a dynamic zone stay as they are",
"revision", rev.Number, "zone", z)
}
}
}
return nil
}
updates := planUpdates(cur, prev, dynamic)
if len(updates) == 0 {
return nil
}
server, err := netip.ParseAddrPort(r.cfg.DNSAddress)
if err != nil {
return fmt.Errorf("dns.dns_address: %w", err)
}
deadline := time.Now().Add(r.updateWait)
for _, u := range updates {
script, err := updateScript(server, u)
if err != nil {
return fmt.Errorf("zone %s: %w", u.zone, err)
}
if err := r.nsupdate(ctx, cur.keyFile, script, deadline); err != nil {
return fmt.Errorf("dynamic update of zone %s failed: %w", u.zone, err)
}
r.log.Info("managed records of a dynamic zone updated", "revision", rev.Number, "zone", u.zone,
"records", len(u.adds), "deleted", len(u.deletes))
}
return nil
}
// nsupdate runs the script, retrying until deadline while the zone is not
// loaded yet. Adds and deletes of exact records are idempotent, so the
// whole script can be repeated.
func (r *Role) nsupdate(ctx context.Context, keyFile, script string, deadline time.Time) error {
for {
out, err := r.run.RunInput(ctx, []byte(script), r.cfg.Nsupdate, "-t", nsupdateTimeout, "-k", keyFile)
if err == nil {
return nil
}
if !retryableUpdateError(out) || time.Now().Add(r.updateRetry).After(deadline) {
return err
}
t := time.NewTimer(r.updateRetry)
select {
case <-ctx.Done():
t.Stop()
return errors.Join(err, ctx.Err())
case <-t.C:
}
}
}
+670
View File
@@ -0,0 +1,670 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
const (
testUpdateKeyName = "ddi-agent-update"
testUpdateSecret = "c2VjcmV0LW9mLXRoZS11cGRhdGUta2V5LTEyMzQ1Ng=="
)
// testUpdateKey is a key file as the generator writes it.
var testUpdateKey = "// Generated by DDI Suite. Do not edit: the DNS agent uses this key\n" +
"// for dynamic updates of the records DDI manages.\n\n" +
"key \"" + testUpdateKeyName + "\" {\n algorithm hmac-sha256;\n secret \"" + testUpdateSecret + "\";\n};\n"
func TestParseUpdateKey(t *testing.T) {
valid := map[string]string{
"generator": testUpdateKey,
"tsig-keygen": "key \"k1\" {\n\talgorithm hmac-sha512;\n\tsecret \"" + testUpdateSecret + "\";\n};\n",
"secret first": "# comment\nkey \"k.example\" { secret \"" + testUpdateSecret + "\"; algorithm HMAC-SHA256; };",
"one line, no nl": "key \"k\" { algorithm hmac-sha1; secret \"" + testUpdateSecret + "\"; };",
}
wantNames := map[string]string{"generator": testUpdateKeyName, "tsig-keygen": "k1", "secret first": "k.example", "one line, no nl": "k"}
for name, src := range valid {
t.Run(name, func(t *testing.T) {
got, err := parseUpdateKey([]byte(src))
if err != nil || got != wantNames[name] {
t.Fatalf("parseUpdateKey = %q, %v", got, err)
}
})
}
sec := `secret "` + testUpdateSecret + `";`
invalid := map[string]string{
"empty": "",
"comments only": "// nothing\n",
"two keys": `key "a" { algorithm hmac-sha256; ` + sec + ` }; key "b" { algorithm hmac-sha256; ` + sec + ` };`,
"no secret": `key "a" { algorithm hmac-sha256; };`,
"two secrets": `key "a" { algorithm hmac-sha256; ` + sec + sec + ` };`,
"no algorithm": `key "a" { ` + sec + ` };`,
"unknown alg": `key "a" { algorithm rsa; ` + sec + ` };`,
"bad base64": `key "a" { algorithm hmac-sha256; secret "not base64!"; };`,
"unquoted": `key "a" { algorithm hmac-sha256; secret ` + testUpdateSecret + `; };`,
"half quoted": `key "a" { algorithm hmac-sha256; secret ` + testUpdateSecret + `"; };`,
"unquoted name": `key a { algorithm hmac-sha256; ` + sec + ` };`,
"bad name": `key "a b" { algorithm hmac-sha256; ` + sec + ` };`,
"extra stmt": `key "a" { algorithm hmac-sha256; ` + sec + ` }; include "/etc/passwd";`,
"other clause": `key "a" { algorithm hmac-sha256; ` + sec + ` foo bar; };`,
"options": `options { directory "/tmp"; };`,
"inline comment": `key "a" { algorithm hmac-sha256; ` + sec + ` // x` + "\n};",
"nul": "key \"a\" { algorithm hmac-sha256; " + sec + " };\x00",
}
for name, src := range invalid {
t.Run(name, func(t *testing.T) {
_, err := parseUpdateKey([]byte(src))
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
})
}
}
// dynJSON renders a dynamic-records.json for the tests.
func dynJSON(t *testing.T, key string, zones map[string][]dynRecord) string {
t.Helper()
f := dynFile{Version: 1, Key: key, Zones: []dynZone{}}
names := make([]string, 0, len(zones))
for z := range zones {
names = append(names, z)
}
slices.Sort(names)
for _, z := range names {
recs := zones[z]
if recs == nil {
recs = []dynRecord{}
}
f.Zones = append(f.Zones, dynZone{Zone: z, Records: recs})
}
b, err := json.Marshal(f)
if err != nil {
t.Fatal(err)
}
return string(b)
}
var allTypes = []dynRecord{
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "A", Data: "192.0.2.5"},
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "AAAA", Data: "2001:db8::5"},
{Name: "5.ad.example.org.", TTL: 300, Type: "PTR", Data: "srv1.ad.example.org."},
{Name: "www.ad.example.org.", TTL: 300, Type: "CNAME", Data: "srv1.ad.example.org."},
{Name: "ad.example.org.", TTL: 300, Type: "MX", Data: "10 mail.example.org."},
{Name: "_dmarc.ad.example.org.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none" "a \"quoted\" \\ \010 x"`},
{Name: "_ldap._tcp.dc._msdcs.ad.example.org.", TTL: 600, Type: "SRV", Data: "0 100 389 dc1.ad.example.org."},
{Name: "sub.ad.example.org.", TTL: 0, Type: "NS", Data: "ns1.example.org."},
{Name: "*.wild.ad.example.org.", TTL: 60, Type: "A", Data: "192.0.2.6"},
{Name: "SRV1.AD.example.org.", TTL: 60, Type: "A", Data: "192.0.2.5"}, // duplicate (case)
{Name: "null.ad.example.org.", TTL: 60, Type: "MX", Data: "0 ."},
}
func TestParseDynamicRecords(t *testing.T) {
dynamic := map[string]bool{"ad.example.org": true, "other.test": true}
src := dynJSON(t, "ddi-agent-update", map[string][]dynRecord{"ad.example.org": allTypes, "other.test": nil})
set, err := parseDynamicRecords([]byte(src), dynamic)
if err != nil {
t.Fatal(err)
}
if set.key != "ddi-agent-update" || len(set.zones) != 2 || len(set.zones["other.test"]) != 0 {
t.Fatalf("set = %+v", set)
}
if got := len(set.zones["ad.example.org"]); got != len(allTypes)-1 {
t.Errorf("%d records, want %d (one duplicate dropped)", got, len(allTypes)-1)
}
// The zone may be written with a trailing dot and in upper case.
upper := strings.Replace(src, `"zone":"ad.example.org"`, `"zone":"AD.Example.org."`, 1)
if _, err := parseDynamicRecords([]byte(upper), dynamic); err != nil {
t.Errorf("zone with trailing dot: %v", err)
}
}
func TestParseDynamicRecordsRejects(t *testing.T) {
dynamic := map[string]bool{"ad.example.org": true}
rec := func(name string, ttl int64, typ, data string) string {
b, _ := json.Marshal(dynRecord{Name: name, TTL: ttl, Type: typ, Data: data})
return `{"version":1,"key":"ddi-agent-update","zones":[{"zone":"ad.example.org","records":[` + string(b) + `]}]}`
}
const n = "srv1.ad.example.org."
cases := map[string]string{
"not JSON": `version: 1`,
"version 2": `{"version":2,"key":"k","zones":[]}`,
"no version": `{"key":"k","zones":[]}`,
"unknown field": `{"version":1,"key":"k","zones":[],"extra":true}`,
"unknown rec field": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[{"name":"` + n + `","ttl":1,"type":"A","data":"192.0.2.1","class":"IN"}]}]}`,
"trailing data": `{"version":1,"key":"k","zones":[]} {}`,
"bad key name": `{"version":1,"key":"k\nsend","zones":[]}`,
"empty key name": `{"version":1,"key":"","zones":[]}`,
"zone not dynamic": `{"version":1,"key":"k","zones":[{"zone":"example.org","records":[]}]}`,
"zone twice": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[]},{"zone":"ad.example.org.","records":[]}]}`,
"zone injection": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org\nupdate delete ad.example.org. NS","records":[]}]}`,
"zone bad label": `{"version":1,"key":"k","zones":[{"zone":"ad..example.org","records":[]}]}`,
"ttl as string": `{"version":1,"key":"k","zones":[{"zone":"ad.example.org","records":[{"name":"` + n + `","ttl":"1","type":"A","data":"192.0.2.1"}]}]}`,
"relative name": rec("srv1", 60, "A", "192.0.2.1"),
"name outside zone": rec("srv1.example.org.", 60, "A", "192.0.2.1"),
"name suffix trick": rec("srv1.bad-ad.example.org.", 60, "A", "192.0.2.1"),
"name with newline": rec("srv1.ad.example.org.\nupdate delete ad.example.org. NS", 60, "A", "192.0.2.1"),
"name with space": rec("srv 1.ad.example.org.", 60, "A", "192.0.2.1"),
"name with semicolon": rec("srv1;.ad.example.org.", 60, "A", "192.0.2.1"),
"name with escape": rec(`srv\0461.ad.example.org.`, 60, "A", "192.0.2.1"),
"wildcard inside": rec("a.*.ad.example.org.", 60, "A", "192.0.2.1"),
"long label": rec(strings.Repeat("a", 64)+".ad.example.org.", 60, "A", "192.0.2.1"),
"empty label": rec("a..ad.example.org.", 60, "A", "192.0.2.1"),
"negative TTL": rec(n, -1, "A", "192.0.2.1"),
"TTL too large": rec(n, 1<<31, "A", "192.0.2.1"),
"type ANY": rec(n, 60, "ANY", "192.0.2.1"),
"type SOA": rec(n, 60, "SOA", "a. b. 1 2 3 4 5"),
"lowercase type": rec(n, 60, "a", "192.0.2.1"),
"type with data": rec(n, 60, "A 192.0.2.9", "192.0.2.1"),
"empty data": rec(n, 60, "A", ""),
"A newline injection": rec(n, 60, "A", "192.0.2.1\nsend\nupdate delete ad.example.org. NS"),
"A CR injection": rec(n, 60, "A", "192.0.2.1\rsend"),
"A semicolon": rec(n, 60, "A", "192.0.2.1;"),
"A with tab": rec(n, 60, "A", "192.0.2.1\t"),
"A is IPv6": rec(n, 60, "A", "2001:db8::1"),
"A not an address": rec(n, 60, "A", "srv1."),
"AAAA is IPv4": rec(n, 60, "AAAA", "192.0.2.1"),
"AAAA with zone": rec(n, 60, "AAAA", "fe80::1%eth0"),
"PTR relative": rec(n, 60, "PTR", "srv1"),
"CNAME two names": rec(n, 60, "CNAME", "a.example.org. b.example.org."),
"CNAME root": rec(n, 60, "CNAME", "."),
"NS non-ASCII": rec(n, 60, "NS", "nä.example.org."),
"MX no preference": rec(n, 60, "MX", "mail.example.org."),
"MX out of range": rec(n, 60, "MX", "70000 mail.example.org."),
"MX signed": rec(n, 60, "MX", "+10 mail.example.org."),
"MX double space": rec(n, 60, "MX", "10 mail.example.org."),
"MX extra field": rec(n, 60, "MX", "10 mail.example.org. x"),
"SRV too few": rec(n, 60, "SRV", "0 100 dc1.ad.example.org."),
"SRV relative": rec(n, 60, "SRV", "0 100 389 dc1"),
"TXT unquoted": rec(n, 60, "TXT", "v=spf1 -all"),
"TXT unterminated": rec(n, 60, "TXT", `"abc`),
"TXT trailing": rec(n, 60, "TXT", `"abc" x`),
"TXT trailing space": rec(n, 60, "TXT", `"abc" `),
"TXT newline": rec(n, 60, "TXT", "\"abc\nsend\""),
"TXT raw quote escape": rec(n, 60, "TXT", `"abc\"`),
"TXT letter escape": rec(n, 60, "TXT", `"a\nb"`),
"TXT short escape": rec(n, 60, "TXT", `"a\01"`),
"TXT escape > 255": rec(n, 60, "TXT", `"a\300"`),
"TXT too long": rec(n, 60, "TXT", `"`+strings.Repeat("a", 256)+`"`),
"TXT after string": rec(n, 60, "TXT", `"a";update delete ad.example.org. NS`),
"data too long": rec(n, 60, "TXT", strings.Repeat(`"`+strings.Repeat("a", 200)+`" `, 50)+`"x"`),
}
for name, src := range cases {
t.Run(name, func(t *testing.T) {
if _, err := parseDynamicRecords([]byte(src), dynamic); err == nil {
t.Fatal("expected an error")
}
})
}
}
func TestUpdateScript(t *testing.T) {
u := zoneUpdate{
zone: "ad.example.org",
deletes: []dynRecord{{Name: "old.ad.example.org.", TTL: 60, Type: "A", Data: "192.0.2.9"}},
adds: []dynRecord{
{Name: "srv1.ad.example.org.", TTL: 3600, Type: "A", Data: "192.0.2.5"},
{Name: "_dmarc.ad.example.org.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none"`},
},
}
got, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
if err != nil {
t.Fatal(err)
}
want := "server 127.0.0.1 53\nzone ad.example.org.\n" +
"update delete old.ad.example.org. A 192.0.2.9\n" +
"update add srv1.ad.example.org. 3600 A 192.0.2.5\n" +
"update add _dmarc.ad.example.org. 300 TXT \"v=DMARC1; p=none\"\n" +
"send\n"
if got != want {
t.Errorf("script =\n%s\nwant\n%s", got, want)
}
got, err = updateScript(netip.MustParseAddrPort("[::1]:5353"), zoneUpdate{zone: "z.test", adds: u.adds[:1]})
if err != nil || !strings.HasPrefix(got, "server ::1 5353\nzone z.test.\n") {
t.Errorf("IPv6 server: %q, %v", got, err)
}
// Defense in depth: a line break never reaches the script.
bad := zoneUpdate{zone: "z.test", adds: []dynRecord{{Name: "a.z.test.", TTL: 1, Type: "A", Data: "192.0.2.1\nsend"}}}
if _, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), bad); err == nil {
t.Error("line break accepted")
}
}
func TestUpdateScriptBatches(t *testing.T) {
var u zoneUpdate
u.zone = "big.test"
for i := range 1200 {
u.adds = append(u.adds, dynRecord{Name: fmt.Sprintf("h%d.big.test.", i), TTL: 60, Type: "A", Data: "192.0.2.1"})
}
u.deletes = []dynRecord{{Name: "gone.big.test.", TTL: 60, Type: "A", Data: "192.0.2.2"}}
got, err := updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
if err != nil {
t.Fatal(err)
}
batches := strings.Split(strings.TrimSuffix(got, "send\n"), "send\n")
if len(batches) != 3 {
t.Fatalf("%d batches, want 3", len(batches))
}
total := 0
for i, b := range batches {
if !strings.HasPrefix(b, "server 127.0.0.1 53\nzone big.test.\n") {
t.Errorf("batch %d lacks server and zone: %.60q", i, b)
}
n := strings.Count(b, "\nupdate ")
if n > maxBatchRecords {
t.Errorf("batch %d has %d records", i, n)
}
total += n
}
if total != 1201 || !strings.Contains(batches[0], "update delete gone.big.test. A 192.0.2.2\n") {
t.Errorf("%d records in total; first batch starts %.120q", total, batches[0])
}
// Long TXT records are cut by size, not count.
u = zoneUpdate{zone: "big.test"}
txt := `"` + strings.Repeat("x", 250) + `"`
for i := range 300 {
u.adds = append(u.adds, dynRecord{Name: fmt.Sprintf("t%d.big.test.", i), TTL: 60, Type: "TXT", Data: txt})
}
got, _ = updateScript(netip.MustParseAddrPort("127.0.0.1:53"), u)
for i, b := range strings.Split(got, "send\n") {
if len(b) > maxBatchBytes+512 {
t.Errorf("batch %d has %d bytes", i, len(b))
}
}
}
func TestPlanUpdates(t *testing.T) {
a := func(name, ip string) dynRecord {
return dynRecord{Name: name + ".z.test.", TTL: 60, Type: "A", Data: ip}
}
prev := &dynSet{zones: map[string][]dynRecord{
"z.test": {a("one", "192.0.2.1"), a("two", "192.0.2.2"), a("Three", "192.0.2.3")},
"gone.test": {{Name: "x.gone.test.", TTL: 60, Type: "A", Data: "192.0.2.4"}},
"left.test": {{Name: "x.left.test.", TTL: 60, Type: "A", Data: "192.0.2.5"}},
}}
cur := &dynSet{zones: map[string][]dynRecord{
"z.test": {a("one", "192.0.2.1"), a("three", "192.0.2.3"), a("four", "192.0.2.4")},
"new.test": {{Name: "y.new.test.", TTL: 60, Type: "A", Data: "192.0.2.6"}},
}}
// gone.test is still a dynamic primary zone, left.test is not.
dynamic := map[string]bool{"z.test": true, "new.test": true, "gone.test": true}
got := planUpdates(cur, prev, dynamic)
if len(got) != 3 || got[0].zone != "gone.test" || got[1].zone != "new.test" || got[2].zone != "z.test" {
t.Fatalf("updates = %+v", got)
}
if len(got[0].adds) != 0 || len(got[0].deletes) != 1 {
t.Errorf("gone.test: %+v", got[0])
}
if len(got[1].adds) != 1 || len(got[1].deletes) != 0 {
t.Errorf("new.test: %+v", got[1])
}
// "Three" equals "three" (names are case-insensitive): only "two" goes.
if len(got[2].adds) != 3 || len(got[2].deletes) != 1 || got[2].deletes[0].Name != "two.z.test." {
t.Errorf("z.test: %+v", got[2])
}
if got := planUpdates(cur, nil, dynamic); len(got) != 2 {
t.Errorf("first revision: %+v", got)
}
empty := &dynSet{zones: map[string][]dynRecord{}}
if got := planUpdates(empty, empty, dynamic); len(got) != 0 {
t.Errorf("nothing to do: %+v", got)
}
}
func TestRecordIdentity(t *testing.T) {
same := [][2]dynRecord{
{{Name: "A.z.", Type: "AAAA", Data: "2001:DB8:0::1"}, {Name: "a.z.", Type: "AAAA", Data: "2001:db8::1"}},
{{Name: "a.z.", Type: "CNAME", Data: "B.z."}, {Name: "a.z.", Type: "CNAME", Data: "b.z."}},
}
for _, p := range same {
if p[0].identity() != p[1].identity() {
t.Errorf("%+v and %+v differ", p[0], p[1])
}
}
txt1 := dynRecord{Name: "a.z.", Type: "TXT", Data: `"A"`}
txt2 := dynRecord{Name: "a.z.", Type: "TXT", Data: `"a"`}
if txt1.identity() == txt2.identity() {
t.Error("TXT data must compare case-sensitively")
}
}
func TestRetryableUpdateError(t *testing.T) {
for out, want := range map[string]bool{
"update failed: NOTAUTH": true,
"update failed: SERVFAIL": true,
"; Communication with 127.0.0.1#53 failed: timed out": true,
"couldn't get address for 'x': not found": false,
"update failed: REFUSED": false,
"update failed: NOTZONE": false,
} {
if got := retryableUpdateError([]byte(out)); got != want {
t.Errorf("%q: %v, want %v", out, got, want)
}
}
}
// --- Role tests (Unix paths) ---
const updateKeysConf = "key \"xfer\" {\n algorithm hmac-sha256;\n secret \"eGZlcg==\";\n};\n" +
"key \"" + testUpdateKeyName + "\" {\n algorithm hmac-sha256;\n secret \"" + testUpdateSecret + "\";\n};\n"
// dynRevision writes a revision with the dynamic zones in zones and the
// update files for records (nil records: no update files).
func (e *env) dynRevision(n uint64, zones []zone, records map[string][]dynRecord) revisionInfo {
e.t.Helper()
files := map[string]string{
confFile: confFor(e.zoneDir, zones),
optionsFile: "recursion no;\n",
keysFile: updateKeysConf,
}
createOnly := map[string]bool{}
for _, z := range zones {
p := zonesPrefix + zonePrefix + z.name
files[p] = zoneFile(z.name, n, z.extra)
createOnly[p] = z.dynamic
}
if records != nil {
files[updateKeyFile] = testUpdateKey
files[dynRecordsFile] = dynJSON(e.t, testUpdateKeyName, records)
}
r := writeRevision(e.t, n, files, createOnly)
return revisionInfo{Revision: r, keyFile: filepath.Join(r.Dir, updateKeyFile)}
}
type revisionInfo struct {
Revision core.Revision
keyFile string
}
var adZones = []zone{{name: "office.test"}, {name: "ad.office.test", dynamic: true}}
func adA(name, ip string) dynRecord {
return dynRecord{Name: name + ".ad.office.test.", TTL: 3600, Type: "A", Data: ip}
}
func (e *env) nsupdateCalls() []string {
var out []string
for _, c := range e.run.Calls() {
if strings.HasPrefix(c, "nsupdate ") {
out = append(out, c)
}
}
return out
}
func TestValidateDynamicRecords(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
recs := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}}
if err := e.role.Validate(ctx, e.dynRevision(1, adZones, recs).Revision); err != nil {
t.Fatalf("valid revision rejected: %v", err)
}
if got := e.nsupdateCalls(); len(got) != 0 {
t.Errorf("Validate ran nsupdate: %v", got)
}
for name, c := range map[string]struct {
mutate func(files map[string]string)
want string
}{
"only the key file": {func(f map[string]string) { delete(f, dynRecordsFile) }, "both"},
"only the records file": {func(f map[string]string) { delete(f, updateKeyFile) }, "both"},
"key name mismatch": {func(f map[string]string) {
f[updateKeyFile] = strings.Replace(f[updateKeyFile], testUpdateKeyName, "other-key", 1)
}, `holds the key "other-key"`},
"key unknown to named": {func(f map[string]string) {
f[keysFile] = "key \"xfer\" {\n algorithm hmac-sha256;\n secret \"eGZlcg==\";\n};\n"
}, "does not define the update key"},
"no keys file": {func(f map[string]string) { delete(f, keysFile) }, "which the revision lacks"},
"managed zone": {func(f map[string]string) {
f[dynRecordsFile] = dynJSON(t, testUpdateKeyName, map[string][]dynRecord{
"office.test": {{Name: "a.office.test.", TTL: 1, Type: "A", Data: "192.0.2.1"}}})
}, "not a dynamic primary zone"},
"injection": {func(f map[string]string) {
f[dynRecordsFile] = dynJSON(t, testUpdateKeyName, map[string][]dynRecord{
"ad.office.test": {adA("srv1", "192.0.2.5\nupdate delete ad.office.test. ANY")}})
}, "control or non-ASCII character"},
"bad key file": {func(f map[string]string) {
f[updateKeyFile] = "key \"" + testUpdateKeyName + "\" { algorithm hmac-sha256; };"
}, updateKeyFile},
} {
t.Run(name, func(t *testing.T) {
files := map[string]string{
confFile: confFor(e.zoneDir, adZones),
optionsFile: "recursion no;\n",
keysFile: updateKeysConf,
updateKeyFile: testUpdateKey,
dynRecordsFile: dynJSON(t, testUpdateKeyName, recs),
}
createOnly := map[string]bool{}
for _, z := range adZones {
p := zonesPrefix + zonePrefix + z.name
files[p] = zoneFile(z.name, 2, "")
createOnly[p] = z.dynamic
}
c.mutate(files)
rev := writeRevision(t, 2, files, createOnly)
e.run.Reset()
err := e.role.Validate(ctx, rev)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want it to contain %q", err, c.want)
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
if e.run.Calls() != nil {
t.Errorf("programs ran for an invalid revision: %v", e.run.Calls())
}
})
}
}
func TestActivateAppliesDynamicRecords(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
// Revision 1: add only, after the reload; the update files stay in the
// revision directory.
recs1 := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5"), adA("srv2", "192.0.2.6")}}
rev1 := e.dynRevision(1, adZones, recs1)
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
calls := e.run.Calls()
wantCall := "nsupdate -t 30 -k " + rev1.keyFile
if len(calls) != 4 || calls[2] != "rndc -k /etc/bind/rndc.key reload" || calls[3] != wantCall {
t.Fatalf("calls = %v", calls)
}
want1 := "server 127.0.0.1 53\nzone ad.office.test.\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nupdate add srv2.ad.office.test. 3600 A 192.0.2.6\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want1 {
t.Fatalf("inputs = %q", in)
}
for _, f := range []string{updateKeyFile, dynRecordsFile} {
if exists(filepath.Join(e.confDir, f)) {
t.Errorf("%s was installed into conf_dir", f)
}
}
// Revision 2 removes srv2 and adds srv3: exactly srv2 is deleted.
recs2 := map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5"), adA("srv3", "192.0.2.7")}}
rev2 := e.dynRevision(2, adZones, recs2)
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision); err != nil {
t.Fatal(err)
}
want2 := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv2.ad.office.test. A 192.0.2.6\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nupdate add srv3.ad.office.test. 3600 A 192.0.2.7\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want2 {
t.Fatalf("inputs = %q", in)
}
if got := e.nsupdateCalls(); len(got) != 1 || got[0] != "nsupdate -t 30 -k "+rev2.keyFile {
t.Errorf("nsupdate must use the new revision's key: %v", got)
}
// Revision 3 lists the zone without records: all managed ones go.
rev3 := e.dynRevision(3, adZones, map[string][]dynRecord{"ad.office.test": nil})
e.run.Reset()
if err := e.role.Activate(ctx, rev3.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
want3 := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv1.ad.office.test. A 192.0.2.5\n" +
"update delete srv3.ad.office.test. A 192.0.2.7\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want3 {
t.Fatalf("inputs = %q", in)
}
// Revision 4 drops the dynamic zone: BIND no longer serves it, nothing
// is sent.
rev4 := e.dynRevision(4, adZones[:1], nil)
e.run.Reset()
if err := e.role.Activate(ctx, rev4.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
if got := e.nsupdateCalls(); len(got) != 0 {
t.Errorf("nsupdate for a dropped zone: %v", got)
}
// Unchanged revision: everything is added again (idempotent), nothing
// deleted.
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
if in := e.run.Inputs(); len(in) != 1 || strings.Contains(in[0], "delete") {
t.Errorf("inputs = %q", in)
}
}
func TestActivateDynamicWithoutPreviousFiles(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
// The previous revision predates ADR 0026; its zone file is BIND's.
rev1 := e.dynRevision(1, adZones, nil)
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
if len(e.nsupdateCalls()) != 0 {
t.Fatalf("nsupdate without update files: %v", e.run.Calls())
}
rev2 := e.dynRevision(2, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
e.run.Reset()
if err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision); err != nil {
t.Fatal(err)
}
if in := e.run.Inputs(); len(in) != 1 || strings.Contains(in[0], "delete") || !strings.Contains(in[0], "update add srv1") {
t.Errorf("inputs = %q", in)
}
}
func TestActivateDynamicFailureRestores(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
rev1 := e.dynRevision(1, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
if err := e.role.Activate(ctx, rev1.Revision, nil); err != nil {
t.Fatal(err)
}
before := snapshot(t, e)
zones2 := []zone{{name: "office.test", extra: "www IN A 192.0.2.10\n"}, {name: "ad.office.test", dynamic: true}}
rev2 := e.dynRevision(2, zones2, map[string][]dynRecord{"ad.office.test": {adA("srv2", "192.0.2.6")}})
e.run.Reset()
e.run.fnInput = func(string, string, []string) ([]byte, error) {
return []byte("update failed: REFUSED"), errors.New("exit status 2")
}
err := e.role.Activate(ctx, rev2.Revision, &rev1.Revision)
if err == nil || !strings.Contains(err.Error(), "REFUSED") || !strings.Contains(err.Error(), "ad.office.test") {
t.Fatalf("err = %v", err)
}
if strings.Contains(err.Error(), testUpdateSecret) {
t.Errorf("error quotes the secret: %v", err)
}
if got := snapshot(t, e); !mapsEqual(got, before) {
t.Errorf("files not restored")
}
calls := e.run.Calls()
// REFUSED is final: no retry; then BIND is reloaded with the old files.
if len(e.nsupdateCalls()) != 1 || len(calls) != 6 || calls[4] != "rndc -k /etc/bind/rndc.key reconfig" {
t.Errorf("calls = %v", calls)
}
// The core's rollback activates revision 1 again with revision 2 as
// previous: srv2 is deleted, srv1 added.
e.run.fnInput = nil
e.run.Reset()
if err := e.role.Activate(ctx, rev1.Revision, &rev2.Revision); err != nil {
t.Fatal(err)
}
want := "server 127.0.0.1 53\nzone ad.office.test.\nupdate delete srv2.ad.office.test. A 192.0.2.6\n" +
"update add srv1.ad.office.test. 3600 A 192.0.2.5\nsend\n"
if in := e.run.Inputs(); len(in) != 1 || in[0] != want {
t.Errorf("rollback inputs = %q", in)
}
}
func TestActivateDynamicRetriesWhileZoneLoads(t *testing.T) {
ctx := context.Background()
e := newEnv(t)
e.role.updateRetry = time.Millisecond
attempts := 0
e.run.fnInput = func(string, string, []string) ([]byte, error) {
attempts++
if attempts < 3 {
return []byte("update failed: NOTAUTH"), errors.New("exit status 2")
}
return nil, nil
}
rev := e.dynRevision(1, adZones, map[string][]dynRecord{"ad.office.test": {adA("srv1", "192.0.2.5")}})
if err := e.role.Activate(ctx, rev.Revision, nil); err != nil {
t.Fatal(err)
}
if attempts != 3 {
t.Errorf("%d attempts, want 3", attempts)
}
// A zone that never loads fails after updateWait.
e.role.updateWait = 30 * time.Millisecond
e.run.fnInput = func(string, string, []string) ([]byte, error) {
return []byte("update failed: SERVFAIL"), errors.New("exit status 2")
}
e.run.Reset()
start := time.Now()
err := e.role.Activate(ctx, rev.Revision, &rev.Revision)
if err == nil || !strings.Contains(err.Error(), "SERVFAIL") {
t.Fatalf("err = %v", err)
}
if n := len(e.nsupdateCalls()); n < 2 || time.Since(start) > 5*time.Second {
t.Errorf("%d attempts in %s", n, time.Since(start))
}
}
func TestNewDecodesNsupdate(t *testing.T) {
if DefaultConfig().Nsupdate != "nsupdate" {
t.Error("default nsupdate program")
}
cfg := DefaultConfig()
cfg.Nsupdate = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "dns.nsupdate") {
t.Errorf("empty nsupdate accepted: %v", err)
}
}
+189
View File
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"fmt"
"strings"
"sync"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
)
// maxParallelQueries bounds concurrent SOA queries of one health check.
const maxParallelQueries = 8
// Health runs "rndc status" and, for every managed (not create-only) zone
// of expected, an SOA query to dns_address whose serial must equal the
// revision number (the generator writes the revision as SOA serial).
// Dynamic zones are not compared: BIND increments their serial on updates.
//
// Secondary zones are queried too, any serial counts as loaded. They do not
// change the state: right after an activation a secondary zone may still
// wait for its first transfer from a primary that has not got the revision
// yet, which must not roll the revision back. A zone that does not answer
// yet is reported as a degraded check "secondary zone <name>".
//
// State: Failed if rndc status fails or no managed zone serves the expected
// serial; Degraded if only some do or rndc status reports that the last
// reload failed or is still in progress; OK otherwise.
func (r *Role) Health(ctx context.Context, expected *core.Revision) core.Health {
var h core.Health
status := core.HealthCheck{Name: "rndc status", State: core.HealthOK}
out, err := r.run.Run(ctx, r.cfg.Rndc, append(append([]string(nil), r.cfg.RndcArgs...), "status")...)
if err != nil {
status.State = core.HealthFailed
status.Message = err.Error()
} else {
var reloadOK bool
status.Message, reloadOK = summarizeStatus(string(out))
if !reloadOK {
status.State = core.HealthDegraded
}
}
h.Checks = append(h.Checks, status)
var zones []string
if expected != nil {
for _, f := range expected.Files {
if z, ok := strings.CutPrefix(f.Path, zonesPrefix+zonePrefix); ok && !f.CreateOnly && validZoneName(z) {
zones = append(zones, z)
}
}
}
zoneChecks := r.checkZones(ctx, zones, expected)
h.Checks = append(h.Checks, zoneChecks...)
var secondaries []string
if expected != nil {
secondaries = r.secondaryZones(*expected)
}
secondaryChecks := r.checkSecondaries(ctx, secondaries)
h.Checks = append(h.Checks, secondaryChecks...)
good := 0
for _, c := range zoneChecks {
if c.State == core.HealthOK {
good++
}
}
loaded := 0
for _, c := range secondaryChecks {
if c.State == core.HealthOK {
loaded++
}
}
switch {
case status.State == core.HealthFailed:
h.State = core.HealthFailed
h.Message = "named does not answer rndc status"
case status.State != core.HealthOK && (len(zones) == 0 || good > 0):
h.State = core.HealthDegraded
h.Message = status.Message
case len(zones) > 0 && good == 0:
h.State = core.HealthFailed
h.Message = fmt.Sprintf("no zone serves serial %d", uint32(expected.Number)) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
case good < len(zones):
h.State = core.HealthDegraded
h.Message = fmt.Sprintf("%d of %d zones serve serial %d", good, len(zones), uint32(expected.Number)) //nolint:gosec // see above
default:
h.State = core.HealthOK
h.Message = "named is running"
if len(zones) > 0 {
h.Message += fmt.Sprintf("; %d zones serve serial %d", len(zones), uint32(expected.Number)) //nolint:gosec // see above
}
}
if len(secondaries) > 0 && h.State != core.HealthFailed {
h.Message += fmt.Sprintf("; %d of %d secondary zones loaded", loaded, len(secondaries))
}
return h
}
// checkSecondaries queries the SOA of every secondary zone. A zone that
// does not answer authoritatively yet gets a degraded check; it does not
// count for the overall state (see Health).
func (r *Role) checkSecondaries(ctx context.Context, zones []string) []core.HealthCheck {
return r.querySOAs(ctx, zones, func(z string, serial uint32, err error) core.HealthCheck {
c := core.HealthCheck{Name: "secondary zone " + z, State: core.HealthOK, Message: fmt.Sprintf("serial %d", serial)}
if err != nil {
c.State = core.HealthDegraded
c.Message = "not loaded yet, waiting for a zone transfer (SOA query: " + err.Error() + ")"
}
return c
})
}
// checkZones queries the SOA of every managed zone, whose serial must be
// the revision number.
func (r *Role) checkZones(ctx context.Context, zones []string, expected *core.Revision) []core.HealthCheck {
if len(zones) == 0 {
return make([]core.HealthCheck, 0)
}
want := uint32(expected.Number) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
return r.querySOAs(ctx, zones, func(z string, serial uint32, err error) core.HealthCheck {
c := core.HealthCheck{Name: "zone " + z}
switch {
case err != nil:
c.State = core.HealthFailed
c.Message = "SOA query: " + err.Error()
case serial != want:
c.State = core.HealthFailed
c.Message = fmt.Sprintf("serial %d, expected %d", serial, want)
default:
c.State = core.HealthOK
c.Message = fmt.Sprintf("serial %d", serial)
}
return c
})
}
// querySOAs queries the SOA of every zone in parallel (bounded) and turns
// each answer into a check with eval.
func (r *Role) querySOAs(ctx context.Context, zones []string, eval func(zone string, serial uint32, err error) core.HealthCheck) []core.HealthCheck {
checks := make([]core.HealthCheck, len(zones))
sem := make(chan struct{}, maxParallelQueries)
var wg sync.WaitGroup
for i, z := range zones {
wg.Add(1)
sem <- struct{}{}
go func() {
defer func() { <-sem; wg.Done() }()
serial, err := querySOA(ctx, r.dial, r.cfg.DNSAddress, z)
checks[i] = eval(z, serial, err)
}()
}
wg.Wait()
return checks
}
// summarizeStatus picks the version, the reload state and the final status
// line from the output of rndc status, e.g. "BIND 9.20.3 (Stable Release);
// server is up and running". reloadOK is false if named reports
// "reload/reconfig failed" or "reload/reconfig in progress" (printed by
// named_server_status in BIND 9.18 and 9.20 while the last reload is not
// done).
func summarizeStatus(out string) (summary string, reloadOK bool) {
var parts []string
reloadOK = true
for _, l := range strings.Split(out, "\n") {
l = strings.TrimSpace(l)
if v, ok := strings.CutPrefix(l, "version: "); ok {
if i := strings.Index(v, " <id:"); i >= 0 {
v = v[:i]
}
parts = append(parts, v)
}
if strings.HasPrefix(l, "reload/reconfig ") {
parts = append(parts, l)
reloadOK = false
}
if strings.HasPrefix(l, "server is ") {
parts = append(parts, l)
}
}
if len(parts) == 0 {
return command.Excerpt([]byte(out)), reloadOK
}
return strings.Join(parts, "; "), reloadOK
}
+290
View File
@@ -0,0 +1,290 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"strings"
"sync"
"testing"
"golang.org/x/net/dns/dnsmessage"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
// fakeDNS answers SOA queries on 127.0.0.1 over UDP and TCP (same port).
type fakeDNS struct {
addr string
mu sync.Mutex
// serials maps a zone (without trailing dot) to its SOA serial.
serials map[string]uint32
// truncate lists zones answered with TC over UDP.
truncate map[string]bool
// nonAuth lists zones answered without the AA bit.
nonAuth map[string]bool
tcpUsed int
}
func startFakeDNS(t *testing.T) *fakeDNS {
t.Helper()
f := &fakeDNS{serials: map[string]uint32{}, truncate: map[string]bool{}, nonAuth: map[string]bool{}}
var (
ln net.Listener
pc net.PacketConn
err error
)
for range 20 {
if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
t.Skipf("no TCP on 127.0.0.1: %v", err)
}
if pc, err = net.ListenPacket("udp", ln.Addr().String()); err == nil {
break
}
_ = ln.Close()
}
if err != nil {
t.Skipf("no UDP port pair on 127.0.0.1: %v", err)
}
f.addr = ln.Addr().String()
t.Cleanup(func() { _ = ln.Close(); _ = pc.Close() })
go func() {
buf := make([]byte, 65535)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
if resp := f.answer(buf[:n], true); resp != nil {
_, _ = pc.WriteTo(resp, from)
}
}
}()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = c.Close() }()
var l [2]byte
if _, err := io.ReadFull(c, l[:]); err != nil {
return
}
q := make([]byte, binary.BigEndian.Uint16(l[:]))
if _, err := io.ReadFull(c, q); err != nil {
return
}
f.mu.Lock()
f.tcpUsed++
f.mu.Unlock()
resp := f.answer(q, false)
out := make([]byte, 2+len(resp))
binary.BigEndian.PutUint16(out, uint16(len(resp)))
copy(out[2:], resp)
_, _ = c.Write(out)
}()
}
}()
return f
}
func (f *fakeDNS) set(zone string, serial uint32) {
f.mu.Lock()
defer f.mu.Unlock()
f.serials[zone] = serial
}
func (f *fakeDNS) answer(query []byte, udp bool) []byte {
var p dnsmessage.Parser
h, err := p.Start(query)
if err != nil {
return nil
}
q, err := p.Question()
if err != nil {
return nil
}
zone := strings.TrimSuffix(q.Name.String(), ".")
f.mu.Lock()
serial, known := f.serials[zone]
trunc := udp && f.truncate[zone]
auth := !f.nonAuth[zone]
f.mu.Unlock()
rh := dnsmessage.Header{ID: h.ID, Response: true, Authoritative: auth && known}
switch {
case !known:
rh.RCode = dnsmessage.RCodeRefused
case trunc:
rh.Truncated = true
}
b := dnsmessage.NewBuilder(nil, rh)
_ = b.StartQuestions()
_ = b.Question(q)
if known && !trunc {
_ = b.StartAnswers()
// An unrelated record first, to exercise skipping.
_ = b.AResource(dnsmessage.ResourceHeader{Name: q.Name, Class: dnsmessage.ClassINET, TTL: 60},
dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}})
_ = b.SOAResource(dnsmessage.ResourceHeader{Name: q.Name, Class: dnsmessage.ClassINET, TTL: 60},
dnsmessage.SOAResource{
NS: dnsmessage.MustNewName("ns1." + zone + "."), MBox: dnsmessage.MustNewName("hostmaster." + zone + "."),
Serial: serial, Refresh: 3600, Retry: 600, Expire: 1209600, MinTTL: 300,
})
}
msg, err := b.Finish()
if err != nil {
return nil
}
return msg
}
func healthEnv(t *testing.T) (*env, *fakeDNS) {
t.Helper()
e := newEnv(t)
f := startFakeDNS(t)
e.role.cfg.DNSAddress = f.addr
e.run.fn = func(name string, args []string) ([]byte, error) {
return []byte("version: BIND 9.20.3 (Stable Release) <id:x>\nnumber of zones: 103\nserver is up and running\n"), nil
}
return e, f
}
func TestHealthOK(t *testing.T) {
e, f := healthEnv(t)
rev := e.revision(7, sampleZones)
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 7)
f.set("dyn.test", 1) // dynamic zones are not compared
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthOK {
t.Fatalf("health = %+v", h)
}
if len(h.Checks) != 3 || h.Checks[0].Message != "BIND 9.20.3 (Stable Release); server is up and running" {
t.Errorf("checks = %+v", h.Checks)
}
if got := e.run.Calls(); len(got) != 1 || got[0] != "rndc -k /etc/bind/rndc.key status" {
t.Errorf("calls = %v", got)
}
}
func TestHealthStates(t *testing.T) {
e, f := healthEnv(t)
rev := e.revision(7, sampleZones)
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 6)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthDegraded || !strings.Contains(h.Message, "1 of 2") {
t.Errorf("one stale zone: %+v", h)
}
f.set("office.test", 6)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed {
t.Errorf("all zones stale: %+v", h)
}
f.set("office.test", 7)
f.set("2.0.192.in-addr.arpa", 7)
f.mu.Lock()
f.nonAuth["office.test"] = true
f.mu.Unlock()
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthDegraded {
t.Errorf("non-authoritative answer: %+v", h)
}
f.mu.Lock()
f.nonAuth["office.test"] = false
f.mu.Unlock()
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("version: BIND 9.20.3 <id:x>\nreload/reconfig in progress\nserver is up and running\n"), nil
}
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthDegraded || !strings.Contains(h.Message, "in progress") {
t.Errorf("reload in progress: %+v", h)
}
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("rndc: connect failed: 127.0.0.1#953: connection refused"), errors.New("exit status 1")
}
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed || !strings.Contains(h.Checks[0].Message, "connection refused") {
t.Errorf("rndc failure: %+v", h)
}
}
func TestHealthSecondaryZones(t *testing.T) {
e, f := healthEnv(t)
rev := e.revisionWithKeys(7, clusterZones, testKeys)
f.set("office.test", 7)
f.set("remote.test", 3) // any serial: the primary decides
// 2.0.192.in-addr.arpa has not been transferred yet: reported, but the
// state stays OK, so the revision is not rolled back.
h := e.role.Health(context.Background(), &rev)
if h.State != core.HealthOK || !strings.Contains(h.Message, "1 of 2 secondary zones loaded") {
t.Fatalf("health = %+v", h)
}
checks := map[string]core.HealthCheck{}
for _, c := range h.Checks {
checks[c.Name] = c
}
if c := checks["secondary zone remote.test"]; c.State != core.HealthOK || c.Message != "serial 3" {
t.Errorf("loaded secondary: %+v", c)
}
if c := checks["secondary zone 2.0.192.in-addr.arpa"]; c.State != core.HealthDegraded || !strings.Contains(c.Message, "waiting for a zone transfer") {
t.Errorf("missing secondary: %+v", c)
}
if c := checks["zone office.test"]; c.State != core.HealthOK {
t.Errorf("primary zone: %+v", c)
}
if len(h.Checks) != 4 {
t.Errorf("checks = %+v", h.Checks)
}
// A member that serves only secondary zones is healthy while waiting.
rev = e.revisionWithKeys(8, clusterZones[1:], testKeys)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthOK || !strings.Contains(h.Message, "1 of 2") {
t.Errorf("secondary only: %+v", h)
}
// The primary zone still decides.
f.set("office.test", 6)
rev = e.revisionWithKeys(7, clusterZones, testKeys)
if h := e.role.Health(context.Background(), &rev); h.State != core.HealthFailed || strings.Contains(h.Message, "secondary") {
t.Errorf("stale primary zone: %+v", h)
}
}
func TestHealthWithoutRevision(t *testing.T) {
e, _ := healthEnv(t)
if h := e.role.Health(context.Background(), nil); h.State != core.HealthOK || len(h.Checks) != 1 {
t.Errorf("health = %+v", h)
}
}
func TestQuerySOAFallsBackToTCP(t *testing.T) {
f := startFakeDNS(t)
f.set("big.test", 42)
f.mu.Lock()
f.truncate["big.test"] = true
f.mu.Unlock()
serial, err := querySOA(context.Background(), defaultDial, f.addr, "big.test")
if err != nil || serial != 42 {
t.Fatalf("serial = %d, %v", serial, err)
}
f.mu.Lock()
tcpUsed := f.tcpUsed
f.mu.Unlock()
if tcpUsed != 1 {
t.Errorf("TCP used %d times", tcpUsed)
}
if _, err := querySOA(context.Background(), defaultDial, f.addr, "unknown.test"); err == nil || !strings.Contains(err.Error(), "Refused") {
t.Errorf("unknown zone: %v", err)
}
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// queryTimeout bounds one SOA query (UDP and, if truncated, TCP).
const queryTimeout = 3 * time.Second
type dialFunc func(ctx context.Context, network, address string) (net.Conn, error)
func defaultDial(ctx context.Context, network, address string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, address)
}
// querySOA asks server for the SOA record of zone and returns its serial.
// The answer must be authoritative, so that a zone BIND failed to load
// (and that might be answered from elsewhere) does not count. A truncated
// UDP answer is repeated over TCP.
func querySOA(ctx context.Context, dial dialFunc, server, zone string) (uint32, error) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
name, err := dnsmessage.NewName(strings.TrimSuffix(zone, ".") + ".")
if err != nil {
return 0, fmt.Errorf("invalid zone name %q: %w", zone, err)
}
var idb [2]byte
if _, err := rand.Read(idb[:]); err != nil {
return 0, err
}
id := binary.BigEndian.Uint16(idb[:])
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: id})
b.EnableCompression()
if err := b.StartQuestions(); err != nil {
return 0, err
}
q := dnsmessage.Question{Name: name, Type: dnsmessage.TypeSOA, Class: dnsmessage.ClassINET}
if err := b.Question(q); err != nil {
return 0, err
}
msg, err := b.Finish()
if err != nil {
return 0, err
}
resp, err := exchangeUDP(ctx, dial, server, msg, id)
if err != nil {
return 0, err
}
serial, truncated, err := parseSOA(resp, id, q)
if truncated {
if resp, err = exchangeTCP(ctx, dial, server, msg); err != nil {
return 0, err
}
serial, _, err = parseSOA(resp, id, q)
}
return serial, err
}
func exchangeUDP(ctx context.Context, dial dialFunc, server string, msg []byte, id uint16) ([]byte, error) {
conn, err := dial(ctx, "udp", server)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
if _, err := conn.Write(msg); err != nil {
return nil, err
}
buf := make([]byte, 65535)
for {
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
// Ignore stray datagrams with another ID.
if n >= 2 && binary.BigEndian.Uint16(buf[:2]) == id {
return buf[:n], nil
}
}
}
func exchangeTCP(ctx context.Context, dial dialFunc, server string, msg []byte) ([]byte, error) {
conn, err := dial(ctx, "tcp", server)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
framed := make([]byte, 2+len(msg))
binary.BigEndian.PutUint16(framed, uint16(len(msg))) //nolint:gosec // a query is far below 64 KiB
copy(framed[2:], msg)
if _, err := conn.Write(framed); err != nil {
return nil, err
}
var l [2]byte
if _, err := io.ReadFull(conn, l[:]); err != nil {
return nil, err
}
resp := make([]byte, binary.BigEndian.Uint16(l[:]))
if _, err := io.ReadFull(conn, resp); err != nil {
return nil, err
}
return resp, nil
}
// parseSOA extracts the SOA serial for q from a response. truncated is
// true if the server set the TC bit (the caller retries over TCP).
func parseSOA(resp []byte, id uint16, q dnsmessage.Question) (serial uint32, truncated bool, err error) {
var p dnsmessage.Parser
h, err := p.Start(resp)
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
switch {
case h.ID != id || !h.Response:
return 0, false, errors.New("unexpected DNS response")
case h.Truncated:
return 0, true, nil
case h.RCode != dnsmessage.RCodeSuccess:
return 0, false, fmt.Errorf("server answered %s", rcodeString(h.RCode))
case !h.Authoritative:
return 0, false, errors.New("answer is not authoritative (zone not loaded?)")
}
qs, err := p.AllQuestions()
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
if len(qs) != 1 || qs[0].Type != q.Type || !strings.EqualFold(qs[0].Name.String(), q.Name.String()) {
return 0, false, errors.New("DNS response is for another question")
}
for {
ah, err := p.AnswerHeader()
if errors.Is(err, dnsmessage.ErrSectionDone) {
return 0, false, errors.New("no SOA record in the answer")
}
if err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
if ah.Type != dnsmessage.TypeSOA || !strings.EqualFold(ah.Name.String(), q.Name.String()) {
if err := p.SkipAnswer(); err != nil {
return 0, false, fmt.Errorf("invalid DNS response: %w", err)
}
continue
}
soa, err := p.SOAResource()
if err != nil {
return 0, false, fmt.Errorf("invalid SOA record: %w", err)
}
return soa.Serial, false, nil
}
}
func rcodeString(rc dnsmessage.RCode) string {
s := rc.String()
return strings.TrimPrefix(s, "RCode")
}
+217
View File
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: Apache-2.0
//go:build integration
package integration
import (
"fmt"
"net/netip"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
const (
keaSocketDir = "/run/kea"
keaSocket = keaSocketDir + "/kea4-ctrl-socket"
keaConfFile = "/etc/kea/kea-dhcp4.conf"
dhcpConfig = testDir + "/agent-dhcp.yaml"
)
// A small page size makes Leases page through three leases.
const dhcpAgentConfig = `dhcp:
config_file: ` + keaConfFile + `
control_socket: ` + keaSocket + `
lease_page_size: 2
command_timeout: 30s
`
// Kea 2.6.3+/3.x only accept control sockets in KEA_CONTROL_SOCKET_DIR,
// which must not be accessible by others.
const keaScript = `set -e
mkdir -p ` + keaSocketDir + ` /var/lib/kea
chmod 750 ` + keaSocketDir + `
exec kea-dhcp4 -c ` + keaConfFile + `
`
// keaConf renders a generator-like configuration (see the server's keagen
// golden files) with the lease_cmds hook from hooksDir.
func keaConf(socket, hooksDir string, subnets ...string) string {
return fmt.Sprintf(`{
"Dhcp4": {
"interfaces-config": {
"interfaces": ["*"],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": %q
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": %q
}
],
"valid-lifetime": 3600,
"subnet4": [%s]
}
}
`, socket, hooksDir+"/libdhcp_lease_cmds.so", strings.Join(subnets, ","))
}
const (
subnet1 = `
{
"id": 1,
"subnet": "192.0.2.0/24",
"pools": [{"pool": "192.0.2.100 - 192.0.2.199"}],
"option-data": [{"name": "routers", "data": "192.0.2.1"}]
}`
subnet2 = `
{
"id": 2,
"subnet": "198.51.100.0/24",
"valid-lifetime": 1800,
"pools": [{"pool": "198.51.100.100 - 198.51.100.199"}]
}`
// A pool outside its subnet: Kea rejects the configuration.
brokenSubnet = `
{
"id": 3,
"subnet": "203.0.113.0/24",
"pools": [{"pool": "10.0.0.1 - 10.0.0.9"}]
}`
)
// keaHooksDir finds the directory of libdhcp_lease_cmds.so in the image
// (Kea 3.x loads hooks only from its built-in hooks directory).
func keaHooksDir(t *testing.T, image string) string {
t.Helper()
pullImage(t, image)
out := mustDocker(t, "run", "--rm", "--entrypoint", "sh", image, "-c",
"dirname \"$(find / -name libdhcp_lease_cmds.so 2>/dev/null | head -n1)\"")
dir := strings.TrimSpace(out)
if dir == "" || dir == "." {
t.Fatalf("libdhcp_lease_cmds.so not found in %s", image)
}
return dir
}
func TestDHCPRole(t *testing.T) {
driver := driverBinary(t)
for _, image := range images("DDI_KEA_IMAGES",
"docker.cloudsmith.io/isc/docker/kea-dhcp4:2.6.5 docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1") {
t.Run(image, func(t *testing.T) { testDHCPRole(t, image, driver) })
}
}
func testDHCPRole(t *testing.T, image string, driver []byte) {
hooks := keaHooksDir(t, image)
tr := newTree(t)
tr.file(keaConfFile, []byte(keaConf(keaSocket, hooks)), 0o640)
tr.file(driverPath, driver, 0o755)
tr.file(dhcpConfig, []byte(dhcpAgentConfig), 0o644)
conf1 := keaConf(keaSocket, hooks, subnet1)
conf2 := keaConf(keaSocket, hooks, subnet1, subnet2)
rev1 := tr.revision("1", 1, map[string]string{"kea-dhcp4.conf": conf1})
rev2 := tr.revision("2", 2, map[string]string{"kea-dhcp4.conf": conf2})
revBroken := tr.revision("broken", 3, map[string]string{"kea-dhcp4.conf": keaConf(keaSocket, hooks, subnet1, brokenSubnet)})
revSocket := tr.revision("socket", 3, map[string]string{"kea-dhcp4.conf": keaConf(keaSocketDir+"/other-socket", hooks, subnet1)})
c := startContainer(t, image, tr, keaScript, "KEA_CONTROL_SOCKET_DIR="+keaSocketDir)
if r := c.driver("dhcp", dhcpConfig, "wait"); r.Error != "" {
t.Fatalf("kea-dhcp4 did not start: %+v %+v", r, r.Health)
}
t.Run("version", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "version")
if r.Error != "" || !strings.HasPrefix(r.Version, "Kea ") {
t.Fatalf("version = %q, error %q", r.Version, r.Error)
}
})
r := c.driver("dhcp", dhcpConfig, "apply", rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 1: %+v %+v", r, r.Health)
}
r = c.driver("dhcp", dhcpConfig, "apply", rev2, rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 2: %+v %+v", r, r.Health)
}
if got := c.cat(keaConfFile); got != conf2 {
t.Fatalf("installed configuration differs from revision 2:\n%s", got)
}
t.Run("invalid configuration is rejected", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "apply", revBroken, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "configuration check failed") {
t.Fatalf("got %+v", r)
}
})
t.Run("other control socket is rejected", func(t *testing.T) {
r := c.driver("dhcp", dhcpConfig, "apply", revSocket, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "dhcp.control_socket") {
t.Fatalf("got %+v", r)
}
})
t.Run("failed reload restores the previous file", func(t *testing.T) {
// Activate without Validate, as if kea-dhcp4 -t had missed it.
r := c.driver("dhcp", dhcpConfig, "activate", revBroken, rev2)
if r.Error == "" || !strings.Contains(r.Error, "config-reload") {
t.Fatalf("got %+v", r)
}
if got := c.cat(keaConfFile); got != conf2 {
t.Errorf("configuration not restored:\n%s", got)
}
r = c.driver("dhcp", dhcpConfig, "health", rev2)
if r.Health == nil || r.Health.State != core.HealthOK {
t.Fatalf("health after restore: %+v", r.Health)
}
})
t.Run("leases", func(t *testing.T) {
macs := map[string]string{
"192.0.2.150": "1A:1B:1C:1D:1E:01",
"192.0.2.151": "1A:1B:1C:1D:1E:02",
"198.51.100.120": "1A:1B:1C:1D:1E:03",
}
for ip, mac := range macs {
subnet := 1
if strings.HasPrefix(ip, "198.") {
subnet = 2
}
args := fmt.Sprintf(`{"ip-address": %q, "hw-address": %q, "hostname": "host-%s", "subnet-id": %d}`,
ip, mac, strings.ReplaceAll(ip, ".", "-"), subnet)
r := c.driver("dhcp", dhcpConfig, "kea", "lease4-add", args)
if r.Error != "" || r.Response == nil || r.Response.Result != 0 {
t.Fatalf("lease4-add %s: %+v %+v", ip, r, r.Response)
}
}
r := c.driver("dhcp", dhcpConfig, "leases")
if r.Error != "" || len(r.Leases) != len(macs) {
t.Fatalf("leases: %+v", r)
}
for _, l := range r.Leases {
want, ok := macs[l.IP.String()]
if !ok || l.MAC != strings.ToLower(want) || l.State != core.LeaseActive || l.Expires.Before(l.Updated) {
t.Errorf("unexpected lease %+v", l)
}
if wantSubnet := uint32(1); netip.MustParsePrefix("198.51.100.0/24").Contains(l.IP) {
if l.SubnetID != 2 {
t.Errorf("lease %s: subnet %d", l.IP, l.SubnetID)
}
} else if l.SubnetID != wantSubnet {
t.Errorf("lease %s: subnet %d", l.IP, l.SubnetID)
}
}
})
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: Apache-2.0
//go:build integration
package integration
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
)
const updateKeyName = "ddi-agent-update"
// dynRecord mirrors a record of dynamic-records.json (ADR 0026).
type dynRecord struct {
Name string `json:"name"`
TTL int `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
func tsigSecret(t *testing.T) string {
t.Helper()
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(b)
}
func keyStmt(name, secret string) string {
return fmt.Sprintf("key %q {\n algorithm hmac-sha256;\n secret %q;\n};\n", name, secret)
}
// dynamicRevision renders a revision with the managed zone office.test and
// the dynamic zone ad.office.test whose updates need the key
// ddi-agent-update (the agent) or dc (a "domain controller"). records nil
// means no update files.
func dynamicRevision(t *testing.T, serial uint64, keysConf, updateKey string, records []dynRecord) (map[string]string, []string) {
t.Helper()
office := dnsZone{name: "office.test", records: "ns1 IN A 192.0.2.53\nwww IN A 192.0.2.10\n"}
ad := dnsZone{name: "ad.office.test", dynamic: true}
files, createOnly := dnsRevision([]dnsZone{office, ad}, serial, defaultOptions)
acl := `acl "ddi-update-ad.office.test" { 127.0.0.1/32; };`
if !strings.Contains(files["ddi-suite.conf"], acl) {
t.Fatalf("fixture changed: no update ACL in\n%s", files["ddi-suite.conf"])
}
files["ddi-suite.conf"] = strings.Replace(files["ddi-suite.conf"], acl,
`acl "ddi-update-ad.office.test" { key "`+updateKeyName+`"; key "dc"; };`, 1)
files["ddi-suite-keys.conf"] = "// Generated by DDI Suite (test fixture).\n\n" + keysConf
if records != nil {
files["ddi-suite-update.key"] = "// Generated by DDI Suite (test fixture).\n\n" + updateKey
doc := map[string]any{"version": 1, "key": updateKeyName,
"zones": []any{map[string]any{"zone": "ad.office.test", "records": records}}}
data, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
files["dynamic-records.json"] = string(data)
}
return files, createOnly
}
func TestDNSDynamicRecords(t *testing.T) {
driver := driverBinary(t)
for _, image := range images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.18 internetsystemsconsortium/bind9:9.20") {
t.Run(image, func(t *testing.T) { testDNSDynamicRecords(t, image, driver) })
}
}
func testDNSDynamicRecords(t *testing.T, image string, driver []byte) {
agentSecret, dcSecret := tsigSecret(t), tsigSecret(t)
agentKey := keyStmt(updateKeyName, agentSecret)
keysConf := agentKey + keyStmt("dc", dcSecret)
a := func(name, ip string) dynRecord {
return dynRecord{Name: name + ".ad.office.test.", TTL: 3600, Type: "A", Data: ip}
}
srv := dynRecord{Name: "_ldap._tcp.dc._msdcs.ad.office.test.", TTL: 600, Type: "SRV", Data: "0 100 389 dc1.ad.office.test."}
txt := dynRecord{Name: "_dmarc.ad.office.test.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none"`}
tr := newTree(t)
tr.file("/etc/bind/named.conf", []byte(namedConf), 0o644)
tr.file("/etc/bind/rndc.key", rndcKey(t), 0o640)
tr.file(bindConfDir+"/ddi-suite.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-keys.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-options.conf", []byte("// bootstrap\n"), 0o640)
tr.file(driverPath, driver, 0o755)
tr.file(dnsConfig, []byte(dnsAgentConfig), 0o644)
// Revision 1 creates the dynamic zone (its initial file has no managed
// records: they come by dynamic update).
files1, co1 := dynamicRevision(t, 1, keysConf, agentKey, []dynRecord{a("srv1", "192.0.2.5"), a("srv2", "192.0.2.6"), srv, txt})
rev1 := tr.revision("dyn1", 1, files1, co1...)
// Revision 2 drops srv2 and adds srv3.
files2, co2 := dynamicRevision(t, 2, keysConf, agentKey, []dynRecord{a("srv1", "192.0.2.5"), a("srv3", "192.0.2.7"), srv, txt})
rev2 := tr.revision("dyn2", 2, files2, co2...)
// Revision 3 gives named another secret for the update key than
// nsupdate uses: the updates fail and the revision is rolled back.
files3, co3 := dynamicRevision(t, 3, keyStmt(updateKeyName, tsigSecret(t))+keyStmt("dc", dcSecret), agentKey,
[]dynRecord{a("srv1", "192.0.2.5"), a("srv4", "192.0.2.8")})
rev3 := tr.revision("dyn3", 3, files3, co3...)
// Revision 4 still serves the zone but manages no records in it.
files4, co4 := dynamicRevision(t, 4, keysConf, agentKey, []dynRecord{})
rev4 := tr.revision("dyn4", 4, files4, co4...)
c := startContainer(t, image, tr, bindScript)
for _, prog := range []string{"nsupdate", "dig"} {
if _, all, ok := c.exec("sh", "-c", "command -v "+prog); !ok {
t.Fatalf("%s is missing in %s: %s", prog, image, all)
}
}
if r := c.driver("dns", dnsConfig, "wait"); r.Error != "" {
t.Fatalf("named did not start: %+v", r)
}
dig := func(name, typ string) []string {
t.Helper()
out, all, ok := c.exec("dig", "@127.0.0.1", "+short", "+time=2", "+tries=2", name, typ)
if !ok {
t.Fatalf("dig %s %s: %s", name, typ, all)
}
var res []string
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l = strings.TrimSpace(l); l != "" {
res = append(res, l)
}
}
slices.Sort(res)
return res
}
expect := func(name, typ string, want ...string) {
t.Helper()
slices.Sort(want)
if got := dig(name, typ); !slices.Equal(got, want) {
t.Errorf("%s %s = %q, want %q", name, typ, got, want)
}
}
r := c.driver("dns", dnsConfig, "apply", rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 1: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A", "192.0.2.5")
expect("srv2.ad.office.test", "A", "192.0.2.6")
expect("_ldap._tcp.dc._msdcs.ad.office.test", "SRV", "0 100 389 dc1.ad.office.test.")
expect("_dmarc.ad.office.test", "TXT", `"v=DMARC1; p=none"`)
for _, f := range []string{"ddi-suite-update.key", "dynamic-records.json"} {
if c.exists(bindConfDir + "/" + f) {
t.Errorf("%s was installed into conf_dir", f)
}
}
if dyn := c.cat(bindZoneDir + "/db.ad.office.test"); strings.Contains(dyn, "srv1") {
t.Errorf("initial file of the dynamic zone already has srv1:\n%s", dyn)
}
// A domain controller registers itself and adds a second address to a
// managed name.
// printf expands the \n escapes of its format argument.
update := `server 127.0.0.1\nzone ad.office.test\n` +
`update add dc1.ad.office.test. 300 A 192.0.2.20\n` +
`update add srv2.ad.office.test. 300 A 192.0.2.99\nsend\n`
if _, all, ok := c.exec("sh", "-c", `printf "$1" | nsupdate -y "$2"`, "sh", update, "hmac-sha256:dc:"+dcSecret); !ok {
t.Fatalf("third-party update failed: %s", all)
}
expect("srv2.ad.office.test", "A", "192.0.2.6", "192.0.2.99")
r = c.driver("dns", dnsConfig, "apply", rev2, rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 2: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A", "192.0.2.5")
expect("srv2.ad.office.test", "A", "192.0.2.99") // only DDI's address is gone
expect("srv3.ad.office.test", "A", "192.0.2.7")
expect("dc1.ad.office.test", "A", "192.0.2.20")
expect("_dmarc.ad.office.test", "TXT", `"v=DMARC1; p=none"`)
t.Run("failed update rolls back", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "apply", rev3, rev2)
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "dynamic update of zone ad.office.test") {
t.Fatalf("got %+v", r)
}
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); !strings.Contains(got, agentSecret) {
t.Error("keys file not restored")
}
expect("srv4.ad.office.test", "A")
expect("srv3.ad.office.test", "A", "192.0.2.7")
})
r = c.driver("dns", dnsConfig, "apply", rev4, rev2)
if r.Outcome != "succeeded" {
t.Fatalf("revision 4: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A")
expect("srv3.ad.office.test", "A")
expect("_ldap._tcp.dc._msdcs.ad.office.test", "SRV")
expect("_dmarc.ad.office.test", "TXT")
expect("srv2.ad.office.test", "A", "192.0.2.99")
expect("dc1.ad.office.test", "A", "192.0.2.20")
}
+288
View File
@@ -0,0 +1,288 @@
// SPDX-License-Identifier: Apache-2.0
//go:build integration
package integration
import (
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
const (
bindConfDir = "/etc/bind/ddi-suite"
bindZoneDir = "/var/lib/bind/ddi-suite"
dnsConfig = testDir + "/agent-dns.yaml"
)
// namedConf is a distribution-like named.conf that includes the two DDI
// files. "notify no" in the options block lets a revision provoke a
// duplicate-option error that only the live configuration check sees.
const namedConf = `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; };
notify no;
include "` + bindConfDir + `/ddi-suite-options.conf";
};
include "` + bindConfDir + `/ddi-suite-keys.conf";
include "` + bindConfDir + `/ddi-suite.conf";
`
const dnsAgentConfig = `dns:
conf_dir: ` + bindConfDir + `
zone_dir: ` + bindZoneDir + `
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
command_timeout: 60s
`
// The container prepares the directories the way an installer would
// (configuration root:bind, zones bind:bind) and runs named in the
// foreground with logs on stderr.
const bindScript = `set -e
mkdir -p ` + bindZoneDir + `
chown bind:bind ` + bindZoneDir + `
chmod 750 ` + bindZoneDir + `
chown -R root:bind ` + bindConfDir + `
chmod 750 ` + bindConfDir + `
chmod 640 ` + bindConfDir + `/*.conf /etc/bind/rndc.key
chgrp bind /etc/bind/rndc.key
exec named -u bind -g -c /etc/bind/named.conf
`
func rndcKey(t *testing.T) []byte {
t.Helper()
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
return []byte(fmt.Sprintf("key \"rndc-key\" {\n\talgorithm hmac-sha256;\n\tsecret %q;\n};\n",
base64.StdEncoding.EncodeToString(secret)))
}
// dnsZone is a zone of a fixture revision.
type dnsZone struct {
name string
dynamic bool
// secondary zones transfer from 192.0.2.1 (unreachable) with the key
// "xfer"; they have no zone file.
secondary bool
records string
}
// dnsRevision renders generator-like files (see the server's bindgen
// golden files) for the zones with the given SOA serial.
func dnsRevision(zones []dnsZone, serial uint64, options string) (map[string]string, []string) {
var conf strings.Builder
conf.WriteString("// Generated by DDI Suite (test fixture). Do not edit.\n")
for _, z := range zones {
if z.dynamic {
fmt.Fprintf(&conf, "\nacl \"ddi-update-%s\" { 127.0.0.1/32; };\n", z.name)
}
}
files := map[string]string{"ddi-suite-options.conf": "// Generated by DDI Suite (test fixture).\n\n" + options}
var createOnly []string
for _, z := range zones {
if z.secondary {
fmt.Fprintf(&conf, "\nzone %q {\n type secondary;\n file \"%s/secondary/db.%s\";\n"+
" primaries { 192.0.2.1 key \"xfer\"; };\n notify no;\n};\n", z.name, bindZoneDir, z.name)
continue
}
update := "none"
if z.dynamic {
update = fmt.Sprintf("%q", "ddi-update-"+z.name)
}
fmt.Fprintf(&conf, "\nzone %q {\n type primary;\n file \"%s/db.%s\";\n notify no;\n allow-update { %s; };\n};\n",
z.name, bindZoneDir, z.name, update)
p := "zones/db." + z.name
files[p] = fmt.Sprintf("; Zone %s, generated by DDI Suite (test fixture).\n$ORIGIN %s.\n$TTL 3600\n"+
"@ IN SOA ns1.office.test. hostmaster.office.test. %d 3600 600 1209600 300\n"+
"@ IN NS ns1.office.test.\n%s", z.name, z.name, serial, z.records)
if z.dynamic {
createOnly = append(createOnly, p)
}
}
files["ddi-suite.conf"] = conf.String()
return files, createOnly
}
const defaultOptions = "recursion no;\nallow-transfer { none; };\n"
func TestDNSRole(t *testing.T) {
driver := driverBinary(t)
for _, image := range images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.18 internetsystemsconsortium/bind9:9.20") {
t.Run(image, func(t *testing.T) { testDNSRole(t, image, driver) })
}
}
func testDNSRole(t *testing.T, image string, driver []byte) {
office := dnsZone{name: "office.test", records: "ns1 IN A 192.0.2.53\nwww IN A 192.0.2.10\n" +
"@ IN MX 10 mail.office.test.\nmail IN A 192.0.2.25\n"}
reverse := dnsZone{name: "2.0.192.in-addr.arpa", records: "10 IN PTR www.office.test.\n53 IN PTR ns1.office.test.\n"}
dynamic := dnsZone{name: "ad.office.test", dynamic: true, records: "dc1 IN A 192.0.2.20\n"}
tr := newTree(t)
tr.file("/etc/bind/named.conf", []byte(namedConf), 0o644)
tr.file("/etc/bind/rndc.key", rndcKey(t), 0o640)
tr.file(bindConfDir+"/ddi-suite.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-keys.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-options.conf", []byte("// bootstrap\n"), 0o640)
tr.file(driverPath, driver, 0o755)
tr.file(dnsConfig, []byte(dnsAgentConfig), 0o644)
files1, co1 := dnsRevision([]dnsZone{office, reverse, dynamic}, 1, defaultOptions)
rev1 := tr.revision("1", 1, files1, co1...)
office2 := office
office2.records += "intranet IN CNAME www.office.test.\n"
dynamic2 := dynamic
dynamic2.records += "dc2 IN A 192.0.2.21\n"
files2, co2 := dnsRevision([]dnsZone{office2, dynamic2}, 2, defaultOptions) // reverse zone dropped
rev2 := tr.revision("2", 2, files2, co2...)
files2Options := files2["ddi-suite-options.conf"]
broken, coB := dnsRevision([]dnsZone{office2, dynamic2}, 3, defaultOptions)
broken["zones/db.office.test"] += "broken IN A not-an-address\n"
revBroken := tr.revision("broken", 3, broken, coB...)
mismatch, coM := dnsRevision([]dnsZone{office2}, 3, defaultOptions)
mismatch["ddi-suite.conf"] = strings.ReplaceAll(mismatch["ddi-suite.conf"], bindZoneDir, "/etc/bind/zones")
revMismatch := tr.revision("mismatch", 3, mismatch, coM...)
dup, coD := dnsRevision([]dnsZone{office2, dynamic2}, 4, defaultOptions+"notify no;\n")
revDup := tr.revision("dup", 4, dup, coD...)
// Serial 99 in revision 5: valid, but the health check must fail.
stale, coS := dnsRevision([]dnsZone{office2, dynamic2}, 99, defaultOptions)
revStale := tr.revision("stale", 5, stale, coS...)
// Revision 6 (M5) has TSIG keys and a secondary zone whose primary is
// unreachable.
keys := "// Generated by DDI Suite (test fixture).\n\nkey \"xfer\" {\n algorithm hmac-sha256;\n secret \"" +
base64.StdEncoding.EncodeToString([]byte("xfer-secret-of-the-test-cluster!")) + "\";\n};\n"
clusterFiles, coC := dnsRevision([]dnsZone{office2, dynamic2, {name: "remote.test", secondary: true}}, 6, defaultOptions)
clusterFiles["ddi-suite-keys.conf"] = keys
revCluster := tr.revision("cluster", 6, clusterFiles, coC...)
c := startContainer(t, image, tr, bindScript)
if r := c.driver("dns", dnsConfig, "wait"); r.Error != "" {
t.Fatalf("named did not start: %+v", r)
}
t.Run("version", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "version")
if r.Error != "" || !strings.HasPrefix(r.Version, "BIND 9.") {
t.Fatalf("version = %q, error %q", r.Version, r.Error)
}
})
r := c.driver("dns", dnsConfig, "apply", rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 1: %+v %+v", r, r.Health)
}
assertZones(t, r.Health, 2)
if !c.exists(bindZoneDir + "/db.2.0.192.in-addr.arpa") {
t.Fatal("reverse zone file missing after revision 1")
}
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); !strings.Contains(got, "No TSIG keys") {
t.Errorf("revision without keys file: keys file is\n%s", got)
}
r = c.driver("dns", dnsConfig, "apply", rev2, rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 2: %+v %+v", r, r.Health)
}
assertZones(t, r.Health, 1)
if c.exists(bindZoneDir + "/db.2.0.192.in-addr.arpa") {
t.Error("zone file of the dropped reverse zone still exists")
}
if dyn := c.cat(bindZoneDir + "/db.ad.office.test"); strings.Contains(dyn, "dc2") || !strings.Contains(dyn, "dc1") {
t.Errorf("create-only zone file was overwritten:\n%s", dyn)
}
t.Run("broken zone is rejected", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "apply", revBroken, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "office.test") {
t.Fatalf("got %+v", r)
}
})
t.Run("zone directory mismatch is rejected", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "apply", revMismatch, rev2)
if r.Outcome != "rejected" || !strings.Contains(r.Error, "dns.zone_dir") {
t.Fatalf("got %+v", r)
}
})
t.Run("failed live check rolls back", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "apply", revDup, rev2)
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "activate") {
t.Fatalf("got %+v", r)
}
if got := c.cat(bindConfDir + "/ddi-suite-options.conf"); got != files2Options {
t.Errorf("options not restored:\n%s", got)
}
})
t.Run("failed health check rolls back", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "-health-timeout", "10s", "apply", revStale, rev2)
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "health") {
t.Fatalf("got %+v health %+v", r, r.Health)
}
assertZones(t, r.Rollback, 1)
})
r = c.driver("dns", dnsConfig, "health", rev2)
if r.Health == nil || r.Health.State != core.HealthOK {
t.Fatalf("health of revision 2: %+v", r.Health)
}
// The secondary zone waits for its transfer without failing the
// deployment; BIND reads the keys; the secondary directory belongs to
// bind like zone_dir.
r = c.driver("dns", dnsConfig, "apply", revCluster, rev2)
if r.Outcome != "succeeded" {
t.Fatalf("revision with keys and a secondary zone: %+v %+v", r, r.Health)
}
assertZones(t, r.Health, 1)
waiting := false
for _, ch := range r.Health.Checks {
if ch.Name == "secondary zone remote.test" && ch.State == core.HealthDegraded {
waiting = true
}
}
if !waiting {
t.Errorf("no degraded check for the secondary zone: %+v", r.Health.Checks)
}
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); got != keys {
t.Errorf("keys file:\n%s", got)
}
if out, all, ok := c.exec("stat", "-c", "%U:%G %a", bindZoneDir+"/secondary"); !ok || strings.TrimSpace(out) != "bind:bind 750" {
t.Errorf("secondary directory: %q %s", out, all)
}
}
// assertZones checks an OK health result with n zone checks.
func assertZones(t *testing.T, h *health, n int) {
t.Helper()
if h == nil || h.State != core.HealthOK {
t.Fatalf("health = %+v", h)
}
zones := 0
for _, c := range h.Checks {
if strings.HasPrefix(c.Name, "zone ") {
zones++
}
}
if zones != n {
t.Errorf("%d zone checks, want %d: %+v", zones, n, h.Checks)
}
}
+287
View File
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: Apache-2.0
// Command driver runs the DNS or DHCP role inside a BIND or Kea container
// for the role integration tests (agent/role/integration). It mimics the
// agent core's apply pipeline (validate, activate, health with retries,
// rollback) so that the tests exercise the roles against the real services.
// It is a test tool and not part of the agents.
//
// Usage:
//
// driver -role dns|dhcp -config <file> [-health-timeout 30s] <command> [args]
//
// Commands (results are printed as one JSON object on stdout):
//
// version ServiceVersion
// wait Health(nil) until OK (service started)
// validate <rev> Validate
// activate <rev> [<prev>] Activate only (no validation)
// health <rev> Health once
// apply <rev> [<prev>] Validate, Activate, Health, rollback to prev
// leases LeaseSource.Leases (dhcp)
// kea <command> [<json>] send a control command to Kea (dhcp)
//
// A revision directory holds revision.json ({"number": n, "files": [...]},
// files as in a bundle manifest) and the files below files/.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/dhcp"
"git.beugsiud.de/beug.k/DDI/agent/role/dns"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Result is the JSON printed by every command.
type Result struct {
Error string `json:"error,omitempty"`
Outcome string `json:"outcome,omitempty"`
Version string `json:"version,omitempty"`
Health *core.Health `json:"health,omitempty"`
Rollback *core.Health `json:"rollback_health,omitempty"`
Leases []core.Lease `json:"leases,omitempty"`
Response *dhcp.Response `json:"response,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("driver", flag.ContinueOnError)
fs.SetOutput(stderr)
roleName := fs.String("role", "", "dns or dhcp")
cfgFile := fs.String("config", "", "agent configuration file with the role section")
healthTimeout := fs.Duration("health-timeout", 30*time.Second, "how long apply retries the health check")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() == 0 {
_, _ = fmt.Fprintln(stderr, "missing command")
return 2
}
role, err := newRole(*roleName, *cfgFile)
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 2
}
if ls, ok := role.(core.LogSetter); ok {
ls.SetLogger(slog.New(slog.NewTextHandler(stderr, nil)))
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
res, err := dispatch(ctx, role, *healthTimeout, fs.Args())
if err != nil {
res.Error = err.Error()
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res); err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
}
return 0
}
func newRole(name, cfgFile string) (core.Role, error) {
sections := map[string]yaml.Node{}
if cfgFile != "" {
data, err := os.ReadFile(cfgFile) //nolint:gosec // test tool, path from the command line
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &sections); err != nil {
return nil, err
}
}
decode := func(v any) error {
n, ok := sections[name]
if !ok {
return nil
}
return n.Decode(v)
}
switch name {
case "dns":
return dns.New(decode)
case "dhcp":
return dhcp.New(decode)
default:
return nil, fmt.Errorf("unknown role %q", name)
}
}
func dispatch(ctx context.Context, role core.Role, healthTimeout time.Duration, args []string) (Result, error) {
var res Result
cmd, args := args[0], args[1:]
switch cmd {
case "version":
v, err := role.ServiceVersion(ctx)
res.Version = v
return res, err
case "wait":
h := waitHealth(ctx, role, nil, 60*time.Second)
res.Health = &h
if h.State != core.HealthOK {
return res, errors.New("service did not become healthy")
}
return res, nil
case "validate":
rev, err := loadRevision(arg(args, 0))
if err != nil {
return res, err
}
return res, role.Validate(ctx, rev)
case "activate":
rev, err := loadRevision(arg(args, 0))
if err != nil {
return res, err
}
prev, err := optionalRevision(arg(args, 1))
if err != nil {
return res, err
}
return res, role.Activate(ctx, rev, prev)
case "health":
rev, err := optionalRevision(arg(args, 0))
if err != nil {
return res, err
}
h := role.Health(ctx, rev)
res.Health = &h
return res, nil
case "apply":
rev, err := loadRevision(arg(args, 0))
if err != nil {
return res, err
}
prev, err := optionalRevision(arg(args, 1))
if err != nil {
return res, err
}
return apply(ctx, role, rev, prev, healthTimeout), nil
case "leases":
src, ok := role.(core.LeaseSource)
if !ok {
return res, errors.New("role has no leases")
}
leases, err := src.Leases(ctx)
res.Leases = leases
return res, err
case "kea":
r, ok := role.(*dhcp.Role)
if !ok || len(args) == 0 {
return res, errors.New("usage: kea <command> [<json arguments>]")
}
var cmdArgs any
if len(args) > 1 {
cmdArgs = json.RawMessage(args[1])
}
resp, err := r.Command(ctx, args[0], cmdArgs)
res.Response = resp
return res, err
default:
return res, fmt.Errorf("unknown command %q", cmd)
}
}
func arg(args []string, i int) string {
if i < len(args) {
return args[i]
}
return ""
}
// apply follows the agent core (ADR 0021): a rejected revision changes
// nothing; a failed activation or health check rolls back to prev.
func apply(ctx context.Context, role core.Role, rev core.Revision, prev *core.Revision, healthTimeout time.Duration) Result {
var res Result
if err := role.Validate(ctx, rev); err != nil {
res.Outcome = "rejected"
res.Error = err.Error()
return res
}
if err := role.Activate(ctx, rev, prev); err != nil {
res.Error = "activate: " + err.Error()
} else {
h := waitHealth(ctx, role, &rev, healthTimeout)
res.Health = &h
if h.State == core.HealthOK {
res.Outcome = "succeeded"
return res
}
res.Error = "health: " + h.Message
}
if prev == nil {
res.Outcome = "failed"
return res
}
if err := role.Activate(ctx, *prev, &rev); err != nil {
res.Outcome = "failed"
res.Error += "; rollback: " + err.Error()
return res
}
h := waitHealth(ctx, role, prev, healthTimeout)
res.Rollback = &h
if h.State == core.HealthOK {
res.Outcome = "rolled_back"
} else {
res.Outcome = "failed"
}
return res
}
func waitHealth(ctx context.Context, role core.Role, rev *core.Revision, timeout time.Duration) core.Health {
deadline := time.Now().Add(timeout)
for {
h := role.Health(ctx, rev)
if h.State == core.HealthOK || time.Now().After(deadline) || ctx.Err() != nil {
return h
}
time.Sleep(500 * time.Millisecond)
}
}
func optionalRevision(dir string) (*core.Revision, error) {
if dir == "" || dir == "-" {
return nil, nil
}
rev, err := loadRevision(dir)
if err != nil {
return nil, err
}
return &rev, nil
}
func loadRevision(dir string) (core.Revision, error) {
if dir == "" {
return core.Revision{}, errors.New("missing revision directory")
}
data, err := os.ReadFile(filepath.Join(dir, "revision.json")) //nolint:gosec // test tool
if err != nil {
return core.Revision{}, err
}
var m struct {
Number uint64 `json:"number"`
Files []bundle.File `json:"files"`
}
if err := json.Unmarshal(data, &m); err != nil {
return core.Revision{}, fmt.Errorf("%s/revision.json: %w", dir, err)
}
return core.Revision{Number: m.Number, Dir: filepath.Join(dir, "files"), Files: m.Files}, nil
}
+283
View File
@@ -0,0 +1,283 @@
// SPDX-License-Identifier: Apache-2.0
//go:build integration
// Package integration runs the DNS and DHCP roles against real BIND and Kea
// (ADR 0021). Run with
//
// go test -tags integration ./agent/role/integration/
//
// Requires the docker CLI and a Go toolchain. The roles must run next to the
// service, so the test cross-compiles ./driver for the Docker daemon's
// architecture, copies it together with fixture revisions into the service
// container (docker cp, no bind mounts, no published ports) and runs it with
// docker exec. This works with a remote or shared Docker daemon. Image lists
// can be overridden with DDI_BIND_IMAGES and DDI_KEA_IMAGES (space
// separated).
package integration
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
const (
testDir = "/ddi-test"
driverPath = testDir + "/driver"
)
func images(env, def string) []string {
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
return strings.Fields(v)
}
return strings.Fields(def)
}
// docker runs the docker CLI and returns stdout and, for messages, stdout
// and stderr combined.
func docker(t *testing.T, args ...string) (stdout, combined string, err error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
var out, all bytes.Buffer
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = io.MultiWriter(&out, &all)
cmd.Stderr = &all
err = cmd.Run()
return out.String(), all.String(), err
}
func mustDocker(t *testing.T, args ...string) string {
t.Helper()
out, all, err := docker(t, args...)
if err != nil {
t.Fatalf("docker %s: %v\n%s", strings.Join(args, " "), err, all)
}
return out
}
var (
driverOnce sync.Once
driverBin string
driverErr string
)
// buildDriver cross-compiles the driver for the daemon's architecture.
func buildDriver(t *testing.T) string {
t.Helper()
driverOnce.Do(func() {
out, all, err := docker(t, "version", "--format", "{{.Server.Arch}}")
arch := strings.TrimSpace(out)
if err != nil || arch == "" {
driverErr = "cannot determine the Docker daemon's architecture: " + all
return
}
dir, err := os.MkdirTemp("", "ddi-role-driver-")
if err != nil {
driverErr = err.Error()
return
}
bin := filepath.Join(dir, "driver")
cmd := exec.Command("go", "build", "-trimpath", "-o", bin, "./driver")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+arch)
if out, err := cmd.CombinedOutput(); err != nil {
driverErr = "go build ./driver: " + err.Error() + "\n" + string(out)
return
}
driverBin = bin
})
if driverErr != "" {
t.Fatal(driverErr)
}
return driverBin
}
func writeFile(t *testing.T, p string, content []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, content, mode); err != nil {
t.Fatal(err)
}
// os.WriteFile applies the umask; docker cp keeps the mode.
if err := os.Chmod(p, mode); err != nil {
t.Fatal(err)
}
}
// tree is a directory that becomes the container's root via docker cp.
type tree struct {
t *testing.T
root string
}
func newTree(t *testing.T) *tree { return &tree{t: t, root: t.TempDir()} }
func (tr *tree) file(containerPath string, content []byte, mode os.FileMode) {
writeFile(tr.t, filepath.Join(tr.root, filepath.FromSlash(strings.TrimPrefix(containerPath, "/"))), content, mode)
}
// revision stores a revision as the driver expects it and returns its
// path in the container.
func (tr *tree) revision(name string, number uint64, files map[string]string, createOnly ...string) string {
dir := path.Join(testDir, "rev", name)
m := struct {
Number uint64 `json:"number"`
Files []bundle.File `json:"files"`
}{Number: number}
paths := make([]string, 0, len(files))
for p := range files {
paths = append(paths, p)
}
slices.Sort(paths)
for _, p := range paths {
if err := bundle.CheckPath(p); err != nil {
tr.t.Fatal(err)
}
sum := sha256.Sum256([]byte(files[p]))
m.Files = append(m.Files, bundle.File{Path: p, Mode: 0o640, SHA256: hex.EncodeToString(sum[:]),
Size: int64(len(files[p])), CreateOnly: slices.Contains(createOnly, p)})
tr.file(path.Join(dir, "files", p), []byte(files[p]), 0o640)
}
data, err := json.Marshal(m)
if err != nil {
tr.t.Fatal(err)
}
tr.file(path.Join(dir, "revision.json"), data, 0o644)
return dir
}
// container is a started service container with the tree copied in.
type container struct {
t *testing.T
id string
}
// startContainer creates a container that runs script with sh, copies the
// tree into it and starts it. The container is removed after the test; its
// log is printed if the test failed.
func startContainer(t *testing.T, image string, tr *tree, script string, env ...string) *container {
t.Helper()
pullImage(t, image)
args := []string{"create", "--entrypoint", "sh"}
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, image, "-c", script)
c := &container{t: t, id: strings.TrimSpace(mustDocker(t, args...))}
t.Cleanup(func() {
if t.Failed() {
_, logs, _ := docker(t, "logs", "--tail", "200", c.id)
t.Logf("container log (%s):\n%s", image, logs)
}
_, _, _ = docker(t, "rm", "-f", c.id)
})
mustDocker(t, "cp", tr.root+string(filepath.Separator)+".", c.id+":/")
mustDocker(t, "start", c.id)
return c
}
// exec runs a command in the container and returns stdout, combined output
// and whether it succeeded.
func (c *container) exec(args ...string) (string, string, bool) {
c.t.Helper()
out, all, err := docker(c.t, append([]string{"exec", c.id}, args...)...)
return out, all, err == nil
}
// health mirrors core.Health in the driver's JSON.
type health struct {
State core.HealthState
Message string
Checks []core.HealthCheck
}
// result mirrors the driver's Result.
type result struct {
Error string `json:"error"`
Outcome string `json:"outcome"`
Version string `json:"version"`
Health *health `json:"health"`
Rollback *health `json:"rollback_health"`
Leases []core.Lease `json:"leases"`
Response *struct {
Result int `json:"result"`
Text string `json:"text"`
} `json:"response"`
}
// driver runs the driver in the container and decodes its result.
func (c *container) driver(role, config string, args ...string) result {
c.t.Helper()
full := append([]string{driverPath, "-role", role, "-config", config}, args...)
out, all, ok := c.exec(full...)
if !ok {
c.t.Fatalf("driver %s: failed:\n%s", strings.Join(args, " "), all)
}
var r result
if err := json.Unmarshal([]byte(out), &r); err != nil {
c.t.Fatalf("driver %s: invalid output: %v\n%s", strings.Join(args, " "), err, all)
}
c.t.Logf("driver %s -> outcome=%q error=%q", strings.Join(args, " "), r.Outcome, r.Error)
return r
}
func (c *container) cat(p string) string {
c.t.Helper()
out, all, ok := c.exec("cat", p)
if !ok {
c.t.Fatalf("cat %s: %s", p, all)
}
return out
}
func (c *container) exists(p string) bool {
c.t.Helper()
_, _, ok := c.exec("test", "-e", p)
return ok
}
func driverBinary(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile(buildDriver(t))
if err != nil {
t.Fatal(err)
}
return data
}
// pullImage pulls image, retrying because registries time out now and then
// on shared CI runners. An image that is already present is good enough.
func pullImage(t *testing.T, image string) {
t.Helper()
var all string
var err error
for attempt := 1; attempt <= 4; attempt++ {
if _, all, err = docker(t, "pull", "--quiet", image); err == nil {
return
}
time.Sleep(time.Duration(attempt) * 5 * time.Second)
}
if _, _, inspectErr := docker(t, "image", "inspect", image); inspectErr == nil {
t.Logf("docker pull %s failed, using the local image: %v", image, err)
return
}
t.Fatalf("docker pull %s: %v\n%s", image, err, all)
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0
// Package command runs the external programs of the managed services
// (named-checkconf, rndc, kea-dhcp4, ...). Programs are executed directly
// with an argument list, never through a shell, with a timeout and with
// their output captured up to a limit.
package command
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// Limits for captured output.
const (
// MaxCapture is the number of output bytes kept from one run.
MaxCapture = 256 << 10
// MaxErrorOutput is the number of output bytes quoted in an error.
MaxErrorOutput = 4 << 10
)
// Runner runs a program and returns its combined stdout and stderr.
type Runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
// InputRunner is a Runner that can also feed data to the program's
// standard input (e.g. an nsupdate script).
type InputRunner interface {
Runner
RunInput(ctx context.Context, input []byte, name string, args ...string) ([]byte, error)
}
// Exec runs programs on the host. Timeout bounds every run (0 means only
// the context bounds it).
type Exec struct {
Timeout time.Duration
}
var _ InputRunner = Exec{}
// Run implements Runner. A non-zero exit status, a timeout or a missing
// program yields an *Error that quotes the (truncated) output.
func (e Exec) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return e.RunInput(ctx, nil, name, args...)
}
// RunInput implements InputRunner: like Run, with input on the program's
// standard input (nil means none). Errors never quote the input.
func (e Exec) RunInput(ctx context.Context, input []byte, name string, args ...string) ([]byte, error) {
if e.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
defer cancel()
}
var out limitedBuffer
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // programs come from the agent configuration, no shell is involved
cmd.Stdout = &out
cmd.Stderr = &out
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
// Do not wait forever for grandchildren that keep the pipes open.
cmd.WaitDelay = 5 * time.Second
err := cmd.Run()
b := out.Bytes()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
err = fmt.Errorf("%w (%w)", ctxErr, err)
}
return b, &Error{Name: name, Args: args, Err: err, Output: b}
}
return b, nil
}
// Error describes a failed run.
type Error struct {
Name string
Args []string
Err error
Output []byte
}
func (e *Error) Error() string {
var b strings.Builder
b.WriteString(e.Name)
for _, a := range e.Args {
b.WriteByte(' ')
b.WriteString(a)
}
b.WriteString(": ")
b.WriteString(e.Err.Error())
if out := Excerpt(e.Output); out != "" {
b.WriteString(": ")
b.WriteString(out)
}
return b.String()
}
func (e *Error) Unwrap() error { return e.Err }
// Excerpt returns trimmed output for messages, cut to MaxErrorOutput bytes.
// The end of the output is kept because the checkers print the decisive
// line last.
func Excerpt(out []byte) string {
s := strings.TrimSpace(string(out))
if len(s) > MaxErrorOutput {
s = "..." + s[len(s)-MaxErrorOutput:]
}
return s
}
// IsNotFound reports whether err says that the program does not exist.
func IsNotFound(err error) bool {
return errors.Is(err, exec.ErrNotFound)
}
// limitedBuffer keeps the first MaxCapture bytes and discards the rest.
type limitedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
truncated bool
}
func (l *limitedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
if room := MaxCapture - l.buf.Len(); room > 0 {
if len(p) > room {
l.buf.Write(p[:room])
l.truncated = true
} else {
l.buf.Write(p)
}
} else if len(p) > 0 {
l.truncated = true
}
return len(p), nil
}
func (l *limitedBuffer) Bytes() []byte {
l.mu.Lock()
defer l.mu.Unlock()
b := bytes.Clone(l.buf.Bytes())
if l.truncated {
b = append(b, "\n[output truncated]"...)
}
return b
}
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
package command
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
"time"
)
func TestMissingProgram(t *testing.T) {
_, err := Exec{Timeout: time.Second}.Run(context.Background(), "ddi-no-such-program-xyz", "-v")
var ce *Error
if !errors.As(err, &ce) || !IsNotFound(err) {
t.Fatalf("err = %v, want *Error wrapping exec.ErrNotFound", err)
}
if !strings.Contains(err.Error(), "ddi-no-such-program-xyz -v") {
t.Errorf("error does not name the command: %v", err)
}
}
func TestFailingProgramQuotesOutput(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not available")
}
out, err := Exec{Timeout: 10 * time.Second}.Run(context.Background(), sh, "-c", "echo out; echo err >&2; exit 3")
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(string(out), "out") || !strings.Contains(string(out), "err") {
t.Errorf("output = %q", out)
}
if !strings.Contains(err.Error(), "exit status 3") || !strings.Contains(err.Error(), "err") {
t.Errorf("error = %v", err)
}
}
func TestRunInputFeedsStdin(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not available")
}
out, err := Exec{Timeout: 10 * time.Second}.RunInput(context.Background(), []byte("line one\nline two\n"), sh, "-c", "wc -l")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(out)) != "2" {
t.Errorf("output = %q, want 2 lines counted", out)
}
}
func TestTimeout(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not available")
}
start := time.Now()
_, err = Exec{Timeout: 200 * time.Millisecond}.Run(context.Background(), sh, "-c", "sleep 10")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want deadline exceeded", err)
}
if time.Since(start) > 8*time.Second {
t.Error("timeout did not stop the program")
}
}
func TestExcerptAndLimit(t *testing.T) {
long := strings.Repeat("a", MaxErrorOutput) + "END"
ex := Excerpt([]byte(long))
if !strings.HasPrefix(ex, "...") || !strings.HasSuffix(ex, "END") || len(ex) != MaxErrorOutput+3 {
t.Errorf("excerpt has length %d, prefix %q", len(ex), ex[:5])
}
var l limitedBuffer
_, _ = l.Write([]byte(strings.Repeat("b", MaxCapture-1)))
_, _ = l.Write([]byte("cc"))
_, _ = l.Write([]byte("dd"))
b := l.Bytes()
// Only the captured part counts: the marker itself contains letters.
kept := string(b[:MaxCapture])
if !strings.HasSuffix(string(b), "\n[output truncated]") || len(b) != MaxCapture+len("\n[output truncated]") ||
strings.Count(kept, "c") != 1 || strings.Contains(kept, "d") {
t.Errorf("limited buffer kept %d bytes", len(b))
}
}
+234
View File
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: Apache-2.0
// Package fsutil writes configuration files atomically and keeps a backup of
// the files an activation touches so that it can be undone.
package fsutil
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// tempPrefix starts the names of temporary files next to the targets.
const tempPrefix = ".ddi-tmp-"
// Owner is a numeric file owner. Valid is false where ownership is unknown
// (non-Unix systems).
type Owner struct {
UID, GID int
Valid bool
}
// OwnerFor returns the owner a file written to path should get: the owner
// of the existing file, otherwise the owner of its directory. Installers
// thereby decide ownership once by creating the directories (e.g.
// root:bind for configuration, bind:bind for zone files).
func OwnerFor(path string) Owner {
if o, ok := ownerOf(path); ok {
return o
}
if o, ok := ownerOf(filepath.Dir(path)); ok {
return o
}
return Owner{}
}
// WriteFile replaces path atomically: it writes a temporary file in the same
// directory, sets mode and owner, syncs it, renames it over path and syncs
// the directory. The owner is applied only when the process runs as root.
func WriteFile(path string, data []byte, mode fs.FileMode, owner Owner) (err error) {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, tempPrefix+"*")
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
tmp := f.Name()
defer func() {
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
}
}()
if _, err = f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err = f.Chmod(mode.Perm()); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
if err = chown(f, owner); err != nil {
return fmt.Errorf("chown %s: %w", path, err)
}
if err = f.Sync(); err != nil {
return fmt.Errorf("sync %s: %w", path, err)
}
if err = f.Close(); err != nil {
return fmt.Errorf("close %s: %w", path, err)
}
if err = os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename %s: %w", path, err)
}
return SyncDir(dir)
}
// Remove deletes path (absent files are fine) and syncs its directory.
func Remove(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return SyncDir(filepath.Dir(path))
}
// Exists reports whether something exists at path (without following a
// final symbolic link).
func Exists(path string) (bool, error) {
_, err := os.Lstat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, fs.ErrNotExist):
return false, nil
default:
return false, err
}
}
// ReadFile reads a file of a revision or a live configuration file. The
// paths come from the agent configuration and from revision manifests that
// passed bundle.CheckPath.
func ReadFile(path string) ([]byte, error) {
return os.ReadFile(path) //nolint:gosec // see above
}
// Same reports whether path is a regular file with exactly this content and
// permission bits.
func Same(path string, data []byte, mode fs.FileMode) bool {
fi, err := os.Lstat(path)
if err != nil || !fi.Mode().IsRegular() || fi.Size() != int64(len(data)) || fi.Mode().Perm() != mode.Perm() {
return false
}
cur, err := ReadFile(path)
return err == nil && bytes.Equal(cur, data)
}
// MkdirLike creates the directory path unless it exists, with the
// permission bits and (when running as root) the owner of the existing
// directory ref, so that the service that owns ref can write there too.
// An existing directory is left as it is; anything else at path is an
// error.
func MkdirLike(path, ref string) error {
fi, err := os.Lstat(path)
switch {
case err == nil && fi.IsDir():
return nil
case err == nil:
return fmt.Errorf("%s exists and is not a directory", path)
case !errors.Is(err, fs.ErrNotExist):
return err
}
rfi, err := os.Stat(ref)
if err != nil {
return err
}
if !rfi.IsDir() {
return fmt.Errorf("%s is not a directory", ref)
}
if err := os.Mkdir(path, rfi.Mode().Perm()); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
// The umask may have removed bits.
if err := os.Chmod(path, rfi.Mode().Perm()); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
if o, ok := ownerOf(ref); ok {
if err := chownPath(path, o); err != nil {
return fmt.Errorf("chown %s: %w", path, err)
}
}
return SyncDir(filepath.Dir(path))
}
// RequireDir returns an error unless path is an existing directory.
func RequireDir(path string) error {
fi, err := os.Stat(path)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%s is not a directory", path)
}
return nil
}
// Backup remembers the state of files before they are changed: content,
// mode and owner of existing files, or that a file did not exist. It is
// held in memory; configuration files are small, and after a crash the
// agent core rolls back to the last good revision anyway.
type Backup struct {
order []string
saved map[string]saved
}
type saved struct {
exists bool
data []byte
mode fs.FileMode
owner Owner
}
// Save records path unless it was recorded before. Only regular files can
// be saved; anything else at path is an error, so the caller never replaces
// a directory or a symbolic link it could not restore.
func (b *Backup) Save(path string) error {
if _, ok := b.saved[path]; ok {
return nil
}
if b.saved == nil {
b.saved = map[string]saved{}
}
fi, err := os.Lstat(path)
switch {
case errors.Is(err, fs.ErrNotExist):
b.saved[path] = saved{}
case err != nil:
return fmt.Errorf("back up %s: %w", path, err)
case !fi.Mode().IsRegular():
return fmt.Errorf("back up %s: not a regular file (%s)", path, fi.Mode().Type())
default:
data, err := ReadFile(path)
if err != nil {
return fmt.Errorf("back up %s: %w", path, err)
}
o, _ := ownerOf(path)
b.saved[path] = saved{exists: true, data: data, mode: fi.Mode().Perm(), owner: o}
}
b.order = append(b.order, path)
return nil
}
// Paths returns the recorded paths in the order they were saved.
func (b *Backup) Paths() []string { return append([]string(nil), b.order...) }
// Restore puts every recorded file back: existing files get their old
// content, mode and owner, files that did not exist are removed. It goes
// on after errors and returns all of them.
func (b *Backup) Restore() error {
var errs []error
for i := len(b.order) - 1; i >= 0; i-- {
p := b.order[i]
s := b.saved[p]
if s.exists {
if err := WriteFile(p, s.data, s.mode, s.owner); err != nil {
errs = append(errs, fmt.Errorf("restore %s: %w", p, err))
}
continue
}
if err := Remove(p); err != nil {
errs = append(errs, fmt.Errorf("restore %s (remove): %w", p, err))
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: Apache-2.0
//go:build !unix
package fsutil
import "os"
// The agents run on Linux; on other systems (development builds) ownership
// is not handled and directories are not synced.
func ownerOf(string) (Owner, bool) { return Owner{}, false }
func chown(*os.File, Owner) error { return nil }
func chownPath(string, Owner) error { return nil }
// SyncDir is a no-op on systems without directory sync.
func SyncDir(string) error { return nil }
+175
View File
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: Apache-2.0
package fsutil
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWriteFileReplacesAtomically(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.conf")
if err := os.WriteFile(p, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := WriteFile(p, []byte("new"), 0o640, OwnerFor(p)); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(p)
if err != nil || string(got) != "new" {
t.Fatalf("content = %q, %v", got, err)
}
if runtime.GOOS != "windows" {
fi, _ := os.Stat(p)
if fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %o, want 640", fi.Mode().Perm())
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), tempPrefix) {
t.Errorf("temporary file left behind: %s", e.Name())
}
}
}
func TestWriteFileFailsWithoutDirectory(t *testing.T) {
if err := WriteFile(filepath.Join(t.TempDir(), "missing", "f"), []byte("x"), 0o640, Owner{}); err == nil {
t.Fatal("expected an error")
}
}
func TestBackupRestore(t *testing.T) {
dir := t.TempDir()
existing := filepath.Join(dir, "existing")
created := filepath.Join(dir, "created")
removed := filepath.Join(dir, "removed")
for _, p := range []string{existing, removed} {
if err := os.WriteFile(p, []byte("orig "+filepath.Base(p)), 0o640); err != nil {
t.Fatal(err)
}
}
var b Backup
for _, p := range []string{existing, created, removed, existing} {
if err := b.Save(p); err != nil {
t.Fatal(err)
}
}
if got := len(b.Paths()); got != 3 {
t.Fatalf("saved %d paths, want 3 (duplicates ignored)", got)
}
// Change everything.
if err := WriteFile(existing, []byte("changed"), 0o600, Owner{}); err != nil {
t.Fatal(err)
}
if err := WriteFile(created, []byte("new"), 0o600, Owner{}); err != nil {
t.Fatal(err)
}
if err := Remove(removed); err != nil {
t.Fatal(err)
}
if err := b.Restore(); err != nil {
t.Fatal(err)
}
for p, want := range map[string]string{existing: "orig existing", removed: "orig removed"} {
got, err := os.ReadFile(p)
if err != nil || string(got) != want {
t.Errorf("%s = %q, %v; want %q", p, got, err, want)
}
if runtime.GOOS != "windows" {
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
t.Errorf("%s: mode %o not restored", p, fi.Mode().Perm())
}
}
}
if ok, _ := Exists(created); ok {
t.Error("file created after the backup was not removed")
}
}
func TestBackupRefusesDirectories(t *testing.T) {
var b Backup
if err := b.Save(t.TempDir()); err == nil {
t.Fatal("expected an error for a directory")
}
}
func TestSame(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
if Same(p, []byte("x"), 0o640) {
t.Error("missing file reported as same")
}
if err := WriteFile(p, []byte("x"), 0o640, Owner{}); err != nil {
t.Fatal(err)
}
if !Same(p, []byte("x"), 0o640) && runtime.GOOS != "windows" {
t.Error("identical file not recognized")
}
if Same(p, []byte("y"), 0o640) {
t.Error("different content reported as same")
}
if runtime.GOOS != "windows" && Same(p, []byte("x"), 0o600) {
t.Error("different mode reported as same")
}
}
func TestMkdirLike(t *testing.T) {
ref := filepath.Join(t.TempDir(), "zones")
if err := os.Mkdir(ref, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Chmod(ref, 0o770); err != nil {
t.Fatal(err)
}
sub := filepath.Join(ref, "secondary")
if err := MkdirLike(sub, ref); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(sub)
if err != nil || !fi.IsDir() {
t.Fatalf("not created: %v", err)
}
if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o770 {
t.Errorf("mode = %o, want 770", fi.Mode().Perm())
}
// Existing directories stay as they are (content included).
if err := os.WriteFile(filepath.Join(sub, "db.x"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := MkdirLike(sub, ref); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(sub, "db.x")); err != nil {
t.Error("content of the existing directory changed")
}
// A file in the way and a missing reference are errors.
file := filepath.Join(ref, "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
if MkdirLike(file, ref) == nil {
t.Error("file accepted as directory")
}
if MkdirLike(filepath.Join(ref, "other"), filepath.Join(ref, "missing")) == nil {
t.Error("missing reference accepted")
}
if MkdirLike(filepath.Join(ref, "other"), file) == nil {
t.Error("file accepted as reference")
}
}
func TestRequireDir(t *testing.T) {
dir := t.TempDir()
if err := RequireDir(dir); err != nil {
t.Fatal(err)
}
f := filepath.Join(dir, "f")
_ = os.WriteFile(f, nil, 0o600)
if RequireDir(f) == nil || RequireDir(filepath.Join(dir, "nope")) == nil {
t.Fatal("expected errors")
}
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0
//go:build unix
package fsutil
import (
"os"
"syscall"
)
func ownerOf(path string) (Owner, bool) {
fi, err := os.Lstat(path)
if err != nil {
return Owner{}, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return Owner{}, false
}
return Owner{UID: int(st.Uid), GID: int(st.Gid), Valid: true}, true
}
// chown applies owner if the process may change ownership (root). Other
// users cannot give files away, and their files belong to them anyway.
func chown(f *os.File, owner Owner) error {
if !owner.Valid || os.Geteuid() != 0 {
return nil
}
return f.Chown(owner.UID, owner.GID)
}
// chownPath is chown for a path (not following a final symbolic link).
func chownPath(path string, owner Owner) error {
if !owner.Valid || os.Geteuid() != 0 {
return nil
}
return os.Lchown(path, owner.UID, owner.GID)
}
// SyncDir flushes directory entries (renames, removals) to disk.
func SyncDir(dir string) error {
d, err := os.Open(dir) //nolint:gosec // dir is a configured directory of the role
if err != nil {
return err
}
defer func() { _ = d.Close() }()
return d.Sync()
}
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: Apache-2.0
//
// Seed for the development environment: no TSIG keys yet. From M5 on, the
// DNS agent writes the keys of the revision here (ADR 0023).
+4 -2
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
//
// Development BIND configuration. On managed hosts the distribution's
// named.conf only needs the include line below; everything else under
// /etc/bind/ddi-suite is written by the DNS agent (ADR 0004).
// named.conf only needs the include lines below (the keys before the
// zones); everything else under /etc/bind/ddi-suite is written by the DNS
// agent (ADR 0004, ADR 0023).
options {
directory "/var/cache/bind";
@@ -15,4 +16,5 @@ options {
dnssec-validation no;
};
include "/etc/bind/ddi-suite/ddi-suite-keys.conf";
include "/etc/bind/ddi-suite/ddi-suite.conf";
+6
View File
@@ -24,3 +24,9 @@ license:
# stand in for it during development.
account_id: "9de54794-8e94-4250-83f6-87e3d5686a0b"
public_key: "40495568fa239e44736e28949ae2ae93f2c9b6e6bc6d917f4c8cc0eb629e3c62"
agent_api:
# gRPC endpoint for the agents (mTLS with the internal CA).
listen: "127.0.0.1:8444"
public_address: "127.0.0.1:8444"
server_names: ["127.0.0.1", "localhost"]
+39
View File
@@ -0,0 +1,39 @@
# SPDX-License-Identifier: Apache-2.0
#
# Template of the nfpm configuration of the .deb and .rpm packages (ADR 0024),
# shared by all binaries. scripts/build-packages.sh replaces the placeholders
# (at-sign NAME at-sign) per binary and architecture (ADR 0018: no product
# names in this file). The
# packages contain only the binary, its systemd unit and the license text;
# configuration, users and services are set up by the installer.
name: "@PKG_NAME@"
arch: "@PKG_ARCH@"
platform: linux
version: "@PKG_VERSION@"
version_schema: semver
release: "1"
section: net
priority: optional
maintainer: "@PKG_MAINTAINER@"
vendor: "@PKG_VENDOR@"
homepage: "@PKG_HOMEPAGE@"
license: "@PKG_LICENSE@"
description: "@PKG_DESCRIPTION@"
contents:
- src: "@PKG_SRC_DIR@/binary"
dst: "/usr/bin/@PKG_NAME@"
file_info:
mode: 0755
- src: "@PKG_SRC_DIR@/unit.service"
dst: "/usr/lib/systemd/system/@PKG_NAME@.service"
file_info:
mode: 0644
- src: "@PKG_SRC_DIR@/LICENSE"
dst: "/usr/share/doc/@PKG_NAME@/LICENSE"
file_info:
mode: 0644
scripts:
postinstall: "@PKG_SCRIPTS_DIR@/postinstall.sh"
postremove: "@PKG_SCRIPTS_DIR@/postremove.sh"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
#
# Package hook (deb postinst, rpm %post): make systemd see the new or
# updated unit. Enabling and starting services is left to the installer.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
exit 0
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
#
# Package hook (deb postrm, rpm %postun): make systemd forget the removed
# unit. Stopping and disabling services is left to the installer.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
exit 0
+51
View File
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: Apache-2.0
#
# Template of the dhcp agent unit (ADR 0018, 0024). Placeholders (at-sign NAME at-sign)
# are replaced by scripts/render-units.sh. The
# installer sets User= and Group= to the Kea DHCPv4 user in a drop-in
# (10-user.conf): the agent runs with the rights of the service it manages.
[Unit]
Description=@DISPLAY_NAME@ dhcp agent (Kea DHCPv4)
Documentation=https://git.beugsiud.de/beug.k/DDI
Wants=network-online.target
After=network-online.target isc-kea-dhcp4-server.service kea-dhcp4.service kea-dhcp4-server.service
# Enabled by the installer right away; starts once the agent is enrolled
# (`enroll` as root starts it directly).
ConditionPathExists=/var/lib/@PRODUCT@/agent-dhcp/identity/agent.json
[Service]
Type=simple
ExecStart=@BINDIR@/@BIN_AGENT_DHCP@ run
Restart=on-failure
RestartSec=5s
StateDirectory=@PRODUCT@/agent-dhcp
StateDirectoryMode=0700
UMask=0027
ReadWritePaths=-/etc/kea -/run/kea
# Hardening (ADR 0024).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
# AF_NETLINK: "kea-dhcp4 -t" (validation) detects the interfaces via netlink.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+50
View File
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: Apache-2.0
#
# Template of the dns agent unit (ADR 0018, 0024). Placeholders (at-sign NAME at-sign)
# are replaced by scripts/render-units.sh. The
# installer sets User= and Group= to the BIND (named) user in a drop-in
# (10-user.conf): the agent runs with the rights of the service it manages.
[Unit]
Description=@DISPLAY_NAME@ dns agent (BIND (named))
Documentation=https://git.beugsiud.de/beug.k/DDI
Wants=network-online.target
After=network-online.target named.service bind9.service
# Enabled by the installer right away; starts once the agent is enrolled
# (`enroll` as root starts it directly).
ConditionPathExists=/var/lib/@PRODUCT@/agent-dns/identity/agent.json
[Service]
Type=simple
ExecStart=@BINDIR@/@BIN_AGENT_DNS@ run
Restart=on-failure
RestartSec=5s
StateDirectory=@PRODUCT@/agent-dns
StateDirectoryMode=0700
UMask=0027
ReadWritePaths=-/etc/bind/@PRODUCT@ -/etc/named/@PRODUCT@ -/var/lib/bind/@PRODUCT@
# Hardening (ADR 0024).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+49
View File
@@ -0,0 +1,49 @@
# SPDX-License-Identifier: Apache-2.0
#
# Template of the management server unit (ADR 0018, 0024). Placeholders
# (at-sign NAME at-sign) are replaced by scripts/render-units.sh.
[Unit]
Description=@DISPLAY_NAME@ management server
Documentation=https://git.beugsiud.de/beug.k/DDI
Wants=network-online.target
After=network-online.target postgresql.service
[Service]
Type=simple
User=@PRODUCT@
Group=@PRODUCT@
Environment=@ENV_PREFIX@CONFIG_FILE=@CONFIG_DIR@/server.yaml
ExecStart=@BINDIR@/@BIN_SERVER@ serve
Restart=on-failure
RestartSec=5s
StateDirectory=@PRODUCT@/server
StateDirectoryMode=0750
UMask=0027
# Hardening (ADR 0024).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
# Only for the address check (ping): raw ICMP sockets where unprivileged
# ICMP sockets are not permitted (ADR 0025), like /bin/ping.
CapabilityBoundingSet=CAP_NET_RAW
AmbientCapabilities=CAP_NET_RAW
[Install]
WantedBy=multi-user.target
+100
View File
@@ -0,0 +1,100 @@
# Active Directory mit dynamischen Zonen
Diese Anleitung beschreibt, wie die DNS-Zonen einer Active-Directory-Domäne (AD) auf den von der DDI Suite verwalteten BIND-Servern liegen. Grundlage sind [ADR 0023](adr/0023-cluster-und-zonen-m5.md) und [ADR 0026](adr/0026-eintraege-in-dynamischen-zonen.md).
## Grundsatz
Domain Controller (DC) registrieren ihre Einträge selbst per **RFC-2136-Update** (dynamisches DNS): A-Records der DCs, SRV-Records für LDAP, Kerberos und den globalen Katalog sowie die Einträge unter `_msdcs`. Die Zone der AD-Domäne muss deshalb eine **dynamische Zone** sein. Bei dynamischen Zonen gilt:
- Der DNS-Agent legt die Zone in BIND an und schreibt **nur beim ersten Mal** eine Anfangsdatei. Danach gehören Zonendatei und Journal BIND; die DDI Suite überschreibt sie nie.
- Die Records, die die DDI Suite für die Zone kennt – A/AAAA/PTR aus der Adresstabelle und die in der Oberfläche angelegten Records der Zone –, trägt der DNS-Agent auf dem Primary bei jeder Revision **per dynamischem Update** ein (`nsupdate` mit dem Systemschlüssel `ddi-agent-update`). Was in DDI gelöscht oder geändert wurde, entfernt er dabei genau als diesen Record. Records anderer Herkunft (von den DCs, von Clients, per Hand) fasst er nie an.
- Ändert oder löscht jemand einen von DDI verwalteten Record direkt in BIND, stellt die nächste Revision den Stand aus DDI wieder her.
- Updates werden nur von den eingetragenen Quellen angenommen: IP-Adressen bzw. Netze (`allow_update`), TSIG-Schlüssel der Zone und der Systemschlüssel des Agents.
**Nicht unterstützt (Version 1):** GSS-TSIG (Kerberos-gesicherte „sichere“ Updates, wie sie AD-integrierte Microsoft-DNS-Server verwenden). Updates werden daher über die IP-Adressen der DCs oder über TSIG abgesichert.
## Einrichtung
1. **Zone anlegen** (*Zonen → Neue Zone*): Name der AD-Domäne, z. B. `ad.example.com`, Modus **dynamisch**, DNS-Cluster wählen.
2. **Update-Quellen** eintragen: die IP-Adressen aller Domain Controller als `/32` bzw. `/128`, z. B. `192.0.2.10/32` und `192.0.2.11/32`. Keine ganzen Client-Netze eintragen – sonst kann jeder Rechner darin Einträge der Domäne überschreiben.
3. **`_msdcs`:** Die Einträge unter `_msdcs.ad.example.com` gehören in **dieselbe** dynamische Zone. Eine eigene Zone `_msdcs.…` ist nicht nötig (und wird nicht abgeleitet); die DCs legen die Records darin selbst an.
4. **Reverse-Zonen:** Sollen DCs oder Clients auch PTR-Records eintragen, die betroffene Reverse-Zone ebenfalls als dynamische Zone anlegen (z. B. `2.0.192.in-addr.arpa`) und dieselben Update-Quellen eintragen. Die PTR-Records der Adresstabelle hält der Agent darin ebenso aktuell. Reverse-Zonen, in die niemand außer DDI schreibt, bleiben besser `managed`.
5. **Revision erzeugen und ausrollen.** Der Primary des Clusters nimmt die Updates an, die Secondaries erhalten die Änderungen per NOTIFY und IXFR. Mit der ersten dynamischen Zone legt der Server den Systemschlüssel `ddi-agent-update` an; er steht in der Liste der TSIG-Schlüssel, lässt sich rotieren, aber nicht löschen.
6. **Windows-Seite:** Auf den DCs als DNS-Server die Adressen der BIND-Server eintragen. In den DNS-Einstellungen der Netzwerkkarte „Adressen dieser Verbindung in DNS registrieren“ aktiv lassen. Die DCs schicken Updates an den Server aus dem SOA-Record (MNAME) der Zone; das ist der Primary des Clusters (bzw. `primary_ns`).
Prüfen lässt sich die Registrierung nach einem Neustart des Dienstes „Netlogon“ auf einem DC (`nltest /dsregdns`) mit:
```sh
dig @<primary> SRV _ldap._tcp.dc._msdcs.ad.example.com
```
## Bestehende Windows-DNS-Zone nach DDI
So zieht eine AD-integrierte Zone eines Windows-DNS-Servers auf die BIND-Server der DDI Suite um. Beispiel: Domäne `ad.example.com`, DCs `192.0.2.10` und `192.0.2.11`, BIND-Primary `192.0.2.53`.
1. **Bestand sichern:** Auf einem DC die Zone exportieren, z. B. `dnscmd /zoneexport ad.example.com ad.example.com.dns` (Datei unter `%SystemRoot%\System32\dns`) oder `Get-DnsServerResourceRecord -ZoneName ad.example.com | Export-Csv records.csv`. Die Liste dient als Vorlage und zum Vergleich.
2. **Zone in DDI anlegen:** *Zonen → Neue Zone*, Name `ad.example.com`, Modus **dynamisch**, DNS-Cluster wählen, unter `allow_update` die Adressen aller DCs als `/32` bzw. `/128`. Gibt es unter Windows eine eigene Zone `_msdcs.ad.example.com` (Standard für die Stammdomäne einer Gesamtstruktur), entfällt sie: Ihre Records landen in der dynamischen Zone `ad.example.com`. Reverse-Zonen bei Bedarf ebenso dynamisch anlegen (Einrichtung, Punkt 4).
3. **Übrige Einträge übernehmen:** Mitgliedsserver, Drucker und andere feste Adressen aus dem Export als Einträge der Adresstabelle importieren (CSV-Import in ein Netz mit der Domain `ad.example.com`; DDI erzeugt dann A und PTR) oder, wenn sie zu keinem verwalteten Netz gehören, als Records der Zone anlegen. Die Einträge der DCs selbst (A, SRV, `_msdcs`) nicht übernehmen – die DCs tragen sie gleich selbst ein.
4. **Ausrollen** und prüfen, dass BIND die Zone bedient: `dig @192.0.2.53 SOA ad.example.com` und für einen übernommenen Eintrag `dig @192.0.2.53 A server1.ad.example.com`.
5. **DCs umstellen:** Auf jedem DC in den Einstellungen der Netzwerkkarte die BIND-Server als DNS-Server eintragen (der DC selbst bzw. `127.0.0.1` nicht mehr an erster Stelle). Die DCs schicken ihre Updates an den Server aus dem SOA-Record (MNAME) der Zone, also den Primary des Clusters. Alternativ behalten die DCs ihren eigenen DNS-Server, wenn dieser die Zone nicht mehr selbst hält, sondern per bedingter Weiterleitung an die BIND-Server verweist: Dann finden sie den Primary über den SOA-Record und schicken ihm die Updates direkt.
6. **Neu registrieren lassen**, auf jedem DC:
```bat
ipconfig /registerdns
nltest /dsregdns
```
`nltest /dsregdns` trägt die SRV-Records des DC-Locators neu ein; gleichwertig ist ein Neustart des Dienstes „Netlogon“ (`net stop netlogon && net start netlogon`). Die vollständige Liste der Records, die Netlogon registrieren will, steht in `%SystemRoot%\System32\config\netlogon.dns`.
7. **Prüfen:**
```sh
dig @192.0.2.53 SRV _ldap._tcp.dc._msdcs.ad.example.com
dig @192.0.2.53 SRV _kerberos._tcp.ad.example.com
dig @192.0.2.53 A dc1.ad.example.com
```
Auf einem DC zusätzlich `dcdiag /test:dns`. Fehlen Records, im Log des Primary (`named`) nach `update … denied` suchen: Meist passt die Quelladresse des DCs nicht zu `allow_update`.
8. **Clients umstellen:** Die DNS-Server der Clients auf die BIND-Server setzen – in DDI über die DNS-Server der Netze (DHCP-Option 6), bei festen Adressen per Hand oder Gruppenrichtlinie. Den Windows-DNS-Server mit der alten Zone erst abschalten bzw. die Zone dort löschen, wenn alle Leases erneuert sind; bis dahin beide Seiten vergleichen.
**Folgen ohne GSS-TSIG:** „Sichere“ Updates (GSS-TSIG/Kerberos) beherrscht BIND hier nicht. Updates sind nur über die Quelladresse (und TSIG, das Windows nicht kann) abgesichert; wer im Netz eine DC-Adresse fälschen kann, kann Einträge der Domäne ändern – die DCs deshalb in ein eigenes, gefiltertes Netz. Windows-Clients können sich nicht selbst registrieren (ihre Updates werden abgelehnt, solange ihre Netze nicht in `allow_update` stehen, und das sollen sie nicht); ihre Namen kommen aus der Adresstabelle. Namen dynamischer DHCP-Clients wären Aufgabe von Kea-DDNS (noch nicht umgesetzt). Ein Aufräumen veralteter Einträge (Scavenging) gibt es nicht.
## Updates mit TSIG
Statt über IP-Adressen lassen sich Updates mit einem TSIG-Schlüssel absichern, etwa für DHCP-Server, Skripte oder `nsupdate`:
1. *TSIG-Schlüssel → Neuer Schlüssel* (Algorithmus `hmac-sha256`).
2. In der dynamischen Zone den Schlüssel unter „Update-Schlüssel“ auswählen.
3. Geheimnis anzeigen (nur Administratoren, wird protokolliert) und beim Client hinterlegen, z. B. für `nsupdate`:
```text
key "updater.ad.example.com" {
algorithm hmac-sha256;
secret "<Geheimnis>";
};
```
```sh
nsupdate -k updater.key <<EOF
server <primary>
zone ad.example.com
update add host1.ad.example.com. 3600 A 192.0.2.50
send
EOF
```
Microsoft-Clients und -DCs beherrschen TSIG nicht; für sie bleibt nur die Freigabe per IP-Adresse.
## Hinweise
- Wird eine dynamische Zone gelöscht oder einem anderen Cluster zugeordnet, entfernt der Agent den Zonenblock aus der Konfiguration, löscht aber Zonendatei und Journal nicht. Die Daten liegen weiter im Zonenverzeichnis des Servers.
- Eine Zone von `managed` auf `dynamic` umzustellen ist möglich: Die vorhandene Zonendatei bleibt bestehen und wird ab dann von BIND gepflegt. Der umgekehrte Weg verwirft alle per Update angelegten Records.
- Scavenging (Aufräumen veralteter dynamischer Einträge) übernimmt BIND nicht; veraltete Records müssen per `nsupdate` entfernt werden.
- MX-Records einer dynamischen Zone, deren Ziel in der Zone liegt, brauchen dort einen A- oder AAAA-Record: Sonst lehnt BIND das Update des Agents ab (Prüfung `check-integrity`), und das Ausrollen der Revision scheitert.
## Fehlersuche: `dcdiag /test:dns` meldet fehlende Einträge
- **A-Record des DCs da, SRV-Records fehlen:** Den A-Record trägt der DNS-Client ein (`ipconfig /registerdns`), die SRV-Records, den `<GUID>._msdcs`-CNAME, `gc._msdcs` und `DomainDnsZones` trägt **Netlogon** ein. Lief `nltest /dsregdns` (oder der Start von Netlogon), bevor die Update-Quellen stimmten, versucht Netlogon es erst Stunden später erneut. Abhilfe auf jedem DC: `nltest /dsregdns` oder `net stop netlogon && net start netlogon`, dann `dcdiag /test:dns` wiederholen.
- **`gc._msdcs` oder andere A-Records unter `_msdcs` fehlen:** BIND prüft in Primary-Zonen Hostnamen (`check-names`) und lehnt Unterstriche ab. Seit v0.9.1 erzeugt DDI für dynamische Zonen `check-names ignore;`; ältere Versionen nach dem Update neu ausrollen.
- **Updates werden abgelehnt:** Windows schickt Updates an den Server aus dem SOA der Zone (Primary). `nslookup -type=SOA <domain> <dns-server>` muss den BIND-Primary nennen, und dessen Name muss auflösen (z. B. als Eintrag in der Adresstabelle). Die Adressen der DCs müssen unter *Update-Quellen* der Zone stehen. Im Log von BIND (`journalctl -u named` bzw. `-u bind9`) steht bei jeder Ablehnung die Quell-IP (`update … denied`).
- **„invalid DNS server“ / keine Internet-Namen:** DDI betreibt BIND standardmäßig rein autoritativ. Zeigen DCs oder Clients ausschließlich auf BIND, unter *DNS/DHCP-Einstellungen* Rekursion einschalten, die eigenen Netze unter „Rekursion erlaubt für“ eintragen und Forwarder angeben (z. B. Router oder Resolver des Providers).
- **Domain Controller mit DHCP-Adresse:** dcdiag warnt zu Recht; DCs sollten ihre IP fest im Betriebssystem eingetragen haben.
+24 -4
View File
@@ -23,8 +23,10 @@ Sitzungen enden nach 12 Stunden oder nach 1 Stunde Inaktivität (konfigurierbar)
| Recht | Viewer | Operator | Admin |
| --- | --- | --- | --- |
| Netze, Adressen, Lizenzstatus ansehen, exportieren, eigene Filter | ✓ | ✓ | ✓ |
| Netze und Adressen ändern, Import | | ✓ | ✓ |
| Netze, Adressen, Zonen, Records, Cluster, TSIG-Schlüssel (ohne Geheimnis), Lizenzstatus ansehen, exportieren, eigene Filter | ✓ | ✓ | ✓ |
| Netze, Adressen, Zonen und Records ändern, Import | | ✓ | ✓ |
| Revisionen erzeugen, ausrollen, zurückrollen | | ✓ | ✓ |
| Cluster und TSIG-Schlüssel verwalten, Geheimnis eines Schlüssels anzeigen, Agents, Generator-Einstellungen | | | ✓ |
| Benutzer, SSO, Lizenz verwalten, Audit-Log | | | ✓ |
`GET /auth/session` liefert die effektiven Rechte (`permissions`).
@@ -57,7 +59,7 @@ Ist der Server schreibgeschützt (siehe [LICENSING.md](LICENSING.md)), antworten
## Gleichzeitige Änderungen
Netze und Adressen tragen eine `version`. Änderungen (`PUT`) müssen die gelesene Version mitsenden; hat jemand anderes inzwischen geändert, antwortet der Server mit `409 version_conflict`.
Netze, Adressen, Cluster, TSIG-Schlüssel, Zonen und Records tragen eine `version`. Änderungen (`PUT`) müssen die gelesene Version mitsenden; hat jemand anderes inzwischen geändert, antwortet der Server mit `409 version_conflict`.
## Adresstabelle
@@ -72,7 +74,25 @@ Netze und Adressen tragen eine `version`. Änderungen (`PUT`) müssen die gelese
Die Antwort enthält `items` und `total`. `GET /addresses/export` akzeptiert dieselben Filter und liefert CSV.
`POST /addresses/preview` beschreibt vorab die Auswirkungen einer Änderung auf DNS und DHCP (z. B. `dhcp.add_reservation`). Massenänderungen (`/addresses/bulk`) und CSV-Import (`/addresses/import`, mit `dry_run=true` zur Prüfung) gelten ganz oder gar nicht; Probleme werden pro Adresse bzw. Zeile gemeldet.
`POST /addresses/preview` beschreibt vorab die Auswirkungen einer Änderung auf DNS und DHCP (z. B. `dhcp.add_reservation`). Massenänderungen (`/addresses/bulk`) und CSV-Import (`/addresses/import`, mit `dry_run=true` zur Prüfung) gelten ganz oder gar nicht; Probleme werden pro Adresse bzw. Zeile gemeldet. `/addresses/bulk` kennt außerdem `set_description`, `regenerate_hostnames` (neue Zufallsnamen für `dynamic`-Einträge) und `dry_run`: Dann ändert sich nichts, die Antwort meldet Probleme und unter `effects` die zusammengefassten Auswirkungen (Codes wie bei der Vorschau, Parameter `count`; kommt ein Code nur einmal vor, stehen seine Parameter dabei).
## DHCP-Bereiche und IP-Listen (ADR 0025)
Ein Netz hat keine Pools mehr; sein DHCP-Bereich besteht aus den `dynamic`-Einträgen. `Network.pools` ist eine abgeleitete, schreibgeschützte Liste der zusammenhängenden Bereiche; `NetworkRequest` kennt kein `pools`. `dynamic`-Einträge ohne Hostnamen erhalten einen Namen `dyn-` + 8 Zeichen `[a-z0-9]`. `Address.lease_hostname` ist der Name, den der DHCP-Client zuletzt gemeldet hat; er wirkt nicht auf DNS.
- `GET /networks/{id}/ips?offset&limit&filter=all|free|used` (Recht `ipam.read`, `limit` bis 1024, Standard 256): jede Adresse des Netzes in Adressreihenfolge mit Zustand `free`, `network`, `broadcast`, `gateway` oder `used` (dann mit `address`). `all` und `free` nur für IPv4-Netze bis /16, sonst `422 network_too_large`.
- `POST /networks/{id}/range` (Recht `ipam.write`): `{"start", "end", "action": "assign"|"clear", "type", "types", "dry_run"}`. `assign` legt für jede freie Adresse des Bereichs einen Eintrag vom Typ `dynamic`, `static` oder `reserved` an (Netz-, Broadcast- und Gateway-Adresse sowie belegte Adressen zählen als `skipped`); `clear` löscht die Einträge der Typen `types` (Standard `dynamic` und `reserved`). Höchstens 65.536 Adressen, ein Audit-Eintrag `network.range_assign` bzw. `network.range_clear` je Aufruf. Fehlercodes: `range_invalid`, `range_outside_network`, `range_too_large`, `invalid_type`; Regelverstöße der Einträge (z. B. `dhcp_disabled`) als `validation_failed`.
- `POST /addresses/check` (Recht `ipam.read`, 1–100 IDs): je Eintrag Ping (unprivilegiertes ICMP-Echo, Status `reachable`, `unreachable` oder `unavailable` mit Grund) und für Einträge mit DNS-Namen die Vorwärts- und Rückwärtsauflösung bei den autoritativen Servern der Zone (Status `ok`, `missing`, `mismatch`, `error`, mit befragtem Server und Antworten). Unbekannte IDs: `404 not_found`.
## Geheimnisse
TSIG-Schlüssel und HA-Passwörter erzeugt der Server selbst ([ADR 0023](adr/0023-cluster-und-zonen-m5.md)). Die API liefert sie nur an einer Stelle aus: `GET /tsig-keys/{id}/secret` (Recht `settings.admin`, jeder Abruf steht im Audit-Log – ohne das Geheimnis). Dateien mit Schlüsselmaterial erscheinen in Vorschau, Revision, Diff und offenen Änderungen mit `secret: true` und leerem Inhalt; Diffs nennen für sie nur den Status. Das HA-Passwort eines DHCP-Clusters wird nie angezeigt.
Der Systemschlüssel `ddi-agent-update` ([ADR 0026](adr/0026-eintraege-in-dynamischen-zonen.md)) entsteht mit der ersten dynamischen Zone und steht mit `system: true` in `GET /tsig-keys`. Mit ihm gleichen die DNS-Agents die von DDI verwalteten Records dynamischer Zonen ab (Dateien `ddi-suite-update.key`, geheim, und `dynamic-records.json`). Er lässt sich rotieren, aber nicht löschen (`409 key_system`); sein Name ist reserviert (`409 key_exists`).
## Ziele von Revisionen
Dateien in Vorschau, Revision und Diff tragen ein `target`: leer für die Standardgruppe (Agents ohne Cluster), sonst die Agent-ID eines Cluster-Mitglieds. `targets` beschreibt die Ziele mit Rolle, Cluster und Mitgliedsrolle zum Zeitpunkt der Revision. Ein Agent erhält die Dateien seines Ziels, wenn die Revision eines hat, sonst die der Standardgruppe.
## Betriebsendpunkte
+33 -16
View File
@@ -61,24 +61,29 @@ server/internal/config YAML-Datei + Umgebungsvariablen
server/internal/httpserver Listener, Health, Middleware (Request-ID, Log, Header, Recover)
server/internal/httpapi REST-API: Routentabelle, Sitzung/CSRF/Rechte/Schreibschutz, OIDC-Flow
server/internal/service Anwendungsfälle; jede Änderung schreibt ihr Audit in derselben Transaktion
server/internal/ipam Fachlogik Netze/Pools/Adressen (rein): Validierung, effektive Pools,
server/internal/ipam Fachlogik Netze/DHCP-Bereiche/Adressen (rein): Validierung, effektive Pools,
Auslastung, Adresskarte, Änderungsvorschau
server/internal/auth Argon2id, TOTP, Tokens, Rate-Limiter, Rollen und Rechte
server/internal/license LicenseProvider, KeygenProvider, Lizenzdateien, Zustandsautomat, Manager
server/internal/secrets AES-256-GCM mit Master-Key (Schlüssel-IDs, Rotation)
server/internal/store PostgreSQL-Repositories (pgx), Migrationen (goose, embed)
— ab M2/M3 —
server/internal/configgen reine Generatoren für BIND und Kea
server/internal/agentapi gRPC-Dienst für Agents (Enrollment, Stream, Bundles, Leases)
server/internal/pki interne CA, Zertifikate, Sperrliste
server/internal/bundle Bundles bauen und signieren (Ed25519)
server/internal/configgen reine Generatoren für BIND und Kea (derive, bindgen, keagen)
server/internal/agentapi gRPC-Dienst für Agents (Enrollment, Stream, Bundles, Leases), mTLS-Prüfung
server/internal/agenthub offene Agent-Streams: Push neuer Revisionen, Trennen beim Sperren
server/internal/events Ereignisbus für Live-Aktualisierungen (SSE unter /api/v1/events)
server/internal/textdiff Zeilen-Diff (Myers) im Unified-Format für Revisionen
server/internal/pki Agent-CA, Bundle-Signaturschlüssel, Zertifikate ausstellen und prüfen
internal/bundle Bundle-Manifest, Signatur und Prüfung (Ed25519), von Server und Agent genutzt
internal/agentpki Agent-ID im Zertifikat, CA-Fingerprint, TLS-Einstellungen der Agents
agent/core Agent-Kern: Konfiguration, Zustand, Enrollment, Verbindung, Ablauf, Leases
agent/role/dns, agent/role/dhcp Rollen für BIND und Kea
```
Fachlogik (`ipam`, `auth`, `license`) kennt weder HTTP noch SQL. `service` orchestriert Transaktionen; Handler und Repositories sind Adapter um sie herum.
### 3.2 Datenmodell (Kern)
Umgesetzt in M1 (`server/internal/store/migrations`): `app_settings` (u. a. Installationszeitpunkt, OIDC mit verschlüsseltem Secret), `users`, `sessions` (nur Token-Hash), `audit_log`, `networks`, `dhcp_pools`, `addresses` (Typen `static`, `reserved`, `manual_dhcp`, `dynamic`), `saved_filters`, `license_state` (verschlüsselt).
Umgesetzt in M1 (`server/internal/store/migrations`): `app_settings` (u. a. Installationszeitpunkt, OIDC mit verschlüsseltem Secret), `users`, `sessions` (nur Token-Hash), `audit_log`, `networks`, `dhcp_pools` (bis Migration 00006), `addresses` (Typen `static`, `reserved`, `manual_dhcp`, `dynamic`), `saved_filters`, `license_state` (verschlüsselt).
Die Datenbank erzwingt die Kernregeln selbst:
@@ -87,11 +92,20 @@ Die Datenbank erzwingt die Kernregeln selbst:
- IP eindeutig je Netz, Name (FQDN) eindeutig je Zone für Einträge mit DNS-Records
- Adresse liegt im Netz (Trigger), Host-Adresse statt Präfix
Optimistisches Sperren über `version`; alle Änderungen an Pools und Adressen eines Netzes sperren die Netzzeile und sind dadurch serialisiert.
Optimistisches Sperren über `version`; alle Änderungen an den Adressen eines Netzes sperren die Netzzeile und sind dadurch serialisiert.
Ab M2–M5 folgen `zone` (`forward`/`reverse`, Modus `managed`/`dynamic`), `record`, `cluster` (`dns`/`dhcp`), `agent`, `config_revision` (unveränderlich), `deployment` und `pending_change` (Entwürfe).
M3 (Migration 00003): `agents`, `enrollment_tokens`, `pki_keys` (verschlüsselt), `config_revisions` und `config_revision_files` (unveränderlich per Trigger), `deployments`, `dhcp_leases`; Einzelheiten in [ADR 0021](adr/0021-agent-strecke-m3.md). M4 (Migration 00004): `agents.target_revision`, offene Push-Deployments, `config_revisions.source` (Generator-Eingabe für Rollbacks); Entwürfe ergeben sich aus Audit-Log und Konfigurationsvergleich ([ADR 0022](adr/0022-ausrollen-pull-push-m4.md)), eine eigene `pending_change`-Tabelle entfällt. Ab M5 folgen `zone` (`forward`/`reverse`, Modus `managed`/`dynamic`), `record` und `cluster` (`dns`/`dhcp`).
**Pools und feste Adressen:** Pools sind Bereichsdefinitionen. Gateway sowie `static`-, `reserved`- und `manual_dhcp`-Einträge werden aus dem **effektiven** Pool ausgeschnitten (`ipam.EffectivePools`); `dynamic`-Einträge müssen innerhalb eines Pools liegen. Damit gilt die Vorgabe „statische Adressen außerhalb der Pools“ für das, was Kea tatsächlich vergibt, ohne dass Pools manuell zerlegt werden müssen.
**DHCP pro Adresse ([ADR 0025](adr/0025-dhcp-pro-adresse-pruefungen-ui.md), Migration 00006):** Pools werden nicht mehr gepflegt; `dhcp_pools` entfällt. Der DHCP-Bereich eines Netzes mit `dhcp_enabled` ist die Menge seiner `dynamic`-Einträge (nur IPv4, nicht die Gateway-Adresse); jede ununterbrochene Folge davon wird ein Kea-Pool (`ipam.Ranges`, in der API als abgeleitetes `Network.pools`). Damit liegen `static`-, `reserved`- und `manual_dhcp`-Einträge automatisch außerhalb dessen, was Kea vergibt.
| Typ | DHCP | DNS (A/AAAA, PTR) |
| --- | --- | --- |
| `static` | nein | ja |
| `reserved` | nein (gesperrt) | nein |
| `manual_dhcp` | Reservierung MAC → IP | ja |
| `dynamic` | IP gehört zum DHCP-Bereich (ohne MAC) | ja (ab Generator-Eingabe Version 3) |
`dynamic`-Einträge ohne Hostnamen erhalten beim Anlegen (Formular, CSV, Bereichsoperation, Typwechsel, Massenänderung) einen zufälligen Namen `dyn-` + 8 Zeichen `[a-z0-9]`; der eindeutige FQDN-Index umfasst `static`, `manual_dhcp` und `dynamic`. Leases ändern nur vorhandene Einträge (Lease-Felder, vom Client gemeldeter Name in `lease_hostname`, bei `dynamic` die MAC), nie den Hostnamen, und legen keine Einträge an. Bereichsoperationen (`POST /networks/{id}/range`) legen bzw. löschen bis zu 65.536 Einträge in einer Anweisung mit einem Audit-Eintrag.
### 3.3 Änderungsfluss
@@ -146,11 +160,11 @@ type Role interface { // wie wird sie angewendet?
### 4.3 DNS-Rolle
Eine generierte Include-Datei plus Zonendateien der managed Zonen. Dynamische Zonen werden angelegt, aber deren Daten nie überschrieben. Primary/Secondary mit NOTIFY und TSIG-gesichertem AXFR/IXFR (M5). Details: [0004](adr/0004-dns-engine-bind9.md).
Eine generierte Include-Datei plus Zonendateien der managed Zonen. Dynamische Zonen werden angelegt, aber deren Daten nie überschrieben; die Records, die DDI darin verwaltet (Adresstabelle, Records der Zone), gleicht der DNS-Agent per `nsupdate` mit dem Systemschlüssel `ddi-agent-update` ab ([0026](adr/0026-eintraege-in-dynamischen-zonen.md)). Primary/Secondary mit NOTIFY und TSIG-gesichertem AXFR/IXFR (M5). Details: [0004](adr/0004-dns-engine-bind9.md).
### 4.4 DHCP-Rolle
Vollständige `kea-dhcp4.conf`, Pools um `static`/`reserved` herum aufgeteilt, Reservierungen aus `manual_dhcp`. Leases über `lease_cmds` (`lease4-get-page`), daraus Änderungsereignisse an den Server. HA über `libdhcp_ha.so` (M5). Nur frei verfügbare Kea-Bestandteile ([0005](adr/0005-dhcp-engine-kea.md)).
Vollständige `kea-dhcp4.conf`, Pools aus den Folgen von `dynamic`-Einträgen (ADR 0025), Reservierungen aus `manual_dhcp`. Leases über `lease_cmds` (`lease4-get-page`), daraus Änderungsereignisse an den Server. HA über `libdhcp_ha.so` (M5). Nur frei verfügbare Kea-Bestandteile ([0005](adr/0005-dhcp-engine-kea.md)).
## 5. Kommunikation und Sicherheit
@@ -162,7 +176,7 @@ Vollständige `kea-dhcp4.conf`, Pools um `static`/`reserved` herum aufgeteilt, R
| DNS | Clients → BIND | 53/udp+tcp | – |
| Zonentransfer | Secondary ↔ Primary | 53/tcp | TSIG |
| DHCP | Clients/Relays → Kea | 67/udp (Antwort 68/udp) | – |
| Kea-HA | Kea ↔ Kea | konfigurierbar | TLS empfohlen (M5) |
| Kea-HA | Kea ↔ Kea | 8001/tcp (`ha_port`) | HTTP mit Basic-Auth, noch ohne TLS (siehe [SECURITY.md](SECURITY.md#6-was-nicht-geschützt-ist)) |
Vertrauenskette und Schlüssel:
@@ -170,7 +184,7 @@ Vertrauenskette und Schlüssel:
- **CA-Schlüssel** signiert Server- und Agent-Zertifikate ([0008](adr/0008-interne-ca-enrollment.md)).
- **Bundle-Signaturschlüssel** (Ed25519, getrennt von der CA) signiert Konfigurationen ([0009](adr/0009-signierte-config-bundles.md)).
Bedrohungsmodell und Härtung folgen in `docs/SECURITY.md` (M6).
Bedrohungsmodell, Schlüsselverwaltung und Härtung: [SECURITY.md](SECURITY.md). Betrieb (Sicherung, Upgrade, Rotation, Fehlersuche): [OPERATIONS.md](OPERATIONS.md). Ports, Versionen und gemessene Richtwerte: [SYSTEM-REQUIREMENTS.md](SYSTEM-REQUIREMENTS.md).
## 6. Dateisystem auf den Zielsystemen
@@ -196,14 +210,17 @@ Server und Agents lesen eine YAML-Datei (Server: Pfad aus `DDI_CONFIG_FILE`, Bei
| `DDI_LOG_LEVEL`, `DDI_LOG_FORMAT` | Logging |
| `DDI_SESSION_LIFETIME`, `DDI_SESSION_IDLE_TIMEOUT` | Sitzungen |
| `DDI_LICENSE_GRACE_PERIOD`, `KEYGEN_URL`, `KEYGEN_PRODUCT_ID` | Lizenz (siehe [LICENSING.md](LICENSING.md)) |
| `DDI_RETENTION_AUDIT_DAYS`, `DDI_RETENTION_REVISIONS_KEEP` | Aufbewahrung (`retention.audit_days`, `retention.revisions_keep`; 0 = alles behalten) |
`KEYGEN_ACCOUNT_ID` und `KEYGEN_PUBLIC_KEY` gelten nur für Builds ohne eingebettete Werte (Entwicklung). Dauerangaben verstehen Go-Notation und Tage (`14d`).
**Aufbewahrung** ([0024](adr/0024-auslieferung-installer-m6.md)): Ist `retention.audit_days` oder `retention.revisions_keep` gesetzt, räumt der Server fünf Minuten nach dem Start und danach täglich auf. Gelöscht werden Audit-Einträge, die älter als die angegebene Zahl Tage sind, und Revisionen jenseits der neuesten `revisions_keep` (samt Dateien). Nie gelöscht werden die neueste Revision, Revisionen, die ein Agent anwendet, als letzte funktionierende kennt oder als Ziel hat, und Revisionen mit offenen Deployments. Ein Aufräumlauf, der etwas löscht, schreibt den Audit-Eintrag `retention.cleanup` mit den Anzahlen.
## 8. Build, CI und Auslieferung
- `Makefile` als Einstieg: `build`, `test`, `lint`, `fmt`, `generate`, `web`, `dev-up`/`dev-down`, `release`.
- CI: Gitea Actions unter `.gitea/workflows/` ([0003](adr/0003-git-host-gitea-actions.md)). Geprüft werden Go (Format, `vet`, `golangci-lint`, Tests, `govulncheck`), Protobuf (`buf lint`, generierter Code aktuell), Web (ESLint, Typecheck, Build, Tests, `npm audit`), Shell (`shellcheck`), SPDX-Header und die Entwicklungsumgebung (Compose-Smoke-Test).
- Release bei Tag `v*`: Binaries für `linux/amd64` und `linux/arm64`, Tarballs, `SHA256SUMS`, Upload an das Gitea-Release.
- Release bei Tag `v*`: Binaries für `linux/amd64` und `linux/arm64`, Tarballs, `.deb`/`.rpm` (nfpm), `SHA256SUMS` und – mit Secret `RELEASE_SIGNING_KEY` – die Ed25519-Signatur `SHA256SUMS.sig`, Upload an das Gitea-Release ([RELEASE.md](RELEASE.md)).
- Installer `scripts/install.sh` (M6) lädt die Artefakte und prüft die Prüfsummen.
## 9. Entwicklungsumgebung
@@ -212,7 +229,7 @@ Server und Agents lesen eine YAML-Datei (Server: Pfad aus `DDI_CONFIG_FILE`, Bei
## 10. Skalierung
Zielgröße der ersten Version sind bis ca. 50 Agents und einige zehntausend Adressen. Damit es nach oben nicht verbaut ist, gilt:
Zielgröße der ersten Version sind bis ca. 50 Agents und einige zehntausend Adressen. Gemessene Werte für 5.000 und 50.000 Adressen stehen in [SYSTEM-REQUIREMENTS.md](SYSTEM-REQUIREMENTS.md#4-messungen). Damit es nach oben nicht verbaut ist, gilt:
- Tabellen werden serverseitig paginiert und gefiltert. Indizes auf `ip`, `mac`, `hostname`.
- Revisionen speichern strukturierte Daten plus Artefakte. Die Aufbewahrung ist konfigurierbar.
+159
View File
@@ -0,0 +1,159 @@
# Installation
`scripts/install.sh` richtet die drei Rollen der DDI-Suite auf einem Linux-Host ein: den **Management-Server** (`server`), den **DNS-Agent mit BIND** (`agent-dns`) und den **DHCP-Agent mit Kea** (`agent-dhcp`). Die Rollen lassen sich beliebig auf Hosts verteilen oder kombinieren. Entscheidungen dazu: [ADR 0024](adr/0024-auslieferung-installer-m6.md).
## Voraussetzungen
| | |
| --- | --- |
| Systeme | Debian 12 und 13, Ubuntu 22.04 und 24.04 LTS, Rocky Linux/AlmaLinux/RHEL 9 |
| Architekturen | amd64 (x86_64), arm64 (aarch64, z. B. Raspberry Pi 4/5 mit 64-Bit-System) |
| Rechte | root (`sudo`) |
| Init-System | systemd |
| Netz | Zugriff auf die Paketquellen der Distribution, auf `git.beugsiud.de` (Release-Artefakte) und für `agent-dhcp` auf `dl.cloudsmith.io` (ISC-Kea-Pakete); der Server prüft `https://license.beugsiud.de` (nur Warnung) |
| Zeit | synchronisierte Uhr (NTP), sonst warnt der Installer |
Richtwerte stehen in `docs/SYSTEM-REQUIREMENTS.md`; der Installer prüft vorab (Abschnitt 3 dort, Schwellen als Variablen oben im Skript). Mehrere Rollen auf einem Host: RAM und Platte addieren sich, bei CPU zählt das Maximum.
| Prüfung | Warnung | Abbruch |
| --- | --- | --- |
| CPU-Kerne | Server < 2 | – |
| RAM | Server < 3,5 GiB, je Agent < 900 MiB | Server < 1,8 GiB, je Agent < 450 MiB |
| Freier Platz im Dateisystem von `/var/lib` | Server < 20 GiB, je Agent < 5 GiB | Server < 4 GiB, je Agent < 1 GiB |
| Freier Platz für `/usr/local/bin` | – | < 200 MiB |
| Ports | 8001/tcp (Kea-HA) belegt; 53 nur von systemd-resolved auf 127.0.0.53 belegt | 8443/8444 belegt (nicht vom Server), 53 belegt (nicht von `named`), 67/udp belegt (nicht von `kea-dhcp4`) |
| Versionen | BIND < 9.18, Kea < 3.0 (keine HA) | PostgreSQL < 13, Kea < 2.6 |
| Netz | Lizenzserver nicht erreichbar (Server), Agent-API des Servers nicht erreichbar (Agents mit `--server`) | ISC-Repository nicht erreichbar (`--kea-source isc`) |
| Zeit | Uhr nicht per NTP synchronisiert | – |
`--force` macht aus jedem Abbruch eine Warnung; nicht unterstützte Systeme und Architekturen bleiben ein Abbruch.
## Herunterladen, prüfen, ausführen
Das Skript bitte **nicht** per `curl … | sudo bash` ausführen, sondern erst herunterladen und lesen:
```sh
curl -fsSLO https://git.beugsiud.de/beug.k/DDI/raw/branch/main/scripts/install.sh
less install.sh # ansehen, was es tut
chmod +x install.sh
sudo ./install.sh --role server --dry-run # zeigt nur, was passieren würde
sudo ./install.sh --role server
```
Für eine feste Version das Skript aus dem Tag statt aus `main` laden, z. B. `https://git.beugsiud.de/beug.k/DDI/raw/tag/v0.7.0/scripts/install.sh`, und `--version v0.7.0` angeben.
Ohne `--version` installiert das Skript das neueste Release (`/api/v1/repos/beug.k/DDI/releases/latest`). Es lädt die Tarballs der gewählten Rollen und `SHA256SUMS` von `https://git.beugsiud.de/beug.k/DDI/releases/download/<tag>/` und prüft jede Prüfsumme. Ist im Skript ein öffentlicher Signaturschlüssel hinterlegt (`RELEASE_PUBLIC_KEY_PEM`), muss außerdem die Ed25519-Signatur `SHA256SUMS.sig` gültig sein; solange die Releases nicht signiert werden, weist das Skript darauf hin, dass nur die Prüfsummen geprüft wurden.
## Typischer Ablauf
1. **Server installieren:**
```sh
sudo ./install.sh --role server --public-url https://ddi.example.org:8443
```
Am Ende stehen die URL der Oberfläche und ein **einmaliges Administrator-Passwort** (Benutzer `admin`). Es steht nur auf dem Bildschirm, nicht im Log, und muss bei der ersten Anmeldung geändert werden. Interaktiv fragt der Installer nach einem Lizenzschlüssel (optional, Eingabe auch später in der Oberfläche möglich).
2. **Agent registrieren:** In der Oberfläche unter *Agents* ein Enrollment-Token für die Rolle anlegen. Die Oberfläche zeigt zwei gleichwertige Befehle: den Installer-Aufruf für einen neuen Host und `ddi-agent-dns enroll --server <host>:8444 --token <token> --ca-hash sha256:<hex>` für einen Host, auf dem der Agent schon installiert ist. Auf einem neuen Host:
```sh
sudo ./install.sh --role agent-dns --server ddi.example.org:8444 \
--token <token> --ca-hash sha256:<hex>
```
Für den DHCP-Agent entsprechend `--role agent-dhcp` mit einem Token der Rolle `dhcp`. Pro Aufruf wird genau ein Agent registriert.
**Ohne Token** wird der Agent installiert und sein Dienst eingeschaltet; systemd startet ihn erst, wenn eine Identität vorhanden ist (`ConditionPathExists`). Registrieren lässt er sich danach auf zwei Wegen: den Installer erneut mit `--server --token --ca-hash` aufrufen oder den `enroll`-Befehl aus der Oberfläche **als root** ausführen. Dieser übergibt die Identität an den Dienstbenutzer (`bind` bzw. `_kea`) und startet den Dienst (`systemctl enable --now`; mit `--no-start` nicht). Ein Agent, der mit einer älteren Version von Hand registriert wurde und nicht läuft, wird durch `install.sh --upgrade` repariert.
3. **Prüfen:** In der Oberfläche erscheinen die Agents als verbunden; nach dem ersten Ausrollen liefern BIND und Kea die Konfiguration.
Alle drei Rollen auf einem Host: `sudo ./install.sh --role server,agent-dns,agent-dhcp`, danach die Agents wie in Schritt 2 registrieren (Server-Adresse ist der eigene Hostname).
## Optionen
| Option | Bedeutung |
| --- | --- |
| `--role <rolle>` | `server`, `agent-dns`, `agent-dhcp`; mehrfach oder mit Komma getrennt |
| `--version <tag>` | Release, Standard: neuestes |
| `--from-dir <verzeichnis>` | Release-Artefakte (Tarballs, `SHA256SUMS`, ggf. `SHA256SUMS.sig`) aus einem Verzeichnis statt Download (offline, Tests) |
| `--git-token <token>` | Zugriffstoken, falls Releases eine Anmeldung brauchen (alternativ `DDI_GIT_TOKEN`); wird nie geloggt |
| `--server <host:port>`, `--token <token>`, `--ca-hash sha256:<hex>` | Registrierung eines Agents (Werte aus dem Enrollment-Befehl der Oberfläche) |
| `--server-name <name>` | TLS-Name des Servers, falls er vom Host in `--server` abweicht (steht dann im Befehl der Oberfläche) |
| `--kea-source isc\|distro` | Kea aus dem ISC-Repository (Standard, Kea 3.x mit HA) oder aus der Distribution (ohne HA, siehe unten) |
| `--public-url <url>` | URL der Oberfläche, Standard `https://<fqdn>:8443`; ihr Hostname ist auch die Agent-API-Adresse (`<host>:8444`) |
| `--tls-cert <datei>`, `--tls-key <datei>` | eigenes Zertifikat (PEM, mit Kette) und Schlüssel; ohne erzeugt der Installer ein selbstsigniertes Zertifikat |
| `--license-key-file <datei>` | Lizenzschlüssel aktivieren; Fehler (ungültig, Lizenzserver nicht erreichbar) sind nur Warnungen |
| `--upgrade` | Binaries und Units ersetzen, Dienste neu starten |
| `--uninstall` | Rollen entfernen, Konfiguration und Daten bleiben |
| `--purge` | mit `--uninstall`: auch Konfiguration, Daten, Datenbank, Schlüssel und Dienstbenutzer löschen |
| `--dry-run` | nur anzeigen, nichts ändern |
| `--non-interactive` | nie nachfragen (zum Ändern ist dann `--yes` nötig) |
| `--yes` | Rückfrage mit Ja beantworten |
| `--force` | Vorabprüfung nur warnen lassen |
Umgebung: `DDI_RELEASE_BASE_URL` ersetzt die Download-Adresse (`<tag>` darin wird durch die Version ersetzt; ohne `<tag>` ist es das Verzeichnis der Artefakte), `DDI_GIT_TOKEN`.
## Was wird wo installiert
Gemeinsam für alle Rollen:
| Pfad | Inhalt |
| --- | --- |
| `/usr/local/bin/ddi-server`, `ddi-agent-dns`, `ddi-agent-dhcp` | Binaries |
| `/etc/systemd/system/<binary>.service` | systemd-Units (gehärtet, siehe ADR 0024) |
| `/etc/ddi-suite/` | Konfiguration (Verzeichnis 0755, Dateien einzeln geschützt) |
| `/var/lib/ddi-suite/` | Zustand (`server/`, `agent-dns/`, `agent-dhcp/`) |
| `/var/log/ddi-suite/install.log` | Protokoll des Installers (ohne Tokens, Schlüssel und Passwörter) |
| `/usr/local/share/doc/ddi-suite/` | Lizenztexte |
**Server**
| | |
| --- | --- |
| Pakete | PostgreSQL der Distribution (Debian/Ubuntu `postgresql`, EL9 `postgresql-server` aus dem Modul-Stream ≥ 15, höchster verfügbarer), `openssl`, `curl` |
| Benutzer | Systembenutzer `ddi-suite` |
| Datenbank | Rolle und Datenbank `ddi-suite`, Anmeldung per Peer-Authentifizierung über den Unix-Socket (kein Passwort) |
| `/etc/ddi-suite/server.yaml` | Konfiguration (0640 root:ddi-suite) |
| `/etc/ddi-suite/master.key` | Master-Key (0400 ddi-suite) – **sichern**, ohne ihn sind verschlüsselte Daten verloren |
| `/etc/ddi-suite/tls/` | TLS-Zertifikat und Schlüssel der Oberfläche |
| Ports | 8443/tcp (Oberfläche, API), 8444/tcp (Agent-API) |
**DNS-Agent** (läuft als BIND-Benutzer `bind` bzw. `named`)
| | Debian/Ubuntu | EL9 |
| --- | --- | --- |
| Pakete | `bind9`, `bind9-utils`, `bind9-dnsutils` | `bind9.18`, `bind9.18-utils` (sonst `bind`, Warnung unter 9.18) |
| BIND-Konfiguration | `/etc/bind/named.conf`, `named.conf.options` | `/etc/named.conf` |
| Dateien des Agents | `/etc/bind/ddi-suite/` | `/etc/named/ddi-suite/` |
| Zonendateien | `/var/lib/bind/ddi-suite/` (inkl. `secondary/`) | ebenso, SELinux-Typ `named_cache_t` |
| Agent-Konfiguration | `/etc/ddi-suite/agent-dns.yaml` | ebenso |
Der Installer fügt in `named.conf` einen markierten Block ein (`// BEGIN ddi-suite …` bis `// END ddi-suite …`), der `ddi-suite-keys.conf` und `ddi-suite.conf` einbindet, und im `options`-Block einen zweiten mit `ddi-suite-options.conf`. Anweisungen der Distribution, die der Generator selbst setzt (`recursion`, `allow-recursion`, `forwarders`, `allow-transfer`), sowie `listen-on`, `listen-on-v6` und `allow-query`, die BIND auf localhost beschränken (Standard auf EL9), werden mit dem Präfix `//ddi-suite-disabled: ` auskommentiert. Beim Deinstallieren werden die Blöcke entfernt und die Zeilen wiederhergestellt. Danach prüft `named-checkconf` die Konfiguration; scheitert sie, stellt der Installer die Originale wieder her.
**DHCP-Agent** (läuft als Kea-Benutzer `_kea` bzw. `kea`)
| | |
| --- | --- |
| Pakete | `isc-kea-dhcp4`, `isc-kea-hooks` aus dem ISC-Repository `isc/kea-3-2` (Cloudsmith). Der Schlüssel des Repositorys wird nur mit dem Fingerabdruck `360D0B03DFE219DB0F9FEA4C720DD962343B440F` akzeptiert. Auf EL9 wird zusätzlich EPEL aktiviert (die ISC-Pakete brauchen `log4cplus`). |
| Dienst | `isc-kea-dhcp4-server` (Debian/Ubuntu) bzw. `kea-dhcp4` (EL9) |
| Drop-in `/etc/systemd/system/<kea-unit>.service.d/10-ddi-suite.conf` | setzt `KEA_CONTROL_SOCKET_DIR=/run/kea`: Die ISC-Pakete sind mit `/var/run/kea` übersetzt und vergleichen Pfade wörtlich, der Generator verwendet `/run/kea`. Dieselbe Variable bekommt der DHCP-Agent (für `kea-dhcp4 -t`). |
| `/etc/kea/kea-dhcp4.conf` | Startkonfiguration ohne Subnetze mit Control-Socket `/run/kea/kea4-ctrl-socket`, Lease-Datei `/var/lib/kea/kea-leases4.csv` und Hook `lease_cmds`; das Original bleibt als `kea-dhcp4.conf.ddi-suite-orig` erhalten. Der Agent überschreibt die Datei mit jeder Revision. |
| HA-Dateien | `/etc/kea/ddi-suite-ha-user`, `ddi-suite-ha-password` (schreibt der Agent, wenn ein HA-Cluster konfiguriert ist) |
| Agent-Konfiguration | `/etc/ddi-suite/agent-dhcp.yaml` |
Mit `--kea-source distro` kommt Kea aus der Distribution (Debian 12: 2.2, Debian 13: 2.6, Ubuntu 22.04: 2.0, Ubuntu 24.04: 2.4). Diese Versionen können keine HA mit Dateien für Benutzer und Passwort; außerdem schreibt der Generator Hook-Bibliotheken ohne Pfad, was erst Kea 3.x auflöst. Der Weg ist für Hosts ohne Zugang zu Cloudsmith gedacht und nicht Teil der CI-Tests.
Die Agents laufen nicht als root, sondern als Benutzer des Dienstes, den sie verwalten (Drop-in `/etc/systemd/system/<binary>.service.d/10-user.conf`). Zonen-Verzeichnis, Kea-Konfiguration und Pfade entsprechen den Standard-Einstellungen des Generators auf dem Server; deshalb lassen sich Agents verschiedener Distributionen in einem Cluster mischen.
## Erneut ausführen, Upgrade, Deinstallation
- **Erneut ausführen** ist sicher: Vorhandene Konfiguration, Master-Key, Datenbank und Agent-Identität werden nie überschrieben. Ein zweiter Lauf mit denselben Optionen meldet `nothing to change (0 changes)`. Weicht ein installiertes Binary vom Release ab, bleibt es mit einer Warnung stehen.
- **Upgrade:** `sudo ./install.sh --upgrade` (ohne `--role` für alle installierten Rollen, optional `--version`). Ersetzt Binaries und Units, startet die Dienste neu; Datenbankmigrationen laufen beim Serverstart. Vorher Datenbank und `/etc/ddi-suite/master.key` sichern.
- **Deinstallation:** `sudo ./install.sh --uninstall` stoppt und entfernt Dienste, Units und Binaries und nimmt die Einbindung aus `named.conf` heraus. Konfiguration, Zustand, Zonendateien, Kea-Konfiguration, Datenbank und Master-Key bleiben erhalten; BIND, Kea und PostgreSQL laufen weiter.
- **Vollständig entfernen:** `sudo ./install.sh --uninstall --purge` löscht zusätzlich `/etc/ddi-suite`, `/var/lib/ddi-suite`, die Datenbank `ddi-suite` samt Rolle, den Benutzer `ddi-suite`, die Zonendateien, die Dateien des DNS-Agents unter `/etc/bind` bzw. `/etc/named` und stellt die ursprüngliche `kea-dhcp4.conf` wieder her. Das geht auch nach einem einfachen `--uninstall`. Pakete und das ISC-Repository bleiben installiert.
## Fehlerfälle
- Bricht ein Lauf ab, entfernt der Installer die in diesem Lauf angelegten Dateien, Verzeichnisse, Benutzer und die neu angelegte Datenbank und stellt geänderte Dateien wieder her; installierte Pakete bleiben. Details stehen in `/var/log/ddi-suite/install.log`.
- Dienste: `systemctl status ddi-server ddi-agent-dns ddi-agent-dhcp`, Logs mit `journalctl -u <dienst>`, Zustand eines Agents mit `sudo -u bind ddi-agent-dns status` (bzw. als `_kea`/`kea` für `ddi-agent-dhcp`).
- Ein selbstsigniertes Zertifikat lässt sich später ersetzen: Dateien in `/etc/ddi-suite/tls/` austauschen (Schlüssel 0640 root:ddi-suite) und `systemctl restart ddi-server`.
+238
View File
@@ -0,0 +1,238 @@
# Logbuch
Arbeitsprotokoll des Projekts. Es dient als Übergabe zwischen Rechnern und Sitzungen: Was ist fertig, was ist offen, was wurde unterwegs gelernt und wie geht es weiter. Fachliche Entscheidungen stehen in den [ADRs](adr/README.md), die Architektur in [ARCHITECTURE.md](ARCHITECTURE.md). Hier steht, was dort nicht hineingehört.
Neue Einträge oben im Abschnitt „Verlauf“ ergänzen und den Abschnitt „Stand“ aktuell halten.
---
## Stand (2026-10-17, nach ADR 0026)
| Meilenstein | Status | Tag |
| --- | --- | --- |
| M0 Fundament | fertig, CI grün | `v0.1.0` |
| M1 Server-Kern | fertig, CI grün | `v0.2.0` |
| M2 Config-Generator | fertig, CI grün | `v0.3.0` |
| M3 Agent verwaltet | fertig, CI grün | `v0.4.0` |
| M4 Pull und Push | fertig, CI grün | `v0.5.0` |
| M5 Cluster und Zonen | fertig, CI grün | `v0.6.0` |
| M6 Auslieferung | fertig, CI grün | `v0.7.0` |
| Anpassungen nach Rückmeldung (ADR 0025: DHCP pro Adresse, DNS für dynamische Einträge, Bereichs-/Massenänderungen, Prüfung, neue Oberfläche) | fertig | `v0.8.0` |
| Korrekturen aus dem Betrieb: Prüfung mit Raw-ICMP und DNS-Agents (`v0.8.1`), von Hand registrierte Agents als Dienst (`v0.8.2`) | fertig | `v0.8.1`, `v0.8.2` |
| Einträge in dynamischen Zonen per `nsupdate` (ADR 0026), AD-Zone in DDI | fertig | `v0.9.0` |
| M7 (Standalone, DHCPv6, DNSSEC, Discovery) | nur Schnittstellen vorbereitet (Brief §12) | – |
Branch `main`, alles gepusht nach `https://git.beugsiud.de/beug.k/DDI`. Releases mit Artefakten über den Release-Workflow.
### Nächster Schritt: Abnahme auf echten Maschinen (Brief §14)
M0–M6 sind umgesetzt. Was die CI nicht abdecken kann, steht noch aus:
1. **Release-Signatur einrichten** (nur du): Schlüsselpaar erzeugen, Secret `RELEASE_SIGNING_KEY` in Gitea setzen und den öffentlichen Schlüssel in `scripts/install.sh` (`RELEASE_PUBLIC_KEY_PEM`) eintragen – Anleitung in [RELEASE.md](RELEASE.md). Bis dahin prüft der Installer nur `SHA256SUMS` und warnt.
2. **Abnahme nach Brief §14** auf drei VMs (Debian 12 Server, je eine VM für DNS- und DHCP-Agent): Installation nach [INSTALL.md](INSTALL.md), echter Keygen-Lizenzschlüssel (Punkt 1 unten), Oberfläche im Browser durchklicken (Punkt 2), DHCP mit echtem Client (Lease → Adresstabelle → `manual_dhcp`), Lizenz sperren/Keygen unerreichbar (Lesemodus nach Karenzzeit, DNS/DHCP laufen weiter).
3. Danach bewusst entscheiden, welche offenen Punkte vor einem `v1.0.0` nötig sind (z. B. HA mit TLS, Kea-DDNS, Neuverschlüsselung beim Master-Key-Wechsel).
### Offene Punkte
1. **Keygen live geprüft (2026-10-13), aber noch nicht mit dem Go-Code selbst.** Die Aufruffolge des `KeygenProvider` wurde per Skript mit echtem Schlüssel nachgespielt (alle Antworten signiert und gültig; `validate-key` → `NO_MACHINES`, Aktivierung 201, erneut 422 `FINGERPRINT_TAKEN`, Validierung `VALID`, Entitlements `DDI_Networks`, Heartbeat 200, Deaktivierung 204). **Offen:** der Go-Code selbst gegen die echte Instanz, z. B. bei der Abnahme mit `ddi-server license set-key` oder per Live-Test in der CI mit Gitea-Secret. `tools/keygen-setup/keygen-setup.sh` ist nie gegen die Instanz gelaufen.
2. **Oberfläche nie im echten Browser bedient** (inzwischen viele Dialoge: Cluster, Zonen, Records, TSIG). Abgesichert durch Vitest und Smoke-Tests. Bei der Abnahme auf Layout-Fehler und Abläufe achten.
3. **Frontend-Bundle:** Seiten werden nachgeladen; der Hauptteil ist noch 481 KB (154 KB gzip). Weiter verkleinern: Vendor-Split, Sprachdateien nachladen.
4. **Service-Schicht ohne eigene Abdeckungszahl:** Nachweis mit `go test -coverpkg=./server/... ./server/internal/httpapi`.
5. **GitHub-Rate-Limit in der CI:** `setup-go`/`setup-node` holen Manifeste von GitHub. Falls Jobs scheitern: Go/Node in `scripts/ci/install-tools.sh` selbst mit Prüfsumme installieren.
6. **TanStack Table 9 und React Router 8** sind erschienen; bewusst auf Table 8 / Router 7 geblieben.
7. **Kea-HA:** nur mit Kea ≥ 3.0; Zugangsdaten aus Dateien; HA-Verbindung per HTTP mit Basic-Auth **ohne TLS**. Beide Peers melden dieselben Leases (harmlos).
8. **SOA-Seriennummer bei lokalem Rollback:** Rollt der Agent auf dem Primary lokal zurück, sinkt die Seriennummer dort; Secondaries behalten bis zur nächsten Revision die höhere (ADR 0023).
9. **Kea `config-reload` liest die Datei, mit der Kea gestartet wurde.** Der Installer setzt die Pfade passend; wer Kea von Hand umkonfiguriert, siehe `OPERATIONS.md` §10.3.
10. **Server-Tests mit Zeitbezug:** Agent-Kern- und Agent-API-Tests nutzen kurze Intervalle. Bei sporadischen CI-Fehlern zuerst an Timing denken (so auch der AXFR-Integrationstest, behoben durch Start-Reihenfolge).
11. **Nicht umgesetzt aus dem Brief (optional bzw. später):** Kea-DDNS für Namen dynamischer Leases (§5.2, optional); GSS-TSIG; Bindung eines Enrollment-Tokens an einen Cluster (ADR 0008).
12. **Cluster-Kleinigkeiten:** Löschen eines Agents entfernt ihn ohne eigenen Audit-Eintrag aus seinem Cluster; Secondary kann direkt nach dem Ausrollen kurz `tsig indicates error` melden (NOTIFY holt die Zonen danach).
13. **Aus den Messungen:** `GET /addresses` ohne Netzfilter braucht bei 50.000 Einträgen 110–240 ms. Jede Revision speichert alle Dateien voll (50.000 Adressen: 4,6 MiB) – Aufbewahrung (`retention.revisions_keep`) empfehlen bzw. später deduplizieren. Nicht gemessen: Speicher der Agents, 50 verbundene Agents, viele gleichzeitige Benutzer, Kea mit HA.
14. **Schlüsselrotation unvollständig:** kein Neuverschlüsseln vorhandener Werte beim Master-Key-Wechsel; keine Rotation für Agent-CA, Bundle-Schlüssel und HA-Passwort (nur Neuanlage des Clusters). Beschrieben in `OPERATIONS.md`/`SECURITY.md`.
15. **Installer, nicht in der CI geprüft:** `--kea-source distro` (Hooks ohne Pfad), AppArmor/SELinux im erzwingenden Modus (Container setzen sie nicht durch), echte Downloads vom Gitea-Release (die CI nutzt `--from-dir`). EL9 braucht EPEL für Kea von ISC.
16. **Zertifikatserneuerung mit verlorener Antwort:** Agent muss neu registriert werden (`enroll --force`), beschrieben in `OPERATIONS.md` §8.2.
17. **Messwerte nach ADR 0025 neu erheben:** Die Zahlen in `SYSTEM-REQUIREMENTS.md` stammen von vor dem Wechsel zu DHCP pro Adresse (eine voll dynamische /16 sind jetzt 65.521 Einträge mit DNS-Namen). Benchmark-Workflow `bench` erneut laufen lassen. Die IP-Liste eines Netzes blättert in 256er-Seiten statt zu virtualisieren.
18. **ADR 0026, Restpunkte:** MX-Records in dynamischen Zonen, deren Ziel in der Zone keine Adresse hat, lassen das Update der ganzen Zone scheitern (`check-integrity`; Aktivieren schlägt fehl, Rollback) – eine Warnung des Generators fehlt noch. Schlägt die erste Revision mit den neuen Dateien fehl und rollt der Agent auf eine ältere ohne sie zurück, bleiben schon eingetragene Records stehen (sie stehen ohnehin in der Adresstabelle). Sehr große Zonen werden in Transaktionen zu je 500 Records abgeglichen.
19. **Integrationstest `TestBINDDynamicUpdates` übersprungen:** Sein Container (named + nsupdate per Shell-Helfer im selben Container) endet in der CI nach ~30 s ohne jede Ausgabe (Läufe 116–122; auch `docker logs` leer). Der Ablauf ist durch `TestDNSDynamicRecords` (echter Agent-Code gegen BIND 9.18/9.20) und den End-to-End-Test abgedeckt. Ursache klären (lokal mit Docker nachstellen) und Test wieder einschalten.
20. **Sporadisch beobachtet (Lauf 125, Installationstest Debian 12, Upgrade-Schritt):** BIND 9.18 lud die geänderte Reverse-Zone nach `rndc reload` nicht innerhalb der 30 s des Health-Checks (Seriennummer blieb 1); der Agent rollte korrekt zurück. Mit gleichem Code vorher und auf den anderen Systemen grün. Falls es wiederkehrt: prüfen, ob der Agent zonenweise `rndc reload <zone>` nutzen sollte bzw. ob Dateizeitstempel (Sekundenauflösung) eine Rolle spielen.
---
## Arbeitsumgebung
### Werkzeuge (Versionen gepinnt)
| Werkzeug | Version | Hinweis |
| --- | --- | --- |
| Go | 1.27.2 (`go.mod`: `go 1.27.0`, `toolchain go1.27.2`) | `GOTOOLCHAIN=local` empfohlen |
| Node.js | 24 LTS (24.21.0) | npm 11 |
| golangci-lint | 2.14.0 | Konfiguration `.golangci.yml` (v2) |
| buf | 1.73.0 | Remote-Plugins, braucht Netz zu `buf.build` |
| shellcheck | 0.11.0 | |
| jq | 1.8.x | für `scripts/release-upload.sh` |
| Docker + Compose | aktuell | Entwicklungsumgebung, Integrationstests |
Unter Linux installiert `scripts/ci/install-tools.sh` golangci-lint, buf und shellcheck mit Prüfsummen.
### Besonderheit des ursprünglichen Windows-Rechners
Auf dem ersten Arbeitsplatz (verwalteter Windows-Rechner) blockiert eine Defender-ASR-Regel („Block executable files unless they meet a prevalence, age, or trusted list criterion“) das Ausführen frisch kompilierter `.exe`. Folgen:
- Go-Tests, `go run`, `go tool …` und lokal gebaute Plugins laufen dort **nicht**. Möglich sind `go build`, `go vet`, `golangci-lint`, `go test -exec=true ./...` (nur kompilieren), `buf` mit Remote-Plugins, alles mit Node (Vitest, ESLint, Vite, openapi-typescript).
- Kein Docker, kein WSL, kein `make`. Die Make-Ziele wurden deshalb dort nie direkt ausgeführt, sondern ihre Befehle einzeln; die CI führt `make` aus.
- Werkzeuge lagen portabel in `%LOCALAPPDATA%\ddi-tools` (Go, Node, golangci-lint, shellcheck, buf, jq) mit `GOTMPDIR` dort.
- Die Regel nicht umgehen. Auf einem Linux-Rechner (oder mit Ausnahme durch die IT) gilt nichts davon: `make ci` läuft dort komplett.
### Zweiter Windows-Rechner (BRLN-WKS1, ab M5)
Keine ASR-Sperre: Go-Tests laufen, auch mit Datenbank. Weiterhin kein Docker, kein `make`, kein `sudo` (WSL-Ubuntu 26.04 ist vorhanden, aber leer). Integrations- und End-to-End-Tests laufen daher nur in der CI.
- Werkzeuge portabel unter `%LOCALAPPDATA%\ddi-tools` (`go`, `node`, `bin/` mit golangci-lint, shellcheck, buf, jq) und **PostgreSQL 17.10** (`pgsql/`, Daten in `pgdata/`, Port 55432, Anmeldung ohne Passwort).
- In Git-Bash vor Go-Befehlen: `T=$(cygpath -u "$LOCALAPPDATA/ddi-tools"); export PATH="$T/go/bin:$T/node:$T/bin:$PATH" GOTOOLCHAIN=local GOTMPDIR="$(cygpath -w $T/tmp)"`.
- Datenbank starten: `"$T/pgsql/bin/pg_ctl" -D "$T/pgdata" -o "-p 55432 -c listen_addresses=127.0.0.1" -l "$T/pg.log" start`, dann `DDI_TEST_DATABASE_URL="postgres://ddi@127.0.0.1:55432/ddi?sslmode=disable" DDI_REQUIRE_DB_TESTS=1`.
- Die Unit-Tests der Agent-Rollen werden unter Windows übersprungen (Unix-Pfade). Lint zusätzlich für Linux: `GOOS=linux golangci-lint run --build-tags integration ./...`.
### Nützliche Befehle
```sh
make help # Übersicht
make ci # Lint, generierter Code, Tests, Schwachstellen, Build
make dev-up # PostgreSQL, BIND, Kea (Docker)
make dev-admin # erster Admin in der Dev-DB (Einmal-Passwort)
make run-server # Server auf http://127.0.0.1:8080, Konfig deploy/config/server.dev.yaml
cd web && npm run dev # UI mit Hot Reload (Proxy auf :8080)
make generate # Protobuf + TypeScript-API-Typen neu erzeugen
make release # Tarballs + SHA256SUMS nach dist/
```
Datenbanktests brauchen `DDI_TEST_DATABASE_URL` (z. B. `postgres://ddi:ddi-dev-only@127.0.0.1:5432/ddi?sslmode=disable`); jeder Test bekommt ein eigenes Schema. Ohne Variable werden sie übersprungen, in der CI erzwingt `DDI_REQUIRE_DB_TESTS=1` sie.
---
## CI und Git-Host
- **Gitea 1.27.2** (kein Forgejo/GitLab), Actions unter `.gitea/workflows/`. Das automatische Job-Token reicht für Releases.
- Job-Logs sind öffentlich: `https://git.beugsiud.de/beug.k/DDI/actions/runs/<run>/jobs/<job>/logs`. Status eines Commits ohne Token: `GET /api/v1/repos/beug.k/DDI/commits/<sha>/status`. `/actions/runs` braucht ein Token.
- `concurrency: cancel-in-progress` – ein neuer Push bricht laufende Jobs ab, sie erscheinen dann als „failure“. Im Log nach `Job cancelled` sehen, bevor man einen Fehler sucht.
- Der Docker-Daemon der Runner ist geteilt: **keine festen Subnetze, keine festen Host-Ports, keine Bind-Mounts** in Compose-Dateien für die CI (Projektname pro Lauf: `COMPOSE_PROJECT_NAME=ddi-ci-<run_id>`).
- Pushes scheitern gelegentlich einmal mit „Failed to authenticate user“; ein zweiter Versuch klappt. Erstes Push auf einem neuen Rechner erfolgt interaktiv über den Credential Manager (bei 2FA mit Zugriffstoken).
- Jobs: Go (Lint, Tests mit Race-Detector und PostgreSQL-Service, govulncheck), Protobuf, Web (generierte API-Typen aktuell, ESLint, Typecheck, Tests, npm audit, Build), Shell/SPDX, Release-Probebau mit Smoke-Test des echten Binaries, Compose-Smoke-Test.
---
## Konventionen (Kurzfassung)
- Code, Kommentare, Commits, Logs, API-Felder: **Englisch (US-Schreibweise, `misspell` prüft)**. Doku und UI: Deutsch, UI zusätzlich Englisch.
- Conventional Commits, kleine Commits, Tag pro Meilenstein erst bei grüner CI. Commit-Trailer: `Co-Authored-By: …`.
- Jede Quelldatei trägt einen SPDX-Header passend zum Verzeichnis (`scripts/check-spdx.sh`): proprietär `server/`, `web/`, `tools/`; Apache-2.0 `agent/`, `proto/`, `internal/`, `deploy/`, `scripts/`, `test/`. `depguard` verbietet Importe von `server/`/`web` aus Apache-Code.
- OpenAPI (`server/api/openapi.yaml`) ist die Quelle der API. Neue Route = Spezifikation + Routentabelle (`httpapi/routes.go`) + Test; die Vertragstests erzwingen Gleichstand. TypeScript-Typen mit `npm run gen:api` erzeugen und einchecken.
- YAML-Flow-Mappings (`{ … }`) mit Kommas im Text: Beschreibung in Anführungszeichen setzen (hat einmal die Spezifikation zerlegt).
- Fehler an Nutzer: stabile `code`s, Übersetzungen in `web/src/locales/{de,en}.json` unter `errors.*`/`problems.*`/`effects.*`.
- Jede schreibende Service-Operation schreibt ihr Audit in derselben Transaktion; Mehrfachoperationen nutzen `Tx.Nested` (Savepoints), damit ein Fehler die Transaktion nicht abbricht.
- `.gitignore` enthält **kein** globales `*.test` (hat einmal die Zone `db.dev.test` verschluckt).
---
## Verlauf
### 2026-10-17 – Rückmeldungen aus dem Betrieb (`v0.8.1`, `v0.8.2`) und AD-Zone in DDI (`v0.9.0`)
- **v0.8.1, Adressprüfung auf dem echten Server:** Ping meldete „socket: permission denied“ (unprivilegierte ICMP-Sockets nicht erlaubt, `ping_group_range`), DNS fragte nur den System-Resolver. Jetzt Rückfall auf einen Raw-Socket mit `CAP_NET_RAW` (einzige Capability der Server-Unit), DNS-Agents ohne Cluster als Server der Standardgruppe, eigene Frist je Server, Hinweise im Ergebnis (`system_resolver`, `not_deployed` – per Audit-IDs, nicht per Zeitstempel, weil Server- und Datenbankuhr abweichen können). End-to-End- und Installationstest prüfen die Prüfung ausdrücklich.
- **v0.8.2, von Hand registrierte Agents:** Ohne Token installiert und dann mit dem `enroll`-Befehl der Oberfläche (als root) registriert, lief der DNS-Agent nie als Dienst (Unit nicht eingeschaltet, Identität gehörte root). Jetzt: Unit immer eingeschaltet mit `ConditionPathExists`, `enroll` als root übergibt die Dateien an den Dienstbenutzer (`os.Root` gegen Symlink-Tricks) und startet den Dienst, `--upgrade` repariert; die Oberfläche zeigt zusätzlich den Installer-Befehl. Der Installationstest spielt diesen Weg auf allen fünf Systemen nach.
- **CI-Lehre:** `named -g` schreibt schon beim Start „running as: named …“; auf „running“ zu warten endet sofort. Auf die Zeile warten, die mit „ running“ endet (alle Zonen geladen). Außerdem kann die per `docker start -a` angehängte Ausgabe abreißen – bei Fehlern `docker logs` lesen.
- **Umstieg von einer Windows-Domäne:** Der Nutzer will die AD-Zone in DDI betreiben. Einen Export seiner bisherigen DHCP-Zuordnungen (UniFi-Format) habe ich ins Importformat umgebaut (`local/import/`, git-ignoriert; 51 Reservierungen, 11 dynamische Leases, doppelte Hostnamen mit MAC-Endung eindeutig gemacht) und gegen einen lokalen Server per Probelauf geprüft.
### 2026-10-17 – Einträge in dynamischen Zonen, Generator und Server (ADR 0026)
- **Anlass:** In der dynamischen Zone `ddi-test.ad.beugsiud.de` fehlten neue Adressen, weil Records dort nur in die Anfangsdatei gingen. Nach [ADR 0026](adr/0026-eintraege-in-dynamischen-zonen.md) gleicht künftig der DNS-Agent die von DDI verwalteten Records per `nsupdate` ab; Generator/Server und Agent-Seite parallel in zwei Worktrees.
- **Generator:** `configgen.SourceVersion` 4 mit `Source.UpdateKeyID`, `DNSConfig.UpdateKey`; Ziele mit dynamischer Primary-Zone erhalten den Schlüssel in der Update-ACL sowie `ddi-suite-update.key` (geheim) und `dynamic-records.json` (Format in den Präzisierungen der ADR). Eingaben bis Version 3 und Ziele ohne dynamische Primary-Zone erzeugen byte-gleich; neue Golden-Files `dynamic-update`, geänderte `dynamic-keys`.
- **Server:** Migration 00007 (`tsig_keys.system`), Systemschlüssel `ddi-agent-update` beim Anlegen/Ändern einer dynamischen Zone und beim Start, nicht löschbar (`key_system`), Name reserviert (`key_exists`), `system` in der API. Die Adressprüfung meldet `dynamic_zone` nicht mehr.
- **Integrationstest** `TestBINDDynamicUpdates`: `named` mit den Anfangsdateien der Vorgänger-Revision, Abgleich per `nsupdate -k` aus `dynamic-records.json` (kleiner Go-Helfer statt Agent), Prüfung mit `dig`, Löschen genau eines Records neben einem fremden am selben Namen, unsigniertes Update von 127.0.0.1 abgelehnt. Lokal nur kompiliert (`go vet -tags integration`), läuft in der CI.
- **Gelernt:** `named` lehnt mit `check-integrity` ein Update ab, das einen MX auf einen Namen der Zone ohne Adresse hinzufügt – die Transaktion der ganzen Zone scheitert. `nsupdate -k` liest eine Datei im `named.conf`-Format über den Rückfall für Session-Keys, auch wenn sie auf `.key` endet.
### 2026-10-17 – Anpassungen nach Rückmeldung (`v0.8.0`)
- **Rückmeldung des Nutzers** nach M6: Netze aufklappbar mit allen IPs; DHCP pro IP statt Pools; Oberfläche wirkt generiert; Massenänderungen; Prüfung per Ping und DNS; Zufallsname für dynamische Einträge; DNS auch für dynamische Einträge. Umgesetzt nach [ADR 0025](adr/0025-dhcp-pro-adresse-pruefungen-ui.md) mit zwei Agents (Server/Generator, Web); beide brachen einmal am Sitzungslimit ab.
- **Modell:** `dynamic` = IP im DHCP-Bereich ohne MAC, jetzt mit A/PTR; Kea-Pools entstehen aus zusammenhängenden `dynamic`-IPs (`ipam.Ranges`). Migration 00006 wandelt Pools in Einträge um (Zufallsnamen `dyn-` + 8 Zeichen), entfernt `dhcp_pools`, neue Spalte `lease_hostname`. `configgen.SourceVersion` 3: ältere gespeicherte Eingaben erzeugen weiterhin byte-gleich (kein DNS für `dynamic`).
- **Entscheidung (ohne Rückfrage, im Bericht an den Nutzer genannt):** Der DNS-Name gehört dem Eintrag; eine Lease ändert ihn nicht, sonst entstünde nach jeder Vergabe eine Konfigurationsänderung. Den vom Client gemeldeten Namen zeigt `lease_hostname`, per Klick übernehmbar. Client-Namen automatisch in DNS wäre Kea-DDNS (offener Punkt 11).
- **Neue Endpunkte:** `GET /networks/{id}/ips`, `POST /networks/{id}/range` (assign/clear, Probelauf), `POST /addresses/check` (Ping über unprivilegierte ICMP-Sockets, unter Windows „nicht verfügbar“; DNS direkt an die zuständigen Server mit eigenem kleinen Client, weil der Go-Resolver `/etc/hosts` liest), Massenänderung mit `dry_run`, `set_description`, `regenerate_hostnames`.
- **Oberfläche:** Erstmals im echten Browser angesehen (Edge per `playwright-core` aus einem Scratch-Verzeichnis, lokaler Server mit portabler PostgreSQL). Neu: Seitenleiste in Gruppen, volle Breite, dichte Tabellen, Typ-Kürzel statt Auswahlliste je Zeile, Hilfe als „?“-Popover, eine Hauptschaltfläche je Seite; aufklappbare Netze mit IP-Liste, Umschalt-Klick-Auswahl, Bereichs-, Massen- und Prüfdialog.
- **Lehre:** Befehle mit `| tail` verschlucken den Exit-Code (ein Push und ein Lint-Lauf scheinbar erfolgreich). Prüfungen vor Commit/Push ohne Pipe ausführen oder `set -o pipefail` setzen.
### 2026-10-16 – M6 Auslieferung (`v0.7.0`)
- Verträge vorab: [ADR 0024](adr/0024-auslieferung-installer-m6.md), gehärtete Unit-Vorlagen `deploy/systemd/*.service.in`, `scripts/render-units.sh`. Vorher per Probe-Workflow auf einem Wegwerf-Branch geprüft, dass die CI-Runner privilegierte systemd-Container starten können (Debian, Ubuntu, Rocky) und Netz zu Paketquellen, Gitea und Keygen haben. Dann drei Agents (Installer, Release/Server, Messungen/Doku); alle brachen mehrfach am Sitzungslimit ab und liefen nach `SendMessage` weiter. Installer- und Mess-Agent durften für CI-Läufe je einen eigenen Remote-Branch pushen (`m6-installer`, `m6-bench`, danach gelöscht).
- **Installer** `scripts/install.sh`: Rollen `server`, `agent-dns`, `agent-dhcp` auf Debian 12/13, Ubuntu 22.04/24.04, EL9; Vorabprüfung nach `SYSTEM-REQUIREMENTS.md` §3; Prüfsummen und (sobald eingerichtet) Ed25519-Signatur; PostgreSQL mit Peer-Authentifizierung (kein DB-Passwort); Agents laufen als Benutzer ihres Dienstes (`bind`/`named`, `_kea`/`kea`), ohne root; Kea 3.2 aus dem ISC-Repository (EL9 braucht EPEL); markierte Blöcke in `named.conf`, kollidierende Distributionsoptionen umkehrbar auskommentiert; idempotent, `--upgrade`, `--uninstall [--purge]`, `--dry-run`, Aufräumen bei Abbruch. Neu `docs/INSTALL.md`.
- **Installationstest** (Workflow `install`, Lauf 101): je System ein privilegierter systemd-Container, alle Rollen installieren, Agents per Token registrieren, Revision ausrollen, `dig` und Kea-Reservierung prüfen, Hardening und Nicht-root-Prozesse prüfen, zweiter Lauf ohne Änderungen, Upgrade auf eine zweite Version, Deinstallation mit und ohne `--purge`. Auf allen fünf Systemen grün.
- **Release/Server:** `ddi-server license set-key` (stdin, Exit-Codes 0–4), Aufbewahrung (`retention.audit_days`, `retention.revisions_keep`, täglich, nie benutzte Revisionen), `.deb`/`.rpm` mit nfpm, Signatur `SHA256SUMS.sig` im Release-Workflow (Secret `RELEASE_SIGNING_KEY`, Anleitung `docs/RELEASE.md`), Namensprüfung `scripts/check-brand.sh` (ADR 0018).
- **Von den Tests gefunden:** ISC-Kea vergleicht den Socket-Pfad wörtlich mit dem einkompilierten `/var/run/kea` (Lösung: `KEA_CONTROL_SOCKET_DIR=/run/kea` per Drop-in); `kea-dhcp4 -t` braucht Netlink in der Agent-Unit; Rocky-`named.conf` beschränkt `listen-on`/`allow-query` auf localhost; der Server lehnt einen gruppenlesbaren Master-Key ab (ADR korrigiert: 0400); der CSV-Import war mit ~300 Zeilen/s zu langsam für die 50.000-Zeilen-Grenze (jetzt ~1.100/s); der AXFR-Integrationstest war wackelig (Secondary startete vor dem Primary).
- **CI-Lehren:** Ein Runner hatte für das Standard-Build-Netz von Docker kein ausgehendes Netz – Images mit `--network host` bauen. Die CI-Runner haben nur ~1,9 GB freien Platz; der Installationstest prüft deshalb erst, dass die Vorabprüfung abbricht, und nutzt dann `--force`.
### 2026-10-15 – M6 Messungen, Betriebs- und Sicherheitsdoku
- Benchmark `test/bench/` (`make bench`, Workflow `bench`, nur `workflow_dispatch`): Server mit PostgreSQL 17, synthetische Daten (5.000 und 50.000 Einträge) per CSV-Import über die API; BIND 9.20 mit `dnsperf` und Kea 3.2.1 mit `perfdhcp` auf den Dateien aus `GET /config/preview`. Ergebnisse (Lauf 93, Runner 4 vCPU Skylake, 7,7 GiB) in `SYSTEM-REQUIREMENTS.md`; neu `OPERATIONS.md`, `SECURITY.md`.
- Kernzahlen 50.000 Einträge: Server 33–194 MiB RSS (Spitze 235 MiB durch Argon2id), PostgreSQL 128 MiB, DB 72 MiB, Revision 2,8 s und 4,6 MiB, BIND 57 MiB und 50–70k QPS, Kea 42 MiB und 750–1.000 DORA/s.
- **CI-Lehren:** `perfdhcp` ist nicht im Kea-Image, das ISC-Repository muss erst eingerichtet werden (`setup.alpine.sh` von Cloudsmith); `dnsperf` gibt es nicht in Alpine stable; `deb.debian.org:80` war vom Runner zeitweise nicht erreichbar (Rückfall auf HTTPS mit abgeschalteter Peer-Prüfung, Pakete bleiben signaturgeprüft). `perfdhcp` braucht für Relay-Betrieb eine Subnetzauswahl per `relay.ip-addresses`, die das IPAM-Modell noch nicht kennt (im Benchmark per `jq` ergänzt). Beschreibung-only-Änderungen erzeugen keine neue Revision (byte-gleiche Ausgabe). Durchsätze schwanken auf dem geteilten Runner um bis zu 30 %.
### 2026-10-14 – M5 Cluster und Zonen (`v0.6.0`)
- Neuer Rechner (siehe Arbeitsumgebung). Verträge vorab: [ADR 0023](adr/0023-cluster-und-zonen-m5.md), Generator-Modell (Ziele pro Cluster-Mitglied, geheime Artefakte, `Source` Version 2), OpenAPI für Cluster, TSIG-Schlüssel, Zonen und Records. Dann drei Agents in Worktrees (Generator + Agent-Rollen, Server, Web). Server und Generator brachen einmal am Sitzungslimit ab und liefen nach `SendMessage` weiter. Der Server-Agent arbeitete bis zum Zusammenführen gegen eine Übergangslösung (`derive_shim.go`), die beim Merge entfiel.
- **Kernentscheidung:** Revisionen bleiben global (Nummer = SOA-Seriennummer), ihre Dateien tragen ein **Ziel** (`''` = Standardgruppe ohne Cluster, sonst Agent-ID). Ohne Cluster verhält sich alles wie in M4 (`TestM4Compatibility`: byte-gleich bis auf die neue Schlüsseldatei).
- **Geheimnisse:** TSIG-Schlüssel und HA-Zugangsdaten stehen nur in geheimen Dateien (`ddi-suite-keys.conf`, `ddi-suite-ha-user`, `ddi-suite-ha-password`), versiegelt in der Datenbank, in der API nie sichtbar (`secret: true`, auch im Diff), im signierten Bundle im Klartext. Die gespeicherte Generator-Eingabe enthält keine Geheimnisse; sie liegen versiegelt in `config_revisions.secrets` für Rollbacks.
- **Server:** Migration 00005 (Rollen über zusammengesetzte Fremdschlüssel erzwungen), CRUD mit Audit und Rechten, Bundle-Auswahl pro Agent, Lease-Übernahme als `dynamic`-Einträge mit Ereignis `addresses`.
- **Generator:** Primary mit `notify explicit`/`also-notify`/`allow-transfer` per Schlüssel, Secondaries mit `primaries … key`, dynamische Zonen mit Schlüsseln in der Update-ACL, Records mit CNAME-Konfliktprüfung, Kea-HA über einen authentifizierten HTTP-Control-Socket. Nachträglich: Pool-Teilung bei Load-Balancing (Kea verlangt getrennte Pools, `HA_primary`/`HA_secondary`), Kopf der Schlüsseldatei ohne Revisionsnummer (sonst stünde sie in jedem Diff).
- **Web:** Seiten für Cluster, TSIG-Schlüssel, Zonen und Records, Gruppierung von Revisionen/Diffs nach Ziel, Code-Splitting.
- **Von der CI gefunden:** Kea 3.2 lehnt Klartext-Benutzernamen in der HTTP-Authentifizierung ab (`use of clear text 'user' is NOT SECURE`) → Benutzer und Passwort aus Dateien (`user-file`, `basic-auth-user-file`). Der Zonentransfer per TSIG lief im ersten End-to-End-Lauf sofort.
- **Ergebnis:** alle 8 CI-Jobs grün (Lauf 75), End-to-End-Test mit allen M5-Schritten bestanden.
- **End-to-End erweitert:** zweiter BIND als Secondary, zweiter Kea als HA-Partner; Prüfung von Transfer, Records, dynamischen Updates per IP und per TSIG (inkl. abgelehntem Update), Erhalt dynamischer Records über Revisionen, HA-Zustand `hot-standby` über den authentifizierten Socket (ohne Anmeldung 401) und Lease-Übernahme.
- Neu: `docs/ACTIVE-DIRECTORY.md`.
### 2026-10-13 – M4 Pull und Push (`v0.5.0`)
- Gleiches Vorgehen wie M3: Verträge vorab ([ADR 0022](adr/0022-ausrollen-pull-push-m4.md), OpenAPI, Proto-Feld `GetDesiredStateResponse.deployment_id`, Paket `server/internal/textdiff` mit Myers-Diff), dann drei Agents in Worktrees (Server, Web, Agent-Kern + E2E). Alle drei brachen einmal am Sitzungslimit ab und liefen nach `SendMessage` weiter.
- **Server:** Migration 00004 (`agents.target_revision`, Deployment-Ergebnisse `pending`/`superseded`, `requested_by`, `config_revisions.source` mit der Generator-Eingabe), Betriebsmodi, Push mit offenen Deployments (erneut gesendet beim Verbinden, beim Polling über `deployment_id`), Rollback durch erneutes Rendern der gespeicherten Eingabe mit neuer Nummer, Diff, offene Änderungen (Audit seit der Revision + Konfigurationsvergleich), Ereignisbus `server/internal/events` und `GET /events` (SSE, Schreib-Deadline wird je Nachricht verschoben), `Tx.AfterCommit` für Ereignisse erst nach dem Commit.
- **Agent-Kern:** Push über Polling, jede Push-ID nur einmal anwenden (letzte 20 in `state.json`), endgültig abgelehnte Berichte verwerfen statt die Warteschlange zu blockieren.
- **Web:** Live-Aktualisierung per `EventSource` mit Polling als Rückfall, Hinweis „N Änderungen warten auf Ausrollen“, Diff-Ansicht, „Jetzt ausrollen“, Rollback, Moduswechsel.
- **Entscheidung:** Pushes gehen nie unter die aktive Revision eines Agents (Replay-Schutz, ADR 0009); dafür gibt es den Rollback. Präzisierungen des Servers stehen als Abschnitt in ADR 0022.
- **E2E erweitert:** offene Änderungen, `manual`-Agent bleibt stehen bis zum Push, Push-Deployment `succeeded`, Diff, Rollback auf Revision 1 (Namen verschwinden, Seriennummer steigt), SSE-Ereignis. Erster Lauf scheiterte nur am Testskript selbst: jeder Schritt meldete sich neu an und lief in die Login-Ratenbegrenzung (Sitzung wird jetzt wiederverwendet).
- **Keygen live:** siehe Offene Punkte (Ablauf per Skript mit echter Lizenz vollständig geprüft).
### 2026-10-13 – M3 Agent verwaltet (`v0.4.0`)
- **Vorgehen:** zuerst die Verträge selbst festgelegt und committet (`internal/bundle`: Manifest, Ed25519-Signatur, Pfadregeln; `internal/agentpki`: Agent-ID als URI-SAN, CA-Fingerprint, TLS; `agent/core/role.go`: Rollen-Schnittstelle; OpenAPI für Agents/Tokens/Revisionen/Deployments/Leases; [ADR 0021](adr/0021-agent-strecke-m3.md)). Dann drei Agents parallel in **eigenen Git-Worktrees** (`.claude/worktrees/`, git-ignoriert): Server, Agent-Kern, BIND/Kea-Rollen; jeder committet auf seinem Branch, Zusammenführen per `git merge`. Ging konfliktfrei. Die Web-Oberfläche habe ich parallel gegen die Spec gebaut.
- Die Agents brachen einmal am Sitzungslimit der API ab; mit `SendMessage` an dieselbe Agent-ID ließen sie sich samt Kontext fortsetzen, unversionierte Dateien im Worktree blieben erhalten.
- **Server:** Migration 00003 (`pki_keys`, `agents`, `enrollment_tokens`, `config_revisions` + Dateien, unveränderlich per Trigger, `deployments` mit Duplikatschutz, `dhcp_leases`), `pki` (CA und Bundle-Schlüssel beim ersten Start, verschlüsselt; Serverzertifikat bei jedem Start im Speicher), `agenthub` (offene Streams, Push bei neuer Revision, Trennen beim Sperren), `agentapi` (gRPC, TLS 1.3, eigene Zertifikatsprüfung im Interceptor: Kette, Agent-ID, Seriennummer = aktuelle, Sperre; Enroll ohne, Renew mit bis 30 Tage abgelaufenem Zertifikat), REST-Endpunkte, Konfiguration `agent_api.*` (`DDI_AGENT_API_LISTEN`, `…_PUBLIC_ADDRESS`, `…_SERVER_NAMES`), Metrik `ddi_agents_connected{role}`.
- **Agent-Kern:** Konfigurationsdatei, Zustand in Dateien (atomar, Lock, 10 Revisionen), `enroll` mit Fingerprint-Prüfung, mTLS-Stream mit Polling-Rückfall und Backoff, Zertifikatserneuerung ab halber Laufzeit, Ablauf Prüfen → Aktivieren → Health → Rollback, keine automatische Wiederholung abgelehnter Revisionen, Berichtswarteschlange, Lease-Diffs mit vollständigem Stand (Batches ≤ 5000, nur der erste mit `full_sync`).
- **Rollen:** DNS (Prüfung über temporäre `named.conf` + `named-checkzone`, Aktivieren mit Sicherung, `named-checkconf -z`, `rndc reconfig`/`reload`; Health über `rndc status` und SOA-Abfrage je Zone mit `x/net/dns/dnsmessage`), DHCP (`kea-dhcp4 -t`, `config-reload`/`status-get` über den Unix-Socket, Leases über `lease4-get-page`). Nachgelesen: globales `rndc reload` scheitert nicht an dynamischen Zonen (nur `rndc reload <zone>` tut es), Kea-Lease-Zustände 0–3 (3 = released erst ab Kea 3.0), Control-Socket ab 2.7.9/3.0 nur in `KEA_CONTROL_SOCKET_DIR` mit Modus 0750.
- **Entscheidung:** Agent sperren/löschen und Tokens löschen funktionieren auch im Lizenz-Lesemodus (Sicherheitsfunktion), siehe `routes.go`.
- **Tests:** Rollen-Integrationstests in echten Containern (Treiber-Binary per `docker cp`, `docker exec`), neu **End-to-End-Test** `test/e2e` (`make test-e2e`, CI-Job „End-to-end“): Server, PostgreSQL, BIND und Kea mit beiden Agents in Containern, gesteuert über `docker compose exec` (die CI erreicht keine Container-Ports): Registrierung mit dem Befehl aus der API, Revision 1 und 2 kommen an, `dig` vorwärts/rückwärts, Reservierung in Kea, unveränderte Daten erzeugen keine Revision, Sperren trennt den Agent, BIND liefert weiter.
- **Ergebnis:** alle 8 CI-Jobs grün; der End-to-End-Test lief beim ersten echten Lauf durch. Abdeckung: agent/core 87,5 %, role/dns 87,5 %, role/dhcp 89,6 %, agentapi 83,5 %, pki 80,1 %, bundle 95,9 %, agentpki 93,8 %, httpapi 80,8 %.
- **CI-Lehren:** `curl … | grep -q` unter `pipefail` scheitert, sobald die Antwort größer wird (SIGPIPE); erst lesen, dann suchen. Auf Windows angelegte Skripte brauchen `git update-index --chmod=+x`. Docker-Hub-Pulls laufen auf dem geteilten Runner gelegentlich in Timeouts; Pulls wiederholen und auf vorhandene Images zurückfallen. Mehrzeilige YAML-Flow-Beschreibungen mit Komma immer in Anführungszeichen (zweites Mal passiert).
### 2026-10-13 – M2 Config-Generator (`v0.3.0`)
- Arbeit mit drei parallelen Agents nach einem vorab festgelegten Vertrag (`server/internal/configgen/model.go`): `derive`, `bindgen`, `keagen` je in eigenem Unterpaket (gleichzeitiges Arbeiten im selben Go-Paket hätte die Builds gegenseitig blockiert). Agents haben nicht committet; Integration, Tests und Push zentral.
- Ergebnis: alle handgeschriebenen Golden-Files passten im ersten CI-Lauf; Abdeckung bindgen 99,5 %, derive 98,9 %, keagen 98,8 %.
- Integrationstests (`make test-integration`, CI-Job „Integration“): erzeugte Dateien mit `named-checkconf -z`/`named-checkzone` (BIND 9.18, 9.20) und `kea-dhcp4 -t` (Kea 2.6.5, 3.2.1) geprüft, plus Gegenprobe mit kaputter Datei. Dateien per `docker cp` (keine Bind-Mounts). Erste Fehlschläge lagen am Harness (Pull-Meldungen in der Ausgabe), nicht am Generator.
- BIND bekommt zwei Include-Dateien: `ddi-suite.conf` (oberste Ebene) und `ddi-suite-options.conf` (innerhalb von `options`), ADR 0004 ergänzt. TXT-Steuerzeichen werden als `\DDD` escaped, ADR 0014 ergänzt.
- Kea: Hook-Bibliothek in 3.x nur aus dem einkompilierten Verzeichnis (Dateiname genügt); Control-Socket und Lease-Datei müssen in Keas Socket- bzw. Datenverzeichnis liegen. `reservations-out-of-pool`/`-in-subnet` gesetzt, weil Reservierungen aus den effektiven Pools ausgeschnitten sind.
- Reverse-Zonen: IPv4 an Oktettgrenzen (≥/24 → /24-Zone, /16–/23 → alle /24, /8–/15 → alle /16, kleiner → Warnung), IPv6 an Nibble-Grenzen.
- Neu: Migration 00002 (stabile Kea-Subnet-IDs), Generator-Einstellungen (SOA, NS, TTLs, Rekursion, DHCP-Defaults) mit API und Seite, `GET /config/preview` mit Seite **Konfiguration**.
- Offen aus den Agent-Berichten: Relay-Adressen pro Netz fehlen im IPAM-Modell (Kea-`relay` bleibt leer); IPv6-Reverse-Zonen kleiner als /64 erzeugen sehr spezifische Zonen; `_msdcs`-Zonen für AD sind als eigene Zone nicht erlaubt (Records in der übergeordneten dynamischen Zone).
### 2026-10-12 – M1 Server-Kern (`v0.2.0`)
- Keygen-Instanz geprüft: Edition CE 1.8, Modus singleplayer, Account `9de54794-8e94-4250-83f6-87e3d5686a0b`; Signatur `ed25519` über `(request-target) host date digest` mit dem Account-Schlüssel erfolgreich verifiziert. Account-ID und öffentlicher Schlüssel werden beim Build eingebettet (`Makefile`), nicht per Konfiguration überschreibbar.
- Neue ADRs: 0019 (OpenAPI-Vertragstests statt Server-Codegenerierung – `oapi-codegen` war lokal nicht ausführbar), 0020 (Authentifizierung, Sitzungen, Rollen); 0016 um geprüfte Fakten ergänzt.
- Umgesetzt: Konfiguration (YAML + Env), Geheimnisverschlüsselung (AES-256-GCM, Schlüssel-IDs), Schema mit Datenbank-Constraints, Argon2id/TOTP/Sitzungen/CSRF/Rate-Limit/RBAC, Audit, IPAM-Fachlogik (effektive Pools, Auslastung, Adresskarte, Änderungsvorschau), Lizenzmodul (Provider, Lizenzdateien, Zustandsautomat, Manager), Service-Schicht, REST-API (46 Operationen) mit Vertragstests, OIDC-Flow mit PKCE, Metriken, CLI-Befehle, Weboberfläche, `tools/keygen-setup`, `docs/LICENSING.md`, `docs/API.md`.
- Von Tests gefundene und behobene Fehler: Query-String im Pfad der Keygen-Anfragen; TOTP-Code nach dem Aktivieren erneut verwendbar; Schreibschutz griff erst mit bis zu einer Minute Verzögerung; CSV-Import/Massenänderung hätten nach dem ersten DB-Fehler die Transaktion abgebrochen (jetzt Savepoints); ServeMux-Konflikt `"/api/"` vs. `"GET /"` hätte den Server beim Start abstürzen lassen; Constraint-Fehler ohne spezifischen Code; MAC-Suche mit `-` als Trenner.
- Testabdeckung je Paket (CI): auth 83 %, config 84 %, httpapi 81 %, httpserver 94 %, ipam 91 %, license 77 %, secrets 92 %, store 65 %, cmd 68 %.
- Entscheidung zur Auslegung des Briefs: Pools sind Bereiche; Gateway, `static`, `reserved`, `manual_dhcp` werden aus dem **effektiven** Pool ausgeschnitten, `dynamic` muss im Pool liegen (ARCHITECTURE §3.2).
### 2026-10-12 – M0 Fundament (`v0.1.0`)
- Brief nach `docs/` verschoben, ADRs 0001–0018, ARCHITECTURE, README.
- Git-Host als Gitea 1.27.2 erkannt (API-Probe). Kea-Hooks geprüft: `lease_cmds` und `ha` in allen 2.x frei, `host_cmds`/`subnet_cmds` erst ab 3.0 → komplette Konfiguration neu laden statt inkrementell.
- Repo-Grundgerüst, Lizenzaufteilung mit SPDX-Prüfung und `depguard`, gRPC-Vertrag `ddi.agent.v1`, Makefile, reproduzierbarer Release-Build, Gitea-Workflows (CI + Release mit Upload), Compose mit PostgreSQL 17 / BIND 9.20 / Kea 3.2.1 und Smoke-Test.
- In der CI gefunden und behoben: Hinweisseite der UI lieferte 404; festes Compose-Subnetz kollidierte mit dem Runner-Host; `db.dev.test` durch `*.test` in `.gitignore` nie eingecheckt.
- Release `v0.1.0` erfolgreich über den Workflow veröffentlicht, Download-URL-Schema verifiziert.
+250
View File
@@ -0,0 +1,250 @@
# Betrieb
Dieses Dokument beschreibt den laufenden Betrieb der DDI-Suite: Dienste, Logs, Überwachung, Sicherung, Wiederherstellung, Upgrade, Schlüssel- und Zertifikatspflege, Cluster und Fehlersuche. Installation: [INSTALL.md](INSTALL.md). Voraussetzungen, Ports und Größen: [SYSTEM-REQUIREMENTS.md](SYSTEM-REQUIREMENTS.md). Sicherheitsmodell: [SECURITY.md](SECURITY.md).
Namen und Pfade sind die Standardwerte der zentralen Benennung ([ADR 0018](adr/0018-zentrale-benennung.md)); wer umbenennt, ersetzt `ddi-suite`, `ddi-server`, `ddi-agent-dns` und `ddi-agent-dhcp` entsprechend.
## 1. Dienste und Dateien
| Rolle | systemd-Unit | Benutzer | Konfiguration | Zustand |
| --- | --- | --- | --- | --- |
| Management-Server | `ddi-server.service` | `ddi-suite` | `/etc/ddi-suite/server.yaml`, `/etc/ddi-suite/master.key` | PostgreSQL-Datenbank `ddi-suite`, `/var/lib/ddi-suite/server` |
| DNS-Agent | `ddi-agent-dns.service` | BIND-Benutzer (`bind` bzw. `named`) | `/etc/ddi-suite/agent-dns.yaml` | `/var/lib/ddi-suite/agent-dns` (Identität, Revisionen, Zustand) |
| DHCP-Agent | `ddi-agent-dhcp.service` | Kea-Benutzer (`_kea` bzw. `kea`) | `/etc/ddi-suite/agent-dhcp.yaml` | `/var/lib/ddi-suite/agent-dhcp` |
| BIND | `named.service` bzw. `bind9.service` | Distribution | `named.conf` mit Includes aus `/etc/bind/ddi-suite` bzw. `/etc/named/ddi-suite` | Zonen in `/var/lib/bind/ddi-suite` (inkl. `secondary/`) |
| Kea DHCPv4 | `isc-kea-dhcp4-server.service` (ISC-Pakete auf Debian/Ubuntu) bzw. `kea-dhcp4.service` (ISC-Pakete auf EL) | Kea-Paket | `/etc/kea/kea-dhcp4.conf` (vom Agent geschrieben; das Original liegt als `kea-dhcp4.conf.ddi-suite-orig` daneben) | Leases in `/var/lib/kea/kea-leases4.csv` |
Die Agents schreiben ausschließlich die Dateien, die der Server erzeugt (Include-Dateien, Zonendateien, `kea-dhcp4.conf`, Schlüssel- und HA-Dateien). `named.conf` selbst gehört dem Administrator; der Installer fügt dort nur markierte Blöcke ein (`// BEGIN ddi-suite …` bis `// END ddi-suite …`: `ddi-suite-keys.conf` vor `ddi-suite.conf` auf oberster Ebene, `ddi-suite-options.conf` innerhalb von `options`). Optionen der Distribution, die der Generator selbst setzt (`recursion`, `allow-recursion`, `forwarders`, `allow-transfer`, auf localhost beschränkte `listen-on`/`allow-query`), kommentiert er mit dem Präfix `//ddi-suite-disabled: ` aus; `--uninstall` stellt sie wieder her. Innerhalb der Blöcke nichts von Hand ändern.
Weitere Dateien des Installers:
- Benutzer der Agents per Drop-in `/etc/systemd/system/ddi-agent-<rolle>.service.d/10-user.conf`; Kea erhält das Drop-in `…/<kea-unit>.service.d/10-ddi-suite.conf` mit `KEA_CONTROL_SOCKET_DIR=/run/kea` (die ISC-Pakete sind mit `/var/run/kea` gebaut und vergleichen Pfade wörtlich; die Variable hält den Standardpfad der Generator-Einstellungen gültig). Der DHCP-Agent bekommt dieselbe Variable.
- Installer-Log `/var/log/ddi-suite/install.log`, Zertifikat der Weboberfläche `/etc/ddi-suite/tls/server.{crt,key}` (selbstsigniert, falls keines angegeben wurde; eigenes ablegen und `systemctl restart ddi-server`).
- EL9: Kea aus dem ISC-Repository braucht `log4cplus` aus **EPEL**; EPEL für Kea-Updates aktiviert lassen.
Übliche Befehle:
```sh
systemctl status ddi-server ddi-agent-dns ddi-agent-dhcp
systemctl restart ddi-server # führt beim Start offene Migrationen aus
sudo -u bind ddi-agent-dns status --config /etc/ddi-suite/agent-dns.yaml # Identität, Revisionen, letztes Ergebnis
ddi-server version
```
`ddi-agent-<rolle> status` zeigt Agent-ID, Server, Ablauf des Zertifikats (und ab wann es erneuert wird), CA-Fingerprint, angewendete und letzte funktionierende Revision, die lokal gehaltenen Revisionen (die letzten 10) und eine zuletzt abgelehnte Revision.
## 2. Logs
- Alle Dienste schreiben nach **journald**: `journalctl -u ddi-server -f`, `journalctl -u ddi-agent-dns --since today`.
- Server und Agents loggen standardmäßig **JSON** (eine Zeile je Ereignis), Stufe `info`. Umstellen in der YAML-Datei (`log: {level: debug, format: text}`) oder beim Server per `DDI_LOG_LEVEL`/`DDI_LOG_FORMAT`.
- **Korrelations-ID:** Jede HTTP-Anfrage erhält eine `request_id` (aus einem gültigen `X-Request-ID`-Header übernommen oder neu erzeugt), die in jeder Logzeile dieser Anfrage, im Antwort-Header `X-Request-ID` und im Audit-Eintrag (`request_id`) steht. Fehlermeldungen der Oberfläche nennen sie; damit findet man die Logzeilen: `journalctl -u ddi-server -o cat | grep '"request_id":"<id>"'`.
- Deployments erscheinen im Server-Log und im Agent-Log mit Revisionsnummer, Agent-ID und Deployment-ID.
- Geheimnisse (Passwörter, Tokens, Schlüssel, Lizenzschlüssel) werden nie geloggt, auch nicht im Installer-Log `/var/log/ddi-suite/install.log`.
- BIND und Kea loggen wie von der Distribution eingerichtet (journald bzw. Syslog).
## 3. Überwachung
| Endpunkt | Zweck |
| --- | --- |
| `GET /healthz` | Lebt der Prozess? Immer `{"status":"ok"}`, ohne Abhängigkeiten. Für Neustart-Logik. |
| `GET /readyz` | Kann der Server arbeiten? Prüft die Datenbank (`{"status":"ok","checks":{"database":"ok"}}`, sonst HTTP 503). Für Load-Balancer und Monitoring. |
| `GET /metrics` | Prometheus-Metriken, u. a. `ddi_http_requests_total`, `ddi_http_request_duration_seconds`, `ddi_agents_connected{role}`, `ddi_license_writable`, `ddi_build_info`, dazu Go- und Prozessmetriken. Ohne Anmeldung – per Firewall oder Reverse-Proxy auf das Monitoring beschränken. |
Empfohlene Alarme:
- `/readyz` nicht 200 über mehrere Minuten.
- `ddi_agents_connected` kleiner als die Zahl der Agents (ein Agent ohne Stream arbeitet im Polling weiter, meldet aber ggf. ein Problem).
- `ddi_license_writable == 0` (Lesemodus, siehe §10.6).
- In der Oberfläche bzw. `GET /api/v1/agents`: Health-Zustand ungleich `ok`, angewendete Revision ≠ Soll-Revision über längere Zeit, Deployments mit Ergebnis `rejected`, `rolled_back` oder `failed`.
- Auf den Agent-Hosts zusätzlich BIND und Kea selbst überwachen (DNS-Abfrage, DHCP-Dienststatus); die Datenebene läuft auch ohne Server und Agent weiter.
- Zeitsynchronisation auf allen Hosts (Zertifikate, TSIG und Lizenzprüfung brauchen korrekte Uhren).
## 4. Sicherung
Was gesichert werden muss:
| Was | Wo | Wie oft | Hinweis |
| --- | --- | --- | --- |
| **Datenbank** | PostgreSQL `ddi-suite` | täglich, vor jedem Upgrade | Enthält alle Fachdaten, Revisionen, Audit-Log und – versiegelt – CA, Bundle-Schlüssel, TSIG-Schlüssel, HA-Passwörter, Lizenzzustand. |
| **Master-Key** | `/etc/ddi-suite/master.key` | einmalig und nach jeder Änderung | **Ohne ihn ist die Datenbank nur teilweise brauchbar** (§5). Getrennt von der Datenbanksicherung aufbewahren (z. B. Passwortsafe, Tresor). |
| **Server-Konfiguration** | `/etc/ddi-suite/` (`server.yaml`, TLS-Zertifikat und -Schlüssel) | nach Änderungen | |
| **Agent-Identität** (optional) | `/var/lib/ddi-suite/agent-<rolle>/identity/` | nach dem Enrollment | Erspart nach einer Neuinstallation des Agent-Hosts das erneute Registrieren. Ohne Sicherung: neu registrieren (§8.2). |
| BIND-/Kea-Grunddateien | `named.conf`, `rndc.key`, Kea-Leases | nach Bedarf | Generierte Dateien muss man nicht sichern, der Server liefert sie neu. Leases (`kea-leases4.csv`) sichern, wenn laufende Vergaben eine Neuinstallation überstehen sollen. |
Datenbank sichern (als root auf dem Server, Peer-Authentifizierung):
```sh
sudo -u postgres pg_dump --format=custom --file=/var/backups/ddi-suite-$(date +%F).dump ddi-suite
```
Der Dump enthält keine Klartext-Geheimnisse, aber IPAM-Daten und Passwort-Hashes: verschlüsselt ablegen. Ein Dump im laufenden Betrieb ist konsistent (eine Transaktion); der Server muss dafür nicht angehalten werden.
## 5. Wiederherstellung
1. Server installieren wie in [INSTALL.md](INSTALL.md) beschrieben, **ohne** ihn zu starten (bzw. danach `systemctl stop ddi-server`).
2. Den gesicherten **Master-Key** nach `/etc/ddi-suite/master.key` legen (Eigentümer `ddi-suite`, Modus 0400) und die gesicherte `server.yaml` zurückspielen.
3. Datenbank leeren und zurückspielen:
```sh
sudo -u postgres dropdb ddi-suite && sudo -u postgres createdb -O ddi-suite ddi-suite
sudo -u postgres pg_restore --role=ddi-suite --no-owner --dbname=ddi-suite /var/backups/ddi-suite-<datum>.dump
```
4. `systemctl start ddi-server`. Neuere Server-Versionen migrieren das Schema beim Start.
5. Agents verbinden sich von selbst wieder: CA und Bundle-Schlüssel stammen aus der Sicherung, ihre Zertifikate bleiben gültig. Agents, die seit der Sicherung ihr Zertifikat erneuert haben, kennt der Server nur mit der alten Seriennummer: Sie werden abgewiesen und müssen neu registriert werden (§8.2). Ebenso Agents, die erst nach der Sicherung registriert wurden.
6. Liegt die Sicherung hinter der höchsten Revision, die Agents schon angewendet haben, lehnen sie ältere Nummern ab (Replay-Schutz). Abhilfe: so lange neue Revisionen erzeugen bzw. einen Rollback auf den gewünschten Stand ausführen, bis die Nummer über der höchsten angewendeten liegt; die Oberfläche zeigt die angewendete Revision je Agent.
**Master-Key verloren = Geheimnisse verloren.** Ohne den passenden Master-Key startet der Server nicht. Ein neuer Master-Key macht die versiegelten Werte nicht wieder lesbar. Der einzige Weg ist ein Neuaufbau der Geheimnisse: neuer Master-Key, Einträge in `pki_keys` löschen (der Server erzeugt CA und Bundle-Schlüssel beim Start neu), alle Agents neu registrieren, alle TSIG-Schlüssel neu anlegen bzw. erneuern, DHCP-Cluster neu anlegen (HA-Passwort), OIDC-Secret neu eintragen, TOTP für alle Benutzer neu einrichten, Lizenz neu eintragen. Die IPAM-Daten bleiben erhalten. Bis dahin laufen DNS und DHCP mit ihrer letzten Konfiguration weiter.
## 6. Upgrade
- **Server:** `sudo ./install.sh --upgrade --role server [--version <tag>]` ersetzt Binary und Unit und startet den Dienst neu (Details in [INSTALL.md](INSTALL.md)). Datenbankmigrationen laufen beim Start automatisch (`database.auto_migrate`, Standard an); manuell mit `ddi-server migrate`. Vorher die Datenbank sichern (§4) – Migrationen werden nicht automatisch zurückgenommen; ein Downgrade heißt: alte Version installieren und die Sicherung zurückspielen.
- **Upgrade auf DHCP pro Adresse ([ADR 0025](adr/0025-dhcp-pro-adresse-pruefungen-ui.md), Migration 00006):** Jede freie Adresse eines bisherigen Pools wird ein `dynamic`-Eintrag mit Zufallsnamen `dyn-…` (ohne Netz-, Broadcast- und Gateway-Adresse); bisher namenlose `dynamic`-Einträge erhalten einen Zufallsnamen, kollidierende Lease-Namen wandern nach `lease_hostname`. Große Pools werden zu vielen Zeilen (ein /16-Pool: rund 65.000) und zählen gegen das Adresslimit der Lizenz. Die nächste Revision enthält für alle diese Einträge A- und PTR-Records; die Kea-Pools bleiben gleich. Ältere gespeicherte Revisionen werden beim Rollback unverändert erzeugt (ohne Records für `dynamic`).
- **Agents:** `sudo ./install.sh --upgrade --role agent-dns` bzw. `agent-dhcp` auf jedem Agent-Host. Identität und Revisionen bleiben erhalten. Während des Neustarts läuft BIND bzw. Kea unverändert weiter.
- **Reihenfolge:** erst der Server, dann die Agents. Das gRPC-Protokoll wird nur rückwärtskompatibel erweitert (neue Felder, keine geänderten Bedeutungen); ein Server bedient Agents der Vorversion. Agents, die neuer als der Server sind, werden nicht unterstützt. Die Versionen aller Agents zeigt die Agent-Liste.
- **BIND/Kea:** Pakete der Distribution bzw. des ISC-Repositorys wie gewohnt aktualisieren. Nach einem Wechsel der Kea-Hauptversion (z. B. 2.6 → 3.x) mit einer neuen Revision prüfen, dass `kea-dhcp4 -t` die Konfiguration akzeptiert (der Agent tut das bei jedem Ausrollen).
- **Releases prüfen:** Der Installer prüft `SHA256SUMS` und die Signatur ([RELEASE.md](RELEASE.md)). Von Hand: `sha256sum -c SHA256SUMS` und `scripts/sign-release.sh verify SHA256SUMS SHA256SUMS.sig release-signing.pub.pem`.
- **Pakete:** Wer statt der Tarballs die `.deb`-/`.rpm`-Pakete des Releases nutzt (`apt install ./ddi-server_<version>-1_amd64.deb` bzw. `dnf install ./ddi-server-<version>-1.x86_64.rpm`), aktualisiert damit nur Binary (`/usr/bin`) und Unit; die Pakete legen keine Benutzer oder Konfiguration an und starten nichts. Eingerichtet wird weiterhin mit dem Installer, danach `systemctl restart ddi-server`.
## 7. Aufbewahrung (Retention)
Audit-Log und Revisionen wachsen mit jeder Änderung (Messwerte in [SYSTEM-REQUIREMENTS.md §7](SYSTEM-REQUIREMENTS.md#7-plattenplatz-planung)). Standardmäßig wird nichts gelöscht. In `server.yaml`:
```yaml
retention:
audit_days: 365 # Audit-Einträge älter als 365 Tage löschen (0 = unbegrenzt, Standard)
revisions_keep: 200 # nur die neuesten 200 Revisionen behalten (0 = alle, Standard)
```
Der Server räumt auf, sobald einer der beiden Werte gesetzt ist (auch per Umgebung: `DDI_RETENTION_AUDIT_DAYS`, `DDI_RETENTION_REVISIONS_KEEP`): fünf Minuten nach dem Start und danach alle 24 Stunden, in einer Transaktion, auch im Lizenz-Lesemodus. Gelöscht werden Audit-Einträge, die älter als `audit_days` Tage sind (höchstens 36.500), und Revisionen jenseits der neuesten `revisions_keep` samt ihren Dateien. **Nie gelöscht** werden die neueste Revision, jede Revision, die ein Agent anwendet, als letzte funktionierende kennt oder als Soll-Revision hat, und Revisionen mit offenen (`pending`) Deployments. Deployments selbst bleiben erhalten (sie nennen nur die Nummer). Hat ein Lauf etwas gelöscht, schreibt er den Audit-Eintrag `retention.cleanup` mit den Anzahlen und den gelöschten Revisionsnummern.
Folgen: Ein Rollback auf eine gelöschte Revision und ein Diff gegen eine gelöschte Vorgängerin sind nicht mehr möglich (`not_found`). Wer das Audit-Log länger braucht als die Aufbewahrung, exportiert es vorher (`GET /api/v1/audit/export`, Seite **Audit**). Nach großen Löschläufen gibt PostgreSQL den Platz erst durch `VACUUM` an die Tabelle zurück (Autovacuum erledigt das), an das Dateisystem erst durch `VACUUM FULL`.
## 8. Schlüssel und Zertifikate
### 8.1 Agent-Zertifikate
Agent-Zertifikate laufen 14 Tage und werden automatisch ab der Hälfte der Laufzeit über die bestehende Verbindung erneuert (neuer Schlüssel). Ein Agent, der länger offline war, darf sein abgelaufenes Zertifikat bis 30 Tage nach Ablauf noch für die Erneuerung benutzen. Danach ist ein neues Enrollment nötig.
Das Serverzertifikat des Agent-Ports stellt der Server bei jedem Start neu aus; es muss nicht gepflegt werden. Das Zertifikat der Weboberfläche (`http.tls_cert_file`) erneuert der Administrator selbst (eigenes oder ACME-Zertifikat ablegen, `systemctl restart ddi-server`).
### 8.2 Erneuerung mit verlorener Antwort, Neuregistrierung
Der Server speichert je Agent nur die Seriennummer des **aktuellen** Zertifikats. Geht die Antwort auf eine Erneuerung verloren (Verbindungsabbruch genau in diesem Moment, Absturz vor dem Speichern), gilt beim Server schon das neue Zertifikat, der Agent hat aber nur das alte. Der Agent wird dann abgewiesen (Log: Zertifikat unbekannt bzw. ersetzt) und muss **neu registriert** werden. Ebenso nach mehr als 30 Tagen offline, nach einer Wiederherstellung des Servers aus einer älteren Sicherung (§5) oder nach Verlust des Zustandsverzeichnisses.
Neu registrieren:
1. In der Oberfläche den alten Agent **löschen** (er verlässt dabei seinen Cluster) und ein neues Enrollment-Token für die Rolle erzeugen.
2. Auf dem Agent-Host:
```sh
sudo -u bind ddi-agent-dns enroll --config /etc/ddi-suite/agent-dns.yaml --force \
--server <host:8444> --token <token> --ca-hash sha256:<hex>
systemctl restart ddi-agent-dns
```
(`--force` ersetzt die vorhandene Identität; der Befehl steht ohne `--force` fertig in der Oberfläche.)
3. Den neuen Agent ggf. wieder seinem Cluster zuordnen und eine Revision ausrollen. Die zuletzt angewendete Revisionsnummer bleibt im Zustand erhalten.
Während der ganzen Zeit läuft BIND bzw. Kea mit der letzten Konfiguration weiter.
### 8.3 Master-Key-Rotation
Die Master-Key-Datei kann mehrere Schlüssel enthalten (`<id> <base64>` je Zeile); der erste verschlüsselt, alle entschlüsseln.
```sh
systemctl stop ddi-server
cp -a /etc/ddi-suite/master.key /root/master.key.bak
ddi-server gen-master-key --out /tmp/new.key --id k2 # neue Datei mit einem Schlüssel
# Zeile "k2 …" aus /tmp/new.key als ERSTE Schlüsselzeile in master.key einfügen, alte Zeile darunter lassen
shred -u /tmp/new.key
chown ddi-suite /etc/ddi-suite/master.key && chmod 0400 /etc/ddi-suite/master.key
systemctl start ddi-server
```
Neue und geänderte Geheimnisse werden ab dann mit `k2` versiegelt; vorhandene bleiben mit dem alten Schlüssel lesbar. Ein Werkzeug, das alle vorhandenen Werte neu verschlüsselt, gibt es noch nicht (ADR 0015 sieht es vor). Die alte Zeile darf deshalb **nicht** entfernt werden, solange Werte unter ihr liegen; wer sie loswerden will, erneuert die betroffenen Geheimnisse (TSIG-Schlüssel rotieren, OIDC-Secret neu eintragen, TOTP neu einrichten …). CA und Bundle-Schlüssel lassen sich nicht neu versiegeln, ohne sie neu zu erzeugen (§5). Beide Schlüsselzeilen gehören in die Sicherung.
Der Server startet nur, wenn die Datei weder für die Gruppe noch für andere lesbar ist (Modus 0400 oder 0600).
### 8.4 TSIG-Schlüssel und HA-Passwort
- **TSIG:** Seite **TSIG-Schlüssel** → „Erneuern“ (bzw. `POST /api/v1/tsig-keys/{id}/rotate`), danach eine Revision ausrollen. Das gilt auch für den Transferschlüssel eines DNS-Clusters. Direkt nach dem Ausrollen kann ein Secondary kurz `tsig indicates error` melden, wenn er den neuen Schlüssel vor dem Primary aktiviert; NOTIFY holt den Transfer danach nach (§10.4). Externe Clients mit dem Schlüssel (`nsupdate`, DHCP-Server von Domain Controllern) mit dem neuen Geheimnis versorgen (Admins: „Geheimnis anzeigen“, wird auditiert).
- **HA-Passwort:** wird beim Anlegen eines DHCP-Clusters erzeugt; es gibt keinen Erneuern-Knopf. Zum Erneuern den Cluster neu anlegen (§9). Benutzer und Passwort stehen auf den Kea-Hosts nur in `ddi-suite-ha-user` und `ddi-suite-ha-password` neben der Kea-Konfiguration, weil Kea 3.x Klartext-Zugangsdaten in der Konfiguration ablehnt.
- **`rndc`-Schlüssel:** `rndc-confgen -a`, BIND neu starten; die Agent-Konfiguration verweist per `rndc_args` auf die Schlüsseldatei.
## 9. Cluster-Mitglieder hinzufügen und entfernen
**DNS (Primary + Secondaries):**
- *Secondary hinzufügen:* Agent auf dem neuen Host installieren und registrieren, in der Oberfläche dem Cluster als `secondary` mit der Adresse hinzufügen, über die er den Primary erreicht. Revision ausrollen. Der Primary erhält ihn in `also-notify`, der Secondary holt alle Zonen per AXFR. Firewall: 53/tcp und 53/udp zwischen beiden.
- *Secondary entfernen:* aus dem Cluster entfernen, ausrollen, danach Agent löschen oder deinstallieren. Ein entfernter, noch laufender Agent erhält wieder die Konfiguration der Standardgruppe (alle Zonen ohne Cluster als Primary) – deshalb erst entfernen, dann den Dienst stoppen.
- *Primary wechseln:* neuen Primary als `primary` eintragen und den alten im selben Schritt zum `secondary` machen oder entfernen (es gibt genau einen Primary), ausrollen. Dynamische Zonen: deren Daten liegen nur beim bisherigen Primary (Journal); vor dem Wechsel per AXFR auf dem neuen Primary bereitstellen oder nach dem Wechsel neu befüllen lassen (Domain Controller registrieren ihre Records erneut).
**DHCP (Kea-HA):**
- *Zweiten Server hinzufügen:* Agent registrieren, dem Cluster als `secondary` mit der Adresse des HA-Listeners hinzufügen (Port `ha_port`, Standard 8001), ausrollen. Beide Peers synchronisieren die Leases und gehen in `hot-standby` (bzw. `load-balancing`). Voraussetzung Kea ≥ 3.0 auf beiden.
- *Server entfernen:* aus dem Cluster entfernen und ausrollen; der verbleibende arbeitet als Einzelserver. Dann den Agent löschen und Kea stoppen.
- *HA-Passwort erneuern:* Mitglieder entfernen, Cluster löschen, neu anlegen, Netze bzw. Standardcluster neu zuordnen, ausrollen.
Clusteränderungen wirken wie alle Änderungen erst mit der nächsten Revision und erscheinen vorher unter „Änderungen warten auf Ausrollen“.
## 10. Fehlersuche
### 10.1 Agent lehnt eine Revision ab
Deployment-Ergebnis `rejected` (Prüfung gescheitert, nichts aktiviert) oder `rolled_back` (aktiviert, Health-Check gescheitert, vorige Revision wiederhergestellt). Die Meldung steht beim Deployment in der Oberfläche und im Agent-Log.
- Häufige Ursachen: Pfade der Generator-Einstellungen passen nicht zur Agent-Konfiguration (`zone_dir`, `control_socket` – der Agent lehnt dann ausdrücklich ab), `named.conf` bindet die Includes nicht oder in falscher Reihenfolge ein, Rechte auf `zone_dir`/`secondary/`, fehlender Kea-Hook (Kea 3.x lädt Hooks nur aus dem eingebauten Verzeichnis), BIND-Optionen der Distribution kollidieren mit generierten Optionen (`recursion`, `allow-transfer` …).
- Eine abgelehnte Revision wird **nicht** automatisch erneut versucht. Nach der Korrektur eine neue Revision erzeugen oder dieselbe Revision per „Jetzt ausrollen“ erneut pushen.
- Prüfen von Hand auf dem Agent-Host: `named-checkconf -z /etc/bind/named.conf`, `kea-dhcp4 -t /etc/kea/kea-dhcp4.conf`.
### 10.2 Rollback
- **Über die Oberfläche:** Revisionen → ältere Revision → „Zurückrollen“ erzeugt eine **neue** Revision mit dem Inhalt der alten (höhere Nummer, also auch höhere SOA-Seriennummer). `auto_pull`-Agents übernehmen sie sofort, `manual`-Agents nach „Jetzt ausrollen“.
- **Automatisch im Agent:** Scheitert der Health-Check, stellt der Agent die letzte funktionierende Revision wieder her. Auf einem DNS-Primary sinkt dabei die Seriennummer; Secondaries behalten die höhere, bis die nächste Revision kommt. Die nächste Revision aus der Oberfläche behebt das.
- Revisionen ältere als M4 (ohne gespeicherte Generator-Eingabe) und Revisionen, deren Geheimnisse fehlen, lassen sich nicht zurückrollen (`rollback_unavailable`).
### 10.3 Kea übernimmt die neue Konfiguration nicht (`config-reload`)
Kea liest bei `config-reload` die Datei, **mit der es gestartet wurde**. Zeigt `dhcp.config_file` des Agents auf eine andere Datei, meldet der Reload Erfolg, Kea arbeitet aber mit der alten Konfiguration weiter; nur der Dateivergleich im Health-Check des Agents fällt auf („Konfiguration weicht ab“). Abhilfe: `config_file` des Agents auf die Datei setzen, die die Kea-Unit mit `-c` übergibt (Standard `/etc/kea/kea-dhcp4.conf`), und Kea neu starten.
Verwandte Punkte: Der Control-Socket muss in Keas Socket-Verzeichnis liegen (`/run/kea`, Modus 0750, Umgebungsvariable `KEA_CONTROL_SOCKET_DIR`), die Lease-Datei in Keas Datenverzeichnis; die Generator-Einstellungen (`control_socket`, `lease_file`, `ha_password_file`) müssen zu den Pfaden auf den Hosts passen.
### 10.4 Secondary überträgt keine Zonen
- `journalctl -u named` auf dem Secondary: `tsig indicates error` bzw. `BADKEY`/`BADSIG` heißt, Primary und Secondary haben unterschiedliche Schlüssel. Direkt nach einer Schlüsseländerung normal (der Secondary war schneller); NOTIFY löst den Transfer wieder aus. Bleibt es dabei: prüfen, dass beide Agents dieselbe Revision anwenden (Oberfläche), und `rndc retransfer <zone>` auf dem Secondary.
- `BADTIME`: Uhren gehen auseinander (TSIG erlaubt 5 Minuten) → NTP prüfen.
- `connection refused`/Timeout: 53/tcp vom Secondary zum Primary freigeben; die Adressen der Mitglieder im Cluster müssen die sein, unter denen sich die Server gegenseitig erreichen.
- `refused` beim Transfer: Der Primary kennt den Secondary nicht (Cluster nicht ausgerollt) oder der Schlüssel fehlt in `allow-transfer`.
- Eine noch nicht übertragene Zone zeigt der Health-Check als „eingeschränkt“, nicht als Fehler.
### 10.5 Agent nicht verbunden
- `ddi-agent-<rolle> status`: Ist das Zertifikat abgelaufen? Stimmt die Serveradresse?
- Netz: Agent → Server 8444/tcp ausgehend. Ein Proxy, der TLS aufbricht, verhindert mTLS.
- Log „certificate … unknown/replaced/revoked“: neu registrieren (§8.2).
- Der Agent fällt bei Störungen des Streams auf Polling zurück; „verbunden“ zeigt nur den Stream.
### 10.6 Lizenz: Lesemodus
Im Zustand `read_only` (Testzeitraum abgelaufen, Lizenz abgelaufen oder gesperrt, Keygen länger als die Karenzzeit nicht erreichbar) lehnt der Server alle Änderungen und das Ausrollen ab; Lesen, Export und die Lizenzverwaltung bleiben möglich, Agents sperren und löschen ebenfalls. **DNS und DHCP laufen unverändert weiter**, Agents holen weiterhin ihre Soll-Revision. Abhilfe: Lizenzschlüssel eintragen bzw. erneuern (Seite **Lizenz** oder auf dem Server `ddi-server license set-key <schluessel.txt`: liest den Schlüssel von stdin, gibt Status, Ablauf und Grenzen aus, nie den Schlüssel; Exit-Code 0 aktiviert, 2 Eingabe leer, zu lang oder mehrzeilig, 3 Schlüssel abgelehnt, 4 Lizenzserver nicht erreichbar oder Antwort nicht vertrauenswürdig, 1 sonstiger Fehler; bei 3 und 4 wird nichts gespeichert), Erreichbarkeit von `https://license.beugsiud.de` (443/tcp ausgehend) prüfen, in isolierten Netzen eine Offline-Lizenzdatei importieren. Einzelheiten: [LICENSING.md](LICENSING.md).
### 10.7 Prüfung „Ping/DNS“ meldet „nicht verfügbar“ oder Fehler
Die Prüfung einzelner Einträge (`POST /addresses/check`, ADR 0025) läuft vom Server aus:
- **Ping `unavailable`:** Der Server versucht zuerst unprivilegierte ICMP-Sockets (`net.ipv4.ping_group_range`), dann einen Raw-Socket mit `CAP_NET_RAW` aus seiner systemd-Unit. „Nicht verfügbar“ heißt, beides ging nicht: Unit älter als v0.8.1 (`install.sh --upgrade`), die Capability in einem Container nicht vorhanden (z. B. LXC ohne `CAP_NET_RAW`) oder der Server läuft nicht unter Linux. Abhilfe ohne Capability: `sysctl -w net.ipv4.ping_group_range="0 2147483647"` und dauerhaft in `/etc/sysctl.d/`. „Nicht verfügbar“ sagt nichts über den Host aus.
- **Ping `unreachable`:** keine Antwort innerhalb von zwei Versuchen à 1 s – Host aus oder ICMP auf dem Weg (Firewall) gesperrt.
- **DNS `error` oder `missing`:** Gefragt werden die Mitglieder des DNS-Clusters der Zone (Primary zuerst), sonst die Nameserver-Adressen aus den Generator-Einstellungen, bei Zonen ohne Cluster außerdem die DNS-Agents unter der Adresse ihrer letzten Verbindung, erst dann der Resolver des Servers („system“). Der Server braucht 53/udp und 53/tcp zu ihnen. Die Hinweise unter dem Ergebnis nennen die häufigsten Gründe für „fehlt“: nur der Resolver des Servers wurde gefragt, oder die Änderung ist **noch nicht ausgerollt**. In **dynamischen Zonen** (z. B. einer Active-Directory-Domäne) trägt der DNS-Agent die Einträge der Adresstabelle beim Ausrollen per `nsupdate` ein ([ADR 0026](adr/0026-eintraege-in-dynamischen-zonen.md)); fehlt dort etwas, zuerst den Bericht des Agents zur Revision ansehen. `mismatch` heißt, der DNS-Server antwortet anders als die Adresstabelle.
### 10.8 Weitere Hinweise
- Server startet nicht, „master key file … must not be accessible by group or others“: `chmod 0400 /etc/ddi-suite/master.key`.
- Server startet nicht, „decryption failed“: falscher Master-Key zur Datenbank (§5).
- CSV-Import: rund 1.100 Zeilen pro Sekunde (gemessen, [SYSTEM-REQUIREMENTS.md §4](SYSTEM-REQUIREMENTS.md#4-messungen)); eine Datei an der Grenze von 50.000 Zeilen braucht knapp eine Minute. Die Anfrage hat dafür eine eigene Frist von 10 Minuten (statt der allgemeinen 60 s).
- Login gesperrt (HTTP 429): Ratenbegrenzung, nach einer Minute wieder ein Versuch. Ein vergessenes Admin-Passwort setzt ein anderer Admin zurück (Benutzer → Passwort zurücksetzen). `ddi-server bootstrap-admin` funktioniert nur, solange es noch keinen Benutzer gibt; deshalb immer mindestens zwei Administratoren oder OIDC-Zugang einrichten.
### 10.9 Einträge in dynamischen Zonen (ADR 0026)
Die von DDI verwalteten Einträge einer dynamischen Zone (Adresstabelle und Records der Zone) trägt der DNS-Agent auf dem Primary nach dem Neuladen von BIND per dynamischem Update ein: `nsupdate -k` mit dem Systemschlüssel `ddi-agent-update` aus der Revision, an `dns_address` der Agent-Konfiguration. Er löscht ausschließlich Einträge, die die vorige Revision verwaltet hat und die neue nicht mehr enthält; Einträge der Domain Controller, anderer Clients oder von Hand bleiben unberührt. Secondaries erhalten die Änderungen per NOTIFY/IXFR.
- `nsupdate` muss auf dem Primary vorhanden sein (Debian/Ubuntu: `bind9-dnsutils`, EL9: `bind-utils`; der Installer richtet beides ein). Programmname oder Pfad: Option `dns.nsupdate` in `agent-dns.yaml` (Standard `nsupdate`).
- Agent-Log: „managed records of a dynamic zone updated“ mit `zone`, `records` (Anzahl der verwalteten Einträge) und `deleted`.
- Scheitert ein Update, scheitert das Aktivieren, und der Agent rollt auf die vorige Revision zurück, die ihren eigenen Stand wieder einträgt. `REFUSED` heißt meist, dass der Schlüssel in der Update-ACL der Zone fehlt; `NOTAUTH` mit `BADSIG`/`BADKEY` im BIND-Log, dass BIND einen anderen Schlüssel kennt. Direkt nach dem Anlegen einer Zone wartet der Agent bis zu 10 s, bis BIND sie geladen hat.
- Einen verwalteten Eintrag, den jemand von Hand ändert oder löscht, setzt die nächste Revision auf den Stand von DDI zurück.
+77
View File
@@ -0,0 +1,77 @@
# Release
Wie ein Release gebaut, signiert und veröffentlicht wird ([ADR 0003](adr/0003-git-host-gitea-actions.md), [ADR 0024](adr/0024-auslieferung-installer-m6.md)).
## Ablauf
1. Meilenstein fertig, CI auf `main` grün.
2. Tag setzen und pushen: `git tag -a v0.7.0 -m "M6 Auslieferung" && git push origin v0.7.0`.
3. Der Workflow `.gitea/workflows/release.yml` baut mit `make release PACKAGES=1 VERSION=<tag>`:
- Tarballs `<binary>_<tag>_linux_<arch>.tar.gz` für `amd64` und `arm64`,
- Pakete `<binary>_<version>-1_<arch>.deb` und `<binary>-<version>-1.<arch>.rpm` (nfpm, siehe unten),
- `SHA256SUMS` über Tarballs und Pakete,
- `SHA256SUMS.sig`, falls das Secret `RELEASE_SIGNING_KEY` gesetzt ist.
4. `scripts/release-upload.sh` legt das Gitea-Release an (Tags mit `-` werden als Vorabversion markiert) und lädt alle Dateien aus `dist/` hoch; vorhandene Dateien gleichen Namens werden ersetzt, der Workflow kann also erneut laufen.
Lokal: `make release` (nur Tarballs), `make release PACKAGES=1` bzw. nach `make release` ein `make packages` (braucht `nfpm`), `make sign-release RELEASE_SIGNING_KEY_FILE=<pfad>`.
## Pakete (`.deb`, `.rpm`)
`scripts/build-packages.sh` nimmt die Binaries aus den Tarballs in `dist/` (dieselben Dateien, keine zweite Übersetzung) und baut je Binary und Architektur ein `.deb` und ein `.rpm` mit [nfpm](https://nfpm.goreleaser.com). nfpm wird in der CI von `scripts/ci/install-tools.sh` in einer festen Version mit Prüfsumme installiert (`NFPM_VERSION`); lokal genügt ein `nfpm` im `PATH` oder `make packages NFPM=/pfad/zu/nfpm`.
| Paket | Inhalt |
| --- | --- |
| Name = Binary-Name (`BIN_SERVER`, `BIN_AGENT_DNS`, `BIN_AGENT_DHCP` aus dem Makefile) | `/usr/bin/<binary>` (0755) |
| | `/usr/lib/systemd/system/<binary>.service` (aus `deploy/systemd/*.service.in`, gerendert mit `/usr/bin`) |
| | `/usr/share/doc/<binary>/LICENSE` (Server proprietär, Agents Apache-2.0) |
- Paketversion: Tag ohne `v` (`v0.7.0` → `0.7.0`, Release `1`); Builds ohne Versions-Tag erhalten `0.0.0-dev.<beschreibung>`.
- Die Pakete enthalten **keine** Konfiguration, legen keine Benutzer an und starten nichts. Die Paket-Skripte rufen nur `systemctl daemon-reload` auf. Eingerichtet wird mit `scripts/install.sh`.
- Konfiguration: Vorlage `deploy/nfpm/package.yaml.in` mit Platzhaltern (ADR 0018). `PKG_MAINTAINER`, `PKG_VENDOR` und `PKG_HOMEPAGE` lassen sich per Umgebung überschreiben.
## Signatur
`SHA256SUMS.sig` ist eine Ed25519-Signatur (64 Byte, binär) über `SHA256SUMS`, erzeugt mit `openssl pkeyutl -sign -rawin`. Weil `SHA256SUMS` alle Artefakte abdeckt, genügt eine Signatur. Der Installer prüft sie mit dem öffentlichen Schlüssel aus seinem Variablenblock.
### Schlüsselpaar erzeugen (einmalig, offline)
```sh
umask 077
openssl genpkey -algorithm ed25519 -out release-signing.pem # privat
openssl pkey -in release-signing.pem -pubout -out release-signing.pub.pem
```
Der private Schlüssel gehört in einen Passwortmanager bzw. auf einen Offline-Datenträger, nicht ins Repository. Wer ihn hat, kann Releases signieren, die jeder Installer annimmt.
### Gitea-Secret setzen
Repository → Einstellungen → Actions → Secrets → **Secret hinzufügen**: Name `RELEASE_SIGNING_KEY`, Wert = kompletter Inhalt von `release-signing.pem` (einschließlich der Zeilen `-----BEGIN PRIVATE KEY-----`/`-----END PRIVATE KEY-----`).
Der Workflow schreibt den Schlüssel nur für die Dauer des Signierens in eine Datei mit Modus 0600 (`umask 077`) und löscht sie danach; er wird nie ausgegeben. Ohne Secret entsteht kein `SHA256SUMS.sig`, der Workflow warnt.
### Öffentlichen Schlüssel in den Installer eintragen
In `scripts/install.sh` im Variablenblock (zwischen `# --- names (ADR 0018) ---` und `# --- end names ---`) den Inhalt von `release-signing.pub.pem` als `RELEASE_PUBLIC_KEY_PEM` eintragen:
```sh
RELEASE_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA…
-----END PUBLIC KEY-----"
```
Ist der Schlüssel gesetzt, bricht der Installer bei fehlender oder ungültiger Signatur ab; ohne Schlüssel prüft er nur die Prüfsummen und weist darauf hin. Der Release-Workflow prüft die frische Signatur mit dem Schlüssel aus `scripts/install.sh`, damit ein falsch eingetragener Schlüssel schon beim Release auffällt.
### Prüfen von Hand
```sh
scripts/sign-release.sh verify SHA256SUMS SHA256SUMS.sig release-signing.pub.pem
# entspricht
openssl pkeyutl -verify -pubin -inkey release-signing.pub.pem -rawin -in SHA256SUMS -sigfile SHA256SUMS.sig
sha256sum -c SHA256SUMS
```
Die CI (Job „Release build (dry run)“) erzeugt bei jedem Lauf einen Wegwerf-Schlüssel und prüft Signieren, Verifizieren sowie die Ablehnung einer veränderten Datei und eines fremden Schlüssels (`scripts/sign-release.sh selftest`).
### Schlüsselwechsel
Neues Schlüsselpaar erzeugen, Secret ersetzen, öffentlichen Schlüssel in `scripts/install.sh` tauschen, neues Release taggen. Bereits verteilte Installer-Skripte kennen nur den alten Schlüssel und lehnen Releases mit dem neuen ab; sie müssen durch das Skript des neuen Releases ersetzt werden.
+154
View File
@@ -0,0 +1,154 @@
# Sicherheit
Dieses Dokument beschreibt das Bedrohungsmodell der DDI-Suite, die Verwaltung der Schlüssel, ihre Rotation, Härtungshinweise und – ebenso wichtig – was das System **nicht** schützt. Grundlage sind Brief §8 und §9 sowie die ADRs [0007](adr/0007-server-agent-grpc-mtls.md), [0008](adr/0008-interne-ca-enrollment.md), [0009](adr/0009-signierte-config-bundles.md), [0015](adr/0015-geheimnisse-verschluesselt.md), [0016](adr/0016-lizenzierung-keygen.md), [0020](adr/0020-authentifizierung-sitzungen.md), [0023](adr/0023-cluster-und-zonen-m5.md) und [0024](adr/0024-auslieferung-installer-m6.md). Betrieb und Abläufe stehen in [OPERATIONS.md](OPERATIONS.md), Voraussetzungen und Ports in [SYSTEM-REQUIREMENTS.md](SYSTEM-REQUIREMENTS.md).
Namen und Pfade (`ddi-suite`, `/etc/ddi-suite`, `ddi-server` …) sind die Standardwerte der zentralen Benennung ([ADR 0018](adr/0018-zentrale-benennung.md)).
## 1. Schutzgüter
| Schutzgut | Warum schützenswert | Wo es liegt |
| --- | --- | --- |
| **Datenebene** (DNS-Antworten, DHCP-Vergabe) | Wer sie verfälscht, lenkt Clients um oder legt das Netz lahm. | BIND und Kea auf den Agent-Hosts |
| **Soll-Konfiguration** (Revisionen) | Wer sie fälscht, steuert alle DNS- und DHCP-Server. | PostgreSQL, signierte Bundles |
| **Master-Key** | Entschlüsselt alle Geheimnisse in der Datenbank. | `/etc/ddi-suite/master.key` (nur Server) |
| **Agent-CA-Schlüssel** | Stellt Agent- und Serverzertifikate aus. | Datenbank, mit dem Master-Key versiegelt |
| **Bundle-Signaturschlüssel** (Ed25519) | Unterschreibt jede Konfiguration, der ein Agent vertraut. | Datenbank, versiegelt |
| **TSIG-Schlüssel, HA-Zugangsdaten** | Erlauben Zonentransfers, dynamische Updates, Steuerung des Kea-Partners. | Datenbank (versiegelt), Bundles, Dateien auf den Agent-Hosts |
| **Benutzerkonten, Sitzungen, TOTP-, OIDC-Secret** | Zugang zur Verwaltung. | Datenbank (Passwörter als Argon2id-Hash, Sitzungen als SHA-256-Hash, TOTP- und OIDC-Secret versiegelt) |
| **Agent-Identität** (privater Schlüssel, Zertifikat) | Erlaubt, sich gegenüber dem Server als dieser Agent auszugeben. | `/var/lib/ddi-suite/agent-<rolle>/identity/` (Modus 0700) |
| **IPAM-Daten, Audit-Log** | Inventar des Netzes, Nachvollziehbarkeit. | Datenbank |
| **Lizenzstatus** | Wirtschaftliches Interesse des Herstellers (Soft-Control, §9). | Datenbank (versiegelt) |
## 2. Vertrauensgrenzen
```
Browser ──HTTPS 8443──▶ Management-Server ──Unix-Socket──▶ PostgreSQL
│ ▲
│ └── HTTPS 443 ausgehend ──▶ Keygen (Lizenz)
│
gRPC/mTLS 8444 ◀─┘ (Agent verbindet sich ausgehend)
│
┌──────────┴───────────┐
▼ ▼
DNS-Agent ──rndc 127.0.0.1──▶ BIND DHCP-Agent ──Unix-Socket──▶ Kea
│ 53/tcp TSIG ▲ │ HTTP+Basic-Auth (HA, ohne TLS)
▼ │ ▼
Secondary ────────┘ Kea-Partner
```
| Grenze | Angreifer könnte … | Schutz |
| --- | --- | --- |
| **Browser ↔ Server** | Sitzungen stehlen, Anfragen fälschen (CSRF), Skripte einschleusen (XSS), Passwörter raten. | TLS ≥ 1.2, HSTS, Content-Security-Policy, `X-Frame-Options: DENY`; Sitzungscookie `Secure`, `HttpOnly`, `SameSite=Lax`; CSRF-Token pro Sitzung plus `Origin`/`Sec-Fetch-Site`-Prüfung; Login-Ratenbegrenzung pro IP und Benutzer; Argon2id; optional TOTP; OIDC mit PKCE, `state`, `nonce`; RBAC pro Route (Viewer, Operator, Admin). |
| **Server ↔ Agent** | Sich als Server ausgeben, als Agent ausgeben, Konfiguration fälschen oder alte wieder einspielen. | TLS 1.3 mit gegenseitigen Zertifikaten der internen CA. Enrollment nur mit Einmal-Token (24 h, in der DB nur als Hash) und vorab bekanntem CA-Fingerprint (`--ca-hash`). Agent-Zertifikate 14 Tage, der Server akzeptiert nur die **aktuelle** Seriennummer je Agent und prüft die Sperre bei jedem Aufruf. Bundles sind mit einem **eigenen** Ed25519-Schlüssel signiert (nicht der CA); der Agent wendet nur Revisionen an, die größer als die zuletzt angewendete sind. |
| **Agent ↔ Daemon** | Über den Steuerkanal BIND/Kea umkonfigurieren. | `rndc` nur auf 127.0.0.1 mit Schlüssel; Kea-Control-Socket als Unix-Socket in einem Verzeichnis mit Modus 0750. Der Agent läuft als Benutzer des Dienstes (`bind`/`named` bzw. `_kea`/`kea`), nicht als root. Jede Konfiguration wird vor dem Aktivieren geprüft (`named-checkconf -z`, `named-checkzone`, `kea-dhcp4 -t`). |
| **Primary ↔ Secondary (DNS)** | Zonen abziehen, gefälschte Zonen unterschieben. | `allow-transfer` nur mit dem TSIG-Transferschlüssel des Clusters (`hmac-sha256`), NOTIFY nur an die Secondaries, Secondaries holen nur mit diesem Schlüssel. |
| **Kea ↔ Kea (HA)** | HA-Befehle senden, Leases manipulieren. | HTTP mit Basic-Auth; Benutzer und Passwort je Cluster zufällig erzeugt, nur in Dateien (Modus 0640). **Ohne TLS** – siehe Abschnitt 6. |
| **Server ↔ Keygen** | Lizenzantworten fälschen. | HTTPS, jede Antwort mit Ed25519 signiert (Account-Schlüssel beim Build eingebettet), Digest, Request-Target, Datum ≤ 5 min, `nonce` bei der Validierung. |
| **Server ↔ PostgreSQL** | Datenbank lesen oder ändern. | Installer: Peer-Authentifizierung über den Unix-Socket, kein Passwort, kein TCP nötig. Geheimnisse sind zusätzlich mit dem Master-Key versiegelt (AES-256-GCM, Zeilenkontext als Associated Data). |
| **Installer ↔ Release-Server** | Manipulierte Binaries ausliefern. | `SHA256SUMS` und, falls der öffentliche Schlüssel im Installer gesetzt ist, die Ed25519-Signatur `SHA256SUMS.sig` (Abschnitt 7). |
## 3. Bedrohungen und ihre Folgen
| Ereignis | Folge | Begrenzung |
| --- | --- | --- |
| Server-Host kompromittiert (root oder Dienstbenutzer) | **Vollständige Kontrolle:** Master-Key und Datenbank geben alle Geheimnisse frei, der Bundle-Schlüssel signiert beliebige Konfigurationen für alle Agents. | Server isoliert betreiben, Zugang streng begrenzen (Abschnitt 5). Agents prüfen jede Konfiguration syntaktisch, fremde Dateipfade sind im Bundle-Format ausgeschlossen. |
| Datenbank oder Backup gestohlen (ohne Master-Key) | IPAM-Daten und Audit-Log lesbar, Geheimnisse nicht; Passwort-Hashes (Argon2id) offline angreifbar; Sitzungen nicht übernehmbar (nur Hashes). | Backups verschlüsselt und getrennt vom Master-Key aufbewahren. |
| Master-Key gestohlen (ohne Datenbank) | Für sich wertlos. | Getrennte Aufbewahrung. |
| Agent-Host kompromittiert | Der Angreifer kontrolliert diesen DNS- bzw. DHCP-Dienst, kennt dessen TSIG- bzw. HA-Geheimnisse und kann sich als dieser Agent beim Server melden (Status, Leases). Andere Agents und der Server sind nicht betroffen; gefälschte Lease-Meldungen erzeugen höchstens `dynamic`-Einträge. | Agent in der Oberfläche **sperren** (wirkt sofort), Transferschlüssel des Clusters und betroffene TSIG-Schlüssel rotieren. |
| Enrollment-Token abgefangen | Wer es vor dem rechtmäßigen Agent nutzt, registriert einen eigenen Agent dieser Rolle und erhält deren Konfiguration samt Geheimnissen. | Kurze Laufzeit, einmalig; nur über vertrauliche Kanäle weitergeben; unbekannte Agents in der Oberfläche prüfen und sperren. |
| Transportweg Agent ↔ Server manipuliert | Ohne CA-Schlüssel kein gültiges Serverzertifikat, ohne Bundle-Schlüssel keine gültige Konfiguration. | – |
| Administratorkonto übernommen | Volle Verwaltung, Ausrollen beliebiger (gültiger) Konfiguration. | TOTP oder OIDC mit MFA, Audit-Log prüfen, Sitzungen per Passwortwechsel beenden. |
## 4. Schlüsselverwaltung
| Schlüssel | Erzeugt von | Liegt | Schutz | Rotation |
| --- | --- | --- | --- | --- |
| **Master-Key** (AES-256, Datei mit Schlüssel-ID) | `ddi-server gen-master-key` (Installer) | `/etc/ddi-suite/master.key` | Der Server startet nur, wenn die Datei **weder für Gruppe noch für andere** lesbar ist (Modus 0400 oder 0600, Eigentümer Dienstbenutzer). Nie in der Datenbank. | Teilweise, siehe 4.1 |
| **Agent-CA** (ECDSA P-256, 10 Jahre) | Server beim ersten Start | Datenbank, versiegelt | Master-Key | Nicht umgesetzt (ADR 0008/0021); bei Kompromittierung neue Installation der PKI, siehe 4.2 |
| **Serverzertifikat Agent-Port** (1 Jahr) | Server bei **jedem Start** aus der CA | nur im Speicher | – | Automatisch bei jedem Neustart |
| **Agent-Zertifikate** (ECDSA P-256, 14 Tage) | Agent erzeugt Schlüssel und CSR, Server signiert | `/var/lib/ddi-suite/agent-<rolle>/identity/` | Modus 0700, Dienstbenutzer | Automatisch ab halber Laufzeit, neuer Schlüssel |
| **Bundle-Signaturschlüssel** (Ed25519) | Server beim ersten Start | Datenbank, versiegelt; öffentlicher Teil bei jedem Agent | Master-Key | Vorgesehen (neuer Schlüssel vom alten signiert, ADR 0009), **nicht umgesetzt** |
| **TSIG-Schlüssel** (`hmac-sha256/384/512`) | Server (Zufall, Länge = Hash-Länge) | Datenbank versiegelt, im Bundle, auf den DNS-Hosts in `ddi-suite-keys.conf` (0640) | Master-Key; API zeigt sie nur Admins über `GET /tsig-keys/{id}/secret` (auditiert) | `POST /tsig-keys/{id}/rotate`, siehe 4.3 |
| **HA-Zugangsdaten** (Benutzer + Passwort je DHCP-Cluster) | Server beim Anlegen des Clusters | Datenbank versiegelt, im Bundle, auf den Kea-Hosts in `ddi-suite-ha-user`/`ddi-suite-ha-password` (0640) | Master-Key; nie in der API | Kein eigener Befehl, siehe 4.3 |
| **`rndc`-Schlüssel** | Paket bzw. Installer (`rndc-confgen`) | `/etc/bind/rndc.key` | Distribution (0640 root:bind) | `rndc-confgen -a` erneut, BIND neu starten |
| **Sitzungen** | Server (256 Bit Zufall) | Cookie; in der DB nur SHA-256 | – | Laufzeit 12 h, Leerlauf 1 h; Logout und Passwortwechsel beenden sie |
| **TOTP-Secret, OIDC-Client-Secret** | Benutzer bzw. Administrator | Datenbank, versiegelt | Master-Key | TOTP ab- und wieder einschalten; OIDC-Secret beim Identity-Provider erneuern und in der Oberfläche eintragen |
| **Enrollment-Token** | Administrator in der Oberfläche | in der DB nur SHA-256 | einmalig, 24 h | Unbenutzte Tokens löschen |
| **Release-Signaturschlüssel** (Ed25519) | Hersteller | Secret `RELEASE_SIGNING_KEY` der CI | nur der Release-Workflow; privater Schlüssel offline | Neues Paar, Secret ersetzen, öffentlichen Schlüssel in `scripts/install.sh` tauschen; ältere Installer lehnen Releases mit dem neuen Schlüssel ab ([RELEASE.md](RELEASE.md#schlüsselwechsel)) |
### 4.1 Master-Key
Die Datei enthält eine Zeile `<schlüssel-id> <base64>` je Schlüssel. Der **erste** Schlüssel verschlüsselt, alle Schlüssel entschlüsseln; jeder Chiffretext trägt die ID seines Schlüssels.
Umgesetzt ist damit: einen neuen Schlüssel als erste Zeile eintragen und den Server neu starten. Neue und geänderte Geheimnisse werden ab dann mit dem neuen Schlüssel versiegelt, bestehende bleiben mit dem alten lesbar. **Nicht umgesetzt** ist das Neuverschlüsseln aller bestehenden Werte; der alte Schlüssel darf deshalb nicht entfernt werden. Ein gestohlener alter Master-Key bleibt damit gefährlich, solange Werte unter ihm liegen. Ablauf und Grenzen: [OPERATIONS.md §8](OPERATIONS.md#8-schlüssel-und-zertifikate).
**Master-Key verloren = Geheimnisse verloren.** Ohne ihn lassen sich CA, Bundle-Schlüssel, TSIG-Schlüssel, HA-Passwörter, TOTP- und OIDC-Secret nicht mehr entschlüsseln; der Server startet mit falschem Schlüssel nicht. Er gehört in jedes Backup – getrennt von der Datenbanksicherung aufbewahrt.
### 4.2 Agent-CA und Bundle-Schlüssel
Beide werden beim ersten Start erzeugt und haben keine Rotationsfunktion. Ist einer von beiden kompromittiert (praktisch nur zusammen mit Datenbank **und** Master-Key, also mit dem Server-Host), bleibt nur der Neuaufbau: neue Server-Installation mit neuer Datenbank oder gezieltes Entfernen der Schlüssel in `pki_keys` (Server erzeugt neue beim Start), anschließend alle Agents neu registrieren. Die Datenebene läuft während dessen mit der letzten Konfiguration weiter.
### 4.3 TSIG und HA
- **TSIG-Schlüssel** (auch der Transferschlüssel eines Clusters) werden mit „Erneuern“ bzw. `POST /tsig-keys/{id}/rotate` neu erzeugt; wirksam mit der nächsten Revision. Primary und Secondaries aktivieren sie nicht exakt gleichzeitig, kurz kann ein Secondary `tsig indicates error` melden, NOTIFY holt den Transfer danach nach. Clients, die per `nsupdate` mit dem Schlüssel aktualisieren (z. B. DHCP-Server von Domain Controllern), müssen das neue Geheimnis erhalten.
- **HA-Zugangsdaten** werden beim Anlegen des DHCP-Clusters erzeugt; eine Erneuerung per Knopf gibt es nicht. Wer sie erneuern muss, legt den Cluster neu an (Mitglieder entfernen, Cluster löschen, neu anlegen, Netze zuordnen, ausrollen). Kea arbeitet in dieser Zeit als Einzelserver weiter.
## 5. Härtung
### Systemdienste (ADR 0024)
- **Eigene Benutzer:** Server als `ddi-suite`; DNS-Agent als BIND-Benutzer (`bind`/`named`), DHCP-Agent als Kea-Benutzer (`_kea`/`kea`). Ein kompromittierter Agent hat damit die Rechte des Dienstes, den er ohnehin konfiguriert – nicht mehr. Kein Dienst läuft als root.
- **systemd-Hardening** in allen Units: `NoNewPrivileges`, `ProtectSystem=strict` mit gezielten `ReadWritePaths`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `ProtectKernel*`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname`, `RestrictAddressFamilies`, `RestrictNamespaces`, `RestrictSUIDSGID`, `LockPersonality`, `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, leere `CapabilityBoundingSet`, `UMask=0027`. Prüfen mit `systemd-analyze security ddi-server`.
- **Dateirechte:** `/etc/ddi-suite/server.yaml` 0640 root:ddi-suite, `master.key` 0400 (Eigentümer `ddi-suite`), Agent-Identität 0700, geheime BIND/Kea-Dateien 0640, Kea-Socket-Verzeichnis 0750.
### Netzwerk
- Ports und Richtungen: [SYSTEM-REQUIREMENTS.md §5](SYSTEM-REQUIREMENTS.md#5-netzwerk). Auf Agent-Hosts muss für den Server **kein** Port offen sein.
- Port 8443 nur aus den Verwaltungsnetzen erreichbar machen, Port 8444 nur aus den Netzen der Agents. `/metrics` liegt am selben Listener wie die Oberfläche und ist ohne Anmeldung abrufbar: per Reverse-Proxy oder Firewall auf das Monitoring beschränken.
- Ein eigenes TLS-Zertifikat (`--tls-cert`/`--tls-key`) statt des selbstsignierten verwenden; TLS ≥ 1.2 ist fest eingestellt (Agent-Port TLS 1.3).
- HA-Verkehr zwischen den Kea-Partnern über ein eigenes, nicht von Clients erreichbares Netz (VLAN) oder einen Tunnel (WireGuard, IPsec) führen.
- BIND: Rekursion ist standardmäßig aus (rein autoritativ). Wer Rekursion einschaltet, begrenzt `allow-recursion` auf die eigenen Netze.
### PostgreSQL
- Der Installer richtet **Peer-Authentifizierung über den Unix-Socket** ein: kein Datenbankpasswort, kein offener TCP-Port. `listen_addresses` kann auf `localhost` bleiben.
- Wer eine entfernte Datenbank nutzt: TLS (`sslmode=verify-full`) und `database.url_file` mit Modus 0640, damit das Passwort nicht in der Hauptkonfiguration steht.
### Konten
- Das erste Admin-Passwort ist einmalig und muss geändert werden. Für Administratoren TOTP aktivieren oder OIDC mit MFA beim Identity-Provider verwenden.
- Rollen sparsam vergeben: Viewer für Leser, Operator für das Tagesgeschäft, Admin nur für Benutzer, Lizenz, OIDC, Cluster und Schlüssel.
### Lieferkette
- Go- und npm-Abhängigkeiten sind gepinnt, die CI prüft mit `govulncheck` und `npm audit`; externe Actions sind per Commit-SHA gepinnt.
- Reproduzierbarer Release-Build (`-trimpath`, feste Build-Zeit aus dem Commit), statische Binaries (`CGO_ENABLED=0`).
## 6. Was nicht geschützt ist
- **Kea-HA ohne TLS.** Die HA-Verbindung nutzt HTTP mit Basic-Auth. Wer den Verkehr zwischen den Partnern mitlesen kann, sieht die Zugangsdaten und kann danach HA-Befehle an beide senden (z. B. Leases ändern, Zustand umschalten). Abhilfe bis zur TLS-Unterstützung: getrenntes Netz oder Tunnel (Abschnitt 5).
- **Dynamische Updates per IP-Adresse.** `allow_update` mit Adressen vertraut der Absenderadresse eines UDP-Pakets; im selben Netz lässt sie sich fälschen. Wo möglich TSIG verwenden, IP-Freigaben auf einzelne Adressen (/32) der Domain Controller beschränken.
- **GSS-TSIG** (Kerberos-gesicherte Updates von Active Directory) wird nicht unterstützt ([ACTIVE-DIRECTORY.md](ACTIVE-DIRECTORY.md)).
- **DHCP selbst ist nicht authentifiziert.** Gefälschte DHCP-Server, Adress-Erschöpfung durch viele MAC-Adressen und MAC-Spoofing gegen Reservierungen lassen sich nur im Netz verhindern (DHCP-Snooping, Port-Security, 802.1X).
- **Keine DNSSEC-Signierung** der Zonen (für M7 vorgesehen); Antworten von BIND sind nicht signiert.
- **Geheimnisse liegen auf den Agent-Hosts im Klartext** (TSIG-Schlüssel in `ddi-suite-keys.conf`, HA-Zugangsdaten), weil BIND und Kea sie so brauchen. Geschützt nur durch Dateirechte.
- **Keine Rotation** von Agent-CA und Bundle-Schlüssel, kein Neuverschlüsseln mit neuem Master-Key (Abschnitt 4).
- **Login-Ratenbegrenzung im Speicher** des Servers: Ein Neustart setzt sie zurück. Sie ersetzt keine starken Passwörter oder MFA.
- **Server-Host = Vertrauensanker.** Wer ihn kontrolliert, kontrolliert alle verwalteten DNS- und DHCP-Server (Abschnitt 3).
- **Audit-Log ist nicht manipulationssicher** gegen Datenbank-Administratoren; wer revisionssichere Protokolle braucht, exportiert es regelmäßig (`GET /audit/export`) in ein getrenntes System.
## 7. Release-Signatur
Jedes Release enthält `SHA256SUMS` über alle Tarballs und Pakete (`.deb`, `.rpm`); ist im Release-Workflow das Secret `RELEASE_SIGNING_KEY` gesetzt, zusätzlich `SHA256SUMS.sig`, eine binäre Ed25519-Signatur (64 Byte) über `SHA256SUMS`. Weil `SHA256SUMS` alle Artefakte abdeckt, sichert diese eine Signatur auch die Pakete. Der private Schlüssel gehört offline aufbewahrt; wer ihn hat, kann Releases signieren, die jeder Installer annimmt. Prüfen von Hand: `scripts/sign-release.sh verify SHA256SUMS SHA256SUMS.sig release-signing.pub.pem`, danach `sha256sum -c SHA256SUMS`. Der Installer enthält den zugehörigen öffentlichen Schlüssel im Variablenblock: Ist er gesetzt, muss die Signatur gültig sein, sonst bricht er ab; ist er leer, prüft er nur die Prüfsummen und weist darauf hin. Manuelle Prüfung und Schlüsselverwaltung: [RELEASE.md](RELEASE.md).
Den Installer selbst vor dem Ausführen herunterladen und lesen, **nicht** per `curl | sudo bash` starten ([INSTALL.md](INSTALL.md)).
## 8. Lizenzprüfung ist ein Soft-Control
Der Management-Server prüft seine Lizenz gegen Keygen, verifiziert jede Antwort mit dem beim Build eingebetteten öffentlichen Schlüssel und lässt sich nicht per Konfiguration auf einen anderen Lizenzserver umlenken ([LICENSING.md](LICENSING.md)). Das verhindert das **bequeme** Umgehen über Konfiguration, gefälschte Antworten oder einen Proxy.
Es ist aber **keine technische Garantie**: Wer die Software auf eigener Hardware betreibt, kann das Binary verändern, die Prüfung entfernen oder die Uhr verstellen. Eine Lizenzprüfung in selbst gehosteter Software ist eine vertragliche und organisatorische Maßnahme mit technischer Unterstützung, kein Kopierschutz. Unabhängig davon gilt: Die Lizenz beeinträchtigt **nie** die Datenebene – Agents prüfen keine Lizenz, DNS und DHCP laufen bei abgelaufener Lizenz unverändert weiter.
## 9. Schwachstellen melden
Sicherheitsprobleme bitte nicht als öffentliches Issue, sondern direkt an den Betreiber des Repositorys melden (Kontakt im Gitea-Profil von `beug.k`).
+235
View File
@@ -0,0 +1,235 @@
# Systemvoraussetzungen
Dieses Dokument nennt Betriebssysteme, Software-Versionen, Browser, Größen je Rolle, Netzwerkports und Plattenbedarf der DDI-Suite. Die Richtwerte stützen sich auf **Messungen** (Abschnitt 4); was geschätzt ist, steht ausdrücklich dabei. Der Installer prüft die Mindestwerte aus Abschnitt 3 vor der Installation. Installation: [INSTALL.md](INSTALL.md), Betrieb: [OPERATIONS.md](OPERATIONS.md), Sicherheit: [SECURITY.md](SECURITY.md).
## 1. Betriebssysteme und Architekturen
| System | Versionen | Architekturen |
| --- | --- | --- |
| Debian | 12 (bookworm), 13 (trixie) | `amd64`, `arm64` |
| Ubuntu LTS | 22.04, 24.04 | `amd64`, `arm64` |
| RHEL-Familie (Rocky Linux, AlmaLinux, RHEL) | 9 | `x86_64`, `aarch64` |
Erkennung über `/etc/os-release` und `uname -m`. Andere Systeme lehnt der Installer ab. Die Binaries sind statisch gebaut (`CGO_ENABLED=0`) und laufen auch auf anderen Linux-Systemen, dort aber ohne Installer und ohne Unterstützung. Erforderlich ist systemd (die Units nutzen Härtungsoptionen ab systemd 249).
Alle drei Rollen (`server`, `agent-dns`, `agent-dhcp`) dürfen auf einem Host liegen; die Mindestwerte addieren sich dann (Abschnitt 3).
## 2. Software
| Komponente | Unterstützt | In der CI getestet | Quelle bei der Installation |
| --- | --- | --- | --- |
| PostgreSQL (nur Server) | ≥ 13 | 17.11 | Paket der Distribution (Debian 12: 15, Debian 13: 17, Ubuntu 22.04: 14, Ubuntu 24.04: 16, EL9: Modul-Stream ≥ 15) |
| BIND 9 (DNS-Agent) | ≥ 9.18 | 9.18, 9.20 | Paket der Distribution (`bind9`; EL9: `bind9.18`) |
| Kea DHCPv4 mit HA (DHCP-Agent) | 3.x | 3.2.1 | ISC-Repository `isc/kea-3-2` (Standard, `--kea-source isc`) |
| Kea DHCPv4 ohne HA | 2.6 | 2.6.5 | Paket der Distribution mit `--kea-source distro` |
| systemd | ≥ 249 | – | Distribution |
- **Kea-HA braucht Kea ≥ 3.0**: Der HA-Partner wird über einen authentifizierten HTTP-Control-Socket angesprochen, Zugangsdaten nur aus Dateien (ADR 0023). Die Distributionen liefern Kea 2.0–2.6; deshalb installiert der Installer standardmäßig aus dem ISC-Repository. Kea-Versionen vor 2.6 sind nicht getestet.
- Genutzt werden nur frei verfügbare Kea-Hooks: `libdhcp_lease_cmds.so` und `libdhcp_ha.so`.
- Agents brauchen keine Datenbank; ihr Zustand liegt in Dateien.
### Browser
Die Weboberfläche nutzt aktuelles JavaScript (ES2023). Unterstützt sind die jeweils aktuellen Versionen (letzte zwei Hauptversionen) von **Firefox**, **Chrome**, **Edge** und **Safari**, auf Desktop und Tablet. Ältere Browser und Internet Explorer werden nicht unterstützt. Cookies müssen erlaubt sein (Sitzungscookie); JavaScript ist Pflicht.
## 3. Richtwerte je Rolle
Größenklassen wie im Auftrag: **klein** bis ca. 5.000 Adressen und 10 Agents, **mittel** bis ca. 50.000 Adressen und 50 Agents. „Gemessen“ bedeutet: aus den Messungen in Abschnitt 4 abgeleitet, mit Reserve für Betriebssystem, Spitzen und Wachstum. Die Reserve ist bewusst groß, weil die Messung nur Datenmengen, keine parallelen Benutzer und keine 50 verbundenen Agents abbildet.
| Rolle | Klein | Mittel | Grundlage |
| --- | --- | --- | --- |
| Management-Server inkl. PostgreSQL | 2 vCPU, **2 GB** RAM (4 GB empfohlen), 20 GB SSD | 4 vCPU, **4 GB** RAM (8 GB empfohlen), 50 GB SSD | gemessen: Server 30–195 MiB RSS, Spitze 235 MiB (Anmeldungen mit Argon2id: 64 MiB je gleichzeitiger Anmeldung); PostgreSQL 65–130 MiB; Datenbank 15 bzw. 72 MiB. Platte: Revisionen und Audit-Log wachsen (Abschnitt 7). CPU: Listenabfragen über 50.000 Einträge brauchen 40–240 ms je Anfrage (eine Abfrage = ein Kern), mehrere Benutzer gleichzeitig profitieren von 4 Kernen. |
| DNS-Agent + BIND | 1 vCPU, **512 MB** RAM (1 GB empfohlen), 5 GB | 2 vCPU, **1 GB** RAM (2 GB empfohlen), 10 GB | gemessen: `named` 22–27 MiB (9.000 Records) bzw. 57–61 MiB (90.000 Records, 657 Zonen); 50.000–70.000 Abfragen/s mit 1,0–1,4 Kernen. Agent geschätzt 20–40 MiB. |
| DHCP-Agent + Kea | 1 vCPU, **512 MB** RAM (1 GB empfohlen), 5 GB | 2 vCPU, **1 GB** RAM (2 GB empfohlen), 10 GB | gemessen: `kea-dhcp4` 20–35 MiB (1.000 Reservierungen) bzw. 42–58 MiB (10.000 Reservierungen, bis 50.000 Leases); 750–1.000 DORA/s mit rund einem Kern. Agent geschätzt 20–40 MiB. |
Gegenüber den Ausgangswerten des Auftrags (Server 4 GB / 8–16 GB, DNS 1 GB / 2–4 GB, DHCP 1 GB / 2 GB) zeigen die Messungen deutlich geringeren Speicherbedarf; die Mindestwerte sind entsprechend gesenkt, die Empfehlungen bleiben nahe am Auftrag, um Reserve für Wachstum, Logs, Paketupdates und Lastspitzen zu lassen. **SSD** ist für den Server empfohlen (PostgreSQL), für Agents auf einem Raspberry Pi Pflicht (Abschnitt 6).
### Vorabprüfung des Installers
Der Installer (`scripts/install.sh`, ADR 0024) prüft vor der Installation diese Werte. **Warnung**: Hinweis, Installation läuft weiter (mit `--non-interactive` ohne Rückfrage). **Abbruch**: Installation stoppt; mit `--force` wird daraus eine Warnung (außer bei Betriebssystem und Architektur).
| Prüfung | Rolle | Warnung, wenn | Abbruch, wenn |
| --- | --- | --- | --- |
| Betriebssystem | alle | – | nicht in Abschnitt 1 (auch mit `--force`) |
| Architektur (`uname -m`) | alle | – | nicht `x86_64`/`amd64` oder `aarch64`/`arm64` (auch mit `--force`) |
| CPU-Kerne (`nproc`) | `server` | < 2 | – |
| | `agent-dns`, `agent-dhcp` | – | – |
| Arbeitsspeicher (`MemTotal`) | `server` | < 3,5 GiB | < 1,8 GiB |
| | `agent-dns` | < 900 MiB | < 450 MiB |
| | `agent-dhcp` | < 900 MiB | < 450 MiB |
| Freier Platz im Dateisystem von `/var/lib` | `server` | < 20 GiB | < 4 GiB |
| | `agent-dns` | < 5 GiB | < 1 GiB |
| | `agent-dhcp` | < 5 GiB | < 1 GiB |
| Freier Platz im Dateisystem von `/usr/local/bin` bzw. `/usr/bin` | alle | – | < 200 MiB |
| Zeitsynchronisation (`timedatectl show -p NTPSynchronized`, aktiver `chronyd`/`systemd-timesyncd`/`ntpd`) | alle | nicht synchronisiert oder kein Zeitdienst aktiv | – |
| Port 8443/tcp, 8444/tcp frei (`ss -ltn`) | `server` | – | von einem anderen Prozess belegt (bei `--upgrade` vom eigenen Dienst erlaubt) |
| Port 53/udp+tcp frei | `agent-dns` | nur `systemd-resolved` auf 127.0.0.53 | von einem anderen Prozess als `named` belegt |
| Port 67/udp frei | `agent-dhcp` | – | von einem anderen Prozess als `kea-dhcp4` belegt (z. B. `dnsmasq`, `isc-dhcp-server`) |
| Port 8001/tcp frei (Kea-HA, Standard `ha_port`) | `agent-dhcp` | belegt | – |
| PostgreSQL-Version des Pakets | `server` | – | < 13 |
| BIND-Version des Pakets | `agent-dns` | < 9.18 | – |
| Kea-Version | `agent-dhcp` | < 3.0 (kein HA möglich) | < 2.6 |
| ISC-Repository erreichbar (443/tcp, nur `--kea-source isc`) | `agent-dhcp` | – | nicht erreichbar (Hinweis auf `--kea-source distro`) |
| Lizenzserver `https://license.beugsiud.de` erreichbar | `server` | nicht erreichbar | – |
| Agent-Port des Servers erreichbar (`--server`) | `agent-dns`, `agent-dhcp` | nicht erreichbar | – |
Mehrere Rollen auf einem Host: Speicher- und Plattengrenzen werden **addiert** (z. B. `server` + `agent-dns` + `agent-dhcp`: Warnung unter 5,3 GiB RAM bzw. 30 GiB Platte, Abbruch unter 2,7 GiB bzw. 6 GiB), für CPU gilt der höchste Wert. Die Grenzen liegen etwas unter den Nennwerten (4 GB, 2 GB, 1 GB, 512 MB), weil der Kernel einen Teil des Speichers für sich behält und `MemTotal` deshalb kleiner ist.
## 4. Messungen
**Gemessen** mit dem Benchmark in [`test/bench/`](../test/bench/) (`make bench`, Workflow `bench` in [`.gitea/workflows/bench.yml`](../.gitea/workflows/bench.yml)) auf dem CI-Runner. Maßgeblich ist Lauf 93 vom 15.10.2026 (Commit `f5f6ceb`); wo ein früherer Lauf (82 vom 14.10.2026, Commit `9c61d31`, gleiche Hardware) deutlich abwich, ist die Spanne beider Läufe angegeben. Der CSV-Import stammt aus Lauf 95 vom 15.10.2026 (Commit `7ea66d9`, nach der Beschleunigung des Imports). Alle Dienste liefen gleichzeitig als Container auf einem Docker-Host:
| Hardware des Runners | |
| --- | --- |
| CPU | Intel Xeon (Skylake, IBRS), virtualisiert |
| Kerne (`nproc`) | 4 |
| RAM | 7,7 GiB |
| Kernel / Docker | 5.15.0-130-generic / 29.1.4 |
Der Docker-Daemon kann mit anderen CI-Jobs geteilt sein, und Lastgenerator und Dienst teilen sich dieselben 4 Kerne; zwischen den beiden Läufen schwankten die Durchsätze von BIND und Kea deshalb um bis zu 30 %. Die Werte sind daher eher **untere Grenzen** dessen, was auf dedizierter Hardware möglich ist.
### Methode
- **Daten** (`test/bench/datagen.sh`): je 1.000 Einträge in /21-Netzen `10.x.y.0/21` (70 % `static`, 20 % `manual_dhcp` mit MAC, 10 % `reserved`; DHCP-Pool in der oberen Hälfte jedes Netzes), alle Namen in der Zone `bench.test`. Datensatz *klein*: 5 Netze, 5.000 Einträge; *mittel*: 50 Netze, 50.000 Einträge. Zusätzlich ein DHCP-Netz `10.200.0.0/16` mit einem Pool über fast das ganze /16 für `perfdhcp`.
- **Server** (Build wie im Release, PostgreSQL 17.11 mit den Standardwerten des offiziellen Images): Import über `POST /api/v1/addresses/import` in Dateien zu 5.000 Zeilen, danach Messung von Speicher (`VmRSS`/`VmHWM` des Prozesses, `docker stats` für PostgreSQL), Datenbank- und Tabellengrößen, `POST /revisions` (Dauer, Tabellenwachstum und gespeicherte, komprimierte Größe), `GET /config/preview`, `GET /config/pending`, 20 Wiederholungen je Filter von `GET /addresses?limit=100` (Median und 95. Perzentil), 50 Umbenennungen per `PUT /addresses/{id}` mit Wachstum des Audit-Logs, danach eine weitere Revision.
- **BIND 9.20**: Die Dateien aus `GET /config/preview` (unverändert) in einem BIND-Container, `named` neu gestartet; Ladezeit aus dem BIND-Log („starting BIND“ bis „all zones loaded“). `dnsperf` (Debian-Paket) in einem eigenen Container: 15 s mit 16 Clients und bis zu 500 offenen Abfragen (A und PTR existierender Namen, 10 % NXDOMAIN), dann 15 s mit festen 5.000 Abfragen/s.
- **Kea 3.2.1**: `kea-dhcp4.conf` aus `GET /config/preview`; einzige Änderung: Das /16-Testnetz erhält die Adresse des `perfdhcp`-Containers als Relay (`relay.ip-addresses`), weil Relay-Adressen noch nicht im IPAM-Modell stehen. `perfdhcp` (ISC-Paket) sendet als Relay per Unicast, je 10 s mit 50.000 simulierten Clients bei steigender Rate; spätere Läufe verlängern teils bestehende Leases. CPU aus `/proc/<pid>/stat` (100 % = ein Kern).
### Ergebnisse: Management-Server
| Messgröße | 5.000 Einträge | 50.000 Einträge |
| --- | ---: | ---: |
| Netze / Adresseinträge | 6 / 5.000 | 51 / 50.000 |
| CSV-Import über die API | 4,4 s (1.130 Einträge/s) | 41 s für 45.000 neue (1.090 Einträge/s) |
| Server-RSS nach dem Import / Höchstwert | 29 / 235 MiB | 33 / 235 MiB |
| Server-RSS am Ende (nach Vorschau, Revisionen) | 37–53 MiB | 137–194 MiB |
| PostgreSQL-Container (Speicher) | 67 MiB | 128 MiB |
| Datenbankgröße nach dem Import | 15 MiB | 72 MiB |
| davon Tabelle `addresses` inkl. Indizes | 2,1 MiB | 20 MiB |
| davon Audit-Log (ein Eintrag je importierter Adresse) | 4,3 MiB (5.010 Einträge) | 42 MiB (50.107 Einträge) |
| `POST /revisions` | 0,29–0,34 s | 2,8–2,9 s |
| Speicher je Revision: Tabellenwachstum / gespeicherte Daten | 0,7 / 0,5 MiB | 4,6 / 4,2 MiB |
| Erzeugte Dateien: DNS-Dateien / Gesamtgröße | 300 / 0,8 MiB | 660 / 7,0 MiB |
| `GET /config/preview` | 0,09 s | 0,62 s |
| `GET /config/pending` | 0,08 s | 0,71–0,89 s |
| `GET /addresses?limit=100` ohne Filter (Median / p95) | 13 / 14 ms | 110 / 120 ms |
| … mit `network_id` (ein Netz) | 5 / 7 ms | 5 / 7 ms |
| … mit `type=manual_dhcp` | 7 / 8 ms | 40 / 50 ms |
| … Suche nach Hostname-Präfix (`q=h3-12`) | 14 / 17 ms | 115 / 135 ms |
| … Suche nach MAC-Präfix | 16 / 23 ms | 131 / 145 ms |
| … sortiert nach Hostname, letzte Seite | 20 / 33 ms | 204 / 236 ms |
| `PUT /addresses/{id}` (Mittel aus 50) | 5,0 ms | 5,8 ms |
| Audit-Wachstum je Änderung | 1,5 KiB | 1,6 KiB |
Der Höchstwert des Server-Speichers (235 MiB) stammt von den Anmeldungen (Argon2id mit 64 MiB je Hashberechnung), nicht von der Datenmenge. Jede Revision speichert alle erzeugten Dateien vollständig (komprimiert): Eine Revision nach 50 Änderungen ist so groß wie die erste.
### Ergebnisse: BIND 9.20
| Messgröße | 5.000 Einträge | 50.000 Einträge |
| --- | ---: | ---: |
| Zonen / A- und PTR-Records | 297 / 9.001 | 657 / 90.001 |
| Zonendateien | 0,6 MiB | 5,4 MiB |
| Start bis alle Zonen geladen | 0,07–0,08 s | 0,35–0,43 s |
| `named`-RSS nach dem Laden / nach den Läufen | 22 / 27 MiB | 57 / 61 MiB |
| `dnsperf` maximal: Abfragen/s (Lauf 93 / Lauf 82) | 68.200 / 69.400 | 51.600 / 72.000 |
| … mittlere Latenz / verloren | 2,2 ms / 0,08 % | 2,2–3,2 ms / 0,1 % |
| … CPU von `named` | 126–141 % | 104–141 % |
| `dnsperf` bei 5.000 Abfragen/s: mittlere Latenz | 0,2 ms | 0,2–0,7 ms |
Die Zonenzahl enthält 256 leere Reverse-Zonen des /16-Testnetzes. Der Durchsatz hängt kaum von der Zonengröße ab; die Grenze setzen die geteilten 4 Kerne, die Schwankung zwischen den Läufen kommt vom geteilten Runner.
### Ergebnisse: Kea 3.2.1
| Messgröße | 5.000 Einträge | 50.000 Einträge |
| --- | ---: | ---: |
| Subnetze / Reservierungen | 6 / 1.000 | 51 / 10.000 |
| `kea-dhcp4 -t` (Prüfung beim Ausrollen) | 0,08–0,13 s | 0,36–0,39 s |
| Start bis `DHCP4_STARTED` | 0,06–0,07 s | 0,48–0,58 s |
| `kea-dhcp4`-RSS nach dem Start | 20 MiB | 42 MiB |
Lauf 93, Werte je Zelle „klein / mittel“:
| `perfdhcp`-Rate (Ziel) | DORA/s erreicht | Verluste Discover-Offer | mittlere Verzögerung Request-Ack | CPU Kea | RSS Kea | Lease-Datei kumuliert |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 250/s | 250 / 250 | 0 % / 0 % | 0,8 / 1,8 ms | 29 / 31 % | 22 / 44 MiB | 0,2 MiB |
| 500/s | 500 / 500 | 0 % / 0 % | 0,8 / 1,5 ms | 56 / 61 % | 24 / 46 MiB | 0,4 MiB |
| 750/s | 750 / 717 | 0 % / 2,6 % | 1,7 / 6,9 ms | 83 / 89 % | 26 / 47 MiB | 0,6 MiB |
| 1.000/s | 1.000 / 795 | 0 % / 12,7 % | 2,2 / 20 ms | 117 / 112 % | 28 / 48 MiB | 0,8 MiB |
| 1.500/s | 1.050 / 781 | 17 % / 29 % | 24 / 26 ms | 153 / 137 % | 31 / 49 MiB | 1,0 MiB |
| 2.000/s | 917 / 690 | 33 % / 42 % | 21 / 22 ms | 171 / 141 % | 33 / 51 MiB | 1,3 MiB |
| 4.000/s | 503 / 284 | 65 % / 74 % | 15 / 20 ms | 165 / 116 % | 36 / 50 MiB | 1,5 MiB |
In Lauf 82 erreichte Kea bei 1.000/s mit beiden Datensätzen 987 DORA/s (Verluste unter 1 %), bei 2.000/s rund 880 DORA/s.
**Ergebnis:** Auf 4 geteilten Kernen bedient Kea mit der erzeugten Konfiguration **750–1.000 vollständige Vergaben pro Sekunde** ohne nennenswerte Verluste; mit 10.000 Reservierungen eher am unteren Ende. Darüber bricht der Durchsatz ein (Kea verwirft unter Überlast Pakete; Lastgenerator und Kea konkurrieren um dieselben Kerne). Zum Vergleich: 10.000 Clients mit einer Lease-Zeit von einer Stunde erneuern im Mittel rund 6 Leases pro Sekunde, ein gleichzeitiger Neustart aller 10.000 Clients dauert bei 750/s etwa 15 s. Die Lease-Datei wächst um rund 85 Byte je Vergabe oder Verlängerung und wird von Kea stündlich verdichtet (`lfc-interval` 3600 s).
### Was nicht gemessen wurde (geschätzt)
- Speicher der **Agents** selbst (Go-Prozess, geschätzt 20–40 MiB je Agent) und des Servers mit **50 verbundenen Agents** (je Agent ein gRPC-Stream; geschätzt wenige MiB zusätzlich).
- **Parallele Benutzer** der Oberfläche; die Latenzen oben gelten für eine Anfrage zur Zeit.
- **Kea-HA** (zwei Partner): Jede Vergabe wird zusätzlich an den Partner gemeldet; die Rate sinkt nach Kea-Handbuch spürbar. Mit HA ist von deutlich weniger als den gemessenen 750–1.000 DORA/s auszugehen.
- Speichermedium: Die Messung lief auf dem Speicher des Runners (vermutlich SSD). Auf SD-Karten sind PostgreSQL und die Kea-Lease-Datei deutlich langsamer.
## 5. Netzwerk
| Verbindung | Richtung | Port | Pflicht |
| --- | --- | --- | --- |
| Weboberfläche und REST-API | Browser → Server | 8443/tcp (HTTPS) | ja |
| Agent-Protokoll (gRPC, mTLS) | **Agent → Server** (ausgehend vom Agent) | 8444/tcp | ja; auf dem Agent-Host muss kein Port für den Server offen sein |
| DNS-Abfragen | Clients → BIND | 53/udp und 53/tcp | DNS-Agent |
| NOTIFY | Primary → Secondaries | 53/udp (und tcp) | DNS-Cluster |
| Zonentransfer (AXFR/IXFR, TSIG) | Secondary → Primary | 53/tcp | DNS-Cluster |
| Dynamische Updates (RFC 2136) | Domain Controller/Clients → Primary | 53/udp und 53/tcp | dynamische Zonen |
| DHCP | Clients → Kea (Broadcast) bzw. Relay → Kea (Unicast) | 67/udp | DHCP-Agent |
| DHCP-Antworten | Kea → Clients bzw. Kea → Relay | 68/udp (Clients), 67/udp (Relay) | DHCP-Agent |
| DHCP-Relay | Router/Switch (`ip helper-address`) → Kea | 67/udp | Netze ohne eigenen Kea im Segment |
| Kea-HA | Kea ↔ Kea, beide Richtungen | 8001/tcp (`ha_port` je Cluster) | DHCP-Cluster mit zwei Servern |
| Lizenzprüfung | Server → `license.beugsiud.de` | 443/tcp | ja (bei Ausfall 14 Tage Karenzzeit) |
| Installation von Kea | Agent-Host → `dl.cloudsmith.io` (ISC-Repository) | 443/tcp | nur bei Installation/Upgrade mit `--kea-source isc` |
| Release-Download | Hosts → `git.beugsiud.de` | 443/tcp | nur bei Installation/Upgrade (alternativ `--from-dir`) |
| Paketquellen der Distribution | alle Hosts → Spiegel | 80/443/tcp | bei Installation/Upgrade |
| Zeitsynchronisation | alle Hosts → NTP-Server | 123/udp | **ja** |
| PostgreSQL | Server → lokale Datenbank | Unix-Socket | ja (kein Netzport nötig) |
| `rndc`, Kea-Control-Socket | Agent → Dienst, lokal | 127.0.0.1:953/tcp bzw. Unix-Socket | ja (nur lokal) |
| Monitoring | Prometheus → Server `/metrics`, `/readyz` | 8443/tcp | optional, Zugriff begrenzen |
Ports 8443 und 8444 sind in `server.yaml` (`http.listen`, `agent_api.listen`) bzw. in der Oberfläche (`ha_port` je DHCP-Cluster) änderbar. Wer Agents über NAT oder einen Proxy anbindet: Der Agent-Port braucht durchgehendes TLS (kein TLS-aufbrechender Proxy), die Adresse im Enrollment-Befehl kommt aus `agent_api.public_address`.
### Zeitsynchronisation ist Pflicht
Zertifikate (14 Tage Laufzeit), TSIG (±5 Minuten), Lizenzprüfung (signierte Antworten höchstens 5 Minuten alt), TOTP (±30 s) und Kea-HA hängen an korrekten Uhren. Auf allen Hosts muss ein Zeitdienst laufen (`systemd-timesyncd`, `chronyd` oder `ntpd`). Der Installer warnt, wenn die Uhr nicht synchronisiert ist.
## 6. Raspberry Pi und andere ARM-Systeme
- Unterstützt ist **64-Bit-ARM** (`arm64`/`aarch64`) mit einem 64-Bit-Betriebssystem (z. B. Raspberry Pi OS 64 Bit auf Debian-12-Basis, Ubuntu 24.04 für Raspberry Pi). 32-Bit-Systeme (`armhf`) nicht.
- Ein Raspberry Pi 4 oder 5 mit 2 GB (Agents) bzw. 4 GB (Server, klein) genügt nach den Messungen; für den Server mit 50.000 Adressen einen Pi 5 mit 8 GB oder eine VM wählen.
- **SSD statt SD-Karte:** PostgreSQL (Server), die Lease-Datei von Kea (jede Vergabe wird geschrieben), BIND-Journale dynamischer Zonen und die Journale von systemd schreiben laufend. SD-Karten verschleißen dadurch schnell und sind langsam. Für den Server ist eine SSD über USB 3 oder NVMe **Pflicht**, für Agents dringend empfohlen. Wo nur eine SD-Karte möglich ist: hochwertige Karte („High Endurance“), `Storage=volatile` oder eine Größenbegrenzung für journald, keine Swap-Datei auf der Karte.
- Ohne batteriegepufferte Uhr startet ein Pi mit falscher Zeit: Zeitsynchronisation vor dem Start der Dienste abwarten (`systemd-time-wait-sync.service` aktivieren), sonst schlagen TLS und TSIG bis zur Synchronisation fehl.
## 7. Plattenplatz-Planung
Was wächst, mit gemessenen Werten aus Abschnitt 4:
| Was | Wachstum (gemessen) | Beispiel |
| --- | --- | --- |
| **Revisionen** (Server) | 0,7 MiB je Revision bei 5.000, **4,6 MiB** bei 50.000 Adressen (alle Dateien jeder Revision, komprimiert gespeichert; davon 4,2 MiB Daten, der Rest Tabellen- und Indexaufwand) | 50.000 Adressen, 10 Revisionen pro Arbeitstag: ≈ 46 MiB/Tag, ≈ 10 GiB/Jahr |
| **Audit-Log** (Server) | ≈ 1,5–1,6 KiB je Änderung (vorher/nachher als JSON); ein Import erzeugt einen Eintrag je Zeile | 1.000 Änderungen pro Tag: ≈ 0,5 GiB/Jahr |
| Adressen | ≈ 0,4 KiB je Eintrag inkl. Indizes | 50.000 Einträge: 20 MiB |
| Lease-Erfassung | erzeugt keine Audit-Einträge; Leases und `dynamic`-Einträge nur einmal je Adresse | vernachlässigbar |
| PostgreSQL-WAL, Sicherungen | WAL bis `max_wal_size` (Standard 1 GiB); Dumps etwa in Größe der Datenbank, komprimiert deutlich kleiner | Platz für mehrere Dumps einplanen |
| Agent: lokale Revisionen | die letzten 10 Revisionen der eigenen Rolle | 50.000 Adressen: DNS ≈ 55 MiB, DHCP ≈ 15 MiB |
| BIND: Journale dynamischer Zonen | je nach Update-Rate, BIND verdichtet sie selbst | meist < 100 MiB |
| Kea: Lease-Datei | ≈ 85 Byte je Vergabe/Verlängerung, stündlich verdichtet | 10.000 Clients: wenige MiB |
| Logs (journald) | nach Konfiguration von journald | Standard: max. 10 % des Dateisystems |
**Aufbewahrung:** Ohne Einstellung bleiben alle Revisionen und Audit-Einträge erhalten. Bei großen Datenbeständen und häufigem Ausrollen `retention.revisions_keep` (z. B. 200–500) und bei Bedarf `retention.audit_days` setzen ([OPERATIONS.md §7](OPERATIONS.md#7-aufbewahrung-retention)); die neueste, die von Agents angewendeten und die offen ausgerollten Revisionen werden nie gelöscht. Mit `revisions_keep: 500` bleiben bei 50.000 Adressen höchstens ≈ 2,3 GiB Revisionen.
Die Plattenempfehlungen in Abschnitt 3 (20 GB bzw. 50 GB für den Server) reichen damit für mehrere Jahre bei moderater Änderungsrate; bei täglich vielen Revisionen ohne Aufbewahrungsregel ist mit ≈ 10 GiB pro Jahr und 50.000 Adressen zu rechnen.
## 8. Sicherung, Wiederherstellung, Upgrade (Kurzfassung)
Einzelheiten in [OPERATIONS.md](OPERATIONS.md).
- **Sichern:** PostgreSQL-Datenbank (`pg_dump`), den **Master-Key** `/etc/ddi-suite/master.key` (getrennt aufbewahren), `/etc/ddi-suite/` und optional die Agent-Identitäten (`/var/lib/ddi-suite/agent-<rolle>/identity/`). CA- und Bundle-Schlüssel liegen verschlüsselt in der Datenbank und sind damit Teil des Dumps.
- **Wiederherstellen:** Server installieren, Master-Key und Konfiguration zurücklegen, Dump einspielen, Server starten; Agents verbinden sich wieder. **Ohne Master-Key sind alle Geheimnisse verloren** (CA, TSIG, HA-Passwörter): dann neu aufbauen und Agents neu registrieren.
- **Upgrade:** erst den Server (`install.sh --upgrade`, Migrationen laufen beim Start), dann die Agents. Vorher die Datenbank sichern. DNS und DHCP laufen während eines Upgrades weiter.
+4
View File
@@ -18,6 +18,10 @@ Der Brief legt BIND 9 fest. Zu klären sind Mindestversion und die Art der Anste
- Kein `rndc addzone`/`catalog zones` in Version 1: Die Include-Datei ist vollständig generiert und damit deterministisch prüfbar. Katalogzonen sind eine mögliche spätere Optimierung für Secondaries.
- Rekursion ist standardmäßig aus.
## Nachträge
- **2026-10-13 (M2):** Statt einer Include-Datei erzeugt der Generator zwei: `ddi-suite.conf` (ACLs und `zone`-Blöcke, auf oberster Ebene eingebunden) und `ddi-suite-options.conf` (Rekursion, `allow-recursion`, `forwarders`, `allow-transfer`), die **innerhalb** des `options`-Blocks der Distribution eingebunden wird. BIND erlaubt nur einen `options`-Block; `include` ist dort zulässig (Parser in 9.18/9.20: „include statements wherever a map body clause can occur“). Die Distributionskonfiguration darf diese Optionen dann nicht selbst setzen; das richtet der Installer (M6) ein. Dynamische Zonen brauchen Schreibrechte von `named` auf das Zonenverzeichnis (Journale).
## Konsequenzen
- Distributionspakete reichen auf Debian 12/13 und Ubuntu 22.04/24.04 (alle ≥ 9.18). Auf RHEL 9 ist die Paketlage (`bind` 9.16 vs. `bind9.18`) in M6 zu prüfen.
+4
View File
@@ -14,6 +14,10 @@
- **SOA-Seriennummer** = Revisionsnummer des Clusters (streng monoton, 32 Bit). Bei Übernahme bestehender Zonen mit höherer Seriennummer wird ein Offset gespeichert.
- **Tests:** Golden-Files unter `testdata/` (Aktualisierung mit `go test ./... -update`), Fuzz-Tests für die Validierer und in den Integrationstests (`/test`) die Prüfung jeder erzeugten Konfiguration mit echtem `named-checkconf`/`named-checkzone` und `kea-dhcp4 -t` in Containern.
## Nachträge
- **2026-10-13 (M2):** Aufteilung in die Pakete `configgen` (Eingabemodell), `configgen/derive` (Ableitung aus IPAM), `configgen/bindgen` und `configgen/keagen` (Renderer). TXT-Inhalte mit Bytes außerhalb von 0x20–0x7E werden nicht abgelehnt, sondern als `\DDD` escaped (RFC 1035 §5.1); Namen mit Steuerzeichen bleiben verboten. Die Anfangsdatei dynamischer Zonen darf Records enthalten (z. B. vorbereitete SRV-Einträge); danach gehört sie BIND. Die Integrationstests liegen unter `server/internal/configgen/integration` (Build-Tag `integration`), weil Go-`internal`-Pakete aus `/test` nicht importierbar sind.
## Konsequenzen
Gleicher Datenbankstand ergibt byte-gleiche Konfiguration. Diffs zwischen Revisionen sind deshalb aussagekräftig.
+55
View File
@@ -0,0 +1,55 @@
# 0021 – Agent-Strecke: Revisionen, PKI, Ablauf im Agent (M3)
- **Status:** Akzeptiert
- **Datum:** 2026-10-13
## Kontext
ADR 0007–0011 legen Protokoll, CA, Signatur, Binaries und Agent-Zustand fest. Für M3 (erste durchgängige Strecke) fehlen die konkreten Regeln, auf die sich Server, Agent-Kern und Rollen verlassen. Gemeinsame Typen liegen in `internal/bundle` (Manifest, Signatur), `internal/agentpki` (Agent-ID im Zertifikat, CA-Fingerprint, TLS) und `agent/core/role.go` (Rollen-Schnittstelle); die REST-API steht in `server/api/openapi.yaml`.
## Entscheidung
### Revisionen
- Eine **globale, fortlaufende Revisionsnummer** (1 … 2³²−1, weil sie zugleich SOA-Seriennummer ist). Eine Revision enthält die DNS- und die DHCP-Artefakte, Warnungen, Kommentar, Ersteller und Zeitpunkt und ist **unveränderlich**.
- Erzeugt wird sie ausdrücklich mit `POST /api/v1/revisions` (Recht `deploy`, im Lesemodus gesperrt). Der Server erzeugt die Konfiguration zuerst mit der Nummer der letzten Revision; ist sie byte-gleich, entsteht keine neue Revision (Antwort 200). Sonst wird mit der nächsten Nummer erzeugt und gespeichert (201). Kann ein Teil nicht erzeugt werden (z. B. fehlende DNS-Einstellungen), wird nichts gespeichert (422).
- Soll-Revision jedes Agents ist in M3 die neueste Revision. Betriebsmodi pro Agent, Push aus dem UI und Entwürfe folgen in M4; das Feld `mode` existiert bereits (Standard `auto_pull`).
- Bundles werden beim Abruf (`FetchBundle`) für den Agent signiert: Agent-ID und Rolle aus dem Zertifikat, Artefakte der Rolle, `created_at` = Zeitpunkt der Revision. Gleiche Eingabe ergibt gleiche Bytes.
### PKI und Agent-API
- Beim ersten Start erzeugt der Server die **Agent-CA** (ECDSA P-256, 10 Jahre) und den **Bundle-Signaturschlüssel** (Ed25519). Beide privaten Schlüssel liegen mit dem Master-Key verschlüsselt in der Datenbank (ADR 0015).
- Das **Serverzertifikat** des Agent-Ports wird bei jedem Start im Speicher aus der CA ausgestellt (1 Jahr); es muss nirgends gespeichert oder erneuert werden. SANs aus `agent_api.server_names`. Der Server sendet Zertifikat und CA (Kette), damit der Agent beim Enrollment den Fingerprint prüfen kann.
- Konfiguration: `agent_api.listen` (Standard `:8444`), `agent_api.public_address` (`host:port` für den angezeigten Enrollment-Befehl; Standard: Host aus `http.public_url` und Port aus `listen`), `agent_api.server_names` (Standard: Host aus `public_address`).
- **Client-Zertifikate:** 14 Tage, CN = Agent-ID, URI-SAN `urn:<produkt>:agent:<uuid>`, nur `ClientAuth`, zufällige 128-Bit-Seriennummer. Der Server speichert pro Agent **nur die Seriennummer des aktuellen Zertifikats**. Eine Verbindung wird nur akzeptiert, wenn Kette, Gültigkeit, Agent-ID, Seriennummer und Sperrstatus stimmen. Nach einer Erneuerung ist das alte Zertifikat damit sofort wertlos; eine Sperrliste ist nicht nötig.
- Der Listener fordert Client-Zertifikate an, prüft sie aber selbst (Interceptor), weil `Enroll` ohne Zertifikat und `RenewCertificate` mit einem bis zu 30 Tage abgelaufenen Zertifikat erlaubt sein müssen.
- **Enrollment-Token** `<id>.<secret>`: ID 16 Hex-Zeichen, Secret 32 Zufallsbytes (base64url). In der Datenbank liegt SHA-256 des Secrets (hohe Entropie, kein Passwort-Hash nötig). Einmalig, an die Rolle gebunden, Standard 24 h gültig. Fehlversuche werden pro Quell-IP begrenzt.
- Der Agent erneuert sein Zertifikat, sobald weniger als die Hälfte der Laufzeit übrig ist, mit neuem Schlüssel.
- `Connect`: Agent sendet `Hello`, Server antwortet mit `Welcome` (Modus, Pull-Intervall Standard 60 s, Heartbeat Standard 30 s, Soll-Revision). Neue Revisionen werden verbundenen Agents im Modus `auto_pull` sofort als `ApplyRevisionCommand` gesendet; ohne Stream holt der Agent sie im Pull-Intervall über `GetDesiredState`. Ein gesperrter Agent wird sofort getrennt.
### Agent
- Konfigurationsdatei `/etc/ddi-suite/agent-<rolle>.yaml` (`--config`): `state_dir`, `log`, Zeitlimits und der Abschnitt der Rolle (`dns:` bzw. `dhcp:`). Serveradresse, Servername, Agent-ID, Schlüssel und Zertifikate schreibt `enroll` nach `state_dir/identity/` (ADR 0011).
- Befehle: `enroll --server <host:port> --token <token> --ca-hash sha256:<hex> [--server-name <name>]`, `run`, `status`, `version`.
- **Ablauf** (siehe `agent/core/role.go`): Bundle prüfen (`bundle.Verify`, Revision > angewendete) → unter `revisions/<n>/` ablegen → `Validate` → `Activate` → `Health` (bis zu 30 s wiederholt) → bei Fehler `Activate(letzte funktionierende)`. Ergebnis `succeeded`, `rejected`, `rolled_back` oder `failed` geht per `ReportDeployment` an den Server; ist er nicht erreichbar, wird der Bericht gespeichert und später gesendet.
- Eine abgelehnte oder zurückgerollte Revision wird **nicht automatisch erneut** versucht (sonst Endlosschleife bei jedem Poll). Erst eine neuere Revision oder ein ausdrücklicher Push derselben Nummer startet einen neuen Versuch.
- Ohne Server läuft der Dienst mit der zuletzt aktiven Konfiguration weiter.
- **Leases:** Implementiert die Rolle `LeaseSource`, fragt der Kern sie alle 30 s ab und meldet Änderungen; beim Start, nach jeder neuen Verbindung und stündlich einen vollständigen Stand (`full_sync`). Die Sequenznummer ist persistent.
### DNS-Rolle (BIND)
- `conf_dir` (Standard `/etc/bind/ddi-suite`) erhält `ddi-suite.conf` und `ddi-suite-options.conf`, `zone_dir` (Standard `/var/lib/bind/ddi-suite`, muss der Generator-Einstellung entsprechen) erhält den Inhalt von `zones/`. `zone_dir` liegt unter `/var/lib/bind`, weil BIND dort schreiben darf (Journale dynamischer Zonen; AppArmor auf Ubuntu).
- `Validate`: `named-checkconf` auf einer temporären `named.conf`, die die neuen Include-Dateien einbindet, und `named-checkzone` für jede Zonendatei (Zonenname aus `db.<zone>`).
- `Activate`: bisherige Dateien sichern, neue atomar schreiben, veraltete entfernen, `named-checkconf -z` auf die echte `named.conf`; schlägt das fehl, Sicherung zurück und Fehler. Danach `rndc reconfig` und `rndc reload`.
- `Health`: `rndc status` und SOA-Abfrage jeder verwalteten Zone an `dns_address` (Standard `127.0.0.1:53`); die Seriennummer muss der Revision entsprechen.
### DHCP-Rolle (Kea)
- `config_file` (Standard `/etc/kea/kea-dhcp4.conf`), `control_socket` (muss der Generator-Einstellung entsprechen).
- `Validate`: `kea-dhcp4 -t` auf die neue Datei. `Activate`: Sicherung, atomar schreiben, `config-reload` über den Control-Socket, bei Fehler Sicherung zurück. `Health`: `status-get`. Leases: `lease4-get-page`.
## Konsequenzen
- Server, Agent-Kern und Rollen lassen sich unabhängig entwickeln und testen; der Kern kennt weder BIND noch Kea.
- Zielpfade stehen zweimal (Generator-Einstellung und Agent-Konfiguration). Der Installer (M6) setzt beide gleich; die Rollen prüfen beim `Validate`, dass die erzeugte Konfiguration ihr `zone_dir` bzw. ihren `control_socket` verwendet, und lehnen sonst mit klarer Meldung ab.
- Schlüsselrotation des Bundle-Signaturschlüssels (ADR 0009) und CA-Rotation sind vorbereitet, aber nicht umgesetzt.
+58
View File
@@ -0,0 +1,58 @@
# 0022 – Ausrollen: Betriebsmodi, Push, Änderungsübersicht, Diff, Rollback, Live-Status (M4)
- **Status:** Akzeptiert
- **Datum:** 2026-10-13
## Kontext
M3 rollt jede neue Revision sofort auf alle Agents aus. M4 verlangt laut Brief Betriebsmodi pro Agent, „Jetzt ausrollen“ aus dem UI, Entwürfe, eine Diff-Ansicht, den Deployment-Status live und Rollback über das UI. Grundlage sind ADR 0007, 0009 und 0021.
## Entscheidung
### Betriebsmodi und Soll-Revision
- `auto_pull` (Standard): Soll-Revision ist immer die neueste Revision; neue Revisionen werden sofort gepusht (wie M3).
- `manual`: Soll-Revision ist die zuletzt ausdrücklich für diesen Agent ausgerollte Revision (`agents.target_revision`, anfangs 0). Ohne Push ändert sich nichts.
- Umschalten über `PATCH /agents/{id}` (`mode`, Recht `settings.admin`). Der Server teilt dem verbundenen Agent den neuen Modus über ein erneutes `Welcome` mit (Stream wird dazu neu aufgebaut: der Hub trennt die Verbindung, der Agent verbindet sich sofort neu).
### Push („Jetzt ausrollen“)
- `POST /revisions/{n}/deploy` (Recht `deploy`) mit optionaler Agent-Liste (leer = alle nicht gesperrten Agents). Für jeden Agent legt der Server ein Deployment im Zustand `pending` mit eigener ID an, setzt bei `manual`-Agents `target_revision = n` und sendet `ApplyRevisionCommand(n, deployment_id)` über den Stream, falls verbunden.
- Ältere Revisionen als die neueste dürfen nicht auf `auto_pull`-Agents gepusht werden (sie würden beim nächsten Abruf wieder auf die neueste wechseln), und keine Revision darf auf einen Agent gepusht werden, der bereits eine neuere aktiv hat (der Replay-Schutz des Agents nach ADR 0009 würde sie ablehnen): in beiden Fällen Fehler `rollback_required`; dafür gibt es den Rollback. Die gerade aktive Revision erneut zu pushen ist erlaubt (erneutes Anwenden).
- Der Agent merkt sich die IDs der zuletzt bearbeiteten Push-Deployments und wendet dieselbe ID nie zweimal an; ein erneut angekündigter, schon bearbeiteter Push führt nur zum erneuten Senden des Berichts. Der Server beantwortet doppelte Berichte erfolgreich, ohne etwas zu ändern, und kündigt nur Deployments im Zustand `pending` an.
- **Nicht verbundene Agents** erhalten offene Push-Aufträge beim nächsten Verbindungsaufbau (der Server sendet die offenen `pending`-Deployments erneut) bzw. beim Polling: `GetDesiredStateResponse.deployment_id` ist gesetzt, solange ein Push offen ist; der Agent behandelt die Soll-Revision dann als Push, auch im Modus `manual`.
- `ReportDeployment` mit dieser ID schließt das Deployment ab (Ergebnis wie bisher). Berichte ohne ID (Pulls) werden wie in M3 als neue Zeile gespeichert.
### Änderungsübersicht („Entwürfe“)
- IPAM-Änderungen wirken sofort in der Datenbank; ausgerollt wird erst mit einer Revision. Eine eigene `pending_change`-Tabelle ist deshalb unnötig: Die offenen Änderungen ergeben sich aus dem Audit-Log seit der neuesten Revision, der Unterschied in der Konfiguration aus dem Vergleich „jetzt erzeugt“ gegen „neueste Revision“ (erzeugt mit deren Nummer, damit die SOA-Seriennummer keinen Unterschied vortäuscht).
- `GET /config/pending` liefert beides; die Oberfläche zeigt „X Änderungen warten auf Ausrollen“ in der Kopfzeile.
### Diff
- `GET /revisions/{n}/diff?against=m` (Standard m = n−1) liefert pro Datei Status (`added`, `removed`, `modified`) und einen Unified-Diff (Paket `server/internal/textdiff`, Myers-Algorithmus, 3 Kontextzeilen). Unveränderte Dateien werden ausgelassen.
### Rollback
- `POST /revisions/{n}/rollback` erzeugt eine **neue** Revision mit dem Inhalt von n (ADR 0009). Damit die SOA-Seriennummer trotzdem steigt, wird nicht der alte Text kopiert, sondern die gespeicherte **Generator-Eingabe** von n (`config_revisions.source`, JSON, ab Migration 00004) mit der neuen Nummer erneut gerendert. Revisionen ohne gespeicherte Eingabe (vor M4) können nicht zurückgerollt werden (`rollback_unavailable`).
- Danach gilt die neue Revision wie jede andere: `auto_pull`-Agents erhalten sie sofort, `manual`-Agents nach einem Push.
### Live-Status
- `GET /events` liefert einen **Server-Sent-Events**-Strom für angemeldete Benutzer (Recht `ipam.read`). Ereignisse enthalten nur Kennungen, keine Daten: `agent` (`id`), `deployment` (`id`, `agent_id`, `revision`), `revision` (`number`), `pending`; seit M5 außerdem `addresses` (`network_ids`) für Adressen, die die Lease-Erfassung angelegt oder geändert hat (ADR 0023). Die Oberfläche lädt daraufhin die betroffenen Abfragen neu und fällt bei Verbindungsabbruch auf Polling zurück. Alle 25 s ein Kommentar als Keepalive.
- SSE statt WebSocket: nur Server→Browser nötig, funktioniert mit Sitzungscookie, Proxys und `EventSource` ohne Zusatzbibliothek; der Endpunkt nutzt die bestehende Sitzungsprüfung.
- Quelle ist ein prozessinterner Ereignisbus (`server/internal/events`), den Dienst und Agent-API bei Änderungen benachrichtigen. Mehrere Server-Instanzen sind nicht vorgesehen (ADR 0006).
## Präzisierungen bei der Umsetzung
- Beim Wechsel nach `manual` wird `target_revision` auf die neueste Revision gesetzt: Der Agent bleibt auf seinem Stand, statt eine Soll-Revision 0 anzuzeigen.
- Offene Pushes werden auch dann `superseded`, wenn eine neue Revision oder ein Rollback sie für `auto_pull`-Agents überholt, beim Wechsel nach `auto_pull` (für ältere Revisionen) und wenn beim Verbinden oder Abrufen eine andere Revision Soll ist. Ein später doch eintreffender Bericht überschreibt `superseded` mit dem echten Ergebnis.
- „Änderungen seit der neuesten Revision“ werden über die Audit-ID des `revision.create`/`revision.rollback`-Eintrags abgegrenzt, nicht über Zeitstempel (Server- und Datenbankuhr können abweichen). Die Einträge enthalten keine IP-Adressen, weil auch Leser sie abrufen.
- Die Revisionsnummer steht auch im Kopfkommentar der BIND-Dateien; ein Rollback-Diff zeigt daher neben der Seriennummer eine Kommentarzeile je Datei.
- Der Ereignisstrom beginnt mit `retry: 5000`, sendet alle 25 s `: keepalive` und endet mit der Sitzung (abgemeldet, abgelaufen, Benutzer gesperrt; geprüft je Keepalive-Intervall, ohne die Sitzung zu verlängern).
## Konsequenzen
- Revisionen bleiben unveränderlich, Rollbacks sind nachvollziehbar und die Seriennummer steigt immer.
- Die Oberfläche braucht kein Polling mehr für Agents und Deployments; Polling bleibt als Rückfall.
- Ein offener Push für einen dauerhaft abgeschalteten Agent bleibt `pending`, bis der Agent sich meldet, gelöscht wird oder ein neuer Push ihn ersetzt (ältere offene Deployments desselben Agents werden dabei als `superseded` abgeschlossen).
+86
View File
@@ -0,0 +1,86 @@
# 0023 – Cluster, Zonen, Records, TSIG und Lease-Erfassung (M5)
- **Status:** Akzeptiert
- **Datum:** 2026-10-14
## Kontext
Bis M4 erhalten alle DNS-Agents dieselben Dateien (jede Zone als Primary) und alle DHCP-Agents dieselbe Kea-Konfiguration. Zonen werden aus den Domains und CIDRs der Netze abgeleitet. M5 verlangt laut Brief DNS-Primary/Secondary mit NOTIFY und TSIG-gesichertem AXFR/IXFR, Kea-HA, Zonen- und Record-Verwaltung, dynamische Zonen mit Active-Directory-Beispiel und die automatische Erfassung dynamischer Leases als Adressen. Grundlage sind ADR 0004, 0005, 0009, 0014, 0015, 0021 und 0022.
## Entscheidung
### Cluster
- Tabelle `clusters` (`id`, `name` eindeutig, `role` = `dns`|`dhcp` als bestehender Typ `agent_role`, `description`, Versionsfeld). Mitglieder in `cluster_members` (`cluster_id`, `agent_id` eindeutig – ein Agent gehört höchstens einem Cluster an –, `role` = `primary`|`secondary`, `address`). Die Rolle des Agents muss zur Rolle des Clusters passen; die Datenbank erzwingt das über zusammengesetzte Fremdschlüssel auf `(id, role)` von `clusters` und `agents`.
- **DNS-Cluster:** genau ein `primary` (partieller Unique-Index), beliebig viele `secondary`. `address` ist die IP, über die Primary und Secondaries einander erreichen (NOTIFY, AXFR/IXFR, Port 53). Optional überschreiben `primary_ns` (SOA-MNAME) und `name_servers` (NS-Records) die Generator-Einstellungen für die Zonen des Clusters. Zu jedem DNS-Cluster legt der Server automatisch einen **Transfer-Schlüssel** (TSIG, `hmac-sha256`) an.
- **DHCP-Cluster:** höchstens ein `primary` und ein `secondary`. Mit beiden Mitgliedern wird Kea-HA erzeugt, im Modus `hot-standby` (Standard; der `secondary` wird Keas `standby`) oder `load-balancing`. Mit einem Mitglied arbeitet es als Einzelserver. `address` ist die IP des HA-Listeners, `ha_port` (Standard 8001) dessen Port.
- Netze erhalten `dhcp_cluster_id`, Zonen `cluster_id` (beide optional). In den Generator-Einstellungen bestimmen `dns.default_cluster_id` und `dhcp.default_cluster_id`, wohin Zonen bzw. Netze ohne eigene Zuordnung gehören.
- **Standardgruppe:** Zonen und Netze, die danach keinem Cluster gehören, erhalten wie bisher alle Agents der Rolle, die **keinem** Cluster angehören (jede Zone als Primary, kein HA). Ohne Cluster verhält sich das System also genau wie in M4.
- Ein Agent wird erst nach der Registrierung im UI einem Cluster zugeordnet; die Bindung eines Enrollment-Tokens an einen Cluster (ADR 0008) entfällt vorerst.
### Revisionen pro Ziel
- Revisionen bleiben global und fortlaufend (Nummer = SOA-Seriennummer). Die Dateien einer Revision tragen zusätzlich ein **Ziel** (`config_revision_files.target`): `''` für die Standardgruppe, sonst die Agent-ID eines Cluster-Mitglieds. Jedes Cluster-Mitglied erhält ein eigenes Ziel, auch wenn es nichts bedient (dann ohne Zonen bzw. Subnetze) – der Generator erzeugt je Ziel immer mindestens die Hauptdateien.
- Ein Agent erhält beim Abruf die Dateien des Ziels mit seiner ID, wenn die Revision eines hat, sonst die der Standardgruppe. Maßgeblich ist allein der Inhalt der Revision: Änderungen an Clustern wirken wie alle anderen Änderungen erst mit der nächsten Revision und erscheinen vorher unter den offenen Änderungen.
- Revision, Vorschau, Diff und offene Änderungen nennen das Ziel jeder Datei; `targets` beschreibt die Ziele (Rolle, Cluster, Mitgliedsrolle) mit den Namen zum Zeitpunkt der Revision.
- `configgen.Source` erhält ein Feld `Version` (M5: 2). Gespeicherte Eingaben älterer Revisionen enthalten keine Zonen, Records und Cluster; sie werden damit genau wie in M4 erzeugt und bleiben für Rollbacks renderbar.
### Geheimnisse in Revisionen
- TSIG-Schlüssel (`tsig_keys`: `name` eindeutig, `algorithm` aus `hmac-sha256`, `hmac-sha384`, `hmac-sha512`, `secret` mit dem Master-Key versiegelt nach ADR 0015) und das HA-Passwort eines DHCP-Clusters (versiegelt in `clusters`) gelangen in Konfigurationsdateien. Solche Dateien markiert der Generator als **geheim** (`Artifact.Secret`); sie enthalten nur die Geheimnisse, nie andere Konfiguration.
- Inhalte geheimer Dateien liegen in `config_revision_files` versiegelt (zugehörige Daten: Revision, Rolle, Ziel, Pfad). Die REST-API liefert sie **nie** aus (`secret: true`, `content: ""`); Diffs nennen nur den Status. Nur das signierte Bundle an den Agent enthält sie im Klartext (mTLS).
- In der gespeicherten Generator-Eingabe (`config_revisions.source`) stehen keine Geheimnisse; sie liegen versiegelt in `config_revisions.secrets` (JSON-Objekt Name → Wert) und werden für Rollbacks wieder eingesetzt.
- Schlüssel werden vom Server erzeugt (Zufall, Länge = Hash-Länge). Administratoren können das Geheimnis anzeigen (`GET /tsig-keys/{id}/secret`, auditiert, z. B. für `nsupdate` oder DHCP-Server von Domain Controllern) und erneuern (`POST /tsig-keys/{id}/rotate`). Transfer-Schlüssel gehören ihrem Cluster und werden mit ihm gelöscht.
### DNS: Zonen, Records, Primary/Secondary
- **Explizite Zonen** (`zones`: `name` eindeutig, `kind` `forward`|`reverse`, `mode` `managed`|`dynamic`, `cluster_id`, `default_ttl` optional, `allow_update` als CIDR-Liste, Update-Schlüssel über `zone_update_keys`, `description`).
- **Abgeleitete Zonen** bleiben erhalten: Für jede Netz-Domain ohne explizite Zone gleichen Namens oder darüber entsteht eine managed Forward-Zone, für jedes Netz die Reverse-Zonen wie bisher, sofern keine explizite Zone gleichen Namens oder darüber existiert. `GET /zones/derived` listet sie; das UI bietet an, sie als explizite Zone zu übernehmen.
- A/AAAA-Records aus Adressen kommen in die Forward-Zone mit dem **längsten passenden Suffix** des FQDN, PTR-Records entsprechend in die Reverse-Zonen. Gibt es keine, entsteht eine Warnung.
- **Records** (`dns_records`) gehören zu expliziten Zonen: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV`, `NS`, `PTR`; Name relativ zur Zone (`@` = Apex), TTL optional, typisierte Datenfelder. Der Server prüft beim Speichern mit denselben Regeln wie der Renderer. Konflikte, die erst aus dem Zusammenspiel entstehen (CNAME neben anderen Daten, auch neben A-Records aus Adressen), werden beim Erzeugen verworfen und als Warnung gemeldet.
- **Dynamische Zonen:** Records gehen nur in die Anfangsdatei (`CreateOnly`). `allow-update` erlaubt die Quellen aus `allow_update` und die zugeordneten TSIG-Schlüssel (`key "<name>"` in derselben ACL). Für Active Directory: Records unter `_msdcs` usw. in der dynamischen Zone, unsichere Updates nur von den IPs der Domain Controller oder per TSIG; GSS-TSIG ist nicht Teil von Version 1 (siehe `docs/ACTIVE-DIRECTORY.md`).
- **Primary/Secondary:** Auf dem Primary erhalten die Zonen eines Clusters `notify explicit`, `also-notify` mit den Adressen der Secondaries und dem Transfer-Schlüssel sowie `allow-transfer { key "<transfer-key>"; }`. Secondaries erhalten für jede Zone nur `type secondary`, `primaries { <primary-address> key "<transfer-key>"; }` und eine Zonendatei unter `<zone_dir>/secondary/db.<zone>`, die BIND schreibt (der DNS-Agent legt das Verzeichnis an und verändert dessen Inhalt nie). Dynamische Zonen sind auf Secondaries normale Secondary-Zonen. Ein Cluster ohne Primary bedient keine Zonen (Warnung).
- Schlüssel stehen in der geheimen Datei **`ddi-suite-keys.conf`**, die `named.conf` auf oberster Ebene **vor** `ddi-suite.conf` einbindet (Dateimodus 0640). Sie wird immer erzeugt, notfalls nur mit Kommentar.
### DHCP: Kea-HA
- HA nutzt `libdhcp_ha.so` (frei) zusammen mit `libdhcp_lease_cmds.so` und erfordert **Kea ≥ 3.0**: Der dedizierte HA-Listener prüft in Kea keine Anmeldedaten. Stattdessen öffnet `kea-dhcp4` einen eigenen HTTP-Control-Socket an `address:ha_port` mit Basic-Auth (`authentication.clients` mit `user-file` und `password-file`), `http-dedicated-listener` ist aus, und jeder Peer meldet sich mit `basic-auth-user-file` und `basic-auth-password-file` beim anderen an. **Benutzer und Passwort stehen nur in Dateien:** Kea 3.x lehnt Klartext-`user`/`password` in der HTTP-Authentifizierung ab („use of clear text 'user' is NOT SECURE“, Kea-Handbuch, Sicherheitsprüfungen ab 3.0). Benutzername und Passwort erzeugt der Server pro Cluster; Benutzer- und Passwortdatei (`ddi-suite-ha-user`, `ddi-suite-ha-password`) sind geheime Dateien des Bundles. TLS für HA folgt später.
- Peer-Namen sind fest (`primary`, `secondary`); `this-server-name` unterscheidet die Ziele. `auto-failover` ist an.
- Leases melden beide Peers; die Tabelle `dhcp_leases` hält sie je Agent.
### Lease-Erfassung als Adressen
> **Ersetzt durch [ADR 0025](0025-dhcp-pro-adresse-pruefungen-ui.md)** („Lease-Übernahme“): Leases aktualisieren nur vorhandene Einträge, legen keine an und ändern keinen Hostnamen; der vom Client gemeldete Name steht in `lease_hostname`. Der folgende Abschnitt beschreibt den Stand von M5.
- Beim Speichern gemeldeter Leases gleicht der Server die Adresstabelle ab: Eine **aktive** Lease auf eine IP ohne Eintrag, die in einem Pool eines Netzes liegt, legt einen Eintrag vom Typ `dynamic` an (MAC, Hostname aus der Lease, wenn er ein gültiges Label ist, `first_seen`, `last_seen`, `lease_state`, `lease_expires_at`). Bestehende Einträge erhalten `last_seen`, `lease_state` und `lease_expires_at`; bei `dynamic`-Einträgen werden auch MAC und Hostname aus der Lease übernommen. Abgelaufene und freigegebene Leases ändern nur den Lease-Zustand. Einträge werden nie automatisch gelöscht.
- Diese Änderungen erzeugen keine Audit-Einträge pro Lease (sie ändern keine Konfiguration: `dynamic` erzeugt weder DNS noch DHCP-Reservierungen), sondern ein Ereignis für die Oberfläche. Aus einem erfassten Eintrag wird per Typwechsel nach `manual_dhcp` eine Reservierung.
### Rechte
- Cluster und TSIG-Schlüssel: Lesen mit `ipam.read` (ohne Geheimnisse), Ändern mit `settings.admin`. Zonen und Records: Lesen mit `ipam.read`, Ändern mit `ipam.write`. Alle Änderungen schreiben Audit-Einträge und zählen zu den offenen Änderungen.
## Präzisierungen bei der Umsetzung (Server)
- **Schema (Migration 00005):** `cluster_members` trägt neben der Mitgliedsrolle (`member_role`) die Rolle von Agent und Cluster (`role`); die Fremdschlüssel `(cluster_id, role)` und `(agent_id, role)` erzwingen gleiche Rollen. Zonen (`cluster_role`, fest `dns`) und Netze (`dhcp_cluster_role`, fest `dhcp`) verweisen ebenso über `(id, role)` auf Cluster. Der Transfer-Schlüssel gehört über `tsig_keys.cluster_id` (Löschen kaskadiert) seinem Cluster; `clusters.transfer_key_id` verweist auf `(id, cluster_id)` des Schlüssels, sodass er sich nicht allein löschen lässt. Adressen eines Clusters sind je Cluster eindeutig. Records sind (TTL ausgenommen) je Zone eindeutig, TXT-Daten über einen Hash im Index.
- **Geheimnisse:** TSIG-Geheimnisse liegen als Rohbytes versiegelt in `tsig_keys.secret` (zugehörige Daten: Tabelle, Spalte, ID), das HA-Passwort in `clusters.ha_password`. Der HA-Benutzer heißt `ddi-ha-<12 Hex-Zeichen der Cluster-ID>`, der Transfer-Schlüssel `transfer-<Cluster-ID>`. `config_revisions.secrets` enthält `{"key:<id>": "<base64>", "ha:<cluster-id>": "<passwort>"}`; fehlt ein benötigter Wert, ist ein Rollback `rollback_unavailable`. In die Generator-Eingabe gelangen nur Schlüssel, auf die Zonen oder Cluster verweisen; ein unbenutzter Schlüssel ändert die Konfiguration nicht.
- **Offene Änderungen:** Änderungen an Clustern, Zonen, Records und TSIG-Schlüsseln (`tsig_key.create`/`update`/`delete`/`rotate`) zählen dazu, das Anzeigen eines Geheimnisses (`tsig_key.read_secret`) nicht.
- **Dynamische Zonen** brauchen mindestens eine Quelle in `allow_update` oder einen Schlüssel in `update_key_ids` (`update_source_required`); bei managed Zonen werden beide Listen geleert. Reverse-Zonen werden auf gültige Oktette bzw. Nibbles geprüft.
- **Lease-Erfassung (ersetzt durch ADR 0025):** Ein Hostname wird nur übernommen, wenn er ein gültiges Label ist (sonst bleibt er leer bzw. unverändert); der FQDN folgt aus der Domain des Netzes. Lease-Felder (`last_seen`, `lease_state`, `lease_expires_at`) ändern die `version` nicht, eine geänderte MAC oder ein geänderter Hostname eines `dynamic`-Eintrags schon. Ist das Adresslimit der Lizenz erreicht, werden keine weiteren Einträge angelegt. Die Oberfläche erhält das Ereignis `addresses` mit `network_ids` (ADR 0022).
- **Fehlercodes:** `key_exists`, `invalid_key_name`, `key_in_use`, `key_owned_by_cluster`, `cluster_exists`, `cluster_role_mismatch`, `agent_in_other_cluster`, `cluster_primary_exists`, `cluster_member_limit`, `duplicate_member` (Agent oder Adresse doppelt), `unknown_agent`, `cluster_in_use` (Parameter `zones`, `networks`, `default`, `key_zones`), `zone_exists`, `zone_kind_mismatch`, `zone_not_empty` (`count`), `update_source_required`, `record_exists`, `cname_at_apex`, `field_not_allowed`, `invalid_label`, `invalid_txt`, `ip_family`, `not_found` (als Feldfehler für verwiesene Cluster und Schlüssel), `secrets_unavailable` (kein Master-Key).
## Präzisierungen bei der Umsetzung (Generator und Agent)
- **Warnungen:** `dns.no_zone` (`ip`, `fqdn`), `dns.cname_conflict` (`zone`, `name`, `target`; CNAME neben anderen Daten, am Apex, auf einem Glue-Namen oder doppelt), `dns.unknown_cluster` (`zone`, `cluster_id`; die Zone wird von niemandem bedient), `dns.cluster_without_primary` (`cluster`, `cluster_id`; nur wenn der Cluster Zonen hat), `dns.unknown_key` (`zone`, `key` = Schlüssel-ID), `dns.record_unknown_zone` (`zone_id`, `name`), `dhcp.unknown_cluster` (`network_id`, `cidr`, `cluster_id`).
- Secondaries erhalten `notify no;`, damit sie einander nicht benachrichtigen. `allow-transfer { key …; }` steht beim Primary, sobald ein Transfer-Schlüssel gesetzt ist.
- Der Kopf von `ddi-suite-keys.conf` enthält keine Revisionsnummer: Die Datei ist geheim, ein Diff zeigt also nur „geändert“, und das soll eine Schlüsseländerung bedeuten.
- Die HA-Zugangsdaten stehen in zwei geheimen Dateien, `ddi-suite-ha-password` und `ddi-suite-ha-user` (Modus 0640), jeweils mit Zeilenumbruch am Ende (Kea liest nur die erste Zeile). Klartext-Benutzer oder -Passwörter stehen nirgends in der Konfiguration, weil Kea 3.x sie ablehnt (Quelltext `basic_auth_config.cc`: „use of clear text 'user' is NOT SECURE“). Die Benutzerdatei liegt im Verzeichnis der Passwortdatei (Einstellung `ha_password_file`); deren Dateiname muss `ddi-suite-ha-password` sein, sonst meldet der Generator einen Fehler. Der DHCP-Agent schreibt nur Dateien dieser beiden Namen in ein vorhandenes, gemeinsames Verzeichnis, auf das Control-Socket-Clients und HA-Peers übereinstimmend verweisen, und zwar vor der Konfiguration; scheitert das Neuladen, stellt er beide wieder her, entfällt HA, löscht er sie. `kea-dhcp4 -t` läuft auf einer Kopie der Konfiguration, die auf die Dateien der Revision zeigt. Der Health-Check vergleicht beide Dateien, ohne Inhalte zu nennen.
- Der DNS-Agent legt `<zone_dir>/secondary` mit Besitzer und Modus von `zone_dir` an. Noch nicht übertragene Secondary-Zonen sind im Health-Check ein eingeschränkter Einzelcheck, aber kein Fehler (kein Rollback). Revisionen von vor M5 erhalten beim Anwenden eine leere Schlüsseldatei, damit `named.conf` sie weiter einbinden kann.
- Load-Balancing verlangt getrennte Pools je Server (Kea-Handbuch, HA): Der Generator teilt jeden Pool in zwei Hälften, die untere für den Primary (`client-classes: ["HA_primary"]`), die obere für den Secondary (`HA_secondary`); ein Pool aus einer Adresse geht an den Primary. Standard bleibt Hot-Standby.
## Konsequenzen
- Server ohne Cluster verhalten sich unverändert; Golden-Files von M2–M4 bleiben gültig.
- Mitglieder verschiedener Rollen eines Clusters erhalten unterschiedliche Dateien; ein Rollback stellt auch die damalige Cluster-Zuordnung wieder her.
- Ein Rollback im Agent kann die Seriennummer auf dem Primary senken; Secondaries behalten dann die höhere Seriennummer, bis eine neue Revision kommt. Rollbacks über das UI erzeugen immer eine neue, höhere Nummer.
- Kea-HA setzt Kea 3.x voraus; mit Kea 2.6 bleibt nur der Einzelserver.
- `named.conf` muss eine weitere Include-Datei einbinden; Entwicklungsumgebung, End-to-End-Test und Installer (M6) werden angepasst.
@@ -0,0 +1,93 @@
# 0024 – Auslieferung: Installer, systemd, Pakete, Release-Signatur (M6)
- **Status:** Akzeptiert
- **Datum:** 2026-10-14
## Kontext
Brief §10 verlangt `scripts/install.sh` für die Rollen `server`, `agent-dns` und `agent-dhcp` auf Debian 12/13, Ubuntu 22.04/24.04 und RHEL-Familie 9, mit Vorabprüfung, Paketinstallation, systemd-Units, Datenbank, Master-Key, Enrollment, Prüfsummen und Signatur, idempotent mit `--upgrade`, `--uninstall`, `--dry-run`, `--non-interactive`, `--yes`. §8 verlangt minimale Rechte und systemd-Hardening. Bisher legen nur die Entwicklungsumgebung und der End-to-End-Test fest, wie BIND, Kea und die Agents zusammenspielen (ADR 0004, 0005, 0021, 0023). Die CI-Runner können privilegierte Container mit systemd starten (geprüft mit Debian 12, Ubuntu 24.04 und Rocky 9).
## Entscheidung
### Installer
- `scripts/install.sh` (Bash, `set -euo pipefail`, shellcheck-sauber). Namen stehen einmal im Variablenblock oben (ADR 0018). Logdatei `/var/log/<produkt>/install.log` (ohne Geheimnisse: Tokens, Lizenzschlüssel und Passwörter werden nie geschrieben).
- **Optionen:** `--role server|agent-dns|agent-dhcp` (mehrfach oder mit Komma), `--version <tag>` (Standard: neuestes Release), `--server <host:port> --token <token> --ca-hash sha256:<hex>` (Agents; die Werte stehen im Enrollment-Befehl der Oberfläche), `--public-url`, `--tls-cert`/`--tls-key` (sonst selbstsigniertes Zertifikat), `--license-key-file` bzw. Abfrage (optional), `--kea-source isc|distro` (Standard `isc`), `--from-dir <dir>` (Release-Artefakte aus einem Verzeichnis statt Download; Tests, Offline), `--upgrade`, `--uninstall` (mit `--purge` auch Daten, Datenbank und Schlüssel), `--dry-run`, `--non-interactive`, `--yes`, `--force` (Vorabprüfung nur warnen). Umgebung: `DDI_RELEASE_BASE_URL` (Standard `https://git.beugsiud.de/beug.k/DDI/releases/download/<tag>/`), `DDI_GIT_TOKEN`/`--git-token`.
- **Ablauf:** OS und Architektur aus `/etc/os-release`/`uname -m` → Vorabprüfung nach `docs/SYSTEM-REQUIREMENTS.md` (CPU, RAM, Platte, belegte Ports, Zeitsynchronisation) → Artefakte laden, `SHA256SUMS` prüfen, Signatur prüfen (s. u.) → Pakete → Benutzer, Verzeichnisse, Konfiguration → Units → Dienste starten → Ergebnis ausgeben (URL, einmaliges Admin-Passwort bzw. „Agent registriert“). Bei Abbruch werden in diesem Lauf angelegte Dateien wieder entfernt; Pakete bleiben installiert.
- **Idempotent:** Vorhandene Konfiguration, Master-Key, Datenbank und Agent-Identität werden nie überschrieben. Ein zweiter Lauf ohne `--upgrade` ändert nichts, was schon stimmt. `--upgrade` ersetzt nur Binaries und Units und startet die Dienste neu (Migrationen laufen beim Serverstart).
### Server-Rolle
- Pakete: PostgreSQL der Distribution (≥ 13; EL9: Modul-Stream ≥ 15), `openssl`, `curl`.
- Systembenutzer `<produkt>` (z. B. `ddi-suite`), Konfiguration `/etc/<produkt>/server.yaml` (0640 root:`<produkt>`), Master-Key `/etc/<produkt>/master.key` (0400, Besitzer der Dienstbenutzer – der Server verweigert den Start, wenn Gruppe oder andere lesen dürfen; erzeugt mit `ddi-server gen-master-key`), Zustand `/var/lib/<produkt>/server` (systemd `StateDirectory`).
- Datenbank: Rolle und Datenbank `<produkt>` mit **Peer-Authentifizierung über den Unix-Socket** (`postgres:///<produkt>?host=/var/run/postgresql`): kein Datenbankpasswort.
- HTTPS auf Port 8443 (Standard), Agent-API auf 8444. Ohne `--tls-cert`/`--tls-key` erzeugt der Installer ein selbstsigniertes Zertifikat für den Hostnamen aus `--public-url` und weist darauf hin.
- Erster Administrator: `ddi-server bootstrap-admin` (Einmal-Passwort wird nur am Ende ausgegeben). Lizenzschlüssel optional über `ddi-server license set-key` (liest den Schlüssel von stdin); vorher wird `https://license.beugsiud.de` angefragt (nur Warnung).
### Agent-Rollen
- **Ein Agent läuft als Benutzer des Dienstes, den er verwaltet:** DNS-Agent als BIND-Benutzer (`bind` bzw. `named`), DHCP-Agent als Kea-Benutzer (`_kea` bzw. `kea`). Ein Agent schreibt ohnehin die Konfiguration dieses Dienstes; mehr Rechte als der Dienst selbst erhält er so nicht, und root ist nicht nötig. Der Benutzer wird per Drop-in (`<unit>.d/10-user.conf`) gesetzt.
- **BIND:** Paket der Distribution (Debian/Ubuntu `bind9`, EL9 `bind9.18`, sonst `bind` mit Warnung bei < 9.18). `conf_dir` `/etc/bind/<produkt>` bzw. `/etc/named/<produkt>`, `zone_dir` überall `/var/lib/bind/<produkt>` (Gruppe des BIND-Benutzers, setgid, schreibbar für named; EL: SELinux-Kontext `named_cache_t` per `semanage fcontext`). Der Installer bindet `ddi-suite-keys.conf`, `ddi-suite.conf` und `ddi-suite-options.conf` in `named.conf` ein (markierter Block, beim Deinstallieren entfernt) und entfernt bzw. kommentiert Optionen der Distribution, die der Generator setzt (`recursion`, `allow-recursion`, `forwarders`, `allow-transfer`, `listen-on` nur falls auf localhost beschränkt).
- **Kea:** Standard aus dem ISC-Repository `isc/kea-3-2` (Cloudsmith, Paketsignatur geprüft), weil die Distributionen Kea 2.0–2.6 liefern und HA Kea ≥ 3.0 braucht (ADR 0023). Mit `--kea-source distro` das Paket der Distribution (ohne HA). Initiale `kea-dhcp4.conf` mit Control-Socket und `lease_cmds`; Pfade wie die Standard-Generator-Einstellungen (`/run/kea/kea4-ctrl-socket`, `/var/lib/kea/kea-leases4.csv`, `/etc/kea/ddi-suite-ha-*`).
- Agent-Konfiguration `/etc/<produkt>/agent-dns.yaml` bzw. `agent-dhcp.yaml`, Zustand `/var/lib/<produkt>/agent-<rolle>`. Enrollment mit `--server --token --ca-hash` (wie der Befehl aus der Oberfläche) als Dienstbenutzer. Ohne Token wird der Agent installiert, aber nicht gestartet.
### systemd
- Vorlagen `deploy/systemd/*.service.in` mit Platzhaltern (`@PRODUCT@`, `@DISPLAY_NAME@`, `@BIN_SERVER@`, `@BIN_AGENT_DNS@`, `@BIN_AGENT_DHCP@`, `@BINDIR@`, `@CONFIG_DIR@`, `@ENV_PREFIX@`). `build-release.sh` legt die gerenderten Units in die Tarballs (`@BINDIR@` = `/usr/local/bin`), Pakete rendern sie mit `/usr/bin`.
- Hardening in allen Units: `NoNewPrivileges`, `ProtectSystem=strict` mit gezielten `ReadWritePaths`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `ProtectKernel*`, `ProtectControlGroups`, `ProtectClock`, `RestrictAddressFamilies`, `RestrictNamespaces`, `RestrictSUIDSGID`, `LockPersonality`, `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, leere `CapabilityBoundingSet` (Ausnahme seit ADR 0025: der Server hat `CAP_NET_RAW` für die Ping-Prüfung), `UMask=0027`. Server: eigener Benutzer.
### Release
- Tarballs enthalten Binary, Unit und Lizenztexte. Zusätzlich optional `.deb`/`.rpm` mit `nfpm` (`make packages`); die Pakete enthalten nur Binary und Unit, eingerichtet wird mit dem Installer.
- **Signatur:** `SHA256SUMS.sig` ist eine Ed25519-Signatur über `SHA256SUMS` (`openssl pkeyutl -sign -rawin`). Der Release-Workflow signiert, wenn das Secret `RELEASE_SIGNING_KEY` (PEM) gesetzt ist. Der Installer enthält den öffentlichen Schlüssel im Variablenblock; ist er gesetzt, muss die Signatur gültig sein, sonst nur Prüfsummen mit Hinweis.
- Die CI prüft, dass Units und Installer keine Produktnamen außerhalb der Variablenblöcke enthalten (ADR 0018).
### Server-Ergänzungen
- `ddi-server license set-key` (Schlüssel von stdin, aktiviert wie `PUT /license`).
- Aufbewahrung (`retention.audit_days`, Standard 0 = unbegrenzt; `retention.revisions_keep`, Standard 0 = alle): täglicher Aufräumlauf. Revisionen, die ein Agent anwendet, die neueste Revision und Revisionen mit offenen Deployments werden nie gelöscht.
### Tests
- CI-Job „Installation“: Release-Artefakte bauen, für jedes System (Debian 12/13, Ubuntu 22.04/24.04, Rocky 9) einen privilegierten systemd-Container starten, Installer und Artefakte per `docker cp` hineinlegen (`--from-dir`), alle drei Rollen installieren, Agents mit einem Token aus der API registrieren, eine Änderung ausrollen und mit `dig` und der Kea-Konfiguration prüfen, Installer erneut laufen lassen (idempotent), `--upgrade`, `--uninstall`.
## Konsequenzen
- Gemischte Distributionen in einem Cluster funktionieren, weil `zone_dir` und die Kea-Pfade überall gleich sind.
- Die Kea-Installation hängt standardmäßig vom ISC-Repository ab; ohne Internetzugang bleibt `--kea-source distro` oder ein eigener Spiegel.
- Agents haben die Rechte ihres Dienstes; ein kompromittierter Agent kann diesen Dienst umkonfigurieren, aber nicht mehr.
## Präzisierungen (Umsetzung)
### `license set-key`
- Liest den Schlüssel von stdin (höchstens 4096 Byte Eingabe, nach dem Trimmen nicht leer, eine Zeile, höchstens 512 Zeichen) und ruft denselben Service wie `PUT /api/v1/license` auf; der Audit-Eintrag `license.set_key` trägt den Akteur `system` (wie `bootstrap-admin`). Ausgegeben werden Status, Grund, Lizenzname, Ablauf, Grenzen und Features, nie der Schlüssel.
- Exit-Codes: `0` aktiviert, `1` sonstiger Fehler (Konfiguration, Datenbank), `2` Eingabe leer/zu groß/mehrzeilig, `3` Schlüssel abgelehnt (`license_invalid`, `license_in_use`), `4` Lizenzserver nicht erreichbar oder Antwort nicht vertrauenswürdig. Wie bei `PUT /license` wird bei `3` und `4` nichts gespeichert; der Installer behandelt `4` als Warnung.
### Aufbewahrung
- Die Löschung braucht keine Migration: Der Unveränderlichkeits-Trigger der Revisionen greift nur bei `UPDATE`; Dateien werden per `ON DELETE CASCADE` mitgelöscht.
- Der Aufräumlauf läuft fünf Minuten nach dem Start und dann alle 24 Stunden, in einer Transaktion mit der Revisionssperre (wie Erzeugen und Ausrollen). Er läuft auch im Lizenz-Lesemodus.
- Geschützt sind die neuesten `revisions_keep` Revisionen (mindestens die neueste) sowie jede Revision, die in `agents.applied_revision`, `last_good_revision` oder `target_revision` steht oder ein Deployment mit Ergebnis `pending` hat.
- **Deployments bleiben erhalten**, auch wenn ihre Revision gelöscht ist: Sie speichern nur die Nummer und sind Teil der Historie. Der Diff einer Revision gegen eine gelöschte Vorgängerin liefert `not_found`; ein Rollback auf eine gelöschte Revision ebenso.
- Audit-Einträge werden nach `at` gelöscht. Hat der Lauf etwas gelöscht, schreibt er `retention.cleanup` (Objekttyp `system`) mit den Anzahlen und dem Bereich der gelöschten Revisionsnummern. Die Anzeige „offene Änderungen“ bleibt korrekt, weil sie ohne den Audit-Eintrag der Revision auf deren Zeitpunkt zurückfällt.
### Pakete, Signatur, Namensprüfung
- **Pakete:** `scripts/build-packages.sh` (`make packages`, `make release PACKAGES=1`) verpackt die Binaries aus den Tarballs (keine zweite Übersetzung) mit nfpm 2.48.0, installiert mit Prüfsumme über `scripts/ci/install-tools.sh` (kein `go run`: nfpm gehört nicht in den Modulgraphen). Paketname = Binary-Name; Inhalt `/usr/bin/<binary>`, `/usr/lib/systemd/system/<binary>.service` (gerendert mit `/usr/bin`), `/usr/share/doc/<binary>/LICENSE`; Paket-Skripte nur `systemctl daemon-reload`. Keine Abhängigkeiten (PostgreSQL, BIND und Kea richtet der Installer ein, Kea je nach `--kea-source` mit anderen Paketnamen). Danach umfasst `SHA256SUMS` Tarballs und Pakete. Details: [RELEASE.md](../RELEASE.md).
- **Signatur:** `scripts/sign-release.sh` (`sign`, `verify`, `public-key`, `selftest`); die Signatur ist binär (64 Byte). Der Release-Workflow prüft die neue Signatur mit dem öffentlichen Schlüssel aus `scripts/install.sh`, falls dort einer steht. Die CI prüft Signieren und Verifizieren bei jedem Lauf mit einem Wegwerf-Schlüssel.
- **Namensprüfung:** `scripts/check-brand.sh` (`make lint-brand`, Job „Shell“) sucht `ddi-suite`, `ddi-server`, `ddi-agent-dns`, `ddi-agent-dhcp` (ohne Groß-/Kleinschreibung) in `deploy/systemd/*.in`, `deploy/nfpm/*` und `scripts/install.sh`. Erlaubt sind die Namen nur im Block zwischen den Zeilen `# --- names (ADR 0018) ---` und `# --- end names ---`, die im Installer genau einmal vorkommen müssen. Die nfpm-Konfiguration ist deshalb eine Vorlage (`deploy/nfpm/package.yaml.in`) mit Platzhaltern wie die Units.
### Installer
- `/etc/<produkt>` ist 0755 root:root: Die Agents (BIND- bzw. Kea-Benutzer) müssen ihre YAML-Datei dort lesen; jede Datei ist einzeln geschützt. Der Master-Key ist 0400 und gehört dem Dienstbenutzer des Servers (der Server startet sonst nicht).
- `conf_dir` des DNS-Agents gehört dem BIND-Benutzer (der Agent schreibt dort ohne root), `zone_dir` und `secondary/` sind 2770.
- Die Kea-Pakete von ISC sind mit `/var/run/kea` gebaut und vergleichen den Socket-Pfad wörtlich. Der Installer setzt `KEA_CONTROL_SOCKET_DIR=/run/kea` per Drop-in für Kea und den DHCP-Agent; so bleibt der Standardpfad der Generator-Einstellungen gültig.
- EL9: Die ISC-RPMs brauchen `log4cplus` aus EPEL; der Installer richtet EPEL ein und weist darauf hin. Der ISC-Schlüssel wird nur mit dem erwarteten Fingerprint angenommen.
- Zusätzlich zu den Generator-Optionen wird `allow-query { localhost; }` auskommentiert (Rocky-Standard). Abgeschaltete Zeilen tragen das Präfix `//<produkt>-disabled: ` und werden beim Deinstallieren wiederhergestellt.
- Basiswerkzeuge (curl, openssl, …) werden vor der Vorabprüfung installiert, weil die Erreichbarkeitsprüfungen sie brauchen. `--upgrade` überspringt die Ressourcenprüfung.
- Je Lauf wird höchstens ein Agent registriert (ein Token). Ein bereits registrierter Agent wird nicht erneut registriert (Neuregistrierung: `enroll --force`, siehe `OPERATIONS.md`).
- `DDI_RELEASE_BASE_URL` darf `<tag>` enthalten; ohne Platzhalter gilt sie als Verzeichnis der Artefakte. `--uninstall --purge` ohne installierte Rollen räumt alle Rollen ab.
- Die systemd-Unit des DHCP-Agents erlaubt `AF_NETLINK`: `kea-dhcp4 -t` erkennt die Schnittstellen per Netlink.
- Nicht in der CI geprüft: `--kea-source distro` (die Hooks werden ohne Pfad referenziert, was Kea 2.x nicht immer auflöst) sowie AppArmor und SELinux im erzwingenden Modus (Container setzen sie nicht durch).
- **Registrierung von Hand (Rückmeldung 2026-10-17):** Ein Agent, der ohne Token installiert und dann mit dem `enroll`-Befehl der Oberfläche registriert wurde, lief nicht als Dienst (Unit nie eingeschaltet, Identität gehörte root). Seitdem schaltet der Installer die Agent-Unit immer ein; `ConditionPathExists` auf `identity/agent.json` lässt systemd sie bis zur Registrierung überspringen. `enroll` als root übergibt das Zustandsverzeichnis an dessen Besitzer (den Dienstbenutzer) und ruft `systemctl enable --now` auf (`--no-start` verhindert das; der Installer nutzt es, weil er selbst startet). Erneuter Lauf und `--upgrade` reparieren die Besitzer und schalten einen registrierten Agent ein. Die Oberfläche zeigt neben dem `enroll`-Befehl den passenden Installer-Aufruf (`install_command`); der Installer kennt dafür `--server-name`.
@@ -0,0 +1,70 @@
# 0025 – DHCP pro Adresse, DNS für dynamische Einträge, Bereichs- und Massenänderungen, Erreichbarkeitsprüfung, Oberfläche
- **Status:** Akzeptiert
- **Datum:** 2026-10-16
## Kontext
Rückmeldung nach M6 (2026-10-16):
1. Netze sollen aufklappbar sein und **alle** IPs zeigen, belegt oder frei. DHCP soll nicht über Pools, sondern **pro IP** (ohne MAC) festgelegt werden.
2. Die Oberfläche wirkt generiert; sie soll wie ein von Hand gebautes Verwaltungswerkzeug aussehen.
3. Massenänderungen sollen möglich sein; einzelne Einträge sollen sich per Ping und DNS-Abfrage prüfen lassen.
4. Ein neuer dynamischer Eintrag erhält einen zufälligen Hostnamen, damit sich der DNS-Eintrag zur IP prüfen lässt.
5. Auch dynamische Einträge sollen DNS-Einträge erhalten (alle Typen außer `reserved`).
Bisher (ARCHITECTURE §3.2, ADR 0014) bestimmen Pools (`dhcp_pools`) den DHCP-Bereich; `dynamic` muss im Pool liegen und erzeugt kein DNS.
## Entscheidung
### Typen
| Typ | DHCP | DNS (A/PTR) |
| --- | --- | --- |
| `static` | nein | ja |
| `reserved` | nein (gesperrt) | nein |
| `manual_dhcp` | Reservierung MAC → IP | ja |
| `dynamic` | **IP gehört zum DHCP-Bereich** (ohne MAC) | **ja** (neu) |
### DHCP-Bereich aus Einzeladressen
- Pools werden nicht mehr gepflegt. Der DHCP-Bereich eines Netzes (mit `dhcp_enabled`) ist die Menge seiner `dynamic`-Einträge; für Kea werden sie zu **zusammenhängenden Bereichen** zusammengefasst (jede ununterbrochene Folge von `dynamic`-IPs ist ein Kea-Pool).
- `NetworkRequest.pools` entfällt; `Network.pools` bleibt in der Antwort als **abgeleitete**, schreibgeschützte Liste dieser Bereiche.
- Migration 00006: Für jeden vorhandenen Pool werden alle freien IPs des Pools als `dynamic`-Einträge angelegt (ohne Netz-, Broadcast- und Gateway-Adresse), dann entfällt `dhcp_pools`. Bestehende `dynamic`-Einträge ohne Hostname erhalten einen Zufallsnamen.
- `configgen.SourceVersion` wird **3**: Ab Version 3 erzeugt `dynamic` A/PTR-Records. Ältere gespeicherte Eingaben (Rollback) werden wie bisher erzeugt; ihre Pools stehen in den gespeicherten Netzen.
### Zufällige Hostnamen
- Wird ein `dynamic`-Eintrag ohne Hostnamen angelegt (Formular, CSV-Import, Bereichsoperation, Typwechsel nach `dynamic`, Migration), erzeugt der Server `dyn-` + 8 Zeichen aus `[a-z0-9]` (kryptographischer Zufall). Der Name ist je Zone eindeutig (bei Kollision neu würfeln).
- Der eindeutige Index auf `fqdn` gilt nun für `static`, `manual_dhcp` **und** `dynamic`.
- Der Hostname gehört dem Eintrag: Eine Lease ändert ihn **nicht** (sonst entstünde nach jeder Vergabe eine Konfigurationsänderung). Den vom Client gemeldeten Namen speichert der Server in der neuen Spalte `lease_hostname`; die Oberfläche zeigt ihn und bietet „Hostname aus Lease übernehmen“ an (normale Änderung, wirkt mit der nächsten Revision).
### Lease-Übernahme (ersetzt ADR 0023, Abschnitt „Lease-Erfassung“)
- Leases aktualisieren nur vorhandene Einträge: `last_seen`, `lease_state`, `lease_expires_at`, `lease_hostname`, bei `dynamic` zusätzlich `mac`. Für IPs ohne Eintrag wird nichts angelegt (Kea vergibt nur IPs aus `dynamic`-Einträgen).
### Alle IPs eines Netzes, Bereichsoperationen
- `GET /networks/{id}/ips?offset&limit&filter=all|free|used`: jede Host-Adresse eines IPv4-Netzes (bis /16) mit Zustand `free`, `network`, `broadcast`, `gateway` oder `used` samt Eintrag. IPv6-Netze nur mit `filter=used`.
- `POST /networks/{id}/range` (Recht `ipam.write`): `assign` legt für alle **freien** IPs eines Bereichs Einträge eines Typs an (`dynamic`, `static`, `reserved`; nicht `manual_dhcp`, das braucht MACs), `clear` löscht Einträge der angegebenen Typen (Standard `dynamic`, `reserved`) im Bereich. Mit `dry_run` nur zählen. Höchstens 65.536 Adressen, ein Audit-Eintrag je Operation (Bereich, Typ, Anzahlen).
### Massenänderungen
- `POST /addresses/bulk` erhält zusätzlich `set_description`, `regenerate_hostnames` (neue Zufallsnamen für `dynamic`-Einträge) und `dry_run` (nichts ändern, Probleme und Auswirkungen melden). Wie bisher alles oder nichts, ein Audit-Eintrag je Adresse.
### Erreichbarkeits- und DNS-Prüfung
- `POST /addresses/check` (Recht `ipam.read`, 1–100 Einträge, parallel, je Prüfung höchstens wenige Sekunden): **Ping** vom Server aus, zuerst über unprivilegierte ICMP-Echo-Sockets (`net.ipv4.ping_group_range`), sind die nicht erlaubt (z. B. in LXC-Containern), über einen Raw-Socket; dafür hat die Server-Unit als einzige Capability `CAP_NET_RAW` (wie `/bin/ping`). Geht beides nicht → Status `unavailable`. **DNS** vorwärts (FQDN → IP) und rückwärts (IP → FQDN).
- Gefragt werden die autoritativen Server der Zone: Mitgliedsadressen des DNS-Clusters bzw. die Adressen der Nameserver aus den Generator-Einstellungen; bei Zonen der Standardgruppe zusätzlich die DNS-Agents ohne Cluster (Adresse ihrer letzten Verbindung, Port 53); erst ohne all das der System-Resolver. Jeder Server hat eine eigene Frist von 2 s, damit ein falsch eingetragener Nameserver die anderen nicht verdrängt. Ergebnis je Richtung `ok`, `missing`, `mismatch` oder `error`, mit befragtem Server und Antworten, dazu Hinweise (`notes`): `dynamic_zone` (Name liegt in einer dynamischen Zone, die Einträge aus der Adresstabelle nur über die Anfangsdatei erhält), `system_resolver` (kein zuständiger Server bekannt), `not_deployed` (Eintrag oder sein Netz seit der letzten Revision geändert; abgegrenzt über Audit-IDs wie die Änderungsübersicht). Die Abfragen gehen direkt an den Server, ohne `/etc/hosts`.
- Der Server prüft nur Adressen aus der Adresstabelle (kein freies Ziel); es gibt keinen periodischen Scan.
### Oberfläche
- Aufbau eines Verwaltungswerkzeugs statt einer Präsentationsseite: linke Navigation in Gruppen, volle Breite, dichte Tabellen, Typ als kleines farbiges Kürzel statt Auswahlliste je Zeile, Erklärungen als Tooltip statt Absatz, eine Hauptschaltfläche je Seite, keine Taglines.
- Netze lassen sich in der Netzliste aufklappen (IP-Liste mit Bereichsauswahl per Umschalt-Klick, Aktionen für die Auswahl: DHCP, statisch, reserviert, freigeben, prüfen).
## Konsequenzen
- DHCP-Bereiche sind so fein wie Einzeladressen; große Bereiche bedeuten viele Zeilen (eine /16 voll dynamisch: 65.534 Einträge – im Rahmen der Messungen aus M6).
- Jede DHCP-Adresse hat einen DNS-Namen; Clients sind über den Namen ihrer IP auflösbar, nicht über ihren eigenen Hostnamen (dafür später Kea-DDNS, siehe LOGBUCH).
- Die Prüfung verlangt, dass ICMP vom Server zu den Netzen erlaubt ist; sonst meldet sie „nicht erreichbar“, was nicht „aus“ bedeutet.
@@ -0,0 +1,45 @@
# 0026 – Einträge der Adresstabelle in dynamischen Zonen (RFC-2136-Updates durch den Agent)
- **Status:** Akzeptiert
- **Datum:** 2026-10-17
## Kontext
Der Nutzer will die DNS-Zone seiner Windows-Domäne (z. B. `ad.beugsiud.de`) in DDI betreiben. Domain Controller tragen ihre SRV- und A-Records per RFC 2136 selbst ein; die Zone muss deshalb dynamisch sein (ADR 0004, 0023). Bisher schreibt DDI Einträge der Adresstabelle in eine dynamische Zone nur einmalig in die Anfangsdatei – danach fehlen neue oder geänderte Adressen dort dauerhaft (gesehen bei `ddi-test.ad.beugsiud.de`). Der Brief (§6.2) sieht vor: „Records in dynamischen Zonen verwaltet der Agent höchstens per `nsupdate`.“
## Entscheidung
- Für jedes Ziel, das mindestens eine dynamische Zone **als Primary** bedient, erzeugt der Generator zwei weitere Artefakte:
- `ddi-suite-update.key` (geheim, 0640): die Schlüsseldefinition im `named.conf`-/`nsupdate`-Format (`key "<name>" { algorithm …; secret "…"; };`). Derselbe Schlüssel steht auch in `ddi-suite-keys.conf`, damit `named` ihn kennt.
- `dynamic-records.json` (nicht geheim, 0640): die von DDI verwalteten Records je dynamischer Zone:
```json
{"version": 1, "key": "ddi-agent-update",
"zones": [{"zone": "ad.example.org",
"records": [{"name": "srv1.ad.example.org.", "ttl": 3600, "type": "A", "data": "192.0.2.5"}]}]}
```
Namen absolut mit Punkt, `data` in Präsentationsform (wie in einer Zonendatei bzw. für `nsupdate`), Zonen und Records sortiert. Enthalten sind A/AAAA/PTR aus der Adresstabelle und die in DDI angelegten Records der Zone (ohne SOA/NS des Apex).
- Der Schlüssel (`ddi-agent-update`, `hmac-sha256`) ist ein **Systemschlüssel**: Der Server legt ihn an, sobald es eine dynamische Zone gibt (beim Anlegen/Ändern einer dynamischen Zone und beim Start), er steht in der Liste der TSIG-Schlüssel (nicht löschbar, rotierbar) und wird automatisch in die Update-ACL jeder dynamischen Zone aufgenommen – zusätzlich zu `allow_update` und den Update-Schlüsseln der Zone.
- Die Anfangsdatei einer dynamischen Zone enthält weiterhin dieselben Records (einmalig, `CreateOnly`).
- **Agent (DNS-Rolle):** Nach dem Aktivieren und Neuladen von BIND gleicht er für jede Zone ab: Records der vorherigen aktiven Revision, die in der neuen fehlen, werden gelöscht (`update delete <name> <type> <data>`, also genau dieser Record), alle Records der neuen Revision werden hinzugefügt (`update add`; vorhandene identische Records ändern nichts). Ausführung mit `nsupdate -k <Schlüsseldatei der Revision>` gegen `dns_address` (Standard 127.0.0.1:53), je Zone eine Transaktion. Ist die Zone direkt nach `rndc reconfig` noch nicht geladen, wird einige Sekunden wiederholt. Ein Fehler lässt das Aktivieren scheitern (Rollback auf die vorige Revision, die ihren eigenen Stand wieder einträgt).
- Fremde Records (der Domain Controller, anderer Clients, per Hand) fasst der Agent nie an: Er löscht ausschließlich Records, die in der vorigen Revision als von DDI verwaltet standen.
- Secondaries erhalten die Änderungen wie bisher per NOTIFY/IXFR.
- Der Hinweis `dynamic_zone` der Adressprüfung (ADR 0025) entfällt für Einträge, die DDI so verwaltet.
## Präzisierungen bei der Umsetzung (Generator und Server)
- **Generator-Eingabe Version 4:** `Source.UpdateKeyID` nennt die ID des Systemschlüssels in `Source.Keys`; sein Geheimnis liegt wie bei jedem Schlüssel versiegelt in `config_revisions.secrets` (`key:<id>`), Rollbacks auf Revisionen ab Version 4 bringen also auch den damaligen Schlüssel mit. Gespeicherte Eingaben bis Version 3 erzeugen byte-gleich wie bisher (kein Schlüssel in der ACL, keine neuen Dateien).
- **Modell:** `DNSConfig.UpdateKey` nennt einen Schlüssel aus `DNSConfig.Keys` (wie `TransferKey`), Dateinamen als Konstanten `configgen.UpdateKeyFile` und `configgen.DynamicRecordsFile`. `derive` setzt ihn nur für Ziele, die mindestens eine dynamische Zone als Primary bedienen; Secondaries und Ziele mit nur managed Zonen erhalten weder Schlüssel noch Dateien. `bindgen` lehnt `UpdateKey` ohne dynamische Primary-Zone oder mit unbekanntem Schlüssel ab (Pfad `update_key`). Fehlt der Systemschlüssel in der Eingabe, meldet der Generator `dns.unknown_key` (`zone`, `key` = `UpdateKeyID`, ggf. leer) für jede dynamische Primary-Zone des Ziels und erzeugt sie wie vor Version 4.
- **Update-ACL:** Der Schlüssel steht in der ACL jeder dynamischen Primary-Zone, alphabetisch zwischen den Update-Schlüsseln der Zone und nur einmal, auch wenn die Zone ihn selbst nennt.
- **`ddi-suite-update.key`** (geheim, 0640): genau eine `key`-Anweisung ohne Kommentare, im Format von `named`s `session.key`. `nsupdate -k` liest sie über seinen Rückfall für Session-Keys (der Versuch als `K*.key`/`.private`-Paar scheitert vorher still); `named-checkconf` akzeptiert die Datei für sich allein.
- **`dynamic-records.json`** (0640, nicht geheim): Schlüssel `version` (1), `key`, `zones`. Jede dynamische Primary-Zone steht darin (nach Namen sortiert, ohne Punkt am Ende), auch ohne Records (`"records": []`), damit der Agent beim letzten Record einer Zone löschen kann. Records stehen in der Reihenfolge der Zonendatei (Owner kanonisch nach RFC 4034, dann Typ, dann Daten), eine Zeile je Record, mit `name` (absolut, mit Punkt), `ttl` (Record-TTL oder Zonen-Standard), `type`, `data` (Präsentationsform wie in der Zonendatei; TXT als gequotete, escapte Character-Strings je höchstens 255 Bytes, Steuerzeichen und Nicht-ASCII als `\DDD`). Inhalt sind alle Records, die der Generator für die Zone erzeugt (A/AAAA/PTR der Adresstabelle, Records der Zone nach Auflösung von CNAME-Konflikten; Records, die sich nur in der TTL unterscheiden, einmal mit der kleineren), außer NS-Records am Apex. Glue-Records der Namensserver stehen nicht darin; ein Record der Adresstabelle, der einem Glue-Record gleicht, aber schon – sonst sähe er gelöscht aus, sobald er Glue wird.
- **Systemschlüssel:** neue Spalte `tsig_keys.system` (Migration 00007; ein Systemschlüssel gehört keinem Cluster). Der Name `ddi-agent-update` ist reserviert: Anlegen per API ergibt `key_exists`, die Migration erklärt einen schon vorhandenen Schlüssel dieses Namens zum Systemschlüssel (sein Geheimnis bleibt gültig). Der Server legt ihn (`hmac-sha256`) in derselben Transaktion an, in der eine Zone als dynamisch angelegt oder geändert wird, sowie beim Start, wenn es dynamische Zonen gibt (ein Fehler dabei wird protokolliert und ist nicht fatal). Das Anlegen steht als `tsig_key.create` mit dem Akteur `system` im Audit-Log und zählt zu den offenen Änderungen. Löschen ergibt `key_system` (409); Rotieren und Beschreibung ändern gehen. Die API zeigt ihn mit `system: true`. Er bleibt bestehen, wenn die letzte dynamische Zone gelöscht wird, gelangt dann aber nicht mehr in die Generator-Eingabe.
- **Adressprüfung:** `dynamic_zone` wird nicht mehr gemeldet; der Wert bleibt in der Spezifikation (als veraltet beschrieben).
- **Gefunden beim Integrationstest:** `named` lehnt mit `check-integrity` (Standard bei Primary-Zonen) ein Update ab, das einen MX-Record auf einen Namen in der Zone ohne A/AAAA-Record hinzufügt – die ganze Transaktion der Zone scheitert dann, und mit ihr das Aktivieren. MX-Ziele in dynamischen Zonen brauchen daher eine Adresse (oder liegen außerhalb der Zone).
## Konsequenzen
- Eine Active-Directory-Zone kann vollständig in DDI liegen: Die DCs registrieren sich selbst (Freigabe per IP oder TSIG), Server, Reservierungen und DHCP-Adressen kommen aus der Adresstabelle.
- Windows-Clients können sich nicht selbst „sicher“ registrieren (GSS-TSIG fehlt weiterhin). Ihre Namen kommen aus der Adresstabelle; Namen dynamischer DHCP-Clients wären Aufgabe von Kea-DDNS (offen).
- Ändert jemand einen von DDI verwalteten Record von Hand, setzt die nächste Revision ihn auf den DDI-Stand zurück; einen von Hand gelöschten trägt sie wieder ein.
+6
View File
@@ -24,3 +24,9 @@ Jede Grundsatzentscheidung wird hier als kurze ADR festgehalten. Format siehe [0
| [0018](0018-zentrale-benennung.md) | Zentrale Benennung von Produkt, Pfaden und Diensten | Akzeptiert |
| [0019](0019-openapi-vertragstests.md) | OpenAPI: Vertragstests statt generierter Server-Stubs | Akzeptiert |
| [0020](0020-authentifizierung-sitzungen.md) | Authentifizierung, Sitzungen und Rollen | Akzeptiert |
| [0021](0021-agent-strecke-m3.md) | Agent-Strecke: Revisionen, PKI, Ablauf im Agent (M3) | Akzeptiert |
| [0022](0022-ausrollen-pull-push-m4.md) | Ausrollen: Betriebsmodi, Push, Änderungsübersicht, Diff, Rollback, Live-Status (M4) | Akzeptiert |
| [0023](0023-cluster-und-zonen-m5.md) | Cluster, Zonen, Records, TSIG und Lease-Erfassung (M5) | Akzeptiert |
| [0024](0024-auslieferung-installer-m6.md) | Auslieferung: Installer, systemd, Pakete, Release-Signatur (M6) | Akzeptiert |
| [0025](0025-dhcp-pro-adresse-pruefungen-ui.md) | DHCP pro Adresse, DNS für dynamische Einträge, Bereichs- und Massenänderungen, Prüfungen, Oberfläche | Akzeptiert |
| [0026](0026-eintraege-in-dynamischen-zonen.md) | Einträge der Adresstabelle in dynamischen Zonen (RFC-2136-Updates durch den Agent) | Akzeptiert |
+1 -1
View File
@@ -15,6 +15,7 @@ require (
github.com/prometheus/client_golang v1.25.0
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.58.0
golang.org/x/net v0.61.0
golang.org/x/oauth2 v0.37.0
google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12
@@ -40,7 +41,6 @@ require (
github.com/sethvargo/go-retry v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: Apache-2.0
// Package agentpki holds the conventions shared by server and agents for the
// agent certificates (docs/adr/0008-interne-ca-enrollment.md): the identity
// URI in the client certificate, the CA fingerprint used to bootstrap trust
// during enrollment and the TLS settings of the agent connection.
package agentpki
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"git.beugsiud.de/beug.k/DDI/internal/brand"
)
// uuidPattern matches the canonical lowercase UUID form used for agent IDs.
var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidAgentID reports whether id is a canonical lowercase UUID.
func ValidAgentID(id string) bool { return uuidPattern.MatchString(id) }
// uriPrefix is "urn:<product>:agent:".
func uriPrefix() string { return "urn:" + brand.Product + ":agent:" }
// AgentURI returns the URI SAN that identifies an agent in its client
// certificate, e.g. "urn:ddi-suite:agent:<uuid>".
func AgentURI(agentID string) *url.URL {
return &url.URL{Scheme: "urn", Opaque: strings.TrimPrefix(uriPrefix(), "urn:") + agentID}
}
// AgentID extracts the agent ID from a client certificate. The certificate
// must carry exactly one agent URI SAN and its common name must match.
func AgentID(cert *x509.Certificate) (string, error) {
var id string
for _, u := range cert.URIs {
s := u.String()
if rest, ok := strings.CutPrefix(s, uriPrefix()); ok {
if id != "" {
return "", errors.New("certificate carries more than one agent ID")
}
id = rest
}
}
switch {
case id == "":
return "", errors.New("certificate carries no agent ID")
case !ValidAgentID(id):
return "", fmt.Errorf("certificate carries an invalid agent ID %q", id)
case cert.Subject.CommonName != id:
return "", errors.New("certificate common name does not match the agent ID")
}
return id, nil
}
// FingerprintPrefix precedes the hex digest in a CA fingerprint.
const FingerprintPrefix = "sha256:"
// Fingerprint returns "sha256:<hex>" of the certificate's DER encoding. The
// enrollment command carries the fingerprint of the agent CA.
func Fingerprint(cert *x509.Certificate) string {
sum := sha256.Sum256(cert.Raw)
return FingerprintPrefix + hex.EncodeToString(sum[:])
}
// ParseFingerprint validates a fingerprint and returns it in canonical
// (lowercase) form.
func ParseFingerprint(s string) (string, error) {
hexPart, ok := strings.CutPrefix(strings.TrimSpace(s), FingerprintPrefix)
if !ok {
return "", fmt.Errorf("CA fingerprint must start with %q", FingerprintPrefix)
}
hexPart = strings.ToLower(hexPart)
if b, err := hex.DecodeString(hexPart); err != nil || len(b) != sha256.Size {
return "", errors.New("CA fingerprint must be 64 hex digits")
}
return FingerprintPrefix + hexPart, nil
}
// VerifyChainWithFingerprint is used by the agent during enrollment, before
// it has the CA certificate: rawCerts (as sent by the server, leaf first)
// must contain a self-signed CA whose fingerprint equals want, and the leaf
// must chain to it and be valid for serverName. It is meant for
// tls.Config.VerifyPeerCertificate together with InsecureSkipVerify.
func VerifyChainWithFingerprint(rawCerts [][]byte, want, serverName string) (*x509.Certificate, error) {
want, err := ParseFingerprint(want)
if err != nil {
return nil, err
}
if len(rawCerts) < 2 {
return nil, errors.New("server did not send its CA certificate")
}
certs := make([]*x509.Certificate, len(rawCerts))
for i, raw := range rawCerts {
if certs[i], err = x509.ParseCertificate(raw); err != nil {
return nil, fmt.Errorf("parse server certificate: %w", err)
}
}
var ca *x509.Certificate
for _, c := range certs[1:] {
if Fingerprint(c) == want {
ca = c
break
}
}
if ca == nil {
return nil, errors.New("server CA does not match the expected fingerprint")
}
roots := x509.NewCertPool()
roots.AddCert(ca)
if _, err := certs[0].Verify(x509.VerifyOptions{Roots: roots, DNSName: serverName}); err != nil {
return nil, fmt.Errorf("verify server certificate: %w", err)
}
return ca, nil
}
// ClientTLSConfig returns the TLS settings agents use after enrollment.
func ClientTLSConfig(ca *x509.Certificate, cert tls.Certificate, serverName string) *tls.Config {
roots := x509.NewCertPool()
roots.AddCert(ca)
return &tls.Config{
MinVersion: tls.VersionTLS13,
RootCAs: roots,
Certificates: []tls.Certificate{cert},
ServerName: serverName,
}
}
+131
View File
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: Apache-2.0
package agentpki
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net/url"
"strings"
"testing"
"time"
)
const testID = "0b6f3c1e-5d2a-4c8e-9f10-123456789abc"
func newCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
cert, _ := x509.ParseCertificate(der)
return cert, key
}
func issue(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey, tmpl *x509.Certificate) *x509.Certificate {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl.SerialNumber = big.NewInt(2)
tmpl.NotBefore = time.Now().Add(-time.Hour)
tmpl.NotAfter = time.Now().Add(time.Hour)
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
cert, _ := x509.ParseCertificate(der)
return cert
}
func TestAgentID(t *testing.T) {
if got := AgentURI(testID).String(); got != "urn:ddi-suite:agent:"+testID {
t.Fatalf("AgentURI = %s", got)
}
ca, key := newCA(t)
good := issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}, URIs: []*url.URL{AgentURI(testID)}})
if id, err := AgentID(good); err != nil || id != testID {
t.Fatalf("AgentID = %q, %v", id, err)
}
other := "11111111-2222-4333-8444-555555555555"
bad := map[string]*x509.Certificate{
"none": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}}),
"cn": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: other}, URIs: []*url.URL{AgentURI(testID)}}),
"two": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}, URIs: []*url.URL{AgentURI(testID), AgentURI(other)}}),
"invalid": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: "X"}, URIs: []*url.URL{AgentURI("X")}}),
"uppercase": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: strings.ToUpper(testID)},
URIs: []*url.URL{AgentURI(strings.ToUpper(testID))}}),
}
for name, c := range bad {
if _, err := AgentID(c); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestFingerprint(t *testing.T) {
ca, _ := newCA(t)
fp := Fingerprint(ca)
if !strings.HasPrefix(fp, "sha256:") || len(fp) != 7+64 {
t.Fatalf("fingerprint %q", fp)
}
if got, err := ParseFingerprint(" " + strings.ToUpper(fp[7:]) + " "); err == nil || got != "" {
t.Errorf("accepted fingerprint without prefix: %q", got)
}
if got, err := ParseFingerprint("sha256:" + strings.ToUpper(fp[7:])); err != nil || got != fp {
t.Errorf("ParseFingerprint = %q, %v", got, err)
}
for _, s := range []string{"sha256:", "sha256:abc", "sha256:" + strings.Repeat("z", 64), "md5:" + fp[7:]} {
if _, err := ParseFingerprint(s); err == nil {
t.Errorf("%q accepted", s)
}
}
}
func TestVerifyChainWithFingerprint(t *testing.T) {
ca, key := newCA(t)
otherCA, otherKey := newCA(t)
leaf := issue(t, ca, key, &x509.Certificate{
Subject: pkix.Name{CommonName: "ddi.example.test"}, DNSNames: []string{"ddi.example.test"},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
foreign := issue(t, otherCA, otherKey, &x509.Certificate{
Subject: pkix.Name{CommonName: "ddi.example.test"}, DNSNames: []string{"ddi.example.test"},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
fp := Fingerprint(ca)
got, err := VerifyChainWithFingerprint([][]byte{leaf.Raw, ca.Raw}, fp, "ddi.example.test")
if err != nil || !got.Equal(ca) {
t.Fatalf("valid chain: %v", err)
}
cases := map[string]struct {
raw [][]byte
fp string
name string
}{
"leaf only": {[][]byte{leaf.Raw}, fp, "ddi.example.test"},
"wrong CA": {[][]byte{leaf.Raw, otherCA.Raw}, fp, "ddi.example.test"},
"leaf of other": {[][]byte{foreign.Raw, ca.Raw}, fp, "ddi.example.test"},
"wrong name": {[][]byte{leaf.Raw, ca.Raw}, fp, "evil.example.test"},
"bad fingerprint": {[][]byte{leaf.Raw, ca.Raw}, "sha256:00", "ddi.example.test"},
"garbage": {[][]byte{leaf.Raw, []byte("x")}, fp, "ddi.example.test"},
}
for name, c := range cases {
if _, err := VerifyChainWithFingerprint(c.raw, c.fp, c.name); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
+211
View File
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: Apache-2.0
// Package bundle defines the signed configuration bundle exchanged between the
// management server and the agents (docs/adr/0009-signierte-config-bundles.md).
//
// A bundle is a JSON manifest, an Ed25519 signature over the exact manifest
// bytes and the artifact contents. The server signs with [Sign]; the agent
// checks with [Verify] before it parses or stores anything.
package bundle
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
)
// FormatVersion is the manifest format produced by this package.
const FormatVersion = 1
// Limits that keep a malicious or broken bundle from exhausting the agent.
const (
MaxFiles = 100_000
MaxPathLen = 255
MaxTotalSize = 512 << 20
)
// Manifest describes one revision for one agent.
type Manifest struct {
Format int `json:"format"`
AgentID string `json:"agent_id"`
Role string `json:"role"`
Revision uint64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
Files []File `json:"files"`
}
// File describes one artifact.
type File struct {
// Path is relative to the role's configuration root, slash separated
// (see [CheckPath]).
Path string `json:"path"`
// Mode holds the Unix permission bits (e.g. 0o640).
Mode uint32 `json:"mode"`
// SHA256 is the lowercase hex digest of the content.
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
// CreateOnly files are written only if they do not exist yet and are
// never overwritten or removed afterwards (initial files of dynamic
// zones).
CreateOnly bool `json:"create_only,omitempty"`
}
// Artifact is a file with its content, the input of [Sign].
type Artifact struct {
Path string
Mode uint32
Content []byte
CreateOnly bool
}
// Sign builds the manifest for the artifacts and signs it. The returned
// manifest bytes must be transmitted unchanged.
func Sign(key ed25519.PrivateKey, agentID, role string, revision uint64, createdAt time.Time, arts []Artifact) (manifest, signature []byte, err error) {
m := Manifest{
Format: FormatVersion, AgentID: agentID, Role: role, Revision: revision,
CreatedAt: createdAt.UTC(), Files: make([]File, len(arts)),
}
for i, a := range arts {
sum := sha256.Sum256(a.Content)
m.Files[i] = File{
Path: a.Path, Mode: a.Mode, SHA256: hex.EncodeToString(sum[:]),
Size: int64(len(a.Content)), CreateOnly: a.CreateOnly,
}
}
if err := m.check(); err != nil {
return nil, nil, err
}
manifest, err = json.Marshal(m)
if err != nil {
return nil, nil, fmt.Errorf("encode manifest: %w", err)
}
if len(key) != ed25519.PrivateKeySize {
return nil, nil, errors.New("invalid signing key")
}
return manifest, ed25519.Sign(key, manifest), nil
}
// Expect lists what the verifier requires of a bundle.
type Expect struct {
AgentID string
Role string
// MinRevision rejects bundles with a revision lower than this value
// (replay protection; 0 accepts every revision).
MinRevision uint64
}
// Verify checks the signature over the exact manifest bytes, then the
// manifest itself (format, agent, role, revision, paths) and finally that
// contents holds exactly the listed files with matching size and digest.
// contents maps the manifest path to the file content.
func Verify(pub ed25519.PublicKey, manifest, signature []byte, contents map[string][]byte, want Expect) (*Manifest, error) {
if len(pub) != ed25519.PublicKeySize {
return nil, errors.New("invalid bundle signing key")
}
if !ed25519.Verify(pub, manifest, signature) {
return nil, errors.New("bundle signature is invalid")
}
var m Manifest
dec := json.NewDecoder(bytes.NewReader(manifest))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("decode manifest: %w", err)
}
if dec.More() {
return nil, errors.New("decode manifest: trailing data")
}
switch {
case m.Format != FormatVersion:
return nil, fmt.Errorf("unsupported manifest format %d", m.Format)
case m.AgentID != want.AgentID:
return nil, fmt.Errorf("bundle is for agent %q, not %q", m.AgentID, want.AgentID)
case m.Role != want.Role:
return nil, fmt.Errorf("bundle is for role %q, not %q", m.Role, want.Role)
case m.Revision == 0:
return nil, errors.New("bundle has no revision")
case m.Revision < want.MinRevision:
return nil, fmt.Errorf("bundle revision %d is older than %d", m.Revision, want.MinRevision)
}
if err := m.check(); err != nil {
return nil, err
}
if len(contents) != len(m.Files) {
return nil, fmt.Errorf("bundle has %d files, manifest lists %d", len(contents), len(m.Files))
}
for _, f := range m.Files {
c, ok := contents[f.Path]
if !ok {
return nil, fmt.Errorf("file %s is missing", f.Path)
}
if int64(len(c)) != f.Size {
return nil, fmt.Errorf("file %s: size %d, manifest says %d", f.Path, len(c), f.Size)
}
sum := sha256.Sum256(c)
if hex.EncodeToString(sum[:]) != f.SHA256 {
return nil, fmt.Errorf("file %s: SHA-256 mismatch", f.Path)
}
}
return &m, nil
}
// check validates the file list: count, total size, paths, duplicates and
// modes.
func (m *Manifest) check() error {
if len(m.Files) > MaxFiles {
return fmt.Errorf("too many files (%d)", len(m.Files))
}
seen := make(map[string]bool, len(m.Files))
var total int64
for _, f := range m.Files {
if err := CheckPath(f.Path); err != nil {
return err
}
if seen[f.Path] {
return fmt.Errorf("duplicate file %s", f.Path)
}
seen[f.Path] = true
if f.Mode&^0o777 != 0 {
return fmt.Errorf("file %s: invalid mode %o", f.Path, f.Mode)
}
if f.Size < 0 {
return fmt.Errorf("file %s: negative size", f.Path)
}
total += f.Size
if total > MaxTotalSize {
return errors.New("bundle exceeds the size limit")
}
}
return nil
}
// CheckPath accepts clean, relative, slash-separated paths of the characters
// A-Z a-z 0-9 . _ - + @ whose elements do not start with a dot. Agents can
// therefore join them to their root directory without escaping it.
func CheckPath(p string) error {
if p == "" || len(p) > MaxPathLen {
return fmt.Errorf("invalid file path %q", p)
}
if path.IsAbs(p) || path.Clean(p) != p {
return fmt.Errorf("file path %q is not clean and relative", p)
}
for _, el := range strings.Split(p, "/") {
if el == "" || el[0] == '.' {
return fmt.Errorf("invalid element in file path %q", p)
}
for i := 0; i < len(el); i++ {
c := el[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') &&
c != '.' && c != '_' && c != '-' && c != '+' && c != '@' {
return fmt.Errorf("invalid character in file path %q", p)
}
}
}
return nil
}
+172
View File
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: Apache-2.0
package bundle
import (
"bytes"
"crypto/ed25519"
"strings"
"testing"
"time"
)
var testTime = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
seed := bytes.Repeat([]byte{7}, ed25519.SeedSize)
priv := ed25519.NewKeyFromSeed(seed)
return priv.Public().(ed25519.PublicKey), priv
}
func sample() []Artifact {
return []Artifact{
{Path: "ddi-suite.conf", Mode: 0o640, Content: []byte("zone \"a.test\" {};\n")},
{Path: "zones/db.a.test", Mode: 0o640, Content: []byte("$TTL 3600\n")},
{Path: "zones/db.dyn.test", Mode: 0o640, Content: []byte("dyn\n"), CreateOnly: true},
}
}
func contents(arts []Artifact) map[string][]byte {
m := make(map[string][]byte, len(arts))
for _, a := range arts {
m[a.Path] = a.Content
}
return m
}
func TestSignVerifyRoundTrip(t *testing.T) {
pub, priv := testKey(t)
arts := sample()
man, sig, err := Sign(priv, "agent-1", "dns", 42, testTime, arts)
if err != nil {
t.Fatal(err)
}
m, err := Verify(pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 42})
if err != nil {
t.Fatal(err)
}
if m.Revision != 42 || len(m.Files) != 3 || !m.Files[2].CreateOnly || m.Files[0].Mode != 0o640 ||
!m.CreatedAt.Equal(testTime) || m.Files[1].Size != int64(len(arts[1].Content)) {
t.Errorf("manifest %+v", m)
}
// Signing is deterministic: same input, same bytes.
man2, sig2, _ := Sign(priv, "agent-1", "dns", 42, testTime, arts)
if !bytes.Equal(man, man2) || !bytes.Equal(sig, sig2) {
t.Error("signing is not deterministic")
}
}
func TestVerifyRejects(t *testing.T) {
pub, priv := testKey(t)
arts := sample()
man, sig, err := Sign(priv, "agent-1", "dns", 5, testTime, arts)
if err != nil {
t.Fatal(err)
}
ok := Expect{AgentID: "agent-1", Role: "dns"}
otherPub, _, _ := ed25519.GenerateKey(nil)
tampered := bytes.Replace(man, []byte(`"revision":5`), []byte(`"revision":6`), 1)
changed := contents(arts)
changed["zones/db.a.test"] = []byte("$TTL 3601\n")
missing := contents(arts)
delete(missing, "zones/db.a.test")
extra := contents(arts)
extra["other"] = nil
short := contents(arts)
short["zones/db.a.test"] = []byte("x")
cases := []struct {
name string
pub ed25519.PublicKey
man, sig []byte
files map[string][]byte
want Expect
msg string
}{
{"wrong key", otherPub, man, sig, contents(arts), ok, "signature"},
{"bad key length", pub[:5], man, sig, contents(arts), ok, "signing key"},
{"tampered manifest", pub, tampered, sig, contents(arts), ok, "signature"},
{"other agent", pub, man, sig, contents(arts), Expect{AgentID: "agent-2", Role: "dns"}, "agent"},
{"other role", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dhcp"}, "role"},
{"replay", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 6}, "older"},
{"changed content", pub, man, sig, changed, ok, "SHA-256"},
{"wrong size", pub, man, sig, short, ok, "size"},
{"missing file", pub, man, sig, missing, ok, "files"},
{"extra file", pub, man, sig, extra, ok, "files"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Verify(c.pub, c.man, c.sig, c.files, c.want)
if err == nil || !strings.Contains(err.Error(), c.msg) {
t.Errorf("err = %v, want %q", err, c.msg)
}
})
}
}
func TestVerifyRejectsUnsafeManifests(t *testing.T) {
pub, priv := testKey(t)
sign := func(raw string) ([]byte, []byte) { return []byte(raw), ed25519.Sign(priv, []byte(raw)) }
cases := map[string]string{
"format": `{"format":2,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]}`,
"no rev": `{"format":1,"agent_id":"a","role":"dns","revision":0,"created_at":"2026-10-13T12:00:00Z","files":[]}`,
"unknown": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[],"x":1}`,
"trailing": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]} {}`,
"escape": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"../etc/passwd","mode":420,"sha256":"","size":0}]}`,
"mode": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":2048,"sha256":"","size":0}]}`,
"dup": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":0},{"path":"x","mode":420,"sha256":"","size":0}]}`,
"negative": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":-1}]}`,
"huge": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":1073741824}]}`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
man, sig := sign(raw)
if _, err := Verify(pub, man, sig, map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}); err == nil {
t.Error("accepted")
}
})
}
if _, _, err := Sign(priv[:3], "a", "dns", 1, testTime, nil); err == nil {
t.Error("Sign accepted a short key")
}
if _, _, err := Sign(priv, "a", "dns", 1, testTime, []Artifact{{Path: "/abs"}}); err == nil {
t.Error("Sign accepted an absolute path")
}
}
func TestCheckPath(t *testing.T) {
good := []string{"ddi-suite.conf", "zones/db.office.test", "kea-dhcp4.conf", "a/b/c+d@e_f-g"}
bad := []string{"", "/abs", "../x", "a/../b", "a//b", "a/", ".hidden", "zones/.x", "a b", "a\\b", "ä",
strings.Repeat("a", MaxPathLen+1)}
for _, p := range good {
if err := CheckPath(p); err != nil {
t.Errorf("%q: %v", p, err)
}
}
for _, p := range bad {
if CheckPath(p) == nil {
t.Errorf("%q accepted", p)
}
}
}
func FuzzVerify(f *testing.F) {
pub, priv := testKey(&testing.T{})
man, _, _ := Sign(priv, "a", "dns", 1, testTime, sample())
f.Add(man)
f.Fuzz(func(t *testing.T, raw []byte) {
// Whatever is signed, Verify must not panic and must never accept a
// path outside the root.
m, err := Verify(pub, raw, ed25519.Sign(priv, raw), map[string][]byte{}, Expect{AgentID: "a", Role: "dns"})
if err != nil {
return
}
for _, file := range m.Files {
if CheckPath(file.Path) != nil {
t.Fatalf("accepted unsafe path %q", file.Path)
}
}
})
}
+9
View File
@@ -122,11 +122,13 @@ message Hello {
HealthStatus health = 4;
// Version of the managed service, e.g. "BIND 9.20.3" or "Kea 3.2.1".
string service_version = 5;
string hostname = 6;
}
message Heartbeat {
uint64 applied_revision = 1;
HealthStatus health = 2;
uint64 last_good_revision = 3;
}
message HealthStatus {
@@ -147,6 +149,9 @@ message Welcome {
OperatingMode mode = 1;
google.protobuf.Duration pull_interval = 2;
google.protobuf.Duration heartbeat_interval = 3;
// Revision the agent should run (0 = none). In auto_pull mode the agent
// applies it right away if it is newer than its applied revision.
uint64 desired_revision = 4;
}
// Command is pushed by the server over the Connect stream.
@@ -187,6 +192,10 @@ message GetDesiredStateResponse {
uint64 desired_revision = 1;
OperatingMode mode = 2;
google.protobuf.Duration pull_interval = 3;
// Set while an explicit push of desired_revision is open (not yet
// reported). The agent then applies desired_revision as a push with this
// deployment ID, also in manual mode (docs/adr/0022-ausrollen-pull-push-m4.md).
string deployment_id = 4;
}
message FetchBundleRequest {
+57 -16
View File
@@ -784,6 +784,7 @@ type Hello struct {
Health *HealthStatus `protobuf:"bytes,4,opt,name=health,proto3" json:"health,omitempty"`
// Version of the managed service, e.g. "BIND 9.20.3" or "Kea 3.2.1".
ServiceVersion string `protobuf:"bytes,5,opt,name=service_version,json=serviceVersion,proto3" json:"service_version,omitempty"`
Hostname string `protobuf:"bytes,6,opt,name=hostname,proto3" json:"hostname,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -853,12 +854,20 @@ func (x *Hello) GetServiceVersion() string {
return ""
}
func (x *Hello) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
type Heartbeat struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppliedRevision uint64 `protobuf:"varint,1,opt,name=applied_revision,json=appliedRevision,proto3" json:"applied_revision,omitempty"`
Health *HealthStatus `protobuf:"bytes,2,opt,name=health,proto3" json:"health,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
AppliedRevision uint64 `protobuf:"varint,1,opt,name=applied_revision,json=appliedRevision,proto3" json:"applied_revision,omitempty"`
Health *HealthStatus `protobuf:"bytes,2,opt,name=health,proto3" json:"health,omitempty"`
LastGoodRevision uint64 `protobuf:"varint,3,opt,name=last_good_revision,json=lastGoodRevision,proto3" json:"last_good_revision,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Heartbeat) Reset() {
@@ -905,6 +914,13 @@ func (x *Heartbeat) GetHealth() *HealthStatus {
return nil
}
func (x *Heartbeat) GetLastGoodRevision() uint64 {
if x != nil {
return x.LastGoodRevision
}
return 0
}
type HealthStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
State HealthState `protobuf:"varint,1,opt,name=state,proto3,enum=ddi.agent.v1.HealthState" json:"state,omitempty"`
@@ -1039,8 +1055,11 @@ type Welcome struct {
Mode OperatingMode `protobuf:"varint,1,opt,name=mode,proto3,enum=ddi.agent.v1.OperatingMode" json:"mode,omitempty"`
PullInterval *durationpb.Duration `protobuf:"bytes,2,opt,name=pull_interval,json=pullInterval,proto3" json:"pull_interval,omitempty"`
HeartbeatInterval *durationpb.Duration `protobuf:"bytes,3,opt,name=heartbeat_interval,json=heartbeatInterval,proto3" json:"heartbeat_interval,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Revision the agent should run (0 = none). In auto_pull mode the agent
// applies it right away if it is newer than its applied revision.
DesiredRevision uint64 `protobuf:"varint,4,opt,name=desired_revision,json=desiredRevision,proto3" json:"desired_revision,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Welcome) Reset() {
@@ -1094,6 +1113,13 @@ func (x *Welcome) GetHeartbeatInterval() *durationpb.Duration {
return nil
}
func (x *Welcome) GetDesiredRevision() uint64 {
if x != nil {
return x.DesiredRevision
}
return 0
}
// Command is pushed by the server over the Connect stream.
type Command struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1390,8 +1416,12 @@ type GetDesiredStateResponse struct {
DesiredRevision uint64 `protobuf:"varint,1,opt,name=desired_revision,json=desiredRevision,proto3" json:"desired_revision,omitempty"`
Mode OperatingMode `protobuf:"varint,2,opt,name=mode,proto3,enum=ddi.agent.v1.OperatingMode" json:"mode,omitempty"`
PullInterval *durationpb.Duration `protobuf:"bytes,3,opt,name=pull_interval,json=pullInterval,proto3" json:"pull_interval,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Set while an explicit push of desired_revision is open (not yet
// reported). The agent then applies desired_revision as a push with this
// deployment ID, also in manual mode (docs/adr/0022-ausrollen-pull-push-m4.md).
DeploymentId string `protobuf:"bytes,4,opt,name=deployment_id,json=deploymentId,proto3" json:"deployment_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetDesiredStateResponse) Reset() {
@@ -1445,6 +1475,13 @@ func (x *GetDesiredStateResponse) GetPullInterval() *durationpb.Duration {
return nil
}
func (x *GetDesiredStateResponse) GetDeploymentId() string {
if x != nil {
return x.DeploymentId
}
return ""
}
type FetchBundleRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Revision uint64 `protobuf:"varint,1,opt,name=revision,proto3" json:"revision,omitempty"`
@@ -1982,16 +2019,18 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" +
"\x0fConnectResponse\x121\n" +
"\awelcome\x18\x01 \x01(\v2\x15.ddi.agent.v1.WelcomeH\x00R\awelcome\x121\n" +
"\acommand\x18\x02 \x01(\v2\x15.ddi.agent.v1.CommandH\x00R\acommandB\t\n" +
"\amessage\"\xe2\x01\n" +
"\amessage\"\xfe\x01\n" +
"\x05Hello\x12#\n" +
"\ragent_version\x18\x01 \x01(\tR\fagentVersion\x12)\n" +
"\x10applied_revision\x18\x02 \x01(\x04R\x0fappliedRevision\x12,\n" +
"\x12last_good_revision\x18\x03 \x01(\x04R\x10lastGoodRevision\x122\n" +
"\x06health\x18\x04 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\x12'\n" +
"\x0fservice_version\x18\x05 \x01(\tR\x0eserviceVersion\"j\n" +
"\x0fservice_version\x18\x05 \x01(\tR\x0eserviceVersion\x12\x1a\n" +
"\bhostname\x18\x06 \x01(\tR\bhostname\"\x98\x01\n" +
"\tHeartbeat\x12)\n" +
"\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\x122\n" +
"\x06health\x18\x02 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\"\xc7\x01\n" +
"\x06health\x18\x02 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\x12,\n" +
"\x12last_good_revision\x18\x03 \x01(\x04R\x10lastGoodRevision\"\xc7\x01\n" +
"\fHealthStatus\x12/\n" +
"\x05state\x18\x01 \x01(\x0e2\x19.ddi.agent.v1.HealthStateR\x05state\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x129\n" +
@@ -2001,11 +2040,12 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" +
"\vHealthCheck\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12/\n" +
"\x05state\x18\x02 \x01(\x0e2\x19.ddi.agent.v1.HealthStateR\x05state\x12\x18\n" +
"\amessage\x18\x03 \x01(\tR\amessage\"\xc4\x01\n" +
"\amessage\x18\x03 \x01(\tR\amessage\"\xef\x01\n" +
"\aWelcome\x12/\n" +
"\x04mode\x18\x01 \x01(\x0e2\x1b.ddi.agent.v1.OperatingModeR\x04mode\x12>\n" +
"\rpull_interval\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\x12H\n" +
"\x12heartbeat_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\"\xd5\x01\n" +
"\x12heartbeat_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\x12)\n" +
"\x10desired_revision\x18\x04 \x01(\x04R\x0fdesiredRevision\"\xd5\x01\n" +
"\aCommand\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12K\n" +
@@ -2024,11 +2064,12 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" +
"\baccepted\x18\x02 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x03 \x01(\tR\amessage\"C\n" +
"\x16GetDesiredStateRequest\x12)\n" +
"\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\"\xb5\x01\n" +
"\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\"\xda\x01\n" +
"\x17GetDesiredStateResponse\x12)\n" +
"\x10desired_revision\x18\x01 \x01(\x04R\x0fdesiredRevision\x12/\n" +
"\x04mode\x18\x02 \x01(\x0e2\x1b.ddi.agent.v1.OperatingModeR\x04mode\x12>\n" +
"\rpull_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\"0\n" +
"\rpull_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\x12#\n" +
"\rdeployment_id\x18\x04 \x01(\tR\fdeploymentId\"0\n" +
"\x12FetchBundleRequest\x12\x1a\n" +
"\brevision\x18\x01 \x01(\x04R\brevision\"\x85\x01\n" +
"\x13FetchBundleResponse\x12\x1a\n" +
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Builds .deb and .rpm packages with nfpm from the release tarballs in DIST
# (made by scripts/build-release.sh) and rewrites DIST/SHA256SUMS to cover
# tarballs and packages (ADR 0024).
#
# Usage: scripts/build-packages.sh [-d DIST] [-p "linux/amd64 linux/arm64"]
#
# Each package holds the binary from its tarball as /usr/bin/<binary>, the
# systemd unit rendered for /usr/bin and the license text. Names and version
# come from the environment (Makefile, ADR 0018): VERSION, PRODUCT,
# DISPLAY_NAME, BIN_SERVER, BIN_AGENT_DNS, BIN_AGENT_DHCP. NFPM names the
# nfpm binary (default: nfpm from PATH, installed by scripts/ci/install-tools.sh).
# Optional: PKG_MAINTAINER, PKG_VENDOR, PKG_HOMEPAGE.
set -euo pipefail
PRODUCT="${PRODUCT:-ddi-suite}"
DISPLAY_NAME="${DISPLAY_NAME:-DDI-Suite}"
BIN_SERVER="${BIN_SERVER:-ddi-server}"
BIN_AGENT_DNS="${BIN_AGENT_DNS:-ddi-agent-dns}"
BIN_AGENT_DHCP="${BIN_AGENT_DHCP:-ddi-agent-dhcp}"
NFPM="${NFPM:-nfpm}"
dist="dist"
platforms="linux/amd64 linux/arm64"
usage() {
sed -n '4,16p' "$0" | sed 's/^# \{0,1\}//'
}
while getopts ":d:p:h" opt; do
case "$opt" in
d) dist="$OPTARG" ;;
p) platforms="$OPTARG" ;;
h)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
root="$(git rev-parse --show-toplevel)"
cd "$root"
if ! command -v "$NFPM" >/dev/null; then
echo "nfpm not found (set NFPM or run scripts/ci/install-tools.sh)" >&2
exit 1
fi
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
BUILD_DATE="${BUILD_DATE:-$(git log -1 --format=%cI 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)}"
# Package versions must be semantic versions: v1.2.3 -> 1.2.3; anything else
# (a commit hash, "dev") becomes a 0.0.0 pre-release that sorts below every
# real release.
if [[ "$VERSION" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]*)?)$ ]]; then
pkg_version="${BASH_REMATCH[1]}"
else
pkg_version="0.0.0-dev.$(tr -c '0-9A-Za-z.\n' '.' <<<"$VERSION")"
fi
# Reproducible timestamps inside the packages.
SOURCE_DATE_EPOCH="$(date -u -d "$BUILD_DATE" +%s 2>/dev/null || echo 0)"
export SOURCE_DATE_EPOCH
declare -A descriptions=(
["$BIN_SERVER"]="${DISPLAY_NAME} management server (IPAM, DNS and DHCP)"
["$BIN_AGENT_DNS"]="${DISPLAY_NAME} agent for BIND 9"
["$BIN_AGENT_DHCP"]="${DISPLAY_NAME} agent for Kea DHCPv4"
)
declare -A licenses=(
["$BIN_SERVER"]="LICENSES/LicenseRef-DDI-Proprietary.txt"
["$BIN_AGENT_DNS"]="LICENSES/Apache-2.0.txt"
["$BIN_AGENT_DHCP"]="LICENSES/Apache-2.0.txt"
)
declare -A license_names=(
["$BIN_SERVER"]="Proprietary"
["$BIN_AGENT_DNS"]="Apache-2.0"
["$BIN_AGENT_DHCP"]="Apache-2.0"
)
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
# Git Bash on Windows (development): nfpm.exe needs Windows paths.
if command -v cygpath >/dev/null; then
workdir="$(cygpath -m "$workdir")"
fi
export PRODUCT DISPLAY_NAME BIN_SERVER BIN_AGENT_DNS BIN_AGENT_DHCP
scripts/render-units.sh /usr/bin "$workdir/units"
PKG_MAINTAINER="${PKG_MAINTAINER:-${DISPLAY_NAME} <maintainer@example.invalid>}"
PKG_VENDOR="${PKG_VENDOR:-$DISPLAY_NAME}"
PKG_HOMEPAGE="${PKG_HOMEPAGE:-https://git.beugsiud.de/beug.k/DDI}"
# render_config NAME ARCH SRC_DIR LICENSE DESCRIPTION > nfpm.yaml
render_config() {
local -a subst=(
PKG_NAME "$1" PKG_ARCH "$2" PKG_SRC_DIR "$3" PKG_LICENSE "$4" PKG_DESCRIPTION "$5"
PKG_VERSION "$pkg_version" PKG_MAINTAINER "$PKG_MAINTAINER" PKG_VENDOR "$PKG_VENDOR"
PKG_HOMEPAGE "$PKG_HOMEPAGE" PKG_SCRIPTS_DIR "$root/deploy/nfpm"
)
local -a expr=()
local i value
for ((i = 0; i < ${#subst[@]}; i += 2)); do
value="${subst[i + 1]}"
# The values end up in sed replacements and double-quoted YAML.
if [[ "$value" == *[\|\&\\\"]* ]]; then
echo "unsafe value for ${subst[i]}: ${value}" >&2
exit 2
fi
expr+=(-e "s|@${subst[i]}@|${value}|g")
done
sed "${expr[@]}" deploy/nfpm/package.yaml.in
}
for platform in $platforms; do
goos="${platform%/*}"
goarch="${platform#*/}"
for bin in "$BIN_SERVER" "$BIN_AGENT_DNS" "$BIN_AGENT_DHCP"; do
name="${bin}_${VERSION}_${goos}_${goarch}"
tarball="${dist}/${name}.tar.gz"
if [[ ! -f "$tarball" ]]; then
echo "missing ${tarball}: run make release first" >&2
exit 1
fi
src="${workdir}/${name}"
mkdir -p "$src"
tar -xzf "$tarball" -C "$workdir" "${name}/${bin}"
mv "${workdir}/${name}/${bin}" "${src}/binary"
cp "$workdir/units/${bin}.service" "${src}/unit.service"
cp "${licenses[$bin]}" "${src}/LICENSE"
render_config "$bin" "$goarch" "$src" "${license_names[$bin]}" "${descriptions[$bin]}" >"${src}/nfpm.yaml"
if grep -q '@[A-Z_]*@' "${src}/nfpm.yaml"; then
echo "unreplaced placeholder in the nfpm configuration of ${name}" >&2
exit 1
fi
for packager in deb rpm; do
echo "packaging ${bin} ${pkg_version} ${goarch} (${packager})"
"$NFPM" package --config "${src}/nfpm.yaml" --packager "$packager" --target "$dist/" >/dev/null
done
done
done
(
cd "$dist"
shopt -s nullglob
sha256sum -- *.tar.gz *.deb *.rpm >SHA256SUMS
)
echo "artefacts in ${dist}:"
cat "${dist}/SHA256SUMS"
+17 -11
View File
@@ -2,7 +2,8 @@
# SPDX-License-Identifier: Apache-2.0
#
# Builds release artefacts: static binaries for each target platform, one
# tarball per binary and platform, and SHA256SUMS.
# tarball per binary and platform (binary, its systemd unit for
# /usr/local/bin, LICENSE and LICENSES/), and SHA256SUMS.
#
# Usage: scripts/build-release.sh [-o OUTDIR] [-p "linux/amd64 linux/arm64"]
#
@@ -28,7 +29,7 @@ outdir="dist"
platforms="linux/amd64 linux/arm64"
usage() {
sed -n '4,12p' "$0" | sed 's/^# \{0,1\}//'
sed -n '4,14p' "$0" | sed 's/^# \{0,1\}//'
}
while getopts ":o:p:h" opt; do
@@ -85,17 +86,12 @@ add_x internal/brand.LogDir "/var/log/${PRODUCT}"
add_x server/internal/license.EmbeddedAccountID "$KEYGEN_ACCOUNT_ID"
add_x server/internal/license.EmbeddedPublicKey "$KEYGEN_PUBLIC_KEY"
# binary name -> package, license file shipped with it
# binary name -> package
declare -A packages=(
["$BIN_SERVER"]="./server/cmd/ddi-server"
["$BIN_AGENT_DNS"]="./agent/cmd/ddi-agent-dns"
["$BIN_AGENT_DHCP"]="./agent/cmd/ddi-agent-dhcp"
)
declare -A licenses=(
["$BIN_SERVER"]="LICENSES/LicenseRef-DDI-Proprietary.txt"
["$BIN_AGENT_DNS"]="LICENSES/Apache-2.0.txt"
["$BIN_AGENT_DHCP"]="LICENSES/Apache-2.0.txt"
)
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
@@ -103,6 +99,11 @@ trap 'rm -rf "$workdir"' EXIT
rm -rf "$outdir"
mkdir -p "$outdir"
# systemd units for binaries in /usr/local/bin (ADR 0024); each tarball
# carries the unit of its binary.
units="${workdir}/units"
scripts/render-units.sh /usr/local/bin "$units"
for platform in $platforms; do
goos="${platform%/*}"
goarch="${platform#*/}"
@@ -116,9 +117,14 @@ for platform in $platforms; do
go build -trimpath -buildvcs=false -ldflags "$ldflags" \
-o "${stage}/${bin}" "${packages[$bin]}"
cp "${licenses[$bin]}" "${stage}/LICENSE"
chmod 0755 "${stage}" "${stage}/${bin}"
chmod 0644 "${stage}/LICENSE"
# Unit and license texts: LICENSE says which license covers what
# (the server is proprietary, the unit files are Apache-2.0).
cp "${units}/${bin}.service" "${stage}/${bin}.service"
cp LICENSE "${stage}/LICENSE"
mkdir -p "${stage}/LICENSES"
cp LICENSES/*.txt "${stage}/LICENSES/"
chmod 0755 "${stage}" "${stage}/LICENSES" "${stage}/${bin}"
chmod 0644 "${stage}/${bin}.service" "${stage}/LICENSE" "${stage}/LICENSES/"*.txt
# Reproducible tarball: fixed order, owner and timestamps.
tar --sort=name --owner=0 --group=0 --numeric-owner \
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Fails if systemd unit templates, nfpm package files or the installer
# contain hard-coded product or binary names (ADR 0018). Names belong in the
# variables of the Makefile and the scripts; templates use placeholders.
#
# Usage: scripts/check-brand.sh [FILE...]
# Without FILE the default set is checked: deploy/systemd/*.in,
# deploy/nfpm/*, scripts/install.sh. Missing files are skipped.
#
# The installer may define the names once, in a block between the lines
# # --- names (ADR 0018) ---
# # --- end names ---
# Each marker must appear exactly once, in this order; everything outside the
# block is checked. The same applies to any checked file containing a marker.
set -euo pipefail
readonly BEGIN_MARKER="# --- names (ADR 0018) ---"
readonly END_MARKER="# --- end names ---"
# Literal names that must not appear (case-insensitive).
readonly PATTERN='ddi-suite|ddi-server|ddi-agent-dns|ddi-agent-dhcp'
cd "$(git rev-parse --show-toplevel)"
files=("$@")
if ((${#files[@]} == 0)); then
shopt -s nullglob
files=(deploy/systemd/*.in deploy/nfpm/* scripts/install.sh)
shopt -u nullglob
fi
failed=0
checked=0
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
checked=$((checked + 1))
begins="$(grep -cxF -- "$BEGIN_MARKER" "$f" || true)"
ends="$(grep -cxF -- "$END_MARKER" "$f" || true)"
if [[ "$f" == scripts/install.sh ]] || ((begins + ends > 0)); then
if ((begins != 1 || ends != 1)); then
echo "$f: expected exactly one '${BEGIN_MARKER}' and one '${END_MARKER}' line" >&2
failed=1
continue
fi
fi
# "line:text" for every offending line outside the names block.
hits="$(awk -v b="$BEGIN_MARKER" -v e="$END_MARKER" -v pat="$PATTERN" '
$0 == b { inside = 1; next }
$0 == e { if (!inside) print NR ":end marker before begin marker"; inside = 0; next }
!inside && tolower($0) ~ pat { print NR ":" $0 }
' "$f")"
if [[ -n "$hits" ]]; then
while IFS= read -r line; do
echo "${f}:${line}" >&2
done <<<"$hits"
failed=1
fi
done
if ((failed)); then
echo "hard-coded product or binary names found (ADR 0018): use the variables or placeholders" >&2
exit 1
fi
echo "brand check passed (${checked} files)"
+51 -21
View File
@@ -2,9 +2,10 @@
# SPDX-License-Identifier: Apache-2.0
#
# Installs pinned, checksum-verified development tools on Linux (amd64/arm64)
# for CI: golangci-lint, buf, shellcheck.
# for CI: golangci-lint, buf, shellcheck, nfpm.
#
# Usage: scripts/ci/install-tools.sh [BINDIR] (default: $HOME/.local/bin)
# Usage: scripts/ci/install-tools.sh [BINDIR] [TOOL...]
# BINDIR defaults to $HOME/.local/bin; without TOOL all tools are installed.
# In GitHub/Gitea Actions the directory is appended to $GITHUB_PATH.
set -euo pipefail
@@ -12,25 +13,35 @@ set -euo pipefail
readonly GOLANGCI_LINT_VERSION="2.14.0"
readonly BUF_VERSION="1.73.0"
readonly SHELLCHECK_VERSION="0.11.0"
readonly NFPM_VERSION="2.48.0"
bindir="${1:-$HOME/.local/bin}"
shift || true
tools=("$@")
if ((${#tools[@]} == 0)); then
tools=(golangci-lint buf shellcheck nfpm)
fi
case "$(uname -m)" in
x86_64 | amd64)
golangci_arch="amd64"
buf_arch="x86_64"
shellcheck_arch="x86_64"
nfpm_arch="x86_64"
golangci_sha="ab90aeb7b066f92a33415b638a50fe5344bbb75a0d32ad30cc248d88f81032ab"
buf_sha="8f2986298ad08f0cc1bf999b9797b7c383adf32d7edf0f73d6f1e1a701baeac1"
shellcheck_sha="b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6"
nfpm_sha="6d7f9a28b2f397934403950fcbba83f598b08d02edbe79a0815b3b4897341750"
;;
aarch64 | arm64)
golangci_arch="arm64"
buf_arch="aarch64"
shellcheck_arch="aarch64"
nfpm_arch="arm64"
golangci_sha="ee7ec5f3453d15ddf106fae5a4d6c71737712348a979d1fe9cd52ec7ea299bae"
buf_sha="902b75267db7f4391e99b7fa0756050e5354234cc0437ef50eee9c788950c7a3"
shellcheck_sha="68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc"
nfpm_sha="a9a75213763783b7669719e505474c748c5ad7dc66591a195619deb50f85c2f4"
;;
*)
echo "unsupported architecture: $(uname -m)" >&2
@@ -48,26 +59,45 @@ fetch() {
echo "$3 $2" | sha256sum -c --quiet -
}
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${golangci_arch}"
fetch "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz" \
"$workdir/golangci.tar.gz" "$golangci_sha"
tar -xzf "$workdir/golangci.tar.gz" -C "$workdir"
install -m 0755 "$workdir/${name}/golangci-lint" "$bindir/golangci-lint"
fetch "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-${buf_arch}" \
"$workdir/buf" "$buf_sha"
install -m 0755 "$workdir/buf" "$bindir/buf"
name="shellcheck-v${SHELLCHECK_VERSION}"
fetch "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${name}.linux.${shellcheck_arch}.tar.gz" \
"$workdir/shellcheck.tar.gz" "$shellcheck_sha"
tar -xzf "$workdir/shellcheck.tar.gz" -C "$workdir"
install -m 0755 "$workdir/${name}/shellcheck" "$bindir/shellcheck"
for tool in "${tools[@]}"; do
case "$tool" in
golangci-lint)
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${golangci_arch}"
fetch "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz" \
"$workdir/golangci.tar.gz" "$golangci_sha"
tar -xzf "$workdir/golangci.tar.gz" -C "$workdir"
install -m 0755 "$workdir/${name}/golangci-lint" "$bindir/golangci-lint"
"$bindir/golangci-lint" version
;;
buf)
fetch "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-${buf_arch}" \
"$workdir/buf" "$buf_sha"
install -m 0755 "$workdir/buf" "$bindir/buf"
"$bindir/buf" --version
;;
shellcheck)
name="shellcheck-v${SHELLCHECK_VERSION}"
fetch "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${name}.linux.${shellcheck_arch}.tar.gz" \
"$workdir/shellcheck.tar.gz" "$shellcheck_sha"
tar -xzf "$workdir/shellcheck.tar.gz" -C "$workdir"
install -m 0755 "$workdir/${name}/shellcheck" "$bindir/shellcheck"
"$bindir/shellcheck" --version | head -n 2
;;
nfpm)
fetch "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \
"$workdir/nfpm.tar.gz" "$nfpm_sha"
tar -xzf "$workdir/nfpm.tar.gz" -C "$workdir" nfpm
install -m 0755 "$workdir/nfpm" "$bindir/nfpm"
"$bindir/nfpm" --version >"$workdir/nfpm.version"
grep GitVersion "$workdir/nfpm.version"
;;
*)
echo "unknown tool: $tool" >&2
exit 2
;;
esac
done
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "$bindir" >>"$GITHUB_PATH"
fi
"$bindir/golangci-lint" version
"$bindir/buf" --version
"$bindir/shellcheck" --version | head -n 2
+5 -5
View File
@@ -39,7 +39,7 @@ server="$(find "$work" -type f -name "$bin_name" | head -n1)"
"$server" gen-master-key -out "$work/master.key" >/dev/null
port=18443
export DDI_HTTP_LISTEN="127.0.0.1:${port}" DDI_MASTER_KEY_FILE="$work/master.key" DDI_STATE_DIR="$work/state"
export DDI_HTTP_LISTEN="127.0.0.1:${port}" DDI_AGENT_API_LISTEN="127.0.0.1:$((port + 1))" DDI_MASTER_KEY_FILE="$work/master.key" DDI_STATE_DIR="$work/state"
# An unreachable license server: the server must start anyway (trial).
export KEYGEN_URL="http://127.0.0.1:1"
@@ -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 '<div id="root">' || fail "SPA fallback"
grep -q '<div id="root">' <<<"$(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"
+1848
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Renders the systemd unit templates deploy/systemd/*.service.in (ADR 0024).
#
# Usage: scripts/render-units.sh <bindir> <outdir>
# writes <outdir>/<BIN_SERVER>.service, <BIN_AGENT_DNS>.service and
# <BIN_AGENT_DHCP>.service with @BINDIR@ = <bindir>.
#
# Names come from the environment (set by the Makefile, ADR 0018): PRODUCT,
# DISPLAY_NAME, BIN_SERVER, BIN_AGENT_DNS, BIN_AGENT_DHCP, ENV_PREFIX.
set -euo pipefail
if [[ $# -ne 2 ]]; then
sed -n '4,11p' "$0" | sed 's/^# \{0,1\}//' >&2
exit 2
fi
bindir="$1"
outdir="$2"
PRODUCT="${PRODUCT:-ddi-suite}"
DISPLAY_NAME="${DISPLAY_NAME:-DDI-Suite}"
BIN_SERVER="${BIN_SERVER:-ddi-server}"
BIN_AGENT_DNS="${BIN_AGENT_DNS:-ddi-agent-dns}"
BIN_AGENT_DHCP="${BIN_AGENT_DHCP:-ddi-agent-dhcp}"
ENV_PREFIX="${ENV_PREFIX:-DDI_}"
# Values end up in sed replacements: allow only safe characters.
for v in "$bindir" "$PRODUCT" "$BIN_SERVER" "$BIN_AGENT_DNS" "$BIN_AGENT_DHCP" "$ENV_PREFIX"; do
[[ "$v" =~ ^[A-Za-z0-9._/+-]+$ ]] || { echo "unsafe value: $v" >&2; exit 2; }
done
[[ "$DISPLAY_NAME" =~ ^[A-Za-z0-9\ ._+-]+$ ]] || { echo "unsafe display name: $DISPLAY_NAME" >&2; exit 2; }
src="$(cd "$(dirname "$0")/../deploy/systemd" && pwd)"
mkdir -p "$outdir"
render() {
sed -e "s|@PRODUCT@|$PRODUCT|g" -e "s|@DISPLAY_NAME@|$DISPLAY_NAME|g" \
-e "s|@BIN_SERVER@|$BIN_SERVER|g" -e "s|@BIN_AGENT_DNS@|$BIN_AGENT_DNS|g" \
-e "s|@BIN_AGENT_DHCP@|$BIN_AGENT_DHCP|g" -e "s|@BINDIR@|$bindir|g" \
-e "s|@CONFIG_DIR@|/etc/$PRODUCT|g" -e "s|@ENV_PREFIX@|$ENV_PREFIX|g" \
"$src/$1" >"$outdir/$2"
if grep -q '@[A-Z_]*@' "$outdir/$2"; then
echo "unreplaced placeholder in $2" >&2
exit 1
fi
}
render server.service.in "$BIN_SERVER.service"
render agent-dns.service.in "$BIN_AGENT_DNS.service"
render agent-dhcp.service.in "$BIN_AGENT_DHCP.service"

Some files were not shown because too many files have changed in this diff Show More