From 87606c73c186d97940be3ec86636b3eac00704f8 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 21 Aug 2026 02:32:26 +0200 Subject: [PATCH] build: package standalone DWH authentication service --- deploy/dwh-auth/dwh-auth.service | 42 ++++++++ deploy/dwh-auth/dwh-auth.tmpfiles.conf | 7 ++ .../dwh-auth/nginx-dwh-location.conf.example | 28 ++++++ deploy/dwh-auth/nginx-http.conf.example | 13 +++ docker/dwh-auth.Dockerfile | 18 ++++ scripts/build-dwh-auth.sh | 94 ++++++++++++++++++ scripts/test-dwh-auth-build-contract.sh | 97 +++++++++++++++++++ 7 files changed, 299 insertions(+) create mode 100644 deploy/dwh-auth/dwh-auth.service create mode 100644 deploy/dwh-auth/dwh-auth.tmpfiles.conf create mode 100644 deploy/dwh-auth/nginx-dwh-location.conf.example create mode 100644 deploy/dwh-auth/nginx-http.conf.example create mode 100644 docker/dwh-auth.Dockerfile create mode 100755 scripts/build-dwh-auth.sh create mode 100755 scripts/test-dwh-auth-build-contract.sh diff --git a/deploy/dwh-auth/dwh-auth.service b/deploy/dwh-auth/dwh-auth.service new file mode 100644 index 00000000..aae6442b --- /dev/null +++ b/deploy/dwh-auth/dwh-auth.service @@ -0,0 +1,42 @@ +[Unit] +Description=Standalone DWH API-key verifier +After=local-fs.target +Wants=local-fs.target + +[Service] +Type=simple +User=dwh-auth +Group=www-data +SupplementaryGroups=dwh-auth +ExecStart=/usr/local/sbin/dwh-auth serve --registry-root /var/lib/dwh-auth --socket /run/dwh-auth/verify.sock +Restart=on-failure +RestartSec=2s +RuntimeDirectory=dwh-auth +RuntimeDirectoryMode=0750 +UMask=0007 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectProc=invisible +ReadOnlyPaths=/var/lib/dwh-auth +ReadWritePaths=/run/dwh-auth +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/deploy/dwh-auth/dwh-auth.tmpfiles.conf b/deploy/dwh-auth/dwh-auth.tmpfiles.conf new file mode 100644 index 00000000..5ef2a219 --- /dev/null +++ b/deploy/dwh-auth/dwh-auth.tmpfiles.conf @@ -0,0 +1,7 @@ +# The registry is provisioned before dwh-auth starts. The SGID directories +# preserve the dwh-auth group for records written by the operator CLI. +d /var/lib/dwh-auth 2750 root dwh-auth - - +d /var/lib/dwh-auth/active 2750 root dwh-auth - - +d /var/lib/dwh-auth/revoked 2750 root dwh-auth - - +# OpenReadOnly requires a pre-existing read-only shared-lock file. +f /var/lib/dwh-auth/.writer.lock 0640 root dwh-auth - - diff --git a/deploy/dwh-auth/nginx-dwh-location.conf.example b/deploy/dwh-auth/nginx-dwh-location.conf.example new file mode 100644 index 00000000..6d472405 --- /dev/null +++ b/deploy/dwh-auth/nginx-dwh-location.conf.example @@ -0,0 +1,28 @@ +# Include these locations inside the HTTPS server that publishes /dwh/. +# The verifier is deliberately reachable only through an internal subrequest. +location = /_check_dwh_key { + internal; + proxy_method GET; + proxy_pass http://unix:/run/dwh-auth/verify.sock:/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-API-Key $http_x_api_key; +} + +location @dwh_auth_unavailable { + return 503; +} + +location /dwh/ { + limit_req zone=dwh_auth burst=100 nodelay; + auth_request /_check_dwh_key; + auth_request_set $dwh_key_id $upstream_http_x_dwh_key_id; + error_page 500 =503 @dwh_auth_unavailable; + + # Never forward the credential. Only the verifier's public identity may + # reach the upstream for audit/rate attribution. + proxy_set_header X-API-Key ""; + proxy_set_header X-DWH-Key-ID ""; + proxy_set_header Host $host; + proxy_pass http://127.0.0.1:3001; +} diff --git a/deploy/dwh-auth/nginx-http.conf.example b/deploy/dwh-auth/nginx-http.conf.example new file mode 100644 index 00000000..02ebe158 --- /dev/null +++ b/deploy/dwh-auth/nginx-http.conf.example @@ -0,0 +1,13 @@ +# Include this file from the nginx http {} context. The map emits only a +# public key ID for canonical v1 keys; all other input is one opaque class. +map $http_x_api_key $dwh_public_key_class { + default opaque; + "~^thtdwh_v1\.([A-Za-z0-9_-]{16})\.[A-Za-z0-9_-]{43}$" v1:$1; +} + +# The secret is never part of this key. This limits each remote address and +# public credential identity/class to 20 requests per second. +map "$remote_addr:$dwh_public_key_class" $dwh_auth_rate_key { + default "$remote_addr:$dwh_public_key_class"; +} +limit_req_zone $dwh_auth_rate_key zone=dwh_auth:10m rate=20r/s; diff --git a/docker/dwh-auth.Dockerfile b/docker/dwh-auth.Dockerfile new file mode 100644 index 00000000..b377e2c5 --- /dev/null +++ b/docker/dwh-auth.Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.26.5-bookworm@sha256:1ecb7edf62a0408027bd5729dfd6b1b8766e578e8df93995b225dfd0944eb651 AS build + +WORKDIR /src/tools/dwh-auth +COPY tools/dwh-auth/go.mod ./ +COPY tools/dwh-auth/ ./ + +RUN set -eux; \ + CGO_ENABLED=0 go test ./... -count=1; \ + mkdir -p /out; \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags='-s -w' -o /out/dwh-auth-linux-amd64 ./cmd/dwh-auth; \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -trimpath -ldflags='-s -w' -o /out/dwh-auth-linux-arm64 ./cmd/dwh-auth; \ + test -s /out/dwh-auth-linux-amd64; \ + test -s /out/dwh-auth-linux-arm64 + +FROM scratch AS export +COPY --from=build /out/ / diff --git a/scripts/build-dwh-auth.sh b/scripts/build-dwh-auth.sh new file mode 100755 index 00000000..bf438a28 --- /dev/null +++ b/scripts/build-dwh-auth.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd -P) +default_output="$repo_root/dist/dwh-auth" +output="$default_output" + +usage() { + echo "usage: $0 [--output ABS_CANONICAL]" >&2 + exit 2 +} + +while (($#)); do + case "$1" in + --output) + (($# >= 2)) || usage + output=$2 + shift 2 + ;; + --help|-h) + usage + ;; + *) + usage + ;; + esac +done + +case "$output" in + # Explicit paths must already be canonical; the default is canonical by construction. + /*) + canonical_output=$(realpath -m "$output") + [[ "$canonical_output" == "$output" ]] || { echo "build-dwh-auth: output must be canonical" >&2; exit 2; } + ;; + *) + echo "build-dwh-auth: output must be an absolute canonical directory" >&2 + exit 2 + ;; +esac + +[[ "$output" != */../* && "$output" != */.. && "$output" != *'/./'* && "$output" != */. ]] || { + echo "build-dwh-auth: output must be canonical" >&2 + exit 2 +} + +if [[ "$output" == / || "$output" == "$repo_root" || "$output" == /tmp || "$output" == /var || "$output" == /home ]]; then + echo "build-dwh-auth: refusing broad output path" >&2 + exit 2 +fi + +parent=$(dirname "$output") +if [[ "$output" == "$default_output" && ! -e "$parent" ]]; then + mkdir -p "$parent" +fi +[[ -d "$parent" && ! -L "$parent" ]] || { + echo "build-dwh-auth: output parent must be an existing non-symlink directory" >&2 + exit 2 +} +leaf=$(basename "$output") +[[ "$parent/$leaf" == "$output" ]] || { + echo "build-dwh-auth: output must be canonical" >&2 + exit 2 +} + +if [[ -e "$output" || -L "$output" ]]; then + [[ -d "$output" && ! -L "$output" ]] || { + echo "build-dwh-auth: output exists and is not a directory" >&2 + exit 2 + } + if [[ -n "$(find "$output" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + echo "build-dwh-auth: refusing non-empty output directory" >&2 + exit 2 + fi +else + mkdir "$output" +fi + +command -v docker >/dev/null 2>&1 || { + echo "build-dwh-auth: Docker is required for the pinned build" >&2 + exit 1 +} + +docker build \ + --file "$repo_root/docker/dwh-auth.Dockerfile" \ + --output "type=local,dest=$output" \ + "$repo_root" + +for arch in amd64 arm64; do + artifact="$output/dwh-auth-linux-$arch" + [[ -s "$artifact" ]] || { + echo "build-dwh-auth: builder did not produce $artifact" >&2 + exit 1 + } +done diff --git a/scripts/test-dwh-auth-build-contract.sh b/scripts/test-dwh-auth-build-contract.sh new file mode 100755 index 00000000..ecb591b6 --- /dev/null +++ b/scripts/test-dwh-auth-build-contract.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd -P) +cd "$repo_root" + +die() { + echo "dwh-auth build/deployment contract: $*" >&2 + exit 1 +} + +builder_digest='golang:1.26.5-bookworm@sha256:1ecb7edf62a0408027bd5729dfd6b1b8766e578e8df93995b225dfd0944eb651' +dockerfile=docker/dwh-auth.Dockerfile +build_script=scripts/build-dwh-auth.sh +service=deploy/dwh-auth/dwh-auth.service +tmpfiles=deploy/dwh-auth/dwh-auth.tmpfiles.conf +http=deploy/dwh-auth/nginx-http.conf.example +location=deploy/dwh-auth/nginx-dwh-location.conf.example + +[[ -f "$dockerfile" ]] || die "missing $dockerfile" +[[ -f "$build_script" ]] || die "missing $build_script" +[[ -f "$service" ]] || die "missing $service" +[[ -f "$tmpfiles" ]] || die "missing $tmpfiles" +[[ -f "$http" ]] || die "missing $http" +[[ -f "$location" ]] || die "missing $location" + +grep -Fq "FROM $builder_digest AS build" "$dockerfile" || die "builder image is not pinned" +grep -Fq 'CGO_ENABLED=0' "$dockerfile" || die "CGO is not disabled" +grep -Fq -- '-trimpath' "$dockerfile" || die "trimpath is missing" +grep -Fq -- '-s -w' "$dockerfile" || die "strip flags are missing" +if grep -Eq '^[[:space:]]*require([[:space:]]|\()' tools/dwh-auth/go.mod; then + die "dwh-auth module declares dependencies" +fi + +for directive in \ + 'User=dwh-auth' \ + 'Group=www-data' \ + 'SupplementaryGroups=dwh-auth' \ + 'NoNewPrivileges=true' \ + 'ProtectSystem=strict' \ + 'ProtectHome=true' \ + 'RestrictAddressFamilies=AF_UNIX' \ + 'CapabilityBoundingSet=' \ + 'UMask=0007'; do + grep -Fq "$directive" "$service" || die "missing systemd directive: $directive" +done +grep -Fq 'RuntimeDirectory=dwh-auth' "$service" || die "runtime directory is missing" +grep -Fq 'RuntimeDirectoryMode=0750' "$service" || die "runtime mode is missing" +grep -Fq 'ReadOnlyPaths=/var/lib/dwh-auth' "$service" || die "registry is not read-only" +grep -Fq 'ExecStart=/usr/local/sbin/dwh-auth serve --registry-root /var/lib/dwh-auth --socket /run/dwh-auth/verify.sock' "$service" \ + || die "unexpected service command" + +grep -Eq '^d[[:space:]]+/var/lib/dwh-auth[[:space:]]+2750[[:space:]]+root[[:space:]]+dwh-auth([[:space:]]|$)' "$tmpfiles" \ + || die "tmpfiles root directory contract is missing" +for child in active revoked; do + grep -Eq "^d[[:space:]]+/var/lib/dwh-auth/$child[[:space:]]+2750[[:space:]]+root[[:space:]]+dwh-auth([[:space:]]|$)" "$tmpfiles" \ + || die "tmpfiles $child directory contract is missing" +done +grep -Eq '^f[[:space:]]+/var/lib/dwh-auth/\.writer\.lock[[:space:]]+0640[[:space:]]+root[[:space:]]+dwh-auth([[:space:]]|$)' "$tmpfiles" \ + || die "tmpfiles writer lock contract is missing" + +grep -Fq 'auth_request /_check_dwh_key;' "$location" || die "DWH auth subrequest is missing" +grep -Fq 'proxy_method GET;' "$location" || die "auth method is not GET" +grep -Fq 'proxy_pass_request_body off;' "$location" || die "auth body is not disabled" +grep -Fq 'proxy_set_header Content-Length "";' "$location" || die "auth body length is not cleared" +grep -Fq 'proxy_set_header X-API-Key $http_x_api_key;' "$location" || die "key is not forwarded to auth" +grep -Fq 'proxy_set_header X-API-Key "";' "$location" || die "key is not cleared upstream" +grep -Fq 'proxy_set_header X-DWH-Key-ID "";' "$location" || die "public key ID is not cleared" +[[ $(grep -Fc 'proxy_set_header X-DWH-Key-ID "";' "$location") -eq 1 ]] || die "public key ID must be cleared exactly once" +grep -Fq 'proxy_pass http://unix:/run/dwh-auth/verify.sock:/verify;' "$location" || die "Unix auth socket is missing" +grep -Fq 'location /dwh/ {' "$location" || die "DWH prefix location is missing" +grep -Fq 'proxy_pass http://127.0.0.1:3001;' "$location" || die "DWH prefix is not preserved" +grep -Fq 'limit_req zone=dwh_auth burst=100 nodelay;' "$location" || die "DWH rate limit is missing" +grep -Fq 'error_page 500 =503 @dwh_auth_unavailable;' "$location" || die "auth infrastructure failure is not 503" +grep -Fq 'map $http_x_api_key $dwh_public_key_class' "$http" || die "public rate-key map is missing" +if rg -n 'limit_req_zone.*\$http_x_api_key|limit_req_zone.*\$dwh_key_secret|limit_req_zone.*\$request' "$http" "$location"; then + die "rate key includes a secret or full request" +fi + +if command -v docker >/dev/null 2>&1; then + out=$(mktemp -d) + trap 'rm -rf "$out"' EXIT + scripts/build-dwh-auth.sh --output "$out" + for arch in amd64 arm64; do + artifact="$out/dwh-auth-linux-$arch" + [[ -s "$artifact" ]] || die "missing non-empty $artifact" + file "$artifact" | grep -Eq 'ELF .*executable' || die "$artifact is not an ELF executable" + readelf -h "$artifact" | grep -Eq 'OS/ABI:[[:space:]]+UNIX - (System V|GNU)' || die "$artifact is not a Linux ELF" + if [[ "$arch" == amd64 ]]; then + readelf -h "$artifact" | grep -Eq 'Machine:.*(X86-64|AMD64)' || die "$artifact has the wrong architecture" + else + readelf -h "$artifact" | grep -Eq 'Machine:.*AArch64' || die "$artifact has the wrong architecture" + fi + done +fi + +echo 'dwh-auth build and deployment contract passed'