diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index 7936dbbf..7b5d2c36 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -10,7 +10,7 @@ on: push: branches: [main, 'release/*'] pull_request: - branches: [main] + branches: [main, 'dev/*'] merge_group: schedule: - cron: '30 6 * * 1' # Weekly on Monday at 06:30 UTC @@ -80,7 +80,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: actions, go, javascript-typescript queries: security-and-quality @@ -96,7 +96,7 @@ jobs: working-directory: app - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 dependency-review: name: "๐Ÿ“ฆ Dependency Review" @@ -227,6 +227,13 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: + # Pin the linter so Go Lint is deterministic. Leaving it unpinned lets + # the action float to a newer golangci-lint whose staticcheck has + # regressed SA5011: it false-positives "possible nil pointer + # dereference" on `if x == nil { t.Fatal(...) }` guards (t.Fatal ends + # the test via Goexit, so the deref below is unreachable). v2.12.2 + # matches the local lefthook go-lint version and reports 0 issues. + version: v2.12.2 working-directory: app go-test: diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 7db55f21..a511f4a1 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -3,6 +3,12 @@ run-name: "๐Ÿท๏ธ Release: Cut โ€” manual by ${{ github.actor }}" on: workflow_dispatch: + inputs: + release_tag: + description: "Explicit tag to cut, e.g. v1.4.0-rc.1 (supports prereleases the auto-computer cannot emit). Leave blank to auto-compute the next stable version from commit history. Must have a non-empty CHANGELOG entry and must not already exist." + required: false + type: string + default: "" permissions: actions: read @@ -85,12 +91,27 @@ jobs: CURRENT_VERSION: ${{ steps.base.outputs.current_version }} LATEST_TAG: ${{ steps.base.outputs.latest_tag }} TARGET_SHA: ${{ steps.target.outputs.sha }} + RELEASE_TAG_INPUT: ${{ inputs.release_tag }} run: | set -euo pipefail - if [ "${BUMP}" = "auto" ] && [ "${LATEST_TAG}" = "v0.0.0" ]; then + if [ -n "${RELEASE_TAG_INPUT}" ]; then + # Operator-supplied tag (the drydock-style explicit path). Supports + # prereleases like v1.4.0-rc.1 that the auto-computer cannot emit. + release_tag="${RELEASE_TAG_INPUT}" + if ! printf '%s' "${release_tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Invalid release_tag '${release_tag}'. Expected vMAJOR.MINOR.PATCH[-prerelease]." + exit 1 + fi + next_version="${release_tag#v}" + case "${next_version}" in + *-*) release_level="prerelease" ;; + *) release_level="explicit" ;; + esac + elif [ "${BUMP}" = "auto" ] && [ "${LATEST_TAG}" = "v0.0.0" ]; then release_level="patch" next_version="0.0.1" + release_tag="v${next_version}" else mapfile -t vars < <(node scripts/release-next-version.mjs \ --current "${CURRENT_VERSION}" \ @@ -106,9 +127,9 @@ jobs: next_version) next_version="${value}" ;; esac done + release_tag="v${next_version}" fi - release_tag="v${next_version}" if git rev-parse -q --verify "refs/tags/${release_tag}" >/dev/null; then echo "::error::Tag already exists: ${release_tag}" exit 1 diff --git a/.github/workflows/security-grype-weekly.yml b/.github/workflows/security-grype-weekly.yml index 50c27d99..d8af4cbe 100644 --- a/.github/workflows/security-grype-weekly.yml +++ b/.github/workflows/security-grype-weekly.yml @@ -101,7 +101,7 @@ jobs: - name: Upload Grype SARIF to code scanning # Code scanning uploads require GHAS, not available on free private repos. if: always() && github.event.repository.visibility == 'public' - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: ${{ steps.grype.outputs.sarif }} @@ -143,7 +143,7 @@ jobs: - name: Upload Grype source SARIF if: always() && github.event.repository.visibility == 'public' - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: ${{ steps.grype-src.outputs.sarif }} @@ -172,7 +172,7 @@ jobs: - name: Upload Gosec SARIF if: always() && github.event.repository.visibility == 'public' - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: gosec-results.sarif diff --git a/.github/workflows/security-scorecard.yml b/.github/workflows/security-scorecard.yml index e385e982..e6f300ff 100644 --- a/.github/workflows/security-scorecard.yml +++ b/.github/workflows/security-scorecard.yml @@ -47,6 +47,6 @@ jobs: publish_results: true - name: Upload to code-scanning - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: results.sarif diff --git a/.snyk b/.snyk index f5980718..e94adb09 100644 --- a/.snyk +++ b/.snyk @@ -1,6 +1,21 @@ # Snyk (https://snyk.io) policy file version: v1.25.0 -ignore: {} +ignore: + # next pins postcss 8.4.31 exactly, but npm overrides (root and + # workspace package.json) force ^8.5.15 โ€” the installed tree and + # package-lock.json contain no postcss below 8.5.15. Snyk's PR check + # resolves workspace manifests standalone without applying npm + # overrides, so it reports a version that is never installed. Mirror + # copies of this policy live in docs/.snyk and website/.snyk because + # manifest-only projects read policy from the manifest's directory. + SNYK-JS-POSTCSS-16189065: + - '*': + reason: >- + Not installed: npm overrides pin postcss to ^8.5.15; the + lockfile has no 8.4.31. Snyk manifest-only resolution does + not apply npm overrides. + expires: 2026-09-15T00:00:00.000Z + created: 2026-06-11T00:00:00.000Z patch: {} # Snyk Code findings cannot be ignored via the `ignore:` section above โ€” diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf49536..cadeefc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0-rc.1] - 2026-06-16 + +### Added + +- **`SecurityOpt` SELinux and system-paths directives are now policy-evaluable.** Three opt-in `request_body.container_create` knobs (all default off โ€” zero behavior change): `deny_selinux_disable` denies `label=disable` and the legacy `label:disable` colon form (which turn off SELinux confinement); `deny_selinux_label_override` denies `label=user:`/`role:`/`type:`/`level:` SELinux context customization; `deny_unconfined_system_paths` denies `systempaths=unconfined` **and** requests that set `MaskedPaths`/`ReadonlyPaths` to an explicit empty array โ€” the Docker CLI translates `--security-opt systempaths=unconfined` into `MaskedPaths: []` client-side, so direct API callers could otherwise clear the masked-path protections without ever sending the SecurityOpt string. Both vectors are covered. +- **Swarm services gained seccomp/AppArmor confinement-mode rails**, completing `ContainerSpec.Privileges` parity with container-create. Three opt-in `request_body.service` knobs (all default off): `deny_unconfined_seccomp` (denies `Privileges.Seccomp.Mode: "unconfined"`), `deny_custom_seccomp_profiles` (denies `Mode: "custom"`, and fail-closed denies a `Seccomp` object carrying a `Profile` blob with no `Mode` โ€” an inline profile the proxy cannot vet can encode an allow-everything policy), and `deny_unconfined_apparmor` (denies `Privileges.AppArmor.Mode: "disabled"`, swarm's equivalent of unconfined). +- **Remote Docker TCP+TLS upstreams with active/passive failover (`upstream.endpoints[]`).** Sockguard can now dial a remote Docker daemon over standard Docker mTLS โ€” or any mix of local `unix://`/bare-path sockets and remote `tcp://host:port` endpoints โ€” with per-endpoint TLS config (`tls.ca_file`/`cert_file`/`key_file`/`server_name`) and insecure opt-ins (`insecure_allow_plain_tcp`, `insecure_skip_tls_verify`). Requests route to the first healthy endpoint in the ordered list; a connect or request failure instantly demotes that endpoint so the next request fails over without retry (Docker writes aren't idempotent). Active connect-level health probes run on a configurable `failover.health_interval`/`health_timeout` schedule, keeping the hot path aware of endpoint state between requests. TLS negotiation lives inside the dialer, so it works transparently across the reverse proxy, exec/attach hijack, and all inspect side-channel paths โ€” the rest of the proxy stack is unaware of whether the upstream is local or remote. The intended topology is active/passive redundancy across equivalent daemons (a swarm VIP + managers, an HA pair) โ€” all endpoints must address the same logical daemon so daemon-local state (container IDs, exec sessions, owner labels) stays consistent. `DOCKER_HOST`/`DOCKER_TLS_VERIFY`/`DOCKER_CERT_PATH` are auto-detected as a single endpoint when no explicit `endpoints` are configured, and `endpoints`/`failover` are reload-immutable while `request_timeout` stays mutable. Legacy `upstream.socket` continues to work as the default single-local-socket path. +- **Three bundled presets for the Portwing Docker agent and drydock self-update (12 โ†’ 15 presets).** `portwing.yaml` covers container lifecycle, image pull/remove, `GET /containers/*/logs` streaming, and event/network/volume/Swarm-service reads with exec denied; `portwing-with-exec.yaml` adds interactive exec (`/containers/*/exec`, `/exec/*/start`, `/exec/*/resize`, `/exec/*/json`). Both Portwing presets disable response redaction so container inspect data forwards intact through the tri-tool topology (sockguard โ†’ Portwing โ†’ drydock) and set `insecure_allow_read_exfiltration: true` for the logs path. `drydock-with-selfupdate.yaml` extends the drydock preset with the exec paths drydock's self-update finalize callback needs, pinned to the finalize entrypoint argv via `allowed_commands`. A ready-to-run `examples/compose/portwing/` stack ships alongside. + +### Fixed + +- **Per-profile in-flight gauge series are now deleted when a hot reload removes the profile.** Previously `sockguard_inflight_requests{profile=...}` series for removed profiles persisted at their last value until process restart, misreporting load. Deletion happens after the handler swap, so a request completing on the old chain can at worst briefly re-create the series at a draining value; it is reaped on the next reload. +- **The bundled `github-actions-runner` and `gitlab-runner` presets now load.** Both allowed unpinned exec plus `GET /containers/*/logs` and `POST /containers/*/attach` streaming without the matching acknowledgement flags, so sockguard's startup validator refused to start with either config. Added `insecure_allow_body_blind_writes: true` (runners execute arbitrary job-step commands, not a fixed allowlist) and `insecure_allow_read_exfiltration: true` (job output streams over the logs/attach APIs); both presets document gating the proxy socket to the runner process via `clients.unix_peer_profiles`/`clients.allowed_cidrs`. + +### Changed + +- **The rate-limit token bucket hot path is now allocation-free.** Bucket state (token count + refill timestamp) packs into a single `atomic.Uint64` (16.16 fixed-point tokens, millisecond timestamp), eliminating the per-admission heap allocation: the hot-path benchmark went from 1 alloc/16 B to 0 allocs/0 B per op (~47 โ†’ ~36 ns/op). Two consequences: `limits.rate.burst` now has a validated upper bound of 65535 (configs above it are rejected at startup with a descriptive error; `tokens_per_second` is implicitly bounded the same way since burst โ‰ฅ tps), and refill granularity is milliseconds rather than nanoseconds โ€” negligible for every supported rate. +- **Dependency refresh.** Bumped `sigstore/sigstore-go` v1.2.0 โ†’ v1.2.1 (image-trust path; no behavior change) and refreshed the docs/website toolchain โ€” Biome 2.4.16 โ†’ 2.5.0, Tailwind 4.3.0 โ†’ 4.3.1 (docs now in lockstep with website), fumadocs 16.9.3 โ†’ 16.10.3, lucide-react 1.17 โ†’ 1.18, turbo 2.9.17 โ†’ 2.9.18. Lockfile regenerated and deduped so the existing `postcss` override resolves cleanly (`npm audit` reports 0 vulnerabilities). + ## [1.3.0] - 2026-06-11 v1.3.0 promotes `1.3.0-rc.3` to stable with no binary delta โ€” the release content is the `[1.3.0-rc.2]` and `[1.3.0-rc.3]` entries below, validated during the rc soak behind a live drydock deployment. Headlines: swarm service create/update now enforces the same identity/privilege rails as container create (closing the posture bypass where a service could request a workload shape `/containers/create` would deny), a zero-padded-UID root-user bypass is sealed across container create and exec, a wide-open dedicated admin listener is rejected at validation rather than just warned about, admin endpoint paths are normalized before matching, non-upgrade hijack responses strip hop-by-hop headers, the `signature_path` hot-reload wedge and three silently-ignored `SOCKGUARD_*` env vars are fixed, release images carry real `commit`/`built` metadata, and multi-arch images cross-compile natively. diff --git a/README.md b/README.md index 8801ffa5..cd717de9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@
-sockguard + + + sockguard +

sockguard

@@ -217,7 +220,7 @@ To run fully unprivileged with a unix socket, pre-create a host directory with t - **v1.2.0 shipped on 2026-06-02** โ€” operational resilience for a wedged daemon. An opt-in **readiness probe** (`health.readiness.*`, default `/ready`) issues a real `GET /containers/json` against the Docker API and returns `503` when the daemon accepts connections but no longer answers โ€” the gap the raw-dial `/health` watchdog misses. An opt-in **`upstream.request_timeout`** bounds finite proxied requests with a total deadline, converting a hung body or heavy read into a fast `504` (`reason_code=upstream_request_timeout`) while exempting streaming and long-lived endpoints. New metrics `sockguard_upstream_api_up` + `sockguard_upstream_readiness_checks_total` mirror the watchdog. The bundled **drydock preset** now allowlists the stock `runc` runtime so drydock recreation stops getting 403'd out of the box. Dependency hygiene: the Go toolchain moves to `1.26.4` (clearing two *reachable* stdlib advisories, GO-2026-5037 / GO-2026-5039), plus the `go-minor` / `npm-minor` / `actions-minor` groups; `govulncheck` reports zero vulnerabilities. - **v1.1.0 shipped on 2026-06-01** โ€” image-trust verification wired end to end: registry digest resolution, cosign signature discovery (classic tag + OCI 1.1 referrers), digest-pinned forwarding, keyed (PEM) and keyless (Fulcio + Rekor) both enforced, swarm-service create/update now subject to the same image-trust policy as container create. A 21-finding security audit landed alongside: closed request-inspection bypasses (plugin multipart, BuildKit `# syntax=`, gzip bombs, swarm-service capability/sysctl/image-trust escapes), read-side sub-resource visibility gating, new `allowed_runtimes` allowlist, hardened config/admin paths (signed-bundle TOCTOU, PID-only peer rejection, admin-listener CIDR backstop), response redaction extended to `HostConfig.Mounts[].Source` and service `PreviousSpec`. CodeQL `actions` analysis and supply-chain dependency hygiene (`govulncheck` reports zero vulnerabilities) round out the release. - **v1.0.0 shipped on 2026-05-20** with the public proxy contract locked: YAML schema, CLI flags, env vars, admin endpoints, and Prometheus metric names are now under the v1.x compatibility promise. -- **12 bundled presets** cover drydock, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, read-only, CIS Docker Benchmark, GitHub Actions self-hosted runner, and GitLab Runner. +- **15 bundled presets** cover drydock, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, read-only, CIS Docker Benchmark, GitHub Actions self-hosted runner, GitLab Runner, Portwing, Portwing with exec, and drydock with self-update. - **Expanded QA hardening** added proxy-vs-daemon differential tests, real-dockerd preset conformance, fuzz corpora for routing and visibility, weekly soak testing, and TLS edge-case coverage. - **Supply-chain verification** covers release images across GHCR, Docker Hub, and Quay.io using the same cosign commands documented for operators. @@ -241,7 +244,7 @@ Most existing socket proxies stop at method/path or regex filtering. Tecnativa a |---|---|---| | ๐Ÿ›ก๏ธ | **Default-Deny Posture** | Everything blocked unless explicitly allowed. No match means deny. | | ๐ŸŽ›๏ธ | **Granular Control** | Allow start/stop while blocking create/exec. Per-operation POST controls with glob matching. | -| ๐Ÿ“‹ | **YAML Configuration** | Declarative rules, glob path patterns, first-match-wins evaluation, and canonical path matching that strips API versions, collapses dot segments, and decodes escaped separators before policy evaluation. 12 bundled workload presets (including CIS Docker Benchmark, self-hosted GitHub Actions runners, and GitLab Runner) plus the default config. | +| ๐Ÿ“‹ | **YAML Configuration** | Declarative rules, glob path patterns, first-match-wins evaluation, and canonical path matching that strips API versions, collapses dot segments, and decodes escaped separators before policy evaluation. 15 bundled workload presets (including CIS Docker Benchmark, self-hosted GitHub Actions runners, GitLab Runner, and Portwing) plus the default config. | | ๐Ÿ“Š | **Structured Access Logging** | JSON access logs with method, raw path, normalized path, decision, matched rule, latency, canonical request ID, W3C `traceparent` correlation fields, and client info. Use `normalized_path` for SIEM correlation and policy analysis; raw `path` is preserved for forensic replay. Canonical request IDs are generated from a buffered pool so request logging does not block on a fresh entropy read per request. | | ๐Ÿ” | **mTLS for Remote TCP** | Non-loopback TCP listeners require mutual TLS by default. Plaintext TCP is explicit legacy mode only. | | ๐ŸŒ | **Client ACL Primitives** | Optional source-CIDR admission checks, client-container label ACLs, listener certificate selectors (CN/DNS/IP/URI SAN/SPKI), profile certificate selectors (CN/DNS/IP/URI/SPIFFE/SPKI), and unix peer credentials let one proxy differentiate callers before the global rule set runs. When mTLS is enabled, certificate selectors follow the verified client leaf certificate rather than an unverified peer slice entry. | @@ -267,13 +270,13 @@ Most existing socket proxies stop at method/path or regex filtering. Tecnativa a

๐Ÿ”Œ Supported Profiles

-### Bundled presets (12) +### Bundled presets (15) -[drydock](app/configs/drydock.yaml) ยท [Traefik](app/configs/traefik.yaml) ยท [Portainer](app/configs/portainer.yaml) ยท [Watchtower](app/configs/watchtower.yaml) ยท [Homepage](app/configs/homepage.yaml) ยท [Homarr](app/configs/homarr.yaml) ยท [Diun](app/configs/diun.yaml) ยท [Autoheal](app/configs/autoheal.yaml) ยท [read-only](app/configs/readonly.yaml) ยท [CIS Docker Benchmark](app/configs/cis-docker-benchmark.yaml) ยท [GitHub Actions self-hosted runner](app/configs/github-actions-runner.yaml) ยท [GitLab Runner](app/configs/gitlab-runner.yaml) +[drydock](app/configs/drydock.yaml) ยท [drydock with self-update](app/configs/drydock-with-selfupdate.yaml) ยท [Portwing](app/configs/portwing.yaml) ยท [Portwing with exec](app/configs/portwing-with-exec.yaml) ยท [Traefik](app/configs/traefik.yaml) ยท [Portainer](app/configs/portainer.yaml) ยท [Watchtower](app/configs/watchtower.yaml) ยท [Homepage](app/configs/homepage.yaml) ยท [Homarr](app/configs/homarr.yaml) ยท [Diun](app/configs/diun.yaml) ยท [Autoheal](app/configs/autoheal.yaml) ยท [read-only](app/configs/readonly.yaml) ยท [CIS Docker Benchmark](app/configs/cis-docker-benchmark.yaml) ยท [GitHub Actions self-hosted runner](app/configs/github-actions-runner.yaml) ยท [GitLab Runner](app/configs/gitlab-runner.yaml) ### Ready-to-run compose examples -[drydock](examples/compose/drydock/) ยท [Traefik](examples/compose/traefik/) ยท [Portainer](examples/compose/portainer/) ยท [Watchtower](examples/compose/watchtower/) ยท [GitHub Actions self-hosted runner](examples/compose/github-actions-runner/) ยท [GitLab Runner](examples/compose/gitlab-runner/) ยท [CIS Docker Benchmark gate](examples/compose/cis-docker-benchmark/) +[drydock](examples/compose/drydock/) ยท [Portwing](examples/compose/portwing/) ยท [Traefik](examples/compose/traefik/) ยท [Portainer](examples/compose/portainer/) ยท [Watchtower](examples/compose/watchtower/) ยท [GitHub Actions self-hosted runner](examples/compose/github-actions-runner/) ยท [GitLab Runner](examples/compose/gitlab-runner/) ยท [CIS Docker Benchmark gate](examples/compose/cis-docker-benchmark/) Each example pairs a downstream Docker API consumer with a `sockguard.yaml` overlay and a short README covering audience, exposed API surface, and security tradeoffs. @@ -299,7 +302,7 @@ How we stack up against other Docker socket proxies: | Per-client admission / policy selection | โŒ | โŒ | Partial (IP/hostname + per-container labels) | โŒ | โŒ | โœ… (CIDR + labels + cert selectors incl. SPKI + unix peer profiles) | | Read-side visibility / redaction | โŒ | โŒ | โŒ | Partial (blocks 7 risky GETs) | โŒ | โœ… (visibility + protected JSON redaction) | | Remote TCP mTLS (listener) | โŒ | โŒ | โŒ | โŒ | โœ… | โœ… (TLS 1.3) | -| Remote daemon upstream (TLS) | โŒ | โŒ | โŒ | โŒ | โœ… | Roadmap (v1.3) | +| Remote daemon upstream (TLS) | โŒ | โŒ | โŒ | โŒ | โœ… | โœ… (failover) | | Structured access logs | โŒ | โŒ | โœ… (JSON option) | โŒ | โŒ | โœ… (request + trace correlation) | | Dedicated audit log schema | โŒ | โŒ | โŒ | โŒ | โŒ | โœ… (JSON schema + reason codes) | | Rate limits / concurrency caps | โŒ | โŒ | โŒ | โŒ | โŒ | โœ… (per-profile token-bucket + global priority gate) | @@ -309,7 +312,7 @@ How we stack up against other Docker socket proxies: | YAML config | โŒ | โŒ | โŒ | โŒ | โŒ | โœ… | | Tecnativa env compat | N/A | โœ… | โŒ | โŒ | โŒ | โœ… | -`11notes/docker-socket-proxy` takes a deliberately narrow stance: a fixed read-only proxy that allows every Docker API `GET` except seven exfiltration-prone endpoints (container `attach/ws`, `export`, `archive`, `secrets`/`configs` listing, `swarm/unlockkey`, `images/{name}/get`) and blocks all writes, shipped as a non-root distroless image โ€” we match its read-side blocking with finer-grained per-field redaction and visibility rules, but additionally allow scoped writes instead of refusing them outright. `hectorm/cetusguard` is the closest in spirit to us: a zero-dependency, default-deny proxy with method + regex path rules and mTLS on both the frontend and backend โ€” but it has no request-body inspection, no per-client policies, no owner isolation, no read-side filtering, no metrics, and no hot-reload. Where we go further is body inspection breadth (every body-bearing Docker write path we can safely constrain), named profiles, ownership isolation, and read-side visibility/redaction. CetusGuard, in turn, can dial a remote Docker daemon over backend TLS today โ€” our upstream is the local socket, with remote TCP upstreams on the v1.3 roadmap. +`11notes/docker-socket-proxy` takes a deliberately narrow stance: a fixed read-only proxy that allows every Docker API `GET` except seven exfiltration-prone endpoints (container `attach/ws`, `export`, `archive`, `secrets`/`configs` listing, `swarm/unlockkey`, `images/{name}/get`) and blocks all writes, shipped as a non-root distroless image โ€” we match its read-side blocking with finer-grained per-field redaction and visibility rules, but additionally allow scoped writes instead of refusing them outright. `hectorm/cetusguard` is the closest in spirit to us: a zero-dependency, default-deny proxy with method + regex path rules and mTLS on both the frontend and backend โ€” but it has no request-body inspection, no per-client policies, no owner isolation, no read-side filtering, no metrics, and no hot-reload. Where we go further is body inspection breadth (every body-bearing Docker write path we can safely constrain), named profiles, ownership isolation, and read-side visibility/redaction. CetusGuard can dial a remote Docker daemon over backend TLS, and sockguard now does too โ€” remote `tcp://host:port` endpoints with per-endpoint mTLS, configured under `upstream.endpoints[]`. We go further with health-checked active/passive failover across redundant endpoints (a swarm VIP, an HA pair), which CetusGuard does not have. @@ -467,15 +470,21 @@ LinuxServer's socket-proxy env surface is already Tecnativa-compatible for the b | **Observability** | Prometheus `/metrics`, dedicated audit schema, trusted request IDs, deny-reason enums, W3C trace/log correlation, active upstream socket watchdog, lock-free hot path | | **Dynamic policy** | `POST /admin/validate` CI gate, `fsnotify` + SIGHUP hot reload with immutable-field gate, monotonic policy versioning, optional dedicated admin listener, cosign-signed policy bundles | +### Shipping in v1.4 + +| Track | Surface | +|---|---| +| **Remote upstreams & failover** | `upstream.endpoints[]` โ€” ordered failover set of Docker daemons (`unix://` or `tcp://host:port`), per-endpoint mTLS (`tls.ca_file`/`cert_file`/`key_file`/`server_name`), per-endpoint insecure opt-ins; active connect-level health probes on configurable `failover.health_interval`/`health_timeout`; request-failure demotes the active endpoint for immediate failover; TLS inside the dialer so the reverse proxy, hijack, and inspect paths are all covered; designed for active/passive redundancy across equivalent daemons (swarm managers, HA pairs) โ€” not cross-daemon fan-out; `DOCKER_HOST`/`DOCKER_TLS_VERIFY`/`DOCKER_CERT_PATH` auto-detected when no endpoints are set | +| **SecurityOpt policy rails** | `deny_selinux_disable`, `deny_selinux_label_override`, `deny_unconfined_system_paths` for `containers/create`; `deny_unconfined_seccomp`, `deny_custom_seccomp_profiles`, `deny_unconfined_apparmor` for `services/create/update`; swarm `ContainerSpec.Privileges` confinement parity with container create | + ### Post-1.0 preview | Tier | Theme | |---|---| -| Security hardening (v1.x) | Continued mutation-test hardening of the rule-evaluation core and config validators; swarm `ContainerSpec` seccomp/AppArmor mode enforcement parity (the `Privileges.NoNewPrivileges`, `User`, `ReadOnly`, and `CapabilityDrop` rails already mirror container-create); `HostConfig.SecurityOpt` `label=`/`systempaths=` policy evaluation (currently passed through) | +| Security hardening (v1.x) | Continued mutation-test hardening of the rule-evaluation core and config validators (swarm `ContainerSpec` confinement parity and `SecurityOpt` `label=`/`systempaths=` evaluation shipped in v1.4) | | Policy refinement (v1.x) | Multiple frontend listeners on the main proxy, named rule path aliases | -| Internals (v1.x) | Code-review backlog: collapse the config โ†’ filter-options โ†’ policy translation layers behind a single source of truth (generated Viper defaults); allocation-free rate-limit bucket state (packed `atomic.Uint64`); profiling-gated JSON redaction fast path; clear per-profile in-flight gauges when a hot reload removes the profile | +| Internals (v1.x) | Code-review backlog: collapse the config โ†’ filter-options โ†’ policy translation layers behind a single source of truth (generated Viper defaults); profiling-gated JSON redaction fast path | | Compliance (v1.x) | CIS Docker Benchmark control mapping, audit-ready policy templates | -| Multi-host (v1.3) | Remote Docker TCP upstreams, multi-upstream fan-out, remote daemon health checking, connection pooling, automatic failover | | Extensibility (v1.x+) | Optional plugin extension points (WASM or Go plugins), OPA/Rego policy integration | @@ -517,20 +526,13 @@ LinuxServer's socket-proxy env surface is already Tecnativa-compatible for the b
-[![SemVer](https://img.shields.io/badge/semver-2.0.0-blue)](https://semver.org/) -[![Conventional Commits](https://img.shields.io/badge/commits-conventional-fe5196?logo=conventionalcommits&logoColor=fff)](https://www.conventionalcommits.org/) -[![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog-E05735)](https://keepachangelog.com/) - ### Built With [![Go 1.26](https://img.shields.io/badge/Go_1.26-00ADD8?logo=go&logoColor=fff)](https://go.dev/) -[![Cobra](https://img.shields.io/badge/Cobra-00ADD8?logo=go&logoColor=fff)](https://github.com/spf13/cobra) -[![Viper](https://img.shields.io/badge/Viper-00ADD8?logo=go&logoColor=fff)](https://github.com/spf13/viper) -[![fsnotify](https://img.shields.io/badge/fsnotify-00ADD8?logo=go&logoColor=fff)](https://github.com/fsnotify/fsnotify) -[![Sigstore](https://img.shields.io/badge/Sigstore-FFC107?logo=sigstore&logoColor=000)](https://www.sigstore.dev/) +[![Sigstore](https://img.shields.io/badge/Sigstore-FFC107?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiMwMDAwMDAiIGQ9Im0xMCAxN2wtNC00bDEuNDEtMS40MUwxMCAxNC4xN2w2LjU5LTYuNTlMMTggOW0tNi04TDMgNXY2YzAgNS41NSAzLjg0IDEwLjc0IDkgMTJjNS4xNi0xLjI2IDktNi40NSA5LTEyVjV6Ii8%2BPC9zdmc%2B)](https://www.sigstore.dev/) [![Wolfi](https://img.shields.io/badge/Wolfi-4A4A55?logo=chainguard&logoColor=fff)](https://edu.chainguard.dev/open-source/wolfi/overview/) [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)](https://www.docker.com/) -[![GoReleaser](https://img.shields.io/badge/GoReleaser-00ADD8?logo=go&logoColor=fff)](https://goreleaser.com/) +[![GoReleaser](https://img.shields.io/badge/GoReleaser-317FE0?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iI2ZmZmZmZiIgY2xhc3M9ImJpIGJpLXJvY2tldC10YWtlb2ZmLWZpbGwiIHZpZXdCb3g9IjAgMCAxNiAxNiI%2BCiAgPHBhdGggZD0iTTEyLjE3IDkuNTNjMi4zMDctMi41OTIgMy4yNzgtNC42ODQgMy42NDEtNi4yMTguMjEtLjg4Ny4yMTQtMS41OC4xNi0yLjA2NWEzLjYgMy42IDAgMCAwLS4xMDgtLjU2MyAyIDIgMCAwIDAtLjA3OC0uMjNWLjQ1M2MtLjA3My0uMTY0LS4xNjgtLjIzNC0uMzUyLS4yOTVhMiAyIDAgMCAwLS4xNi0uMDQ1IDQgNCAwIDAgMC0uNTctLjA5M2MtLjQ5LS4wNDQtMS4xOS0uMDMtMi4wOC4xODgtMS41MzYuMzc0LTMuNjE4IDEuMzQzLTYuMTYxIDMuNjA0bC0yLjQuMjM4aC0uMDA2YTIuNTUgMi41NSAwIDAgMC0xLjUyNC43MzRMLjE1IDcuMTdhLjUxMi41MTIgMCAwIDAgLjQzMy44NjhsMS44OTYtLjI3MWMuMjgtLjA0LjU5Mi4wMTMuOTU1LjEzMi4yMzIuMDc2LjQzNy4xNi42NTUuMjQ4bC4yMDMuMDgzYy4xOTYuODE2LjY2IDEuNTggMS4yNzUgMi4xOTUuNjEzLjYxNCAxLjM3NiAxLjA4IDIuMTkxIDEuMjc3bC4wODIuMjAyYy4wODkuMjE4LjE3My40MjQuMjQ5LjY1Ny4xMTguMzYzLjE3Mi42NzYuMTMyLjk1NmwtLjI3MSAxLjlhLjUxMi41MTIgMCAwIDAgLjg2Ny40MzNsMi4zODItMi4zODZjLjQxLS40MS42NjgtLjk0OS43MzItMS41MjZ6bS4xMS0zLjY5OWMtLjc5Ny44LTEuOTMuOTYxLTIuNTI4LjM2Mi0uNTk4LS42LS40MzYtMS43MzMuMzYxLTIuNTMyLjc5OC0uNzk5IDEuOTMtLjk2IDIuNTI4LS4zNjFzLjQzNyAxLjczMi0uMzYgMi41MzFaIi8%2BCiAgPHBhdGggZD0iTTUuMjA1IDEwLjc4N2E3LjYgNy42IDAgMCAwIDEuODA0IDEuMzUyYy0xLjExOCAxLjAwNy00LjkyOSAyLjAyOC01LjA1NCAxLjkwMy0uMTI2LS4xMjcuNzM3LTQuMTg5IDEuODM5LTUuMTguMzQ2LjY5LjgzNyAxLjM1IDEuNDExIDEuOTI1Ii8%2BCjwvc3ZnPg%3D%3D)](https://goreleaser.com/)
[![Next.js](https://img.shields.io/badge/Next.js-000000?logo=nextdotjs&logoColor=fff)](https://nextjs.org/) [![Fumadocs](https://img.shields.io/badge/Fumadocs-000000?logo=nextdotjs&logoColor=fff)](https://fumadocs.dev/) @@ -538,6 +540,13 @@ LinuxServer's socket-proxy env surface is already Tecnativa-compatible for the b [![Turborepo](https://img.shields.io/badge/Turborepo-EF4444?logo=turborepo&logoColor=fff)](https://turbo.build/repo) [![Biome](https://img.shields.io/badge/Biome_2-60a5fa?logo=biome&logoColor=fff)](https://biomejs.dev/) +[![Anthropic](https://img.shields.io/badge/Anthropic-000000?logo=anthropic&logoColor=fff)](https://www.anthropic.com) +[![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU%2BT3BlbkFJPC90aXRsZT48cGF0aCBmaWxsPSIjZmZmZmZmIiBkPSJNMjIuMjgxOSA5LjgyMTFhNS45ODQ3IDUuOTg0NyAwIDAgMC0uNTE1Ny00LjkxMDggNi4wNDYyIDYuMDQ2MiAwIDAgMC02LjUwOTgtMi45QTYuMDY1MSA2LjA2NTEgMCAwIDAgNC45ODA3IDQuMTgxOGE1Ljk4NDcgNS45ODQ3IDAgMCAwLTMuOTk3NyAyLjkgNi4wNDYyIDYuMDQ2MiAwIDAgMCAuNzQyNyA3LjA5NjYgNS45OCA1Ljk4IDAgMCAwIC41MTEgNC45MTA3IDYuMDUxIDYuMDUxIDAgMCAwIDYuNTE0NiAyLjkwMDFBNS45ODQ3IDUuOTg0NyAwIDAgMCAxMy4yNTk5IDI0YTYuMDU1NyA2LjA1NTcgMCAwIDAgNS43NzE4LTQuMjA1OCA1Ljk4OTQgNS45ODk0IDAgMCAwIDMuOTk3Ny0yLjkwMDEgNi4wNTU3IDYuMDU1NyAwIDAgMC0uNzQ3NS03LjA3Mjl6bS05LjAyMiAxMi42MDgxYTQuNDc1NSA0LjQ3NTUgMCAwIDEtMi44NzY0LTEuMDQwOGwuMTQxOS0uMDgwNCA0Ljc3ODMtMi43NTgyYS43OTQ4Ljc5NDggMCAwIDAgLjM5MjctLjY4MTN2LTYuNzM2OWwyLjAyIDEuMTY4NmEuMDcxLjA3MSAwIDAgMSAuMDM4LjA1MnY1LjU4MjZhNC41MDQgNC41MDQgMCAwIDEtNC40OTQ1IDQuNDk0NHptLTkuNjYwNy00LjEyNTRhNC40NzA4IDQuNDcwOCAwIDAgMS0uNTM0Ni0zLjAxMzdsLjE0Mi4wODUyIDQuNzgzIDIuNzU4MmEuNzcxMi43NzEyIDAgMCAwIC43ODA2IDBsNS44NDI4LTMuMzY4NXYyLjMzMjRhLjA4MDQuMDgwNCAwIDAgMS0uMDMzMi4wNjE1TDkuNzQgMTkuOTUwMmE0LjQ5OTIgNC40OTkyIDAgMCAxLTYuMTQwOC0xLjY0NjR6TTIuMzQwOCA3Ljg5NTZhNC40ODUgNC40ODUgMCAwIDEgMi4zNjU1LTEuOTcyOFYxMS42YS43NjY0Ljc2NjQgMCAwIDAgLjM4NzkuNjc2NWw1LjgxNDQgMy4zNTQzLTIuMDIwMSAxLjE2ODVhLjA3NTcuMDc1NyAwIDAgMS0uMDcxIDBsLTQuODMwMy0yLjc4NjVBNC41MDQgNC41MDQgMCAwIDEgMi4zNDA4IDcuODcyem0xNi41OTYzIDMuODU1OEwxMy4xMDM4IDguMzY0IDE1LjExOTIgNy4yYS4wNzU3LjA3NTcgMCAwIDEgLjA3MSAwbDQuODMwMyAyLjc5MTNhNC40OTQ0IDQuNDk0NCAwIDAgMS0uNjc2NSA4LjEwNDJ2LTUuNjc3MmEuNzkuNzkgMCAwIDAtLjQwNy0uNjY3em0yLjAxMDctMy4wMjMxbC0uMTQyLS4wODUyLTQuNzczNS0yLjc4MThhLjc3NTkuNzc1OSAwIDAgMC0uNzg1NCAwTDkuNDA5IDkuMjI5N1Y2Ljg5NzRhLjA2NjIuMDY2MiAwIDAgMSAuMDI4NC0uMDYxNWw0LjgzMDMtMi43ODY2YTQuNDk5MiA0LjQ5OTIgMCAwIDEgNi42ODAyIDQuNjZ6TTguMzA2NSAxMi44NjNsLTIuMDItMS4xNjM4YS4wODA0LjA4MDQgMCAwIDEtLjAzOC0uMDU2N1Y2LjA3NDJhNC40OTkyIDQuNDk5MiAwIDAgMSA3LjM3NTctMy40NTM3bC0uMTQyLjA4MDVMOC43MDQgNS40NTlhLjc5NDguNzk0OCAwIDAgMC0uMzkyNy42ODEzem0xLjA5NzYtMi4zNjU0bDIuNjAyLTEuNDk5OCAyLjYwNjkgMS40OTk4djIuOTk5NGwtMi41OTc0IDEuNDk5Ny0yLjYwNjctMS40OTk3WiIvPjwvc3ZnPg%3D%3D)](https://openai.com) + +[![SemVer](https://img.shields.io/badge/semver-2.0.0-blue)](https://semver.org/) +[![Conventional Commits](https://img.shields.io/badge/commits-conventional-fe5196?logo=conventionalcommits&logoColor=fff)](https://www.conventionalcommits.org/) +[![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog-E05735)](https://keepachangelog.com/) + ### Community & Support Issues, ideas, and pull requests are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), use [SECURITY.md](SECURITY.md) for private vulnerability disclosure, and use [GitHub Discussions](https://github.com/CodesWhat/sockguard/discussions) for design questions. @@ -546,9 +555,20 @@ For local fuzz triage, run `scripts/local-fuzz.sh --suite ci --fuzztime 2m`. Use Every release image is cosign-signed via GitHub Actions OIDC. Before running a sockguard image in production, verify it with the canonical invocation in the [image verification guide](https://getsockguard.com/docs/verification). +### Part of the CodesWhat ecosystem + + + + + + +
ToolRole
drydockContainer update monitoring โ€” web UI and notification engine
lookoutRemote Docker agent โ€” secure socket-level access from Drydock or standalone
sockguardDocker socket proxy โ€” default-deny allowlist filter protecting the socket
+ +These three tools are designed to layer: sockguard filters the socket, lookout exposes it remotely, and drydock monitors and acts on container state. + **[Apache-2.0 License](LICENSE)** -Built by CodesWhat +CodesWhat [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=kofi&logoColor=white)](https://ko-fi.com/codeswhat) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/codeswhat) diff --git a/RELEASING.md b/RELEASING.md index 6ac0dcad..00500fa4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -51,20 +51,22 @@ Go to **Actions โ†’ Release: Cut** โ†’ **Run workflow** on `main`. The workflow: - Polls until `ci-verify.yml` has a successful run on HEAD -- Computes the next semver from conventional-commit history -- Validates the CHANGELOG entry is non-empty for the computed tag -- Creates and pushes a signed annotated tag using the repo bot identity +- Computes the next **stable** semver from conventional-commit history โ€” **or**, if you supply the optional `release_tag` input (e.g. `v1.4.0-rc.1`), cuts that exact tag instead. This is how prereleases / rc's are cut: the auto-computer only emits stable versions. +- Validates the CHANGELOG entry is non-empty for the tag +- Creates and pushes an annotated tag using the repo bot identity This automatically triggers `release-from-tag.yml`. -**Manual path** (if you need to override the computed version): +> Tags are **not** GPG-signed โ€” the bot pushes a plain annotated tag (`git tag -a`). Release provenance comes from cosign keyless signing of the image plus SLSA attestation in `release-from-tag.yml`, not from a git-tag signature. + +**Manual path** (fallback if you can't use the workflow): ``` -git tag -s v -m "v" +git tag -a v -m "v" git push origin v ``` -Signed tags require your GPG key. The `release-from-tag.yml` workflow fires on any `v*` tag push. +Swap `-a` for `-s` if you want to GPG-sign locally, but signing is optional and not enforced anywhere. The `release-from-tag.yml` workflow fires on any `v*` tag push and gates the release on a green `ci-verify` run for the tag SHA. --- diff --git a/app/.golangci.yml b/app/.golangci.yml index ea94ea82..2a699ed2 100644 --- a/app/.golangci.yml +++ b/app/.golangci.yml @@ -80,3 +80,14 @@ linters: - errcheck - gosec - bodyclose + # SA5011 false-positives in tests: staticcheck (as built into some + # golangci-lint release binaries) doesn't model `t.Fatal`/`t.Fatalf` as + # terminating, so it flags `if x == nil { t.Fatal(...) }; x.field` as a + # possible nil deref even though t.Fatal ends the test via runtime.Goexit. + # It's a test-only idiom and the behaviour differs across linter builds + # (locally clean, red in CI), so scope the suppression to test files. + # Kept active on non-test code, where SA5011 is a genuinely useful check. + - path: _test\.go + linters: + - staticcheck + text: SA5011 diff --git a/app/configs/drydock-with-selfupdate.yaml b/app/configs/drydock-with-selfupdate.yaml new file mode 100644 index 00000000..6fa9b3a7 --- /dev/null +++ b/app/configs/drydock-with-selfupdate.yaml @@ -0,0 +1,177 @@ +# Sockguard โ€” Drydock with Self-Update Preset +# +# Extends the drydock preset with the exec paths required for drydock's +# self-update finalize flow. Use this instead of drydock.yaml when drydock +# is configured to update itself through the sockguard proxy. +# +# Self-update exec flow (source: drydock/app/triggers/providers/docker/): +# 1. drydock creates the replacement container (already covered by drydock.yaml) +# 2. A short-lived helper container is spawned via POST /containers/create +# (helper image, AutoRemove: true, bind-mounts the Docker socket) +# 3. The helper stops the old container, starts the new one, and waits +# for it to become healthy โ€” all via the proxy (covered by lifecycle rules) +# 4. runFinalizeCallbackInContainer(): POST /containers/{id}/exec creates an +# exec instance with Cmd: ['node', 'dist/triggers/providers/docker/ +# self-update-finalize-entrypoint.js']; POST /exec/{id}/start runs it +# (Detach: false, Tty: false); GET /exec/{id}/json confirms the exit code +# 5. The finalize entrypoint POSTs an HTTP callback to drydock's internal API +# (DD_SELF_UPDATE_FINALIZE_URL, typically http://127.0.0.1:3000/โ€ฆ) โ€” that +# call leaves the proxy, so no extra sockguard rule is needed +# +# Security tradeoff: the exec paths below are pinned to the single finalize +# command argv via allowed_commands. This prevents any other exec invocation +# even if drydock or the helper container attempted one. If your drydock build +# uses a different Node entrypoint path, update allowed_commands to match. +# The finalize exec runs as whatever user the drydock image declares (typically +# node/nonroot), so allow_root_user is false here โ€” set it to true only if your +# drydock image runs as root. +# +# Expanded API surface vs. drydock.yaml: +# + POST /containers/{id}/exec โ€” create exec for finalize callback +# + POST /exec/{id}/start โ€” run the finalize entrypoint +# + GET /exec/{id}/json โ€” inspect exit code after finalize +# +# Helper container bind-mounts: the helper always bind-mounts the Docker socket +# path (the same path drydock itself found, typically /var/run/docker.sock) so +# it can reach dockerd directly during the stop/start/wait sequence. Add that +# path to allowed_bind_mounts if sockguard is inspecting the helper's create body. + +upstream: + socket: /var/run/docker.sock + +log: + level: info + format: json + access_log: true + +health: + enabled: true + path: /health + +# Drydock's update flow inspects the running container, then submits the +# inspect payload back to dockerd via POST /containers/create. Redactions must +# be off so HostConfig.Binds / Config.Env / NetworkSettings pass through +# intact โ€” same reasoning as drydock.yaml. +response: + redact_mount_paths: false + redact_container_env: false + redact_network_topology: false + +# Container-create inspection: the helper container always bind-mounts the +# Docker socket (e.g. /var/run/docker.sock:/var/run/docker.sock). Add that +# source path to allowed_bind_mounts so sockguard does not block the helper +# create. If drydock reaches the daemon via TCP (no local socket bind) the +# helper uses NetworkMode instead, and no bind-mount allowlist entry is needed. +# The allowed_commands list pins the finalize exec argv prefix โ€” any exec call +# whose Cmd does not start with this prefix is denied even if the rule allows +# POST /containers/*/exec. +request_body: + container_create: + allowed_bind_mounts: [] + allowed_runtimes: + - runc + exec: + allow_privileged: false + allow_root_user: false + allowed_commands: + - ["node", "dist/triggers/providers/docker/self-update-finalize-entrypoint.js"] + image_pull: + allow_all_registries: true + +rules: + # Health and metadata + - match: { method: GET, path: "/_ping" } + action: allow + - match: { method: HEAD, path: "/_ping" } + action: allow + - match: { method: GET, path: "/version" } + action: allow + - match: { method: GET, path: "/info" } + action: allow + - match: { method: GET, path: "/events" } + action: allow + + # Container reads โ€” narrow set drydock needs (list + inspect + stats). + # Deliberately omits /containers/*/logs, /archive, /export, /attach to + # keep the exfiltration validator happy. + - match: { method: GET, path: "/containers/json" } + action: allow + - match: { method: GET, path: "/containers/*/json" } + action: allow + - match: { method: GET, path: "/containers/*/stats" } + action: allow + - match: { method: GET, path: "/containers/*/top" } + action: allow + - match: { method: GET, path: "/containers/*/changes" } + action: allow + + # Container lifecycle + - match: { method: POST, path: "/containers/*/start" } + action: allow + - match: { method: POST, path: "/containers/*/stop" } + action: allow + - match: { method: POST, path: "/containers/*/restart" } + action: allow + - match: { method: POST, path: "/containers/*/kill" } + action: allow + - match: { method: POST, path: "/containers/*/rename" } + action: allow + - match: { method: POST, path: "/containers/*/update" } + action: allow + - match: { method: POST, path: "/containers/*/wait" } + action: allow + - match: { method: DELETE, path: "/containers/*" } + action: allow + + # Container creation (for updates and the self-update helper container) + - match: { method: POST, path: "/containers/create" } + action: allow + + # Exec โ€” body-inspected per request_body.exec above. The allowed_commands + # list restricts exec to the finalize entrypoint only. POST /exec/*/start + # is a raw HTTP/1.1 upgrade (Detach: false, Tty: false for the finalize path). + - match: { method: POST, path: "/containers/*/exec" } + action: allow + - match: { method: POST, path: "/exec/*/start" } + action: allow + - match: { method: GET, path: "/exec/*/json" } + action: allow + + # Image reads โ€” list + inspect + history. + - match: { method: GET, path: "/images/json" } + action: allow + - match: { method: GET, path: "/images/**/json" } + action: allow + - match: { method: GET, path: "/images/**/history" } + action: allow + - match: { method: POST, path: "/images/create" } + action: allow + - match: { method: DELETE, path: "/images/**" } + action: allow + + # Network read โ€” list + inspect only. + - match: { method: GET, path: "/networks" } + action: allow + - match: { method: GET, path: "/networks/*" } + action: allow + + # Volume read โ€” list + inspect only. + - match: { method: GET, path: "/volumes" } + action: allow + - match: { method: GET, path: "/volumes/*" } + action: allow + + # Distribution (registry digest checks for image freshness). + - match: { method: GET, path: "/distribution/**/json" } + action: allow + + # Swarm services โ€” list + inspect. + - match: { method: GET, path: "/services" } + action: allow + - match: { method: GET, path: "/services/*" } + action: allow + + # Deny everything else + - match: { method: "*", path: "/**" } + action: deny + reason: "not allowed by drydock-with-selfupdate preset" diff --git a/app/configs/github-actions-runner.yaml b/app/configs/github-actions-runner.yaml index 813e5297..b0256876 100644 --- a/app/configs/github-actions-runner.yaml +++ b/app/configs/github-actions-runner.yaml @@ -140,6 +140,15 @@ request_body: allow_all_registries: true allow_imports: false +# Exec without pinned AllowedCommands requires insecure_allow_body_blind_writes; +# log/attach streaming requires insecure_allow_read_exfiltration. Both flags +# acknowledge deliberate trade-offs: the runner must be able to execute arbitrary +# workflow steps (not a fixed command list) and stream job output via the logs +# and attach APIs. Restrict the proxy socket to the runner process UID/IP via +# clients.unix_peer_profiles or clients.allowed_cidrs to keep the risk bounded. +insecure_allow_body_blind_writes: true +insecure_allow_read_exfiltration: true + # Response redactions โ€” leave default-on. The runner reads container # inspect output to set up log streaming, not to enumerate host paths or # env vars. Keeping redactions on means a compromised workflow step diff --git a/app/configs/gitlab-runner.yaml b/app/configs/gitlab-runner.yaml index 82cc5058..4e5a9174 100644 --- a/app/configs/gitlab-runner.yaml +++ b/app/configs/gitlab-runner.yaml @@ -64,6 +64,15 @@ health: enabled: true path: /health +# Exec without pinned AllowedCommands requires insecure_allow_body_blind_writes; +# log/attach streaming requires insecure_allow_read_exfiltration. Both flags +# acknowledge deliberate trade-offs: the runner must be able to execute arbitrary +# job steps (not a fixed command list) and stream job output via the logs and +# attach APIs. Restrict the proxy socket to the runner process UID/IP via +# clients.unix_peer_profiles or clients.allowed_cidrs to keep the risk bounded. +insecure_allow_body_blind_writes: true +insecure_allow_read_exfiltration: true + # Response redactions: ON by default. The runner does not re-submit inspect # payloads verbatim the way Watchtower/Drydock do, so redacting host paths, # env vars, and network topology from read responses is safe and reduces the diff --git a/app/configs/portwing-with-exec.yaml b/app/configs/portwing-with-exec.yaml new file mode 100644 index 00000000..50ae3d94 --- /dev/null +++ b/app/configs/portwing-with-exec.yaml @@ -0,0 +1,181 @@ +# Sockguard โ€” Portwing with Exec Preset +# +# Extends the portwing preset with exec support for interactive terminal access +# through the Portwing agent (e.g. terminal-over-websocket or drydock-driven +# exec sessions in Portwing's edge mode). +# +# Allows: everything in portwing.yaml plus: +# POST /containers/{id}/exec โ€” create an exec instance +# POST /exec/{id}/start โ€” start the exec (raw HTTP/1.1 upgrade) +# POST /exec/{id}/resize โ€” resize the PTY +# GET /exec/{id}/json โ€” inspect exec state and exit code +# +# Exec body inspection: allow_privileged is disabled. allow_root_user is +# enabled because most container workloads run as root and the caller +# (Portwing or drydock) needs unrestricted exec for interactive sessions. +# If your deployment enforces non-root containers, set allow_root_user: false +# and optionally add allowed_commands to pin the permitted argv prefixes. +# +# insecure_allow_body_blind_writes: true is required because sockguard's +# startup validator considers exec endpoints "body-blind" unless +# allowed_commands is non-empty. Interactive sessions cannot be pinned to a +# fixed command argv, so the flag acknowledges that risk explicitly. +# The allow_privileged: false / allow_root_user enforcement layer still +# applies โ€” it gates on the inspect payload, not on argv prefix matching. +# Operators who can constrain exec to known commands should instead replace +# allow_root_user with an allowed_commands list (see drydock-with-selfupdate.yaml +# for the pinned-command pattern) and drop insecure_allow_body_blind_writes. +# +# Use this preset instead of portwing.yaml when: +# - Portwing's terminal/exec feature is in use +# - Drydock drives exec calls through the Portwing edge mode +# - You need exec for container debugging via the Portwing UI +# +# Security note: exec grants arbitrary command execution inside any container +# that Portwing can reach. Keep the proxy socket access-controlled (unix peer +# credentials or client CIDR allowlist) so only the Portwing process can call +# the exec paths. See portwing.yaml for the exec-disabled baseline. + +upstream: + socket: /var/run/docker.sock + +log: + level: info + format: json + access_log: true + +health: + enabled: true + path: /health + +# Response redaction โ€” all three disabled for the drydock passthrough topology. +# See portwing.yaml header for standalone-mode guidance. +response: + redact_mount_paths: false + redact_container_env: false + redact_network_topology: false + +# Exec without pinned AllowedCommands requires insecure_allow_body_blind_writes; +# log streaming requires insecure_allow_read_exfiltration. See the header +# comment for the security-tradeoff rationale for each flag. +insecure_allow_body_blind_writes: true +insecure_allow_read_exfiltration: true + +# Container-create and image-pull inspection mirrors portwing.yaml. +# Exec body inspection: allow_privileged denied, root user allowed for +# interactive sessions. To pin allowed commands, replace allow_root_user +# with an allowed_commands list (argv prefix allowlist). +request_body: + container_create: + allowed_bind_mounts: [] + allowed_runtimes: + - runc + exec: + allow_privileged: false + allow_root_user: true + image_pull: + allow_all_registries: true + +rules: + # Health and metadata + - match: { method: GET, path: "/_ping" } + action: allow + - match: { method: HEAD, path: "/_ping" } + action: allow + - match: { method: GET, path: "/version" } + action: allow + - match: { method: GET, path: "/info" } + action: allow + - match: { method: GET, path: "/events" } + action: allow + + # Container reads โ€” list, inspect, stats, top, changes, and logs. + # /containers/*/logs is required by Portwing's GetContainerLogs(). + # /containers/*/archive, /containers/*/export, and /containers/*/attach + # are intentionally omitted โ€” bulk-data exfiltration paths Portwing does not use. + - match: { method: GET, path: "/containers/json" } + action: allow + - match: { method: GET, path: "/containers/*/json" } + action: allow + - match: { method: GET, path: "/containers/*/logs" } + action: allow + - match: { method: GET, path: "/containers/*/stats" } + action: allow + - match: { method: GET, path: "/containers/*/top" } + action: allow + - match: { method: GET, path: "/containers/*/changes" } + action: allow + + # Container lifecycle + - match: { method: POST, path: "/containers/*/start" } + action: allow + - match: { method: POST, path: "/containers/*/stop" } + action: allow + - match: { method: POST, path: "/containers/*/restart" } + action: allow + - match: { method: POST, path: "/containers/*/kill" } + action: allow + - match: { method: POST, path: "/containers/*/rename" } + action: allow + - match: { method: POST, path: "/containers/*/update" } + action: allow + - match: { method: POST, path: "/containers/*/wait" } + action: allow + - match: { method: DELETE, path: "/containers/*" } + action: allow + + # Container creation (for orchestration and passthrough to drydock) + - match: { method: POST, path: "/containers/create" } + action: allow + + # Exec โ€” body-inspected per request_body.exec above. + # POST /exec/*/start triggers a raw HTTP/1.1 protocol upgrade (Connection: + # Upgrade, Upgrade: tcp); sockguard passes the upgrade through on this path. + # POST /exec/*/resize adjusts the PTY dimensions for interactive terminals. + - match: { method: POST, path: "/containers/*/exec" } + action: allow + - match: { method: POST, path: "/exec/*/start" } + action: allow + - match: { method: POST, path: "/exec/*/resize" } + action: allow + - match: { method: GET, path: "/exec/*/json" } + action: allow + + # Image reads โ€” list + inspect + history. + - match: { method: GET, path: "/images/json" } + action: allow + - match: { method: GET, path: "/images/**/json" } + action: allow + - match: { method: GET, path: "/images/**/history" } + action: allow + - match: { method: POST, path: "/images/create" } + action: allow + - match: { method: DELETE, path: "/images/**" } + action: allow + + # Network read โ€” list + inspect only. + - match: { method: GET, path: "/networks" } + action: allow + - match: { method: GET, path: "/networks/*" } + action: allow + + # Volume read โ€” list + inspect only. + - match: { method: GET, path: "/volumes" } + action: allow + - match: { method: GET, path: "/volumes/*" } + action: allow + + # Distribution (registry digest checks for image freshness). + - match: { method: GET, path: "/distribution/**/json" } + action: allow + + # Swarm services โ€” list + inspect. + - match: { method: GET, path: "/services" } + action: allow + - match: { method: GET, path: "/services/*" } + action: allow + + # Deny everything else + - match: { method: "*", path: "/**" } + action: deny + reason: "not allowed by portwing-with-exec preset" diff --git a/app/configs/portwing.yaml b/app/configs/portwing.yaml new file mode 100644 index 00000000..574b56d8 --- /dev/null +++ b/app/configs/portwing.yaml @@ -0,0 +1,180 @@ +# Sockguard โ€” Portwing Preset +# +# Optimized for the Portwing Docker agent (https://github.com/CodesWhat/portwing). +# Allows: container list/inspect/stats/top/changes/logs, lifecycle (start/stop/ +# restart/kill/rename/update/wait/create/remove), image pull/inspect/ +# remove, /events, narrow network/volume/distribution reads, Swarm +# service reads. +# Denies: exec, build, secrets, plugins, raw archive/export/attach streams. +# +# Log streaming: Portwing's GetContainerLogs() calls GET /containers/{id}/logs +# (including follow=1 for live streaming). This path is classified as a +# read-exfiltration endpoint by sockguard's startup validator because logs can +# contain secrets. insecure_allow_read_exfiltration: true below acknowledges +# that tradeoff. The /containers/*/archive, /containers/*/export, and +# /containers/*/attach paths are intentionally not allowed โ€” those are the +# bulk-data exfil vectors. Operators who want to suppress log access should +# remove the /containers/*/logs rule and the insecure_allow_read_exfiltration +# flag and accept that GetContainerLogs() will return 403. +# +# Redaction posture โ€” redact_mount_paths and redact_container_env are DISABLED +# here because in the production tri-tool topology (sockguard โ†’ Portwing โ†’ drydock) +# Portwing forwards container inspect data to drydock whose update detection reads +# HostConfig.Binds and Config.Env to recreate containers. If sockguard redacts +# those fields to "", drydock faithfully forwards the placeholder into +# POST /containers/create and dockerd rejects with 400. Operators running Portwing +# in standalone mode (not paired with drydock) can safely flip both to true: +# +# response: +# redact_mount_paths: true +# redact_container_env: true +# +# redact_network_topology is also disabled for the same reason: drydock's clone +# logic reconstructs NetworkMode from the inspect spec. +# +# Exec: this preset denies all exec paths. To allow interactive exec sessions +# through Portwing (e.g. for a terminal-over-websocket feature), use the +# portwing-with-exec.yaml preset instead. +# +# Security tradeoff acknowledgment โ€” log streaming: +# GET /containers/{id}/logs is allowed below. sockguard requires +# insecure_allow_read_exfiltration: true for this path because container logs +# can contain environment-variable secrets or other sensitive output that +# should not be forwarded in all deployments. In the Portwing topology the +# agent is the sole consumer of the proxy socket and its own access controls +# gate who can reach the Portwing HTTP API โ€” this is an acceptable tradeoff. +insecure_allow_read_exfiltration: true + +upstream: + socket: /var/run/docker.sock + +log: + level: info + format: json + access_log: true + +health: + enabled: true + path: /health + +# Response redaction โ€” all three disabled for the drydock passthrough topology. +# See the header comment for when to re-enable them in standalone deployments. +response: + redact_mount_paths: false + redact_container_env: false + redact_network_topology: false + +# Portwing needs container creation for its orchestration flows. Container-create +# bodies are inspected by default; bind-mounted workloads are denied until their +# host paths are allowlisted here. Image pulls are inspected too; this preset +# explicitly allows any registry so Portwing can track arbitrary images while still +# denying fromSrc imports. The stock "runc" runtime is allowlisted because Portwing +# (like drydock) recreates containers from inspect specs that carry an explicit +# HostConfig.Runtime โ€” without it every recreation is rejected at +# POST /containers/create with 403 "runtime \"runc\" is not allowlisted". +request_body: + container_create: + allowed_bind_mounts: [] + allowed_runtimes: + - runc + image_pull: + allow_all_registries: true + +rules: + # Health and metadata + - match: { method: GET, path: "/_ping" } + action: allow + - match: { method: HEAD, path: "/_ping" } + action: allow + - match: { method: GET, path: "/version" } + action: allow + - match: { method: GET, path: "/info" } + action: allow + - match: { method: GET, path: "/events" } + action: allow + + # Container reads โ€” list, inspect, stats, top, changes, and logs. + # /containers/*/logs is required by Portwing's GetContainerLogs() call. + # /containers/*/archive, /containers/*/export, and /containers/*/attach + # are intentionally omitted โ€” those are bulk-data exfiltration paths that + # Portwing does not use. insecure_allow_read_exfiltration: true (set above) + # satisfies sockguard's startup validator for the logs path. + - match: { method: GET, path: "/containers/json" } + action: allow + - match: { method: GET, path: "/containers/*/json" } + action: allow + - match: { method: GET, path: "/containers/*/logs" } + action: allow + - match: { method: GET, path: "/containers/*/stats" } + action: allow + - match: { method: GET, path: "/containers/*/top" } + action: allow + - match: { method: GET, path: "/containers/*/changes" } + action: allow + + # Container lifecycle + - match: { method: POST, path: "/containers/*/start" } + action: allow + - match: { method: POST, path: "/containers/*/stop" } + action: allow + - match: { method: POST, path: "/containers/*/restart" } + action: allow + - match: { method: POST, path: "/containers/*/kill" } + action: allow + - match: { method: POST, path: "/containers/*/rename" } + action: allow + - match: { method: POST, path: "/containers/*/update" } + action: allow + - match: { method: POST, path: "/containers/*/wait" } + action: allow + - match: { method: DELETE, path: "/containers/*" } + action: allow + + # Container creation (for orchestration and passthrough to drydock) + - match: { method: POST, path: "/containers/create" } + action: allow + + # Image reads โ€” list + inspect + history. Image names can be namespaced + # (linuxserver/qbittorrent:latest, ghcr.io/owner/repo:tag), so the + # middle is matched with `**`. The trailing /json or /history segment + # keeps the exfiltration validator happy by not matching /images/get + # or /images/*/get (raw tarball exfil). + - match: { method: GET, path: "/images/json" } + action: allow + - match: { method: GET, path: "/images/**/json" } + action: allow + - match: { method: GET, path: "/images/**/history" } + action: allow + - match: { method: POST, path: "/images/create" } + action: allow + - match: { method: DELETE, path: "/images/**" } + action: allow + + # Network read โ€” list + inspect only. + - match: { method: GET, path: "/networks" } + action: allow + - match: { method: GET, path: "/networks/*" } + action: allow + + # Volume read โ€” list + inspect only. + - match: { method: GET, path: "/volumes" } + action: allow + - match: { method: GET, path: "/volumes/*" } + action: allow + + # Distribution (registry digest checks for image freshness). Image + # references in the URL can be multi-segment (registry/owner/repo:tag). + - match: { method: GET, path: "/distribution/**/json" } + action: allow + + # Swarm services โ€” list + inspect. /services/*/logs is intentionally + # omitted to keep the exfiltration validator happy. + - match: { method: GET, path: "/services" } + action: allow + - match: { method: GET, path: "/services/*" } + action: allow + + # Deny everything else + - match: { method: "*", path: "/**" } + action: deny + reason: "not allowed by portwing preset" diff --git a/app/go.mod b/app/go.mod index 8a105a87..b7f8f484 100644 --- a/app/go.mod +++ b/app/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/go-containerregistry v0.21.6 github.com/sigstore/protobuf-specs v0.5.1 github.com/sigstore/sigstore v1.10.8 - github.com/sigstore/sigstore-go v1.1.4 + github.com/sigstore/sigstore-go v1.2.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 ) @@ -27,35 +27,35 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.24.3 // indirect + github.com/go-openapi/analysis v0.25.2 // indirect github.com/go-openapi/errors v0.22.7 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.29.3 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/strfmt v0.26.1 // indirect - github.com/go-openapi/swag v0.25.5 // indirect - github.com/go-openapi/swag/cmdutils v0.25.5 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/fileutils v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/mangling v0.25.5 // indirect - github.com/go-openapi/swag/netutils v0.25.5 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect - github.com/go-openapi/validate v0.25.2 // indirect + github.com/go-openapi/runtime v0.32.3 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/spec v0.22.5 // indirect + github.com/go-openapi/strfmt v0.26.3 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-openapi/validate v0.25.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/certificate-transparency-go v1.3.2 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect - github.com/in-toto/attestation v1.1.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/letsencrypt/boulder v0.20260309.0 // indirect @@ -68,26 +68,25 @@ require ( github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect - github.com/sigstore/rekor v1.5.0 // indirect - github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect - github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect + github.com/sigstore/rekor v1.5.2 // indirect + github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect - github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect + github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect @@ -96,9 +95,10 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gotest.tools/v3 v3.5.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/app/go.sum b/app/go.sum index 5dcccf50..38d3ec91 100644 --- a/app/go.sum +++ b/app/go.sum @@ -1,69 +1,69 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= -cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= -github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= -github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 h1:s/zDSG/a/Su9aX+v0Ld9cimUCdkr5FWPmBV8owaEbZY= -github.com/aws/aws-sdk-go-v2/service/kms v1.50.3/go.mod h1:/iSgiUor15ZuxFGQSTf3lA2FmKxFsQoc2tADOarQBSw= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= @@ -100,8 +100,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -109,66 +109,68 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= -github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= -github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= -github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= -github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= -github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= -github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= -github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= -github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= -github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= -github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= -github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= -github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= -github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= -github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= -github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= +github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= +github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= +github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= +github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= +github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= -github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= @@ -177,18 +179,18 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= -github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -205,34 +207,22 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= -github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= -github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= -github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= @@ -272,10 +262,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -289,30 +279,30 @@ github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgm github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= -github.com/sigstore/rekor v1.5.0 h1:rL7SghHd5HLCtsCrxw0yQg+NczGvM75EjSPPWuGjaiQ= -github.com/sigstore/rekor v1.5.0/go.mod h1:D7JoVCUkxwQOpPDNYeu+CE8zeBC18Y5uDo6tF8s2rcQ= -github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= -github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= +github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= +github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= +github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= -github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= -github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5 h1:+9C6CUkv+J4iT67Lx+H1EGBfAdoAHqXumHadeIj9jA4= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5 h1:BpQx6AhjwIN9LmlO4ypkcMcHiWiepgZQGSw5U69frHU= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5 h1:OFwQZgWkB/6J6W5sy3SkXE4pJnhNRnE2cJd8ySXmHpo= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= -github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= -github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= +github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= +github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -332,20 +322,20 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= -github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= -github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= -github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= +github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= -github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= -github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= @@ -354,36 +344,34 @@ github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97 github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= -go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -402,18 +390,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= -google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -423,8 +411,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= diff --git a/app/internal/banner/banner.go b/app/internal/banner/banner.go index 857d17b5..bae28e2b 100644 --- a/app/internal/banner/banner.go +++ b/app/internal/banner/banner.go @@ -2,44 +2,44 @@ package banner import ( + _ "embed" "fmt" "io" + "os" "runtime" "strings" + "unicode/utf8" "github.com/codeswhat/sockguard/internal/ui" "github.com/codeswhat/sockguard/internal/version" ) -const art = ` ... ... - .=+=.. .=+=.. - ..=--**.. ..=*--+.: - .:=-:=#*:.. .:*#+:--=.. - .:+--..+++=:..:..................=+++:.:-=:. - :.-=--...=+=:.-+***************=::-++:..:-=+.. - ..==--....:***++++++++++++####*+***=... ---+:. - ..==--..-+++++++++++++++++++***++++++=:.:--+:. - ..:+=::++++++++++++++++++++++++++++++++=.-=+.. - ....:++++%@%++#@%++++++++++@@%**#@%++++=.. .. - ..+++++*%@@@@@*+++++++++++%@@@@@*+++++-.: - :.:=+++++#@@@@#*++.++++=-++%@@@@#+++++==.. - ..-=++++%@@#*@%#:.:::::::.+%@**@@@*+++==:.. - ..:++++++++++::%@%..:**=:.-%@=.=+++++++++=... - ..=+==+=.:%@@%@@@.==.... .:=:-@@%@@@+..+===+:.. - ..:+===.*@@%:@*+@@:..:+...+...*@@:@*=@@@-:===+.. - ..====.#%%@@%:@==@@@*.. ...:%@@%:@=*@@@#%::===:.: - ...==-.#%-%%@@@@@%%%%%%=.*%%%%%@@@@@@%**%=.==-..: - ...:..+*#:.:-----::=*:::=*:::-----::.*%*..=-... - ..-====+#@*.#@@=:-:...::=:#@@@.=:#%*:....... - ..:+**%@#*===*+.:#%@@@@@@@@%=:::*%#:#@@@@%=.. - ..+%#*====+#@#===#@@@@@@@@@@@@=%%%@@%:+@@@@@@:. - ..*@@@@@@@%+==+%%+==%@%%%%%%%%%+*@@@@@@*.*%%%%:. - ..#@%%@@@@@@@@%===%@=-=+:........:%@@@@@@@=.....: - ..**...:*#%@@@@@@===%=.:**#***++=.#@@@@@@@@@=.. - ..**:.....=%%@@@@*-..-==========.*%@@@@@@@@@-.: - ...=#*:.. .-%%%%*.......:::... ..#%%%@@@@%%:.: - ...:*%#=:-%%-.. :...: ..:*#%%%#=... - ......... ... . .. +const art = ` โ–“โ–“โ–’ + โ–“โ–ˆโ–ˆโ–“โ–’โ–’ + โ–“โ–“โ–’โ–“โ–“ โ–“โ–ˆโ–“โ–“โ–“โ–“โ–“ + โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–“โ–“โ–“โ–“โ–“โ–“ + โ–“โ–“โ–“โ–’โ–’โ–“โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–“โ–’ + โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ + โ–’โ–’โ–’โ–“โ–’โ–‘โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’โ–’ + โ–’โ–’โ–“โ–“โ–’โ–’โ–’โ–’โ–’โ–’โ–‘โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’ + โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’โ–‘โ–’โ–’โ–’โ–‘โ–’โ–“โ–“โ–“โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’ + โ–ˆโ–ˆโ–“โ–ˆโ–‘ โ–‘โ–’โ–“โ–“โ–’โ–‘โ–“โ–“โ–“โ–‘โ–‘โ–“โ–“โ–“โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’ + โ–ˆโ–ˆโ–ˆโ–’ โ–‘โ–“โ–“โ–’โ–‘โ–‘โ–‘โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’ โ–“โ–“โ–“โ–“ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘ โ–‘โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’ โ–“โ–’โ–“โ–“ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–“โ–“โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–“โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–’โ–“โ–ˆโ–“โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’ + โ–‘โ–’โ–’โ–’โ–‘โ–‘โ–‘โ–‘โ–‘โ–’โ–“โ–ˆโ–ˆโ–ˆโ–’โ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’ + โ–“โ–ˆโ–“โ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’ + โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’โ–’ + โ–‘โ–’โ–‘โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’โ–’ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–’ + โ–’โ–’โ–‘โ–‘โ–‘โ–’โ–“โ–“โ–“โ–“โ–“โ–’โ–’โ–“โ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–“โ–“โ–“โ–’โ–’โ–“โ–’ + โ–“โ–“โ–“โ–“โ–’โ–’โ–’โ–“โ–“โ–“โ–“โ–“โ–’โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–’โ–’โ–“โ–“ + โ–’โ–“โ–“โ–“โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–ˆโ–“โ–ˆโ–ˆโ–“โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ + โ–“โ–“โ–“โ–“โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ + โ–ˆโ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“ โ–ˆโ–ˆโ–“โ–“โ–“โ–“โ–“ ` // artMaxWidth is the widest character row of the banner, computed @@ -48,13 +48,29 @@ const art = ` ... ... var artMaxWidth = func() int { m := 0 for _, line := range strings.Split(strings.TrimRight(art, "\n"), "\n") { - if len(line) > m { - m = len(line) + // Count display columns (runes), not bytes โ€” the art uses + // multi-byte block glyphs (โ–‘โ–’โ–“โ–ˆ), so len() would overcount ~3x + // and break the centering math. + if w := utf8.RuneCountInString(line); w > m { + m = w } } return m }() +// colorArt is a pre-rendered 24-bit (truecolor) half-block rendering of the +// sockguard logo, exactly colorArtWidth columns wide, generated from +// sockguard-logo.png by half-block (โ–€) truecolor sampling. It is shown only on +// truecolor terminals; everything else falls back to the monochrome `art` +// above. +// +//go:embed dog_color.ans +var colorArt string + +// colorArtWidth is the fixed display width (columns) of colorArt. Its rows +// carry ANSI escapes, so this is a known constant rather than a rune count. +const colorArtWidth = 50 + // Info is the runtime summary rendered beneath the ASCII art. type Info struct { Listen string @@ -72,9 +88,15 @@ func Render(w io.Writer, info Info) { access = "on" } p := ui.New(w) + cols := terminalCols(w) fmt.Fprintln(w) - fmt.Fprint(w, p.Cyan(centerArt(art, terminalCols(w)))) + if p.Enabled() && truecolor() { + // colorArt already carries per-cell ANSI; don't wrap it in Cyan. + fmt.Fprint(w, centerArt(colorArt, cols, colorArtWidth)) + } else { + fmt.Fprint(w, p.Cyan(centerArt(art, cols, artMaxWidth))) + } fmt.Fprintln(w) fmt.Fprintf(w, " %s %s %s\n", p.Bold("sockguard"), @@ -98,16 +120,28 @@ func shortCommit(c string) string { return c } +// truecolor reports whether the terminal advertises 24-bit color via +// COLORTERM. The colored banner emits 24-bit escapes, so without this we fall +// back to the monochrome art rather than send codes a 16/256-color terminal +// would mangle. +func truecolor() bool { + switch os.Getenv("COLORTERM") { + case "truecolor", "24bit": + return true + } + return false +} + // centerArt left-pads every row of the banner so the full block is // horizontally centered inside a terminal of `cols` columns. If cols // is 0 (no TTY) or narrower than the art itself, the art is returned // unchanged so piped output and narrow terminals fall back to the // original left-aligned rendering. -func centerArt(block string, cols int) string { - if cols <= artMaxWidth { +func centerArt(block string, cols, width int) string { + if cols <= width { return block } - pad := strings.Repeat(" ", (cols-artMaxWidth)/2) + pad := strings.Repeat(" ", (cols-width)/2) var b strings.Builder trimmed := strings.TrimRight(block, "\n") trailingNewlines := len(block) - len(trimmed) diff --git a/app/internal/banner/banner_test.go b/app/internal/banner/banner_test.go index 8d170f36..19824943 100644 --- a/app/internal/banner/banner_test.go +++ b/app/internal/banner/banner_test.go @@ -6,6 +6,7 @@ import ( "strings" "syscall" "testing" + "unicode/utf8" ) func TestRenderContainsRuntimeInfo(t *testing.T) { @@ -77,20 +78,16 @@ func TestCenterArtPreservesTrailingNewline(t *testing.T) { } } -// centerArtBlock wraps centerArt with a controlled artMaxWidth so the -// tests don't depend on the real banner art's exact dimensions. +// centerArtBlock wraps centerArt with a width measured from the test input so +// the tests don't depend on the real banner art's exact dimensions. func centerArtBlock(in string, cols int) string { - // Save-restore the package-level max width so the test stays - // isolated from the real banner. - old := artMaxWidth - artMaxWidth = 0 + width := 0 for _, line := range strings.Split(strings.TrimRight(in, "\n"), "\n") { - if len(line) > artMaxWidth { - artMaxWidth = len(line) + if w := utf8.RuneCountInString(line); w > width { + width = w } } - defer func() { artMaxWidth = old }() - return centerArt(in, cols) + return centerArt(in, cols, width) } func TestTerminalColsNonFileWriterReturnsZero(t *testing.T) { diff --git a/app/internal/banner/dog_color.ans b/app/internal/banner/dog_color.ans new file mode 100644 index 00000000..64997453 --- /dev/null +++ b/app/internal/banner/dog_color.ans @@ -0,0 +1,22 @@ +                  โ–„โ–„โ–„                              +            โ–„    โ–„โ–€โ–€โ–€โ–€โ–„                            +           โ–€โ–€โ–€โ–€โ–„ โ–€โ–€โ–€โ–€โ–€โ–€โ–„                           +           โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„                          +        โ–„โ–„โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€                          +      โ–„โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„                        +     โ–„โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„                       +  โ–„โ–„โ–„โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€                       + โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„โ–„                โ–„โ–„   +  โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„      โ–„โ–€โ–€โ–„  +  โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„โ–„โ–€โ–€โ–€โ–€  +  โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€   +   โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–„   +     โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€   +           โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€  +            โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€  +             โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€  +              โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€  +               โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€   +               โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€   +               โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€       โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€   +               โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€        โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€    diff --git a/app/internal/clientacl/middleware.go b/app/internal/clientacl/middleware.go index 236a7394..6388fbef 100644 --- a/app/internal/clientacl/middleware.go +++ b/app/internal/clientacl/middleware.go @@ -219,6 +219,13 @@ func Middleware(upstreamSocket string, logger *slog.Logger, opts Options) func(h return middlewareWithDeps(logger, opts, newACLResolveClient(upstreamSocket, resolvedLabelPrefix(opts))) } +// MiddlewareWithRoundTripper is Middleware over the shared upstream RoundTripper +// (typically an *upstream.Resolver) so container-label ACL resolution follows +// the same active endpoint as the proxied request under failover. +func MiddlewareWithRoundTripper(rt http.RoundTripper, logger *slog.Logger, opts Options) func(http.Handler) http.Handler { + return middlewareWithDeps(logger, opts, newACLResolveClientForClient(dockerclient.NewWithRoundTripper(rt), resolvedLabelPrefix(opts))) +} + // resolvedLabelPrefix replicates the label-prefix resolution from // compileOptions so newACLResolveClient can pre-bind a compile hook on the // cache without standing up the full compiled options pipeline first. @@ -374,8 +381,12 @@ func resolveLabelACLRules(client resolvedClient, labelPrefix string) ([]*filter. } func newACLResolveClient(upstreamSocket, labelPrefix string) func(context.Context, netip.Addr) (resolvedClient, bool, error) { + return newACLResolveClientForClient(dockerclient.New(upstreamSocket), labelPrefix) +} + +func newACLResolveClientForClient(client *http.Client, labelPrefix string) func(context.Context, netip.Addr) (resolvedClient, bool, error) { resolver := upstreamResolver{ - client: dockerclient.New(upstreamSocket), + client: client, } cache := newClientCache(clientCacheTTL, clientCacheMaxSize, time.Now, resolver.resolveClient) if labelPrefix != "" { diff --git a/app/internal/cmd/coverage_gaps_test.go b/app/internal/cmd/coverage_gaps_test.go index 0b7e1347..f996614d 100644 --- a/app/internal/cmd/coverage_gaps_test.go +++ b/app/internal/cmd/coverage_gaps_test.go @@ -117,7 +117,7 @@ func TestBuildServeClientProfiles_Error(t *testing.T) { }, } - _, err := buildServeClientProfiles(&cfg) + _, err := buildServeClientProfiles(&cfg, nil) if err == nil { t.Fatal("expected buildServeClientProfiles() to fail") } diff --git a/app/internal/cmd/rules_test.go b/app/internal/cmd/rules_test.go index 816f733a..468dbd00 100644 --- a/app/internal/cmd/rules_test.go +++ b/app/internal/cmd/rules_test.go @@ -4,6 +4,8 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "slices" "strings" "testing" @@ -680,3 +682,39 @@ func TestValidateAndCompileRulesReturnsCompileError(t *testing.T) { t.Fatalf("expected wrapped compile error, got: %v", err) } } + +// TestPresetConfigsPassBuildChain verifies that every shipped preset YAML passes +// the full validateAndCompileRules (BuildChain) check โ€” not just config.Validate. +// This catches missing insecure_allow_* flags that cause the server to refuse +// startup even when config.Load + config.Validate both succeed. +func TestPresetConfigsPassBuildChain(t *testing.T) { + presetsDir := filepath.Join("..", "..", "configs") + + entries, err := os.ReadDir(presetsDir) + if err != nil { + t.Fatalf("failed to read presets directory %s: %v", presetsDir, err) + } + + var yamlFiles []string + for _, e := range entries { + if !e.IsDir() && (filepath.Ext(e.Name()) == ".yaml" || filepath.Ext(e.Name()) == ".yml") { + yamlFiles = append(yamlFiles, e.Name()) + } + } + if len(yamlFiles) == 0 { + t.Fatal("no preset YAML configs found โ€” expected at least one") + } + + for _, name := range yamlFiles { + t.Run(name, func(t *testing.T) { + path := filepath.Join(presetsDir, name) + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load(%s) error: %v", name, err) + } + if _, err := validateAndCompileRules(cfg); err != nil { + t.Fatalf("validateAndCompileRules(%s) error: %v", name, err) + } + }) + } +} diff --git a/app/internal/cmd/serve.go b/app/internal/cmd/serve.go index b95b00a9..caccc275 100644 --- a/app/internal/cmd/serve.go +++ b/app/internal/cmd/serve.go @@ -34,6 +34,7 @@ import ( "github.com/codeswhat/sockguard/internal/ratelimit" "github.com/codeswhat/sockguard/internal/reload" "github.com/codeswhat/sockguard/internal/responsefilter" + "github.com/codeswhat/sockguard/internal/upstream" "github.com/codeswhat/sockguard/internal/version" "github.com/codeswhat/sockguard/internal/visibility" ) @@ -149,7 +150,11 @@ func runServeWithDeps(cmd *cobra.Command, args []string, deps *serveDeps) error if err != nil { return fmt.Errorf("config validation: %w", err) } - if err := deps.verifyUpstreamReachable(cfg.Upstream.Socket, logger); err != nil { + runtime, err := newServeRuntime(cfg, logger, deps) + if err != nil { + return fmt.Errorf("upstream: %w", err) + } + if err := verifyUpstreamReachableForRuntime(cmd.Context(), deps, runtime, cfg, logger); err != nil { return err } @@ -159,7 +164,6 @@ func runServeWithDeps(cmd *cobra.Command, args []string, deps *serveDeps) error versioner := admin.NewPolicyVersioner() initialVersion := versioner.Update(buildInitialPolicySnapshot(deps, cfg, rules, compatActive, bundleResult)) - runtime := newServeRuntime(cfg, logger, deps) runtime.metrics.SetPolicyVersion(initialVersion) handler, chainTeardown := buildServeHandlerChainWithRuntime(serveHandlerBuild{ Cfg: cfg, @@ -204,9 +208,10 @@ func runServeWithDeps(cmd *cobra.Command, args []string, deps *serveDeps) error server := newHTTPServer(swappable) listen := listenerAddr(cfg) + upstreamName := upstreamLabel(runtime.resolver) banner.Render(cmd.ErrOrStderr(), banner.Info{ Listen: listen, - Upstream: cfg.Upstream.Socket, + Upstream: upstreamName, Rules: len(cfg.Rules), LogFormat: cfg.Log.Format, LogLevel: cfg.Log.Level, @@ -215,12 +220,14 @@ func runServeWithDeps(cmd *cobra.Command, args []string, deps *serveDeps) error logger.Info("sockguard started", "version", version.Version, "listen", listen, - "upstream", cfg.Upstream.Socket, + "upstream", upstreamName, "rules", len(cfg.Rules), "log_level", cfg.Log.Level, ) errCh := make(chan error, 1) + stopResolver := runtime.startResolver(cmd.Context()) + defer stopResolver() stopWatchdog := runtime.startWatchdog(cmd.Context(), cfg) defer stopWatchdog() stopReadiness := runtime.startReadiness(cmd.Context(), cfg) @@ -429,13 +436,14 @@ type serveHandlerBuild struct { // buildServeHandler which discards it โ€” the goroutines die with the test // process anyway. func buildServeHandlerChainWithRuntime(b serveHandlerBuild) (http.Handler, func()) { - clientProfiles, err := buildServeClientProfiles(b.Cfg) + resolver := runtimeResolver(b.Runtime, b.Cfg) + clientProfiles, err := buildServeClientProfiles(b.Cfg, resolver) if err != nil { b.Logger.Error("invalid client profile config", "error", err) return invalidClientProfileHandler(), func() {} } - handler := newServeUpstreamHandler(b.Cfg, b.Logger) + handler := newServeUpstreamHandler(b.Cfg, resolver, b.Logger) b.ClientProfiles = clientProfiles layers, teardown := buildServeHandlerLayersWithRuntime(b) for _, layer := range layers { @@ -454,21 +462,49 @@ type serveRuntime struct { metrics *metrics.Registry health *health.Monitor readiness *health.Monitor + // resolver is the shared upstream dial seam (endpoint selection, pooling, + // TLS, failover). All request paths and side channels route through it so + // failover is coherent across the proxy, hijack, and inspect calls. + resolver *upstream.Resolver + // legacyUpstreamSocket records that the upstream is the single local socket + // (no endpoints, no DOCKER_HOST), so startup keeps the original fail-fast + // reachability check. + legacyUpstreamSocket bool } -func newServeRuntime(cfg *config.Config, logger *slog.Logger, deps *serveDeps) *serveRuntime { +func newServeRuntime(cfg *config.Config, logger *slog.Logger, deps *serveDeps) (*serveRuntime, error) { runtime := &serveRuntime{} if cfg.Metrics.Enabled { runtime.metrics = metrics.NewRegistry() } + + resolver, legacy, err := buildUpstreamResolver(cfg, logger, os.Getenv) + if err != nil { + return nil, err + } + runtime.resolver = resolver + runtime.legacyUpstreamSocket = legacy + label := upstreamLabel(resolver) + if cfg.Health.Enabled || cfg.Health.Watchdog.Enabled { - runtime.health = health.NewMonitor(cfg.Upstream.Socket, deps.now(), logger) + runtime.health = health.NewMonitorWithDialer(label, resolver, deps.now(), logger) } if cfg.Health.Readiness.Enabled { timeout, _ := time.ParseDuration(cfg.Health.Readiness.Timeout) - runtime.readiness = health.NewReadinessMonitor(cfg.Upstream.Socket, deps.now(), logger, timeout) + runtime.readiness = health.NewReadinessMonitorWithRoundTripper(label, resolver, deps.now(), logger, timeout) } - return runtime + return runtime, nil +} + +// startResolver launches the resolver's background health/failover probe loop. +// It returns a stop func; the loop also exits when ctx is canceled. +func (r *serveRuntime) startResolver(ctx context.Context) func() { + if r == nil || r.resolver == nil { + return func() {} + } + resolverCtx, cancel := context.WithCancel(ctx) + r.resolver.Start(resolverCtx) + return cancel } func (r *serveRuntime) startWatchdog(ctx context.Context, cfg *config.Config) func() { @@ -523,31 +559,34 @@ func invalidClientProfileHandler() http.Handler { }) } -func buildServeClientProfiles(cfg *config.Config) (map[string]filter.Policy, error) { +func buildServeClientProfiles(cfg *config.Config, res *upstream.Resolver) (map[string]filter.Policy, error) { clientProfiles, err := compileClientProfiles(cfg) if err != nil { return nil, err } for name, profile := range clientProfiles { - profile.PolicyConfig = attachRuntimeInspectors(cfg, profile.PolicyConfig) + profile.PolicyConfig = attachRuntimeInspectors(cfg, res, profile.PolicyConfig) clientProfiles[name] = profile } return clientProfiles, nil } // attachRuntimeInspectors wires the runtime-bound inspectors (currently just -// the exec-start inspector that needs the upstream socket) onto a PolicyConfig -// shaped by config translation. Centralized so every call path that produces -// a filter.PolicyConfig destined for live request evaluation gets the same -// wiring โ€” a future runtime dependency added here propagates to both the -// default policy and every client profile without revisiting two call sites. -func attachRuntimeInspectors(cfg *config.Config, policy filter.PolicyConfig) filter.PolicyConfig { - policy.Exec.InspectStart = filter.NewDockerExecInspector(cfg.Upstream.Socket) +// the exec-start inspector that needs the upstream) onto a PolicyConfig shaped +// by config translation. The inspector issues its GET through the shared +// upstream resolver so exec-identity lookups follow the same active endpoint as +// the exec-create/start they guard under failover. Centralized so every call +// path that produces a filter.PolicyConfig destined for live request evaluation +// gets the same wiring โ€” a future runtime dependency added here propagates to +// both the default policy and every client profile without revisiting two call +// sites. +func attachRuntimeInspectors(cfg *config.Config, res *upstream.Resolver, policy filter.PolicyConfig) filter.PolicyConfig { + policy.Exec.InspectStart = filter.NewDockerExecInspectorWithRoundTripper(upstreamResolverFor(res, cfg)) return policy } -func newServeUpstreamHandler(cfg *config.Config, logger *slog.Logger) http.Handler { - rp := proxy.NewWithOptions(cfg.Upstream.Socket, logger, proxy.Options{ +func newServeUpstreamHandler(cfg *config.Config, res *upstream.Resolver, logger *slog.Logger) http.Handler { + rp := proxy.NewWithTransport(upstreamResolverFor(res, cfg), logger, proxy.Options{ ModifyResponse: responsefilter.New(serveResponseFilterOptions(cfg)).ModifyResponse, }) // Bound finite upstream requests with a total deadline when configured. @@ -568,11 +607,12 @@ func buildServeHandlerLayersWithRuntime(b serveHandlerBuild) ([]serveHandlerLaye cfg, logger, auditLogger := b.Cfg, b.Logger, b.AuditLogger runtime, versioner := b.Runtime, b.Versioner rules, clientProfiles := b.Rules, b.ClientProfiles + resolver := runtimeResolver(runtime, cfg) layers := []serveHandlerLayer{ - namedServeHandlerLayer("withHijack", withHijack(cfg, logger)), - namedServeHandlerLayer("withOwnership", withOwnership(cfg, logger)), - namedServeHandlerLayer("withVisibility", withVisibility(cfg, logger)), - namedServeHandlerLayer("withFilter", withFilter(cfg, logger, rules, clientProfiles)), + namedServeHandlerLayer("withHijack", withHijack(resolver, logger)), + namedServeHandlerLayer("withOwnership", withOwnership(cfg, resolver, logger)), + namedServeHandlerLayer("withVisibility", withVisibility(cfg, resolver, logger)), + namedServeHandlerLayer("withFilter", withFilter(cfg, resolver, logger, rules, clientProfiles)), } // Admin endpoints sit inside filter (so the filter never sees admin paths) @@ -621,7 +661,7 @@ func buildServeHandlerLayersWithRuntime(b serveHandlerBuild) ([]serveHandlerLaye layers = append(layers, namedServeHandlerLayer("withMetricsEndpoint", withMetricsEndpoint(cfg, runtime.metrics))) } layers = append(layers, - namedServeHandlerLayer("withClientACL", withClientACL(cfg, logger)), + namedServeHandlerLayer("withClientACL", withClientACL(cfg, resolver, logger)), ) if runtime.metrics != nil { layers = append(layers, namedServeHandlerLayer("withMetrics", withMetrics(runtime.metrics))) @@ -683,24 +723,25 @@ func namedServeHandlerLayer(name string, with func(http.Handler) http.Handler) s return serveHandlerLayer{name: name, with: with} } -func withHijack(cfg *config.Config, logger *slog.Logger) func(http.Handler) http.Handler { +func withHijack(res *upstream.Resolver, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { // Hijack handler: intercepts attach/exec endpoints for native bidirectional - // streaming with optimized buffers and TCP half-close signaling. - return proxy.HijackHandler(cfg.Upstream.Socket, logger, next) + // streaming with optimized buffers and TCP half-close signaling. Dials the + // same active upstream endpoint as the rest of the proxy. + return proxy.HijackHandlerWithDialer(res, logger, next) } } -func withOwnership(cfg *config.Config, logger *slog.Logger) func(http.Handler) http.Handler { - return ownership.Middleware(cfg.Upstream.Socket, logger, ownership.Options{ +func withOwnership(cfg *config.Config, res *upstream.Resolver, logger *slog.Logger) func(http.Handler) http.Handler { + return ownership.MiddlewareWithRoundTripper(res, logger, ownership.Options{ Owner: cfg.Ownership.Owner, LabelKey: cfg.Ownership.LabelKey, AllowUnownedImages: cfg.Ownership.AllowUnownedImages, }) } -func withVisibility(cfg *config.Config, logger *slog.Logger) func(http.Handler) http.Handler { - return visibility.Middleware(cfg.Upstream.Socket, logger, visibility.Options{ +func withVisibility(cfg *config.Config, res *upstream.Resolver, logger *slog.Logger) func(http.Handler) http.Handler { + return visibility.MiddlewareWithRoundTripper(res, logger, visibility.Options{ VisibleResourceLabels: cfg.Response.VisibleResourceLabels, NamePatterns: cfg.Response.NamePatterns, ImagePatterns: cfg.Response.ImagePatterns, @@ -709,8 +750,8 @@ func withVisibility(cfg *config.Config, logger *slog.Logger) func(http.Handler) }) } -func withFilter(cfg *config.Config, logger *slog.Logger, rules []*filter.CompiledRule, clientProfiles map[string]filter.Policy) func(http.Handler) http.Handler { - return filter.MiddlewareWithOptions(rules, logger, serveFilterOptions(cfg, clientProfiles)) +func withFilter(cfg *config.Config, res *upstream.Resolver, logger *slog.Logger, rules []*filter.CompiledRule, clientProfiles map[string]filter.Policy) func(http.Handler) http.Handler { + return filter.MiddlewareWithOptions(rules, logger, serveFilterOptions(cfg, res, clientProfiles)) } // withHealth wires the /health endpoint onto the runtime monitor. @@ -747,9 +788,9 @@ func withMetrics(registry *metrics.Registry) func(http.Handler) http.Handler { return registry.Middleware() } -func withClientACL(cfg *config.Config, logger *slog.Logger) func(http.Handler) http.Handler { +func withClientACL(cfg *config.Config, res *upstream.Resolver, logger *slog.Logger) func(http.Handler) http.Handler { warnIfLabelACLEnabled(cfg, logger) - return clientacl.Middleware(cfg.Upstream.Socket, logger, serveClientACLOptions(cfg)) + return clientacl.MiddlewareWithRoundTripper(upstreamResolverFor(res, cfg), logger, serveClientACLOptions(cfg)) } // labelACLWarnOnce gates warnIfLabelACLEnabled to a single emission per @@ -790,6 +831,12 @@ func warnLabelACLOnce(cfg *config.Config, logger *slog.Logger, once *sync.Once) // listener applies (#21). Passing only AllowedCIDRs yields a CIDR-only // middleware; when no CIDRs are configured clientacl.Middleware compiles to a // pass-through, so this is a no-op until an operator sets clients.allowed_cidrs. +// +// Because container-label ACLs are never enabled here, the middleware never +// resolves a client by source IP and so never dials the upstream โ€” the socket +// argument is inert (it is not the shared resolver, by design, and is never +// used to reach Docker). It stays on the single-socket constructor deliberately +// so the admin trust boundary carries no dependency on the upstream resolver. func withAdminClientACL(cfg *config.Config, logger *slog.Logger) func(http.Handler) http.Handler { return clientacl.Middleware(cfg.Upstream.Socket, logger, clientacl.Options{ AllowedCIDRs: cfg.Clients.AllowedCIDRs, @@ -1010,18 +1057,18 @@ func serveResponseFilterOptions(cfg *config.Config) responsefilter.Options { } } -func serveFilterOptions(cfg *config.Config, clientProfiles map[string]filter.Policy) filter.Options { +func serveFilterOptions(cfg *config.Config, res *upstream.Resolver, clientProfiles map[string]filter.Policy) filter.Options { return filter.Options{ - PolicyConfig: servePolicyConfig(cfg), + PolicyConfig: servePolicyConfig(cfg, res), Profiles: clientProfiles, ResolveProfile: clientacl.RequestProfile, } } -func servePolicyConfig(cfg *config.Config) filter.PolicyConfig { +func servePolicyConfig(cfg *config.Config, res *upstream.Resolver) filter.PolicyConfig { policy := cfg.RequestBody.ToFilterOptions() policy.DenyResponseVerbosity = filter.ParseDenyResponseVerbosity(cfg.Response.DenyVerbosity) - return attachRuntimeInspectors(cfg, policy) + return attachRuntimeInspectors(cfg, res, policy) } func serveClientACLOptions(cfg *config.Config) clientacl.Options { diff --git a/app/internal/cmd/serve_reload.go b/app/internal/cmd/serve_reload.go index 1e4b9213..45f1ee5d 100644 --- a/app/internal/cmd/serve_reload.go +++ b/app/internal/cmd/serve_reload.go @@ -213,6 +213,16 @@ func (c *reloadCoordinator) reload() { Versioner: c.versioner, }) + // Capture the old concurrency-capped profile set before activeCfg + // advances. Only profiles with a concurrency cap can have contributed + // in-flight gauge series (SetInflight is called only after a successful + // Acquire, which only happens when cp.tracker != nil, which only exists + // when opts.Concurrency != nil && MaxInflight > 0). + var oldInflightProfiles map[string]struct{} + if c.runtime.metrics != nil { + oldInflightProfiles = profileNamesWithConcurrency(c.activeCfg) + } + oldTeardown := c.chainTeardown c.chainTeardown = newTeardown c.activeCfg = newCfg @@ -224,6 +234,21 @@ func (c *reloadCoordinator) reload() { // during that window โ€” tearing them down after the swap is safe // because they perform no per-request work for in-flight calls. c.swappable.Swap(newHandler) + + // Delete gauge series for profiles removed from the new config. This + // runs after Swap so new requests are already dispatched to the new + // chain; the removed profile cannot receive new SetInflight calls from + // the new chain. Completing requests from the old chain that call + // SetInflight(profile, n) after deletion re-create the entry at n + // (always >= 0, clamped by InflightTracker.Release); the series is + // reaped on the next reload cycle. + if c.runtime.metrics != nil { + newInflightProfiles := profileNamesWithConcurrency(newCfg) + for _, name := range removedProfiles(oldInflightProfiles, newInflightProfiles) { + c.runtime.metrics.DeleteInflightProfile(name) + } + } + oldTeardown() // Publish the new generation AFTER the swap so an admin GET to @@ -312,6 +337,40 @@ func (c *reloadCoordinator) verifyBundle() (*policybundle.VerifyResult, []byte, return &res, yamlBytes, nil } +// profileNamesWithConcurrency returns the set of profile names in cfg that +// have a concurrency cap configured (Limits.Concurrency != nil with +// MaxInflight > 0). Only these profiles contribute in-flight gauge series +// because SetInflight is only called from checkProfileConcurrency after a +// successful Acquire, which only occurs when opts.Concurrency != nil. +func profileNamesWithConcurrency(cfg *config.Config) map[string]struct{} { + if cfg == nil { + return nil + } + out := make(map[string]struct{}, len(cfg.Clients.Profiles)) + for _, p := range cfg.Clients.Profiles { + if p.Limits.Concurrency != nil && p.Limits.Concurrency.MaxInflight > 0 { + out[p.Name] = struct{}{} + } + } + return out +} + +// removedProfiles returns the profile names present in oldSet but absent from +// newSet. These are profiles whose in-flight gauge series are orphaned and +// should be cleared. +func removedProfiles(oldSet, newSet map[string]struct{}) []string { + if len(oldSet) == 0 { + return nil + } + var removed []string + for name := range oldSet { + if _, stillPresent := newSet[name]; !stillPresent { + removed = append(removed, name) + } + } + return removed +} + // startReloader wires the coordinator into a reload.Reloader and runs it // in a goroutine. Returns a stop function that cancels the watcher loop // and returns once it has exited. Callers must invoke stop before diff --git a/app/internal/cmd/serve_reload_test.go b/app/internal/cmd/serve_reload_test.go index 195ffed7..473eb95d 100644 --- a/app/internal/cmd/serve_reload_test.go +++ b/app/internal/cmd/serve_reload_test.go @@ -358,6 +358,104 @@ func TestReloadCoordinatorPreservesPolicyVersionOnReject(t *testing.T) { } } +// TestReloadCoordinatorClearsInflightGaugeWhenProfileRemoved verifies that a +// successful reload that drops a profile with a concurrency cap causes the +// profile's sockguard_inflight_requests series to disappear from the next scrape. +func TestReloadCoordinatorClearsInflightGaugeWhenProfileRemoved(t *testing.T) { + t.Parallel() + + // Build initial config with a profile that has a concurrency cap. + initial := config.Defaults() + initial.Rules = []config.RuleConfig{ + {Match: config.MatchConfig{Method: "GET", Path: "/x"}, Action: "allow"}, + } + initial.Clients.Profiles = []config.ClientProfileConfig{ + { + Name: "ci", + Limits: config.LimitsConfig{Concurrency: &config.ConcurrencyConfig{MaxInflight: 5}}, + }, + } + f := newReloadCoordinatorFixture(t, &initial) + + // Simulate the profile being in-flight: stamp the gauge directly. + f.registry.SetInflight("ci", 2) + + // Confirm the series is present before reload. + rec := httptest.NewRecorder() + f.registry.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if !strings.Contains(rec.Body.String(), `sockguard_inflight_requests{profile="ci"} 2`) { + t.Fatalf("pre-reload: ci inflight gauge not present: %s", rec.Body.String()) + } + + // Reload to a config without the ci profile. + newCfg := initial + newCfg.Clients.Profiles = nil + f.loadCfg = &newCfg + f.coordinator.reload() + + if got, ok := f.reloadCount("ok"); !ok || got != 1 { + t.Fatalf("reload did not succeed: ok=%d found=%v", got, ok) + } + + // The series must be gone after reload. + rec = httptest.NewRecorder() + f.registry.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if strings.Contains(rec.Body.String(), `sockguard_inflight_requests{profile="ci"}`) { + t.Fatalf("post-reload: ci inflight gauge still present after profile removal: %s", rec.Body.String()) + } +} + +// TestReloadCoordinatorRetainsInflightGaugeForRemainingProfile verifies that +// a reload that removes one profile with a concurrency cap but keeps another +// only deletes the removed profile's series. +func TestReloadCoordinatorRetainsInflightGaugeForRemainingProfile(t *testing.T) { + t.Parallel() + + initial := config.Defaults() + initial.Rules = []config.RuleConfig{ + {Match: config.MatchConfig{Method: "GET", Path: "/x"}, Action: "allow"}, + } + initial.Clients.Profiles = []config.ClientProfileConfig{ + { + Name: "ci", + Limits: config.LimitsConfig{Concurrency: &config.ConcurrencyConfig{MaxInflight: 5}}, + }, + { + Name: "prod", + Limits: config.LimitsConfig{Concurrency: &config.ConcurrencyConfig{MaxInflight: 10}}, + }, + } + f := newReloadCoordinatorFixture(t, &initial) + + f.registry.SetInflight("ci", 1) + f.registry.SetInflight("prod", 3) + + // Reload keeping prod, removing ci. + newCfg := initial + newCfg.Clients.Profiles = []config.ClientProfileConfig{ + { + Name: "prod", + Limits: config.LimitsConfig{Concurrency: &config.ConcurrencyConfig{MaxInflight: 10}}, + }, + } + f.loadCfg = &newCfg + f.coordinator.reload() + + if got, ok := f.reloadCount("ok"); !ok || got != 1 { + t.Fatalf("reload did not succeed: ok=%d found=%v", got, ok) + } + + rec := httptest.NewRecorder() + f.registry.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := rec.Body.String() + if strings.Contains(body, `sockguard_inflight_requests{profile="ci"}`) { + t.Fatalf("post-reload: ci inflight gauge still present after profile removal: %s", body) + } + if !strings.Contains(body, `sockguard_inflight_requests{profile="prod"} 3`) { + t.Fatalf("post-reload: prod inflight gauge missing or wrong: %s", body) + } +} + // TestNewReloadCoordinatorPreservesNonNilInitialTeardown pins the // CONDITIONALS_NEGATION mutant at serve_reload.go:78 (`initialTeardown == // nil` โ†’ `!= nil`). The constructor's intent is "default a nil teardown diff --git a/app/internal/cmd/serve_test.go b/app/internal/cmd/serve_test.go index 707fcfe4..ae1b008e 100644 --- a/app/internal/cmd/serve_test.go +++ b/app/internal/cmd/serve_test.go @@ -89,7 +89,7 @@ func TestServePolicyConfigAddsRuntimeFilterOptions(t *testing.T) { cfg.RequestBody.Exec.AllowRootUser = true cfg.RequestBody.Exec.AllowedCommands = [][]string{{"/usr/bin/id"}} - got := servePolicyConfig(&cfg) + got := servePolicyConfig(&cfg, nil) if got.DenyResponseVerbosity != filter.DenyResponseVerbosityVerbose { t.Fatalf("DenyResponseVerbosity = %q, want %q", got.DenyResponseVerbosity, filter.DenyResponseVerbosityVerbose) @@ -1510,7 +1510,10 @@ func TestNewServeRuntimeBuildsEnabledObservability(t *testing.T) { cfg.Health.Watchdog.Enabled = true cfg.Metrics.Enabled = true - runtime := newServeRuntime(&cfg, newDiscardLogger(), newServeTestDeps()) + runtime, err := newServeRuntime(&cfg, newDiscardLogger(), newServeTestDeps()) + if err != nil { + t.Fatalf("newServeRuntime: %v", err) + } if runtime.metrics == nil { t.Fatal("metrics registry is nil, want enabled registry") } @@ -1521,7 +1524,10 @@ func TestNewServeRuntimeBuildsEnabledObservability(t *testing.T) { cfg.Health.Enabled = false cfg.Health.Watchdog.Enabled = false cfg.Metrics.Enabled = false - runtime = newServeRuntime(&cfg, newDiscardLogger(), newServeTestDeps()) + runtime, err = newServeRuntime(&cfg, newDiscardLogger(), newServeTestDeps()) + if err != nil { + t.Fatalf("newServeRuntime: %v", err) + } if runtime.metrics != nil { t.Fatal("metrics registry is non-nil, want disabled registry") } diff --git a/app/internal/cmd/serve_test_helpers_test.go b/app/internal/cmd/serve_test_helpers_test.go index 75648f0c..3aa8b8c5 100644 --- a/app/internal/cmd/serve_test_helpers_test.go +++ b/app/internal/cmd/serve_test_helpers_test.go @@ -30,26 +30,34 @@ func indexAfter(s, sub string) int { func buildServeHandler(t *testing.T, cfg *config.Config, logger *slog.Logger, auditLogger *logging.AuditLogger, rules []*filter.CompiledRule, deps *serveDeps) http.Handler { t.Helper() + runtime, err := newServeRuntime(cfg, logger, deps) + if err != nil { + t.Fatalf("newServeRuntime: %v", err) + } handler, teardown := buildServeHandlerChainWithRuntime(serveHandlerBuild{ Cfg: cfg, Logger: logger, AuditLogger: auditLogger, Rules: rules, Deps: deps, - Runtime: newServeRuntime(cfg, logger, deps), + Runtime: runtime, }) t.Cleanup(teardown) return handler } func buildServeHandlerLayers(cfg *config.Config, logger *slog.Logger, auditLogger *logging.AuditLogger, rules []*filter.CompiledRule, deps *serveDeps, clientProfiles map[string]filter.Policy) []serveHandlerLayer { + runtime, err := newServeRuntime(cfg, logger, deps) + if err != nil { + panic("newServeRuntime: " + err.Error()) + } layers, _ := buildServeHandlerLayersWithRuntime(serveHandlerBuild{ Cfg: cfg, Logger: logger, AuditLogger: auditLogger, Rules: rules, Deps: deps, - Runtime: newServeRuntime(cfg, logger, deps), + Runtime: runtime, ClientProfiles: clientProfiles, }) return layers diff --git a/app/internal/cmd/upstream.go b/app/internal/cmd/upstream.go new file mode 100644 index 00000000..d901711e --- /dev/null +++ b/app/internal/cmd/upstream.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/codeswhat/sockguard/internal/config" + "github.com/codeswhat/sockguard/internal/upstream" +) + +// upstreamReachableTimeout bounds the startup reachability probe across all +// endpoints so a hung TLS handshake to one remote daemon cannot stall boot. +const upstreamReachableTimeout = 10 * time.Second + +// resolveUpstreamSpecs determines the ordered endpoint specs for the upstream +// and whether this is the legacy single-local-socket case (which keeps the +// original fail-fast reachability check and log/banner wording). Precedence: +// explicit upstream.endpoints > DOCKER_HOST (tcp) env > upstream.socket. +func resolveUpstreamSpecs(cfg *config.Config, getenv func(string) string, logger *slog.Logger) (specs []upstream.EndpointSpec, legacySocket bool) { + if len(cfg.Upstream.Endpoints) > 0 { + specs = make([]upstream.EndpointSpec, len(cfg.Upstream.Endpoints)) + for i, ep := range cfg.Upstream.Endpoints { + specs[i] = upstream.EndpointSpec{ + Address: ep.Address, + CAFile: ep.TLS.CAFile, + CertFile: ep.TLS.CertFile, + KeyFile: ep.TLS.KeyFile, + ServerName: ep.TLS.ServerName, + InsecureAllowPlainTCP: ep.InsecureAllowPlainTCP, + InsecureSkipTLSVerify: ep.InsecureSkipTLSVerify, + } + } + return specs, false + } + if spec, ok := upstream.SpecsFromDockerEnv(getenv); ok { + logger.Info("using remote upstream from DOCKER_HOST environment", "address", spec.Address) + return []upstream.EndpointSpec{spec}, false + } + return []upstream.EndpointSpec{{Address: cfg.Upstream.Socket}}, true +} + +// buildUpstreamResolver constructs the shared upstream resolver from config, +// loading any per-endpoint TLS material. It returns the resolver, whether the +// legacy single-socket path was taken, and an error for any unbuildable +// endpoint (bad address, missing/invalid TLS files). +func buildUpstreamResolver(cfg *config.Config, logger *slog.Logger, getenv func(string) string) (*upstream.Resolver, bool, error) { + specs, legacy := resolveUpstreamSpecs(cfg, getenv, logger) + endpoints := make([]upstream.Endpoint, 0, len(specs)) + for _, spec := range specs { + ep, err := upstream.BuildEndpoint(spec) + if err != nil { + return nil, legacy, err + } + endpoints = append(endpoints, ep) + } + res, err := upstream.New(endpoints, upstream.Options{ + Interval: durationOrZero(cfg.Upstream.Failover.HealthInterval), + Timeout: durationOrZero(cfg.Upstream.Failover.HealthTimeout), + Logger: logger, + }) + return res, legacy, err +} + +// durationOrZero parses a Go duration, returning 0 for empty or invalid input +// so the resolver falls back to its built-in defaults. Validation has already +// rejected malformed values by the time this runs in production. +func durationOrZero(s string) time.Duration { + if s == "" { + return 0 + } + d, err := time.ParseDuration(s) + if err != nil { + return 0 + } + return d +} + +// upstreamResolverFor returns res when non-nil, otherwise a single-socket +// resolver built from cfg. It lets request-chain helpers accept an optional +// shared resolver (production threads the real one; tests can pass nil to get +// the legacy single-socket behavior without constructing a resolver). +func upstreamResolverFor(res *upstream.Resolver, cfg *config.Config) *upstream.Resolver { + if res != nil { + return res + } + return upstream.NewSingleSocket(cfg.Upstream.Socket) +} + +// runtimeResolver returns the runtime's shared resolver, falling back to a +// single-socket resolver built from cfg when the runtime (or its resolver) is +// absent โ€” the latter only happens in tests that construct a bare serveRuntime. +func runtimeResolver(runtime *serveRuntime, cfg *config.Config) *upstream.Resolver { + if runtime == nil { + return upstreamResolverFor(nil, cfg) + } + return upstreamResolverFor(runtime.resolver, cfg) +} + +// verifyUpstreamReachableForRuntime runs the startup reachability probe against +// the resolved upstream. The legacy single-local-socket path keeps the original +// fail-fast unix-dial check (which classifies not-found / permission errors for +// a precise operator message); the endpoints / DOCKER_HOST path probes every +// configured endpoint, seeds their health state, and fails only when none are +// reachable, so a multi-endpoint failover set can boot with one daemon down. +func verifyUpstreamReachableForRuntime(ctx context.Context, deps *serveDeps, runtime *serveRuntime, cfg *config.Config, logger *slog.Logger) error { + if runtime == nil || runtime.legacyUpstreamSocket || runtime.resolver == nil { + return deps.verifyUpstreamReachable(cfg.Upstream.Socket, logger) + } + probeCtx, cancel := context.WithTimeout(ctx, upstreamReachableTimeout) + defer cancel() + return runtime.resolver.CheckReachable(probeCtx) +} + +// upstreamDisplayFromConfig renders the upstream for human-facing output (the +// validate header) directly from config, without constructing a resolver. +// Configured endpoints take precedence over the legacy socket and show a +// failover count when more than one is listed; DOCKER_* env resolution is a +// serve-time fallback and is intentionally not reflected here. +func upstreamDisplayFromConfig(cfg *config.Config) string { + eps := cfg.Upstream.Endpoints + switch len(eps) { + case 0: + return cfg.Upstream.Socket + case 1: + return eps[0].Address + default: + return fmt.Sprintf("%s (+%d failover)", eps[0].Address, len(eps)-1) + } +} + +// upstreamLabel is the short identifier used in health logs/metrics for the +// upstream: the sole endpoint's name, or the primary with a failover count. +func upstreamLabel(res *upstream.Resolver) string { + eps := res.Endpoints() + switch len(eps) { + case 0: + return "upstream" + case 1: + return eps[0].Name + default: + return eps[0].Name + " (+failover)" + } +} diff --git a/app/internal/cmd/validate.go b/app/internal/cmd/validate.go index bc397be1..5548caf0 100644 --- a/app/internal/cmd/validate.go +++ b/app/internal/cmd/validate.go @@ -58,7 +58,7 @@ func runValidate(cmd *cobra.Command, args []string) error { func printHeader(out io.Writer, p *ui.Printer, cfg *config.Config, compatActive bool) { fmt.Fprintf(out, " %s %s\n", p.Dim("Config "), cfgFile) fmt.Fprintf(out, " %s %s\n", p.Dim("Listen "), listenerAddr(cfg)) - fmt.Fprintf(out, " %s %s\n", p.Dim("Upstream"), cfg.Upstream.Socket) + fmt.Fprintf(out, " %s %s\n", p.Dim("Upstream"), upstreamDisplayFromConfig(cfg)) if compatActive { fmt.Fprintf(out, " %s %s\n", p.Dim("Mode "), "tecnativa compatibility") } diff --git a/app/internal/config/config.go b/app/internal/config/config.go index 5d36b59b..b8445094 100644 --- a/app/internal/config/config.go +++ b/app/internal/config/config.go @@ -59,7 +59,13 @@ type ListenTLSConfig struct { PublicKeySHA256Pins []string `mapstructure:"public_key_sha256_pins"` } -// UpstreamConfig configures the upstream Docker socket. +// UpstreamConfig configures the upstream Docker daemon(s) sockguard proxies to. +// +// The legacy single-daemon shorthand is upstream.socket (a local unix socket). +// upstream.endpoints adds an ordered list of remote (TCP+TLS) or local daemons +// for the SAME logical daemon/swarm, health-checked with automatic failover to +// the first reachable endpoint. When endpoints is empty, socket is used. When +// endpoints is non-empty, it takes precedence and socket is ignored. type UpstreamConfig struct { Socket string `mapstructure:"socket"` // RequestTimeout bounds the total lifetime of a single proxied upstream @@ -72,6 +78,61 @@ type UpstreamConfig struct { // so the deadline never severs a legitimately long response. Empty (the // default) disables the per-request deadline. RequestTimeout string `mapstructure:"request_timeout"` + // Endpoints is an ordered failover set. The first entry is the preferred + // primary; later entries are tried when earlier ones fail their health + // probe. Every endpoint MUST address the same logical daemon/swarm โ€” + // container IDs, exec sessions, and owner labels are daemon-local, so + // failover only makes sense across redundant endpoints (a swarm VIP plus + // its managers, an HA pair behind keepalived), not distinct daemons. + Endpoints []UpstreamEndpoint `mapstructure:"endpoints"` + // Failover tunes the active health-probe loop that drives endpoint + // selection. Ignored unless endpoints is set. + Failover UpstreamFailover `mapstructure:"failover"` +} + +// UpstreamEndpoint is one daemon in an ordered failover set. +type UpstreamEndpoint struct { + // Address is a Docker-style upstream address: a unix socket + // ("unix:///var/run/docker.sock" or a bare path) or a remote daemon + // ("tcp://host:2376"). + Address string `mapstructure:"address"` + // TLS configures the client certificate, key, and CA used to dial a remote + // daemon over TLS. Required for tcp:// endpoints unless an insecure opt-in + // below is set. Meaningless for unix sockets. + TLS UpstreamTLSConfig `mapstructure:"tls"` + // InsecureAllowPlainTCP permits a tcp:// endpoint with no TLS material, + // exposing the Docker API in plaintext to anyone on the path. Mirrors the + // listener-side insecure_allow_plain_tcp acknowledgement. + InsecureAllowPlainTCP bool `mapstructure:"insecure_allow_plain_tcp"` + // InsecureSkipTLSVerify disables verification of the remote daemon's server + // certificate (self-signed homelab daemons). Dangerous in production: it + // defeats authentication of the upstream. + InsecureSkipTLSVerify bool `mapstructure:"insecure_skip_tls_verify"` +} + +// UpstreamTLSConfig is the client-side TLS material for dialing a remote daemon. +type UpstreamTLSConfig struct { + // CAFile verifies the remote daemon's server certificate. Empty uses the + // system roots. + CAFile string `mapstructure:"ca_file"` + // CertFile and KeyFile present a client certificate to the daemon (mutual + // TLS). Both set together or both empty. + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` + // ServerName overrides the SNI / verified hostname. Empty derives it from + // the address host. + ServerName string `mapstructure:"server_name"` +} + +// UpstreamFailover tunes the endpoint health-probe loop. +type UpstreamFailover struct { + // HealthInterval is the active probe period (Go duration, e.g. "5s"). Empty + // uses the resolver default. A negative duration disables continuous + // probing (a single startup probe still runs). + HealthInterval string `mapstructure:"health_interval"` + // HealthTimeout bounds each probe (Go duration, e.g. "2s"). Empty uses the + // resolver default. + HealthTimeout string `mapstructure:"health_timeout"` } // LogConfig configures logging. @@ -162,6 +223,9 @@ type ContainerCreateRequestBodyConfig struct { RequiredLabels []string `mapstructure:"required_labels"` AllowedRuntimes []string `mapstructure:"allowed_runtimes"` ImageTrust ImageTrustConfig `mapstructure:"image_trust"` + DenySelinuxDisable bool `mapstructure:"deny_selinux_disable"` + DenySelinuxLabelOverride bool `mapstructure:"deny_selinux_label_override"` + DenyUnconfinedSystemPaths bool `mapstructure:"deny_unconfined_system_paths"` } // ImageTrustConfig configures cosign signature verification for images @@ -293,19 +357,32 @@ type ConfigRequestBodyConfig struct { // ServiceRequestBodyConfig configures inspection for service create/update. type ServiceRequestBodyConfig struct { - AllowHostNetwork bool `mapstructure:"allow_host_network"` - AllowedBindMounts []string `mapstructure:"allowed_bind_mounts"` - AllowAllRegistries bool `mapstructure:"allow_all_registries"` - AllowOfficial bool `mapstructure:"allow_official"` - AllowedRegistries []string `mapstructure:"allowed_registries"` - AllowAllCapabilities bool `mapstructure:"allow_all_capabilities"` - AllowedCapabilities []string `mapstructure:"allowed_capabilities"` - AllowSysctls bool `mapstructure:"allow_sysctls"` - RequireNonRootUser bool `mapstructure:"require_non_root_user"` - RequireNoNewPrivileges bool `mapstructure:"require_no_new_privileges"` - RequireReadonlyRootfs bool `mapstructure:"require_readonly_rootfs"` - RequireDropAllCapabilities bool `mapstructure:"require_drop_all_capabilities"` - ImageTrust ImageTrustConfig `mapstructure:"image_trust"` + AllowHostNetwork bool `mapstructure:"allow_host_network"` + AllowedBindMounts []string `mapstructure:"allowed_bind_mounts"` + AllowAllRegistries bool `mapstructure:"allow_all_registries"` + AllowOfficial bool `mapstructure:"allow_official"` + AllowedRegistries []string `mapstructure:"allowed_registries"` + AllowAllCapabilities bool `mapstructure:"allow_all_capabilities"` + AllowedCapabilities []string `mapstructure:"allowed_capabilities"` + AllowSysctls bool `mapstructure:"allow_sysctls"` + RequireNonRootUser bool `mapstructure:"require_non_root_user"` + RequireNoNewPrivileges bool `mapstructure:"require_no_new_privileges"` + RequireReadonlyRootfs bool `mapstructure:"require_readonly_rootfs"` + RequireDropAllCapabilities bool `mapstructure:"require_drop_all_capabilities"` + // DenyUnconfinedSeccomp denies service create/update when + // ContainerSpec.Privileges.Seccomp.Mode is "unconfined". Default false (opt-in). + DenyUnconfinedSeccomp bool `mapstructure:"deny_unconfined_seccomp"` + // DenyCustomSeccompProfiles denies service create/update when + // ContainerSpec.Privileges.Seccomp.Mode is "custom". A "custom" profile can + // encode an allow-everything policy equivalent to "unconfined"; enable this + // alongside deny_unconfined_seccomp for full seccomp confinement enforcement. + // Default false (opt-in). + DenyCustomSeccompProfiles bool `mapstructure:"deny_custom_seccomp_profiles"` + // DenyUnconfinedAppArmor denies service create/update when + // ContainerSpec.Privileges.AppArmor.Mode is "disabled" (the swarm equivalent + // of "unconfined" AppArmor). Default false (opt-in). + DenyUnconfinedAppArmor bool `mapstructure:"deny_unconfined_apparmor"` + ImageTrust ImageTrustConfig `mapstructure:"image_trust"` } // SwarmRequestBodyConfig configures inspection for swarm writes. @@ -591,9 +668,11 @@ func (cfg AdminListenConfig) Configured() bool { // // When Enabled, sockguard watches the config file via fsnotify and reloads // on SIGHUP. A reload that mutates any immutable field โ€” listen.*, -// upstream.socket, log.*, health.*, metrics.*, admin.* โ€” is rejected; the -// running config is preserved and the operator must restart sockguard to -// pick the new values up. +// upstream.socket, upstream.endpoints, upstream.failover, log.*, health.*, +// metrics.*, admin.* โ€” is rejected; the running config is preserved and the +// operator must restart sockguard to pick the new values up. (upstream.endpoints +// and upstream.failover are pinned because the long-lived Resolver and its +// health loop are built once at startup; upstream.request_timeout stays mutable.) // // Debounce collapses bursts of filesystem events (editors commonly emit // chmod + write + rename + create per save) into a single reload trigger. diff --git a/app/internal/config/filter_options.go b/app/internal/config/filter_options.go index 4ccdc295..42dc411c 100644 --- a/app/internal/config/filter_options.go +++ b/app/internal/config/filter_options.go @@ -56,6 +56,9 @@ func (c ContainerCreateRequestBodyConfig) ToFilterOptions() filter.ContainerCrea RequiredLabels: c.RequiredLabels, AllowedRuntimes: c.AllowedRuntimes, ImageTrust: c.ImageTrust.toFilterOptions(), + DenySelinuxDisable: c.DenySelinuxDisable, + DenySelinuxLabelOverride: c.DenySelinuxLabelOverride, + DenyUnconfinedSystemPaths: c.DenyUnconfinedSystemPaths, } } @@ -191,6 +194,9 @@ func (c ServiceRequestBodyConfig) ToFilterOptions() filter.ServiceOptions { RequireNoNewPrivileges: c.RequireNoNewPrivileges, RequireReadonlyRootfs: c.RequireReadonlyRootfs, RequireDropAllCapabilities: c.RequireDropAllCapabilities, + DenyUnconfinedSeccomp: c.DenyUnconfinedSeccomp, + DenyCustomSeccompProfiles: c.DenyCustomSeccompProfiles, + DenyUnconfinedAppArmor: c.DenyUnconfinedAppArmor, ImageTrust: c.ImageTrust.toFilterOptions(), } } diff --git a/app/internal/config/filter_options_test.go b/app/internal/config/filter_options_test.go index 94b14545..21287c49 100644 --- a/app/internal/config/filter_options_test.go +++ b/app/internal/config/filter_options_test.go @@ -91,6 +91,9 @@ func TestRequestBodyConfigToFilterOptionsMapsEveryPolicy(t *testing.T) { RequireNoNewPrivileges: true, RequireReadonlyRootfs: true, RequireDropAllCapabilities: true, + DenyUnconfinedSeccomp: true, + DenyCustomSeccompProfiles: true, + DenyUnconfinedAppArmor: true, }, Swarm: SwarmRequestBodyConfig{ AllowForceNewCluster: true, @@ -209,6 +212,9 @@ func TestRequestBodyConfigToFilterOptionsMapsEveryPolicy(t *testing.T) { RequireNoNewPrivileges: true, RequireReadonlyRootfs: true, RequireDropAllCapabilities: true, + DenyUnconfinedSeccomp: true, + DenyCustomSeccompProfiles: true, + DenyUnconfinedAppArmor: true, }, Swarm: filter.SwarmOptions{ AllowForceNewCluster: true, @@ -248,6 +254,24 @@ func TestRequestBodyConfigToFilterOptionsMapsEveryPolicy(t *testing.T) { } } +func TestContainerCreateRequestBodyConfigToFilterOptionsMapsSelinuxAndSystemPaths(t *testing.T) { + cfg := ContainerCreateRequestBodyConfig{ + DenySelinuxDisable: true, + DenySelinuxLabelOverride: true, + DenyUnconfinedSystemPaths: true, + } + got := cfg.ToFilterOptions() + if !got.DenySelinuxDisable { + t.Error("DenySelinuxDisable not propagated") + } + if !got.DenySelinuxLabelOverride { + t.Error("DenySelinuxLabelOverride not propagated") + } + if !got.DenyUnconfinedSystemPaths { + t.Error("DenyUnconfinedSystemPaths not propagated") + } +} + func TestExecRequestBodyConfigToFilterOptionsLeavesRuntimeInspectorUnset(t *testing.T) { got := (ExecRequestBodyConfig{ AllowPrivileged: true, diff --git a/app/internal/config/load.go b/app/internal/config/load.go index 192bcac9..56006d9f 100644 --- a/app/internal/config/load.go +++ b/app/internal/config/load.go @@ -69,6 +69,10 @@ func setLoadDefaults(v *viper.Viper, defaults Config) { v.SetDefault("listen.tls.uri_sans", defaults.Listen.TLS.URISANs) v.SetDefault("listen.tls.public_key_sha256_pins", defaults.Listen.TLS.PublicKeySHA256Pins) v.SetDefault("upstream.socket", defaults.Upstream.Socket) + v.SetDefault("upstream.request_timeout", defaults.Upstream.RequestTimeout) + v.SetDefault("upstream.endpoints", defaults.Upstream.Endpoints) + v.SetDefault("upstream.failover.health_interval", defaults.Upstream.Failover.HealthInterval) + v.SetDefault("upstream.failover.health_timeout", defaults.Upstream.Failover.HealthTimeout) v.SetDefault("log.level", defaults.Log.Level) v.SetDefault("log.format", defaults.Log.Format) v.SetDefault("log.output", defaults.Log.Output) @@ -110,6 +114,9 @@ func setLoadDefaults(v *viper.Viper, defaults Config) { v.SetDefault("request_body.container_create.allow_sysctls", defaults.RequestBody.ContainerCreate.AllowSysctls) v.SetDefault("request_body.container_create.required_labels", defaults.RequestBody.ContainerCreate.RequiredLabels) v.SetDefault("request_body.container_create.allowed_runtimes", defaults.RequestBody.ContainerCreate.AllowedRuntimes) + v.SetDefault("request_body.container_create.deny_selinux_disable", defaults.RequestBody.ContainerCreate.DenySelinuxDisable) + v.SetDefault("request_body.container_create.deny_selinux_label_override", defaults.RequestBody.ContainerCreate.DenySelinuxLabelOverride) + v.SetDefault("request_body.container_create.deny_unconfined_system_paths", defaults.RequestBody.ContainerCreate.DenyUnconfinedSystemPaths) v.SetDefault("request_body.exec.allow_privileged", defaults.RequestBody.Exec.AllowPrivileged) v.SetDefault("request_body.exec.allow_root_user", defaults.RequestBody.Exec.AllowRootUser) v.SetDefault("request_body.exec.allowed_commands", defaults.RequestBody.Exec.AllowedCommands) @@ -163,6 +170,9 @@ func setLoadDefaults(v *viper.Viper, defaults Config) { v.SetDefault("request_body.service.require_no_new_privileges", defaults.RequestBody.Service.RequireNoNewPrivileges) v.SetDefault("request_body.service.require_readonly_rootfs", defaults.RequestBody.Service.RequireReadonlyRootfs) v.SetDefault("request_body.service.require_drop_all_capabilities", defaults.RequestBody.Service.RequireDropAllCapabilities) + v.SetDefault("request_body.service.deny_unconfined_seccomp", defaults.RequestBody.Service.DenyUnconfinedSeccomp) + v.SetDefault("request_body.service.deny_custom_seccomp_profiles", defaults.RequestBody.Service.DenyCustomSeccompProfiles) + v.SetDefault("request_body.service.deny_unconfined_apparmor", defaults.RequestBody.Service.DenyUnconfinedAppArmor) v.SetDefault("request_body.container_create.image_trust.require_rekor_inclusion", defaults.RequestBody.ContainerCreate.ImageTrust.RequireRekorInclusion) v.SetDefault("request_body.container_create.image_trust.verify_timeout", defaults.RequestBody.ContainerCreate.ImageTrust.VerifyTimeout) v.SetDefault("request_body.service.image_trust.require_rekor_inclusion", defaults.RequestBody.Service.ImageTrust.RequireRekorInclusion) diff --git a/app/internal/config/load_env_defaults_test.go b/app/internal/config/load_env_defaults_test.go index 9e5028b2..3f32c0eb 100644 --- a/app/internal/config/load_env_defaults_test.go +++ b/app/internal/config/load_env_defaults_test.go @@ -47,6 +47,81 @@ func TestLoadHonorsServiceHardeningEnvVars(t *testing.T) { } } +func TestLoadHonorsDenySelinuxDisableEnvVar(t *testing.T) { + t.Setenv("SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_SELINUX_DISABLE", "true") + cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml") + if err != nil { + t.Fatalf("Load() = %v", err) + } + if !cfg.RequestBody.ContainerCreate.DenySelinuxDisable { + t.Fatal("DenySelinuxDisable = false, want true from env var") + } +} + +func TestLoadHonorsDenySelinuxLabelOverrideEnvVar(t *testing.T) { + t.Setenv("SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_SELINUX_LABEL_OVERRIDE", "true") + cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml") + if err != nil { + t.Fatalf("Load() = %v", err) + } + if !cfg.RequestBody.ContainerCreate.DenySelinuxLabelOverride { + t.Fatal("DenySelinuxLabelOverride = false, want true from env var") + } +} + +func TestLoadHonorsDenyUnconfinedSystemPathsEnvVar(t *testing.T) { + t.Setenv("SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_UNCONFINED_SYSTEM_PATHS", "true") + cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml") + if err != nil { + t.Fatalf("Load() = %v", err) + } + if !cfg.RequestBody.ContainerCreate.DenyUnconfinedSystemPaths { + t.Fatal("DenyUnconfinedSystemPaths = false, want true from env var") + } +} + +func TestLoadHonorsServiceSeccompAppArmorEnvVars(t *testing.T) { + // Guard: new service seccomp/AppArmor knobs require SetDefault registration + // in setLoadDefaults โ€” without it, SOCKGUARD_* env vars are silently dropped. + t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_DENY_UNCONFINED_SECCOMP", "true") + t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_DENY_CUSTOM_SECCOMP_PROFILES", "true") + t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_DENY_UNCONFINED_APPARMOR", "true") + + cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml") + if err != nil { + t.Fatalf("Load() = %v", err) + } + svc := cfg.RequestBody.Service + if !svc.DenyUnconfinedSeccomp { + t.Error("DenyUnconfinedSeccomp = false, want true from env") + } + if !svc.DenyCustomSeccompProfiles { + t.Error("DenyCustomSeccompProfiles = false, want true from env") + } + if !svc.DenyUnconfinedAppArmor { + t.Error("DenyUnconfinedAppArmor = false, want true from env") + } +} + +func TestLoadHonorsUpstreamFailoverEnvVars(t *testing.T) { + // The nested failover timing fields are env-only unless registered in + // setLoadDefaults; guard both so a dropped SetDefault can't silently strip + // SOCKGUARD_UPSTREAM_FAILOVER_* overrides. + t.Setenv("SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_INTERVAL", "7s") + t.Setenv("SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_TIMEOUT", "3s") + + cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml") + if err != nil { + t.Fatalf("Load() = %v", err) + } + if got := cfg.Upstream.Failover.HealthInterval; got != "7s" { + t.Fatalf("upstream.failover.health_interval = %q, want 7s from env", got) + } + if got := cfg.Upstream.Failover.HealthTimeout; got != "3s" { + t.Fatalf("upstream.failover.health_timeout = %q, want 3s from env", got) + } +} + func TestLoadHonorsImageTrustVerifyTimeoutEnvVar(t *testing.T) { t.Setenv("SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_IMAGE_TRUST_VERIFY_TIMEOUT", "30s") t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_IMAGE_TRUST_VERIFY_TIMEOUT", "45s") diff --git a/app/internal/config/validate.go b/app/internal/config/validate.go index cb215a77..430d8a98 100644 --- a/app/internal/config/validate.go +++ b/app/internal/config/validate.go @@ -11,6 +11,7 @@ import ( "github.com/codeswhat/sockguard/internal/glob" "github.com/codeswhat/sockguard/internal/pkipin" + "github.com/codeswhat/sockguard/internal/upstream" ) // ValidationError holds multiple validation errors. @@ -87,8 +88,15 @@ func plainTCPListenerErrors(label, prefix string, listen ListenConfig) []string func validateUpstream(cfg *Config) []string { var errs []string - if cfg.Upstream.Socket == "" { - errs = append(errs, "upstream.socket is required") + // Either the legacy single socket or at least one endpoint must be set. + // endpoints takes precedence; socket is the fallback when endpoints is empty. + if len(cfg.Upstream.Endpoints) == 0 && cfg.Upstream.Socket == "" { + errs = append(errs, "upstream requires either upstream.socket or at least one upstream.endpoints entry") + } + for i, ep := range cfg.Upstream.Endpoints { + if err := upstream.ValidateSpec(endpointSpec(ep)); err != nil { + errs = append(errs, fmt.Sprintf("upstream.endpoints[%d]: %v", i, err)) + } } if cfg.Upstream.RequestTimeout != "" { timeout, err := time.ParseDuration(cfg.Upstream.RequestTimeout) @@ -96,9 +104,38 @@ func validateUpstream(cfg *Config) []string { errs = append(errs, fmt.Sprintf("upstream.request_timeout must be a positive duration, got %q", cfg.Upstream.RequestTimeout)) } } + if d := cfg.Upstream.Failover.HealthInterval; d != "" { + // Zero is ambiguous: durationOrZero maps it to the resolver default (5s), + // not "disabled", which surprises an operator who writes "0s" meaning off. + // Reject it and steer them to a negative value (disable) or omission + // (default). Negative parses fine and is intentionally allowed. + if parsed, err := time.ParseDuration(d); err != nil { + errs = append(errs, fmt.Sprintf("upstream.failover.health_interval must be a Go duration, got %q", d)) + } else if parsed == 0 { + errs = append(errs, "upstream.failover.health_interval must be non-zero: use a negative duration to disable probing, or omit it for the 5s default") + } + } + if d := cfg.Upstream.Failover.HealthTimeout; d != "" { + if t, err := time.ParseDuration(d); err != nil || t <= 0 { + errs = append(errs, fmt.Sprintf("upstream.failover.health_timeout must be a positive duration, got %q", d)) + } + } return errs } +// endpointSpec adapts a config UpstreamEndpoint to an upstream.EndpointSpec. +func endpointSpec(ep UpstreamEndpoint) upstream.EndpointSpec { + return upstream.EndpointSpec{ + Address: ep.Address, + CAFile: ep.TLS.CAFile, + CertFile: ep.TLS.CertFile, + KeyFile: ep.TLS.KeyFile, + ServerName: ep.TLS.ServerName, + InsecureAllowPlainTCP: ep.InsecureAllowPlainTCP, + InsecureSkipTLSVerify: ep.InsecureSkipTLSVerify, + } +} + func validateLogging(cfg *Config) []string { var errs []string switch cfg.Log.Level { @@ -735,6 +772,18 @@ func validateLimitsConfig(prefix string, cfg LimitsConfig) []string { ratePfx, cfg.Rate.TokensPerSecond, cfg.Rate.Burst)) } + // maxBurstConfig matches the 16-bit integer part of the packed 16.16 + // fixed-point token field in the ratelimit package. Defined here as a + // local constant to avoid an import cycle; ratelimit.MaxPackedBurst + // documents the same value but this validator is the enforcement point. + // burst >= tokens_per_second is already enforced above, so capping burst + // here implicitly caps tokens_per_second too. + const maxBurstConfig = 65535.0 + if effectiveBurst > maxBurstConfig { + errs = append(errs, fmt.Sprintf("%s.burst must not exceed %g (packed token field limit), got %v", + ratePfx, maxBurstConfig, effectiveBurst)) + } + errs = append(errs, validateEndpointCosts(ratePfx+".endpoint_costs", cfg.Rate.EndpointCosts, effectiveBurst)...) } diff --git a/app/internal/config/validate_test.go b/app/internal/config/validate_test.go index 5e3e1dde..1d52263b 100644 --- a/app/internal/config/validate_test.go +++ b/app/internal/config/validate_test.go @@ -1820,3 +1820,55 @@ func TestValidateEndpointCosts(t *testing.T) { }) } } + +// TestValidateLimitsConfig_PackedBurstLimit verifies the upper bound on burst +// introduced by the packed 16.16 token field. The integer part of the field is +// 16 bits, so burst (and therefore tokens_per_second, since burst >= tps) must +// not exceed 65535. +func TestValidateLimitsConfig_PackedBurstLimit(t *testing.T) { + tests := []struct { + name string + cfg LimitsConfig + wantSubstr string // empty = want no error + }{ + { + name: "burst at packed field limit is valid", + cfg: LimitsConfig{Rate: &RateLimitConfig{TokensPerSecond: 65535, Burst: 65535}}, + }, + { + name: "burst one above packed field limit is rejected", + cfg: LimitsConfig{Rate: &RateLimitConfig{TokensPerSecond: 65535, Burst: 65536}}, + wantSubstr: "must not exceed 65535", + }, + { + name: "burst zero (defaults to tps) at limit is valid", + cfg: LimitsConfig{Rate: &RateLimitConfig{TokensPerSecond: 65535, Burst: 0}}, + }, + { + name: "burst zero (defaults to tps) above limit is rejected", + cfg: LimitsConfig{Rate: &RateLimitConfig{TokensPerSecond: 65536, Burst: 0}}, + wantSubstr: "must not exceed 65535", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateLimitsConfig("clients.profiles[0].limits", tt.cfg) + if tt.wantSubstr == "" { + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + return + } + found := false + for _, e := range errs { + if strings.Contains(e, tt.wantSubstr) { + found = true + break + } + } + if !found { + t.Fatalf("expected error containing %q, got: %v", tt.wantSubstr, errs) + } + }) + } +} diff --git a/app/internal/dockerclient/client.go b/app/internal/dockerclient/client.go index f33f6224..a42167e1 100644 --- a/app/internal/dockerclient/client.go +++ b/app/internal/dockerclient/client.go @@ -1,33 +1,29 @@ // Package dockerclient provides a shared *http.Client for side-channel calls -// to the upstream Docker socket (ownership inspection, client ACL resolution, -// visibility label look-ups). All three callers use the same unix-socket -// transport configuration so idle connections are reused across requests. +// to the upstream Docker daemon (ownership inspection, client ACL resolution, +// visibility label look-ups). All callers route through the same upstream +// transport so idle connections are reused and โ€” when the upstream is a +// failover set โ€” so the side channels follow the same active endpoint as the +// main proxy (a split between the request path and its owner-label inspect +// would break owner isolation). package dockerclient import ( - "context" - "net" "net/http" - "time" + + "github.com/codeswhat/sockguard/internal/upstream" ) -// New returns an *http.Client that dials the Docker unix socket at path. -// The transport is tuned to match the main reverse-proxy transport: -// - MaxIdleConnsPerHost: 10 โ€” caps idle connections per host bucket -// - IdleConnTimeout: 90s โ€” matches net/http DefaultTransport -// - ResponseHeaderTimeout: 30s โ€” bounds the wait for upstream headers so an -// unresponsive Docker daemon cannot pin a side-channel goroutine -// -// Callers must not mutate the returned client after construction. +// NewWithRoundTripper returns an *http.Client whose transport is the shared +// upstream RoundTripper (typically an *upstream.Resolver). Routing, pooling, +// TLS, and failover all live in that transport. Callers must not mutate the +// returned client after construction. +func NewWithRoundTripper(rt http.RoundTripper) *http.Client { + return &http.Client{Transport: rt} +} + +// New returns an *http.Client that dials the Docker unix socket at path. It is +// the single-local-socket shorthand retained for callers and tests that have a +// plain socket path; it builds a one-endpoint resolver under the hood. func New(socketPath string) *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) - }, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - ResponseHeaderTimeout: 30 * time.Second, - }, - } + return NewWithRoundTripper(upstream.NewSingleSocket(socketPath)) } diff --git a/app/internal/dockerclient/client_test.go b/app/internal/dockerclient/client_test.go index 7e8eead2..e84f5d9f 100644 --- a/app/internal/dockerclient/client_test.go +++ b/app/internal/dockerclient/client_test.go @@ -3,51 +3,46 @@ package dockerclient_test import ( "context" "net" - "net/http" "path/filepath" "testing" "time" "github.com/codeswhat/sockguard/internal/dockerclient" + "github.com/codeswhat/sockguard/internal/upstream" ) -func TestNew_TransportValues(t *testing.T) { +// TestNew_UsesResolverTransport pins the contract that dockerclient.New wires +// the client to the shared upstream resolver. Pool tunings now live on the +// resolver's per-endpoint transport (see internal/upstream); this package only +// guarantees the side-channel client routes through that resolver so its +// inspect calls follow the same active endpoint as the proxy under failover. +func TestNew_UsesResolverTransport(t *testing.T) { t.Parallel() client := dockerclient.New("/var/run/docker.sock") - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("Transport is %T, want *http.Transport", client.Transport) - } - - if got, want := tr.MaxIdleConnsPerHost, 10; got != want { - t.Errorf("MaxIdleConnsPerHost = %d, want %d", got, want) - } - - if got, want := tr.IdleConnTimeout, 90*time.Second; got != want { - t.Errorf("IdleConnTimeout = %v, want %v", got, want) + if _, ok := client.Transport.(*upstream.Resolver); !ok { + t.Fatalf("Transport is %T, want *upstream.Resolver", client.Transport) } } -func TestNew_DialContextSet(t *testing.T) { +// TestNewWithRoundTripper_UsesGivenTransport verifies the explicit-RoundTripper +// constructor installs exactly the transport it is handed, so the serve wiring +// can share one resolver across the proxy and every side channel. +func TestNewWithRoundTripper_UsesGivenTransport(t *testing.T) { t.Parallel() - client := dockerclient.New("/var/run/docker.sock") - - tr, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("Transport is %T, want *http.Transport", client.Transport) - } + rt := upstream.NewSingleSocket("/var/run/docker.sock") + client := dockerclient.NewWithRoundTripper(rt) - if tr.DialContext == nil { - t.Error("DialContext is nil, want a unix-socket dialer") + if client.Transport != rt { + t.Fatalf("Transport = %p, want the supplied resolver %p", client.Transport, rt) } } -// TestNew_ActualUnixDial exercises the configured DialContext end-to-end: -// it stands up a unix-socket listener, asks the client to dial it, and +// TestNew_ActualUnixDial exercises the configured dialer end-to-end: it stands +// up a unix-socket listener, asks the resolver-backed client to dial it, and // verifies the listener actually accepted a connection. This guards against -// regressions where the dialer is misconfigured (wrong network family, -// wrong path source) but the transport shape still looks right. +// regressions where the dialer is misconfigured (wrong network family, wrong +// path source) but the transport shape still looks right. func TestNew_ActualUnixDial(t *testing.T) { t.Parallel() sockPath := filepath.Join(t.TempDir(), "test.sock") @@ -68,11 +63,13 @@ func TestNew_ActualUnixDial(t *testing.T) { _ = conn.Close() }() - tr := dockerclient.New(sockPath).Transport.(*http.Transport) + // The resolver ignores the network/address arguments and dials its active + // endpoint (the configured unix socket). + resolver := dockerclient.New(sockPath).Transport.(*upstream.Resolver) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - conn, err := tr.DialContext(ctx, "tcp", "docker:0") + conn, err := resolver.DialContext(ctx, "tcp", "docker:0") if err != nil { t.Fatalf("DialContext: %v", err) } diff --git a/app/internal/filter/container_create.go b/app/internal/filter/container_create.go index 10d6fa9e..1a44b884 100644 --- a/app/internal/filter/container_create.go +++ b/app/internal/filter/container_create.go @@ -109,6 +109,22 @@ type ContainerCreateOptions struct { // Default false: any non-empty Sysctls map is denied. AllowSysctls bool + // DenySelinuxDisable prevents label=disable (and label:disable) which + // turns off SELinux confinement for the container. Default false + // (pass-through) for backward-compatibility. + DenySelinuxDisable bool + + // DenySelinuxLabelOverride denies label=user:, label=role:, label=type:, + // label=level: SecurityOpt entries that customize the SELinux context. + // Default false (pass-through). Independent of DenySelinuxDisable. + DenySelinuxLabelOverride bool + + // DenyUnconfinedSystemPaths prevents systempaths=unconfined in SecurityOpt + // AND rejects requests that set MaskedPaths or ReadonlyPaths to an empty + // slice (the direct-API equivalent of systempaths=unconfined). Default false + // for backward-compatibility. + DenyUnconfinedSystemPaths bool + // ImageTrust configures cosign-backed signature verification. ImageTrust ImageTrustOptions } @@ -144,6 +160,10 @@ type containerCreatePolicy struct { allowSysctls bool allowedRuntimes []string + denySelinuxDisable bool + denySelinuxLabelOverride bool + denyUnconfinedSystemPaths bool + // Image trust โ€” non-nil when mode != off. imageTrustVerifier imageVerifier imageFetcher signatureFetcher @@ -227,6 +247,9 @@ func newContainerCreatePolicy(opts ContainerCreateOptions) containerCreatePolicy requiredLabels: normalizeStringList(opts.RequiredLabels), allowSysctls: opts.AllowSysctls, allowedRuntimes: normalizeStringList(opts.AllowedRuntimes), + denySelinuxDisable: opts.DenySelinuxDisable, + denySelinuxLabelOverride: opts.DenySelinuxLabelOverride, + denyUnconfinedSystemPaths: opts.DenyUnconfinedSystemPaths, } // Build image trust verifier. Errors are stored in imageTrustInitErr so @@ -446,6 +469,9 @@ func (p containerCreatePolicy) inspect(logger *slog.Logger, r *http.Request, nor if denyReason := p.denySecurityOptReason(createReq.HostConfig); denyReason != "" { return denyReason, nil } + if denyReason := p.denySystemPathsReason(createReq.HostConfig); denyReason != "" { + return denyReason, nil + } if denyReason := p.denyCapabilityReason(createReq.HostConfig); denyReason != "" { return denyReason, nil } @@ -939,6 +965,25 @@ func (p containerCreatePolicy) denySecurityOptReason(hostConfig containerCreateH } else if p.denyUnconfinedAppArmor && strings.EqualFold(value, "unconfined") { return "container create denied: unconfined apparmor profile is not allowed" } + case "label": + labelValue := strings.ToLower(strings.TrimSpace(value)) + if labelValue == "disable" { + if p.denySelinuxDisable { + return "container create denied: label=disable (SELinux disable) is not allowed" + } + // Otherwise pass through โ€” existing behavior. + } else { + // Any other label= entry (user:, role:, type:, level:) is a + // SELinux context override. + if p.denySelinuxLabelOverride { + return fmt.Sprintf("container create denied: selinux label override %q is not allowed (set deny_selinux_label_override: false to permit)", value) + } + } + case "systempaths": + sysValue := strings.ToLower(strings.TrimSpace(value)) + if sysValue == "unconfined" && p.denyUnconfinedSystemPaths { + return "container create denied: systempaths=unconfined is not allowed" + } } } @@ -957,6 +1002,26 @@ func (p containerCreatePolicy) denySecurityOptReason(hostConfig containerCreateH return "" } +// denySystemPathsReason rejects requests that set MaskedPaths or ReadonlyPaths +// to an explicit empty slice. The Docker CLI translates +// --security-opt systempaths=unconfined into HostConfig.MaskedPaths=[] and +// HostConfig.ReadonlyPaths=[] client-side (using the =unconfined form only). +// Direct API clients can achieve the same effect without the SecurityOpt string. +// A non-nil empty slice means the default masked/readonly path sets are being +// deliberately cleared; nil means the field was absent and daemon defaults apply. +func (p containerCreatePolicy) denySystemPathsReason(hostConfig containerCreateHostConfig) string { + if !p.denyUnconfinedSystemPaths { + return "" + } + if hostConfig.MaskedPaths != nil && len(*hostConfig.MaskedPaths) == 0 { + return "container create denied: clearing MaskedPaths (systempaths=unconfined equivalent) is not allowed" + } + if hostConfig.ReadonlyPaths != nil && len(*hostConfig.ReadonlyPaths) == 0 { + return "container create denied: clearing ReadonlyPaths (systempaths=unconfined equivalent) is not allowed" + } + return "" +} + func (p containerCreatePolicy) denyRequiredLabelsReason(req containerCreateRequest) string { for _, key := range p.requiredLabels { value, ok := req.Labels[key] diff --git a/app/internal/filter/container_create_hardening_test.go b/app/internal/filter/container_create_hardening_test.go index 97cf3662..cf131c82 100644 --- a/app/internal/filter/container_create_hardening_test.go +++ b/app/internal/filter/container_create_hardening_test.go @@ -573,3 +573,134 @@ func TestNewContainerCreatePolicyDeduplicatesCapabilityList(t *testing.T) { } } } + +func TestContainerCreatePolicyEnforcesSelinuxAndSystemPathsPolicy(t *testing.T) { + tests := []struct { + name string + opts ContainerCreateOptions + body string + wantReason string + }{ + // โ”€โ”€ label=disable โ”€โ”€ + { + name: "label=disable denied when configured", + opts: ContainerCreateOptions{DenySelinuxDisable: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=disable"]}}`, + wantReason: "container create denied: label=disable (SELinux disable) is not allowed", + }, + { + name: "label=disable allowed by default (backward compat)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=disable"]}}`, + }, + { + name: "legacy colon form label:disable denied when configured", + opts: ContainerCreateOptions{DenySelinuxDisable: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label:disable"]}}`, + wantReason: "container create denied: label=disable (SELinux disable) is not allowed", + }, + { + name: "label=disable case-insensitive match", + opts: ContainerCreateOptions{DenySelinuxDisable: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=DISABLE"]}}`, + wantReason: "container create denied: label=disable (SELinux disable) is not allowed", + }, + // โ”€โ”€ label=user:/role:/type:/level: overrides โ”€โ”€ + { + name: "label=user allowed when DenySelinuxLabelOverride false (default)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=user:system_u"]}}`, + }, + { + name: "label=user denied when DenySelinuxLabelOverride true", + opts: ContainerCreateOptions{DenySelinuxLabelOverride: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=user:system_u"]}}`, + wantReason: `container create denied: selinux label override "user:system_u" is not allowed (set deny_selinux_label_override: false to permit)`, + }, + { + name: "label=type denied when DenySelinuxLabelOverride true", + opts: ContainerCreateOptions{DenySelinuxLabelOverride: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=type:svirt_lxc_net_t:s0"]}}`, + wantReason: `container create denied: selinux label override "type:svirt_lxc_net_t:s0" is not allowed (set deny_selinux_label_override: false to permit)`, + }, + { + name: "label=level allowed when DenySelinuxLabelOverride false (default)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=level:s0:c100,c200"]}}`, + }, + { + // All-defaults: zero-value opts must not deny label= overrides. + // Proves zero behavior change for existing deployments. + name: "all-defaults policy passes label=user override (zero behavior change)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["label=user:system_u"]}}`, + }, + // โ”€โ”€ systempaths=unconfined SecurityOpt โ”€โ”€ + { + name: "systempaths=unconfined in SecurityOpt denied when configured", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["systempaths=unconfined"]}}`, + wantReason: "container create denied: systempaths=unconfined is not allowed", + }, + { + name: "systempaths=unconfined allowed by default (backward compat)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["systempaths=unconfined"]}}`, + }, + { + name: "systempaths=unknown value passes through (not unconfined)", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["systempaths=other"]}}`, + }, + { + // Note: the Docker CLI only translates --security-opt systempaths=unconfined + // (the = form) to MaskedPaths=[]. The colon form is caught here via + // SecurityOpt parsing but does NOT trigger the MaskedPaths/ReadonlyPaths path. + name: "colon form systempaths:unconfined also denied when configured", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"SecurityOpt":["systempaths:unconfined"]}}`, + wantReason: "container create denied: systempaths=unconfined is not allowed", + }, + // โ”€โ”€ direct-API MaskedPaths/ReadonlyPaths bypass โ”€โ”€ + { + name: "empty MaskedPaths denied when DenyUnconfinedSystemPaths configured", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"MaskedPaths":[]}}`, + wantReason: "container create denied: clearing MaskedPaths (systempaths=unconfined equivalent) is not allowed", + }, + { + name: "empty MaskedPaths allowed by default (backward compat)", + opts: ContainerCreateOptions{AllowAllCapabilities: true}, + body: `{"HostConfig":{"MaskedPaths":[]}}`, + }, + { + name: "empty ReadonlyPaths denied when DenyUnconfinedSystemPaths configured", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"ReadonlyPaths":[]}}`, + wantReason: "container create denied: clearing ReadonlyPaths (systempaths=unconfined equivalent) is not allowed", + }, + { + name: "absent MaskedPaths (null/omitted) allowed even when DenyUnconfinedSystemPaths", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{}}`, + }, + { + name: "non-empty MaskedPaths allowed (customization, not full removal)", + opts: ContainerCreateOptions{DenyUnconfinedSystemPaths: true, AllowAllCapabilities: true}, + body: `{"HostConfig":{"MaskedPaths":["/proc/kcore"]}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewBufferString(tt.body)) + reason, err := newContainerCreatePolicy(tt.opts).inspect(nil, req, "/containers/create") + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if reason != tt.wantReason { + t.Fatalf("inspect() reason = %q, want %q", reason, tt.wantReason) + } + }) + } +} diff --git a/app/internal/filter/container_create_types.go b/app/internal/filter/container_create_types.go index 74b172d9..9b7be4b6 100644 --- a/app/internal/filter/container_create_types.go +++ b/app/internal/filter/container_create_types.go @@ -66,6 +66,14 @@ type containerCreateHostConfig struct { GroupAdd []string `json:"GroupAdd"` ExtraHosts []string `json:"ExtraHosts"` Runtime string `json:"Runtime"` + // MaskedPaths overrides the default set of paths that Docker masks inside + // the container. An empty slice signals systempaths=unconfined intent + // delivered via the direct API path (the Docker CLI converts + // --security-opt systempaths=unconfined to MaskedPaths=[] client-side). + // The pointer distinguishes "not set" (nil) from "explicitly set to empty" + // (non-nil, zero length). + MaskedPaths *[]string `json:"MaskedPaths"` + ReadonlyPaths *[]string `json:"ReadonlyPaths"` } type containerCreateMount struct { diff --git a/app/internal/filter/exec.go b/app/internal/filter/exec.go index 7e76e9ea..fd507304 100644 --- a/app/internal/filter/exec.go +++ b/app/internal/filter/exec.go @@ -6,12 +6,12 @@ import ( "errors" "fmt" "log/slog" - "net" "net/http" "net/url" "regexp" "strings" - "time" + + "github.com/codeswhat/sockguard/internal/upstream" ) const maxExecBodyBytes = 64 << 10 // 64 KiB @@ -282,17 +282,19 @@ func execStartIdentifier(normalizedPath string) (string, bool) { return id, true } -// NewDockerExecInspector returns an exec inspector backed by the Docker unix socket. +// NewDockerExecInspector returns an exec inspector backed by the Docker unix +// socket. It is the single-local-socket shorthand; the multi-endpoint/remote +// path uses NewDockerExecInspectorWithRoundTripper. func NewDockerExecInspector(upstreamSocket string) ExecInspectFunc { - transport := &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", upstreamSocket) - }, - // The exec-inspect call is a short JSON GET; bound the wait for upstream - // headers so an unresponsive daemon cannot pin this goroutine. - ResponseHeaderTimeout: 30 * time.Second, - } - client := &http.Client{Transport: transport} + return NewDockerExecInspectorWithRoundTripper(upstream.NewSingleSocket(upstreamSocket)) +} + +// NewDockerExecInspectorWithRoundTripper returns an exec inspector that issues +// its short JSON GET through the shared upstream RoundTripper (typically an +// *upstream.Resolver), so exec-identity inspection follows the same active +// endpoint as the exec-create/start it guards under failover. +func NewDockerExecInspectorWithRoundTripper(rt http.RoundTripper) ExecInspectFunc { + client := &http.Client{Transport: rt} return func(ctx context.Context, id string) (ExecInspectResult, bool, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/exec/"+url.PathEscape(id)+"/json", nil) diff --git a/app/internal/filter/fuzz_parsers_test.go b/app/internal/filter/fuzz_parsers_test.go index 24ed607f..8fc8be06 100644 --- a/app/internal/filter/fuzz_parsers_test.go +++ b/app/internal/filter/fuzz_parsers_test.go @@ -49,11 +49,31 @@ func FuzzContainerCreate(f *testing.F) { f.Add([]byte(`{"Image":"busybox:1.37","HostConfig":{"Binds":["/srv/sockguard/data:/data:rw","named-cache:/cache"],"Mounts":[{"Type":"bind","Source":"/srv/sockguard/config","Target":"/config"},{"Type":"volume","Source":"build-cache","Target":"/var/cache"}]}}`)) f.Add([]byte(`{"HostConfig":`)) f.Add(bytes.Repeat([]byte("a"), maxContainerCreateBodyBytes+1)) - + // SELinux and systempaths seeds โ€” tested against the strict policy below + // to exercise both the JSON decode path and the denial logic branches. + f.Add([]byte(`{"HostConfig":{"SecurityOpt":["label=disable"]}}`)) + f.Add([]byte(`{"HostConfig":{"SecurityOpt":["label:disable"]}}`)) + f.Add([]byte(`{"HostConfig":{"SecurityOpt":["label=user:system_u"]}}`)) + f.Add([]byte(`{"HostConfig":{"SecurityOpt":["systempaths=unconfined"]}}`)) + f.Add([]byte(`{"HostConfig":{"MaskedPaths":[]}}`)) + f.Add([]byte(`{"HostConfig":{"ReadonlyPaths":[]}}`)) + f.Add([]byte(`{"HostConfig":{"MaskedPaths":null}}`)) + + // permissive policy: exercises JSON decode and existing checks. policy := newContainerCreatePolicy(ContainerCreateOptions{ AllowedBindMounts: []string{"/srv/sockguard"}, }) + // strict policy: exercises the new denial branches for the SELinux and + // systempaths seeds above. + strictPolicy := newContainerCreatePolicy(ContainerCreateOptions{ + AllowedBindMounts: []string{"/srv/sockguard"}, + AllowAllCapabilities: true, + DenySelinuxDisable: true, + DenySelinuxLabelOverride: true, + DenyUnconfinedSystemPaths: true, + }) + f.Fuzz(func(t *testing.T, body []byte) { body = truncateParserFuzzBytes(body, maxContainerCreateBodyBytes+1024) @@ -64,6 +84,14 @@ func FuzzContainerCreate(f *testing.F) { _, _ = io.Copy(io.Discard, req.Body) _ = req.Body.Close() } + + req2 := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader(body)) + _, _ = strictPolicy.inspect(nil, req2, "/containers/create") + + if req2.Body != nil { + _, _ = io.Copy(io.Discard, req2.Body) + _ = req2.Body.Close() + } }) } diff --git a/app/internal/filter/service.go b/app/internal/filter/service.go index e693d236..10cf3c6c 100644 --- a/app/internal/filter/service.go +++ b/app/internal/filter/service.go @@ -37,6 +37,20 @@ type ServiceOptions struct { RequireNoNewPrivileges bool RequireReadonlyRootfs bool RequireDropAllCapabilities bool + // DenyUnconfinedSeccomp denies service create/update when + // ContainerSpec.Privileges.Seccomp.Mode == "unconfined". Default false. + // Note: does not automatically deny Mode=="custom" โ€” see DenyCustomSeccompProfiles. + DenyUnconfinedSeccomp bool + // DenyCustomSeccompProfiles denies service create/update when + // ContainerSpec.Privileges.Seccomp.Mode == "custom". Operators who use + // carefully vetted custom seccomp profiles must leave this false. + // When both DenyUnconfinedSeccomp and DenyCustomSeccompProfiles are true, + // only Mode=="default" (or an absent Seccomp block) is permitted. + DenyCustomSeccompProfiles bool + // DenyUnconfinedAppArmor denies service create/update when + // ContainerSpec.Privileges.AppArmor.Mode == "disabled". Swarm has no + // "unconfined" AppArmor mode; "disabled" is the equivalent. Default false. + DenyUnconfinedAppArmor bool // ImageTrust applies cosign verification to ContainerSpec.Image, matching // the container-create path so swarm services cannot escape image trust. ImageTrust ImageTrustOptions @@ -53,6 +67,9 @@ type servicePolicy struct { requireNoNewPrivileges bool requireReadonlyRootfs bool requireDropAllCapabilities bool + denyUnconfinedSeccomp bool + denyCustomSeccompProfiles bool + denyUnconfinedAppArmor bool imageTrust imageTrustFields } @@ -79,10 +96,28 @@ type serviceContainerSpec struct { } // serviceContainerPrivileges captures the swarm ContainerSpec.Privileges fields -// Sockguard enforces. NoNewPrivileges is a direct boolean here, unlike the -// container-create path where it is encoded as a HostConfig.SecurityOpt string. +// Sockguard enforces. NoNewPrivileges is a direct ContainerSpec.Privileges boolean rather than a +// SecurityOpt string; a nil Privileges block means the flag is unset (denied). type serviceContainerPrivileges struct { - NoNewPrivileges bool `json:"NoNewPrivileges"` + NoNewPrivileges bool `json:"NoNewPrivileges"` + Seccomp *serviceSeccompOpts `json:"Seccomp"` + AppArmor *serviceAppArmorOpts `json:"AppArmor"` +} + +// serviceSeccompOpts mirrors the subset of swarm SeccompOpts that Sockguard inspects. +// Profile []byte is intentionally omitted โ€” the proxy cannot safely decode or +// evaluate the binary blob, and the presence of Mode=="custom" is sufficient to +// enforce deny_custom_seccomp_profiles without parsing the profile content. +// Exception: a non-nil Seccomp with empty Mode and non-empty Profile is treated +// as implicit "custom" (fail-closed) when deny_custom_seccomp_profiles is true. +type serviceSeccompOpts struct { + Mode string `json:"Mode"` + Profile json.RawMessage `json:"Profile,omitempty"` +} + +// serviceAppArmorOpts mirrors the subset of swarm AppArmorOpts that Sockguard inspects. +type serviceAppArmorOpts struct { + Mode string `json:"Mode"` } type serviceMount struct { @@ -119,6 +154,9 @@ func newServicePolicy(opts ServiceOptions) servicePolicy { requireNoNewPrivileges: opts.RequireNoNewPrivileges, requireReadonlyRootfs: opts.RequireReadonlyRootfs, requireDropAllCapabilities: opts.RequireDropAllCapabilities, + denyUnconfinedSeccomp: opts.DenyUnconfinedSeccomp, + denyCustomSeccompProfiles: opts.DenyCustomSeccompProfiles, + denyUnconfinedAppArmor: opts.DenyUnconfinedAppArmor, imageTrust: buildImageTrustFields(opts.ImageTrust), } } @@ -141,6 +179,56 @@ func (p servicePolicy) denyHardeningReason(spec serviceContainerSpec) string { if p.requireDropAllCapabilities && !capDropContainsAll(spec.CapabilityDrop) { return "service denied: ContainerSpec.CapabilityDrop must include \"ALL\"" } + if denyReason := p.denySeccompModeReason(spec.Privileges); denyReason != "" { + return denyReason + } + if denyReason := p.denyAppArmorModeReason(spec.Privileges); denyReason != "" { + return denyReason + } + return "" +} + +// denySeccompModeReason enforces deny_unconfined_seccomp and +// deny_custom_seccomp_profiles against ContainerSpec.Privileges.Seccomp.Mode. +// A nil Seccomp block means no explicit mode was set (Docker uses its default) +// and is always allowed. Mode comparison is case-insensitive; moby emits +// lowercase constants but third-party clients may vary. +// Fail-closed: a non-nil Seccomp with empty Mode but non-empty Profile is +// treated as implicit "custom" when deny_custom_seccomp_profiles is true, +// because the proxy cannot determine confinement intent from a bare blob. +func (p servicePolicy) denySeccompModeReason(priv *serviceContainerPrivileges) string { + if priv == nil || priv.Seccomp == nil { + return "" + } + mode := strings.TrimSpace(priv.Seccomp.Mode) + if p.denyUnconfinedSeccomp && strings.EqualFold(mode, "unconfined") { + return "service denied: unconfined seccomp mode is not allowed (ContainerSpec.Privileges.Seccomp.Mode)" + } + if p.denyCustomSeccompProfiles { + if strings.EqualFold(mode, "custom") { + return "service denied: custom seccomp profiles are not allowed (ContainerSpec.Privileges.Seccomp.Mode)" + } + // A non-nil Seccomp with empty Mode and a non-empty Profile blob is an + // unvettable custom profile; treat as implicit "custom" (fail-closed). + if mode == "" && len(priv.Seccomp.Profile) > 0 { + return "service denied: custom seccomp profiles are not allowed (ContainerSpec.Privileges.Seccomp.Mode)" + } + } + return "" +} + +// denyAppArmorModeReason enforces deny_unconfined_apparmor against +// ContainerSpec.Privileges.AppArmor.Mode. Swarm uses "disabled" where +// container-create uses "unconfined"; both mean "no AppArmor confinement". +// A nil AppArmor block means no explicit mode was set and is always allowed. +func (p servicePolicy) denyAppArmorModeReason(priv *serviceContainerPrivileges) string { + if priv == nil || priv.AppArmor == nil { + return "" + } + mode := strings.TrimSpace(priv.AppArmor.Mode) + if p.denyUnconfinedAppArmor && strings.EqualFold(mode, "disabled") { + return "service denied: disabled apparmor mode is not allowed (ContainerSpec.Privileges.AppArmor.Mode)" + } return "" } diff --git a/app/internal/filter/service_test.go b/app/internal/filter/service_test.go index e7ace9a2..90cf905c 100644 --- a/app/internal/filter/service_test.go +++ b/app/internal/filter/service_test.go @@ -467,6 +467,272 @@ func TestServiceInspectHardeningRailsComposeOnUpdate(t *testing.T) { } } +func TestServiceInspectDenyUnconfinedSeccomp(t *testing.T) { + tests := []struct { + name string + opts ServiceOptions + body string + wantDenied bool + wantSubstr string + }{ + { + name: "unconfined seccomp denied when knob enabled", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"unconfined"}}}}}`, + wantDenied: true, + wantSubstr: "unconfined seccomp mode is not allowed", + }, + { + name: "unconfined seccomp case-insensitive (UNCONFINED)", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"UNCONFINED"}}}}}`, + wantDenied: true, + wantSubstr: "unconfined seccomp mode is not allowed", + }, + { + name: "unconfined seccomp allowed when knob disabled", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: false}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"unconfined"}}}}}`, + wantDenied: false, + }, + { + name: "default seccomp always allowed", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"default"}}}}}`, + wantDenied: false, + }, + { + name: "nil Seccomp block always allowed", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"NoNewPrivileges":true}}}}`, + wantDenied: false, + }, + { + name: "nil Privileges block always allowed", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest"}}}`, + wantDenied: false, + }, + { + name: "custom seccomp NOT denied by deny_unconfined_seccomp alone", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"custom"}}}}}`, + wantDenied: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := newServicePolicy(tc.opts) + req := httptest.NewRequest(http.MethodPost, "/services/create", strings.NewReader(tc.body)) + reason, err := policy.inspect(nil, req, NormalizePath(req.URL.Path)) + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if tc.wantDenied { + if reason == "" { + t.Fatalf("reason = empty, want denial containing %q", tc.wantSubstr) + } + if !strings.Contains(reason, tc.wantSubstr) { + t.Fatalf("reason = %q, want substring %q", reason, tc.wantSubstr) + } + } else { + if reason != "" { + t.Fatalf("reason = %q, want empty (allowed)", reason) + } + } + }) + } +} + +func TestServiceInspectDenyCustomSeccompProfiles(t *testing.T) { + tests := []struct { + name string + opts ServiceOptions + body string + wantDenied bool + wantSubstr string + }{ + { + name: "custom seccomp denied when deny_custom_seccomp_profiles enabled", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"custom"}}}}}`, + wantDenied: true, + wantSubstr: "custom seccomp profiles are not allowed", + }, + { + name: "custom seccomp case-insensitive (CUSTOM)", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"CUSTOM"}}}}}`, + wantDenied: true, + wantSubstr: "custom seccomp profiles are not allowed", + }, + { + name: "custom seccomp allowed when knob disabled", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: false}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"custom"}}}}}`, + wantDenied: false, + }, + { + name: "unconfined not affected by deny_custom_seccomp_profiles alone", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"unconfined"}}}}}`, + wantDenied: false, + }, + { + name: "both knobs together deny both unconfined and custom", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedSeccomp: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"custom"}}}}}`, + wantDenied: true, + wantSubstr: "custom seccomp profiles are not allowed", + }, + { + name: "profile without mode treated as implicit custom (fail-closed)", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Profile":"e30K"}}}}}`, + wantDenied: true, + wantSubstr: "custom seccomp profiles are not allowed", + }, + { + name: "nil seccomp with empty mode and nil profile allowed", + opts: ServiceOptions{AllowOfficial: true, DenyCustomSeccompProfiles: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{}}}}}`, + wantDenied: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := newServicePolicy(tc.opts) + req := httptest.NewRequest(http.MethodPost, "/services/create", strings.NewReader(tc.body)) + reason, err := policy.inspect(nil, req, NormalizePath(req.URL.Path)) + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if tc.wantDenied { + if reason == "" { + t.Fatalf("reason = empty, want denial containing %q", tc.wantSubstr) + } + if !strings.Contains(reason, tc.wantSubstr) { + t.Fatalf("reason = %q, want substring %q", reason, tc.wantSubstr) + } + } else { + if reason != "" { + t.Fatalf("reason = %q, want empty (allowed)", reason) + } + } + }) + } +} + +func TestServiceInspectDenyUnconfinedAppArmor(t *testing.T) { + tests := []struct { + name string + opts ServiceOptions + body string + wantDenied bool + wantSubstr string + }{ + { + name: "disabled apparmor denied when knob enabled", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"AppArmor":{"Mode":"disabled"}}}}}`, + wantDenied: true, + wantSubstr: "disabled apparmor mode is not allowed", + }, + { + name: "disabled apparmor case-insensitive (DISABLED)", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"AppArmor":{"Mode":"DISABLED"}}}}}`, + wantDenied: true, + wantSubstr: "disabled apparmor mode is not allowed", + }, + { + name: "disabled apparmor allowed when knob off", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: false}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"AppArmor":{"Mode":"disabled"}}}}}`, + wantDenied: false, + }, + { + name: "default apparmor always allowed", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"AppArmor":{"Mode":"default"}}}}}`, + wantDenied: false, + }, + { + name: "nil AppArmor block always allowed", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"NoNewPrivileges":true}}}}`, + wantDenied: false, + }, + { + name: "nil Privileges block always allowed with apparmor knob enabled", + opts: ServiceOptions{AllowOfficial: true, DenyUnconfinedAppArmor: true}, + body: `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest"}}}`, + wantDenied: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := newServicePolicy(tc.opts) + req := httptest.NewRequest(http.MethodPost, "/services/create", strings.NewReader(tc.body)) + reason, err := policy.inspect(nil, req, NormalizePath(req.URL.Path)) + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if tc.wantDenied { + if reason == "" { + t.Fatalf("reason = empty, want denial containing %q", tc.wantSubstr) + } + if !strings.Contains(reason, tc.wantSubstr) { + t.Fatalf("reason = %q, want substring %q", reason, tc.wantSubstr) + } + } else { + if reason != "" { + t.Fatalf("reason = %q, want empty (allowed)", reason) + } + } + }) + } +} + +func TestServiceInspectSeccompAndAppArmorRailsCompose(t *testing.T) { + // All new rails satisfied together must allow. + policy := newServicePolicy(ServiceOptions{ + AllowOfficial: true, + DenyUnconfinedSeccomp: true, + DenyCustomSeccompProfiles: true, + DenyUnconfinedAppArmor: true, + }) + body := `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"default"},"AppArmor":{"Mode":"default"}}}}}` + req := httptest.NewRequest(http.MethodPost, "/services/create", strings.NewReader(body)) + + reason, err := policy.inspect(nil, req, NormalizePath(req.URL.Path)) + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if reason != "" { + t.Fatalf("reason = %q, want allow (all confinement rails satisfied)", reason) + } +} + +func TestServiceInspectSeccompDenialOnUpdatePath(t *testing.T) { + // Seccomp/AppArmor denial must also fire on the /services/{id}/update path, + // confirming isServiceWritePath routes update requests to policy inspection. + policy := newServicePolicy(ServiceOptions{ + AllowOfficial: true, + DenyUnconfinedSeccomp: true, + }) + body := `{"TaskTemplate":{"ContainerSpec":{"Image":"nginx:latest","Privileges":{"Seccomp":{"Mode":"unconfined"}}}}}` + req := httptest.NewRequest(http.MethodPost, "/v1.53/services/web/update?version=7", strings.NewReader(body)) + + reason, err := policy.inspect(nil, req, NormalizePath(req.URL.Path)) + if err != nil { + t.Fatalf("inspect() error = %v", err) + } + if !strings.Contains(reason, "unconfined seccomp mode is not allowed") { + t.Fatalf("reason = %q, want unconfined seccomp denial on update path", reason) + } +} + func TestServiceInspectImageTrustDeniesUnverified(t *testing.T) { // A swarm service whose ContainerSpec.Image fails cosign verification is // denied in enforce mode โ€” services must not bypass image trust. diff --git a/app/internal/health/health.go b/app/internal/health/health.go index fef9a3e3..ddfb03cb 100644 --- a/app/internal/health/health.go +++ b/app/internal/health/health.go @@ -13,6 +13,7 @@ import ( "github.com/codeswhat/sockguard/internal/dockerclient" "github.com/codeswhat/sockguard/internal/httpjson" + "github.com/codeswhat/sockguard/internal/upstream" "github.com/codeswhat/sockguard/internal/version" ) @@ -102,6 +103,19 @@ func NewMonitor(upstreamSocket string, startTime time.Time, logger *slog.Logger) ) } +// NewMonitorWithDialer constructs a liveness monitor that dials the upstream +// through dialer (typically an *upstream.Resolver), so /health reflects whether +// the proxy can currently reach an upstream โ€” the active failover endpoint, not +// a fixed socket. label is used only for log/metric identification. +func NewMonitorWithDialer(label string, dialer upstream.Dialer, startTime time.Time, logger *slog.Logger) *Monitor { + return newMonitorWithChecker( + label, + startTime, + logger, + newUpstreamHealthChecker(healthCacheTTL, healthDialTimeout, time.Now, dialer.DialContext), + ) +} + func newMonitorWithChecker(upstreamSocket string, startTime time.Time, logger *slog.Logger, checker *upstreamHealthChecker) *Monitor { if logger == nil { logger = slog.Default() @@ -149,6 +163,10 @@ func (c *upstreamHealthChecker) check(ctx context.Context, upstreamSocket string if c.probe != nil { status, err = c.probe(dialCtx) } else { + // For the legacy net.Dialer these (network, address) args select the unix + // socket. For the resolver-backed dialer (NewMonitorWithDialer) they are + // ignored โ€” the active failover endpoint is chosen by health โ€” and + // upstreamSocket here is just the log/metric label. var conn net.Conn conn, err = c.dial(dialCtx, "unix", upstreamSocket) status = "connected" @@ -347,6 +365,21 @@ func NewReadinessMonitor(upstreamSocket string, startTime time.Time, logger *slo return newMonitorWithChecker(upstreamSocket, startTime, logger, checker) } +// NewReadinessMonitorWithRoundTripper is NewReadinessMonitor over the shared +// upstream RoundTripper (typically an *upstream.Resolver): the GET +// /containers/json probe runs against the active failover endpoint. label is +// used only for log/metric identification. +func NewReadinessMonitorWithRoundTripper(label string, rt http.RoundTripper, startTime time.Time, logger *slog.Logger, timeout time.Duration) *Monitor { + if timeout <= 0 { + timeout = healthDialTimeout + } + client := dockerclient.NewWithRoundTripper(rt) + checker := newReadinessChecker(timeout, time.Now, func(ctx context.Context) (string, error) { + return probeUpstreamAPI(ctx, client) + }) + return newMonitorWithChecker(label, startTime, logger, checker) +} + // probeUpstreamAPI issues a minimal GET /containers/json?limit=1 against the // upstream Docker API. Any transport error or non-2xx status is reported as // unready. The host in the URL is arbitrary โ€” the client dials the unix socket. diff --git a/app/internal/metrics/metrics.go b/app/internal/metrics/metrics.go index dcfa209c..146b3231 100644 --- a/app/internal/metrics/metrics.go +++ b/app/internal/metrics/metrics.go @@ -278,6 +278,22 @@ func (r *Registry) SetInflight(profile string, count int64) { val.(*atomic.Int64).Store(count) } +// DeleteInflightProfile removes the in-flight gauge series for profile from +// the Prometheus exposition. Called during hot reload when a profile that had +// a concurrency cap is absent from the new config, so the stale series does +// not persist after the swap. +// +// A completing request from the old chain that calls SetInflight(profile, n) +// after deletion will re-create the entry at n (the tracker's clamped +// non-negative count). That re-created entry drains to 0 as the last +// in-flight request completes; the next reload cycle will delete it again. +func (r *Registry) DeleteInflightProfile(profile string) { + if r == nil { + return + } + r.inflight.Delete(profile) +} + // Middleware records one metrics observation for each completed HTTP request. func (r *Registry) Middleware() func(http.Handler) http.Handler { if r == nil { diff --git a/app/internal/metrics/metrics_test.go b/app/internal/metrics/metrics_test.go index 1b6f0a63..4a1f63c3 100644 --- a/app/internal/metrics/metrics_test.go +++ b/app/internal/metrics/metrics_test.go @@ -534,6 +534,112 @@ func assertContains(t *testing.T, got, want string) { } } +// TestDeleteInflightProfileRemovesSeriesFromScrape verifies that +// DeleteInflightProfile removes a profile's series from exposition. +func TestDeleteInflightProfileRemovesSeriesFromScrape(t *testing.T) { + t.Parallel() + r := NewRegistry() + r.SetInflight("ci", 3) + + out := renderMetrics(t, r) + assertContains(t, out, `sockguard_inflight_requests{profile="ci"} 3`) + + r.DeleteInflightProfile("ci") + out = renderMetrics(t, r) + if strings.Contains(out, `sockguard_inflight_requests{profile="ci"}`) { + t.Fatalf("profile ci series still present after DeleteInflightProfile: %s", out) + } +} + +// TestDeleteInflightProfileThenSetInflightReCreatesAtZero verifies the +// race-safety property: a completing old-chain request that calls SetInflight +// after deletion re-creates the entry at the tracker's clamped count (0 for a +// drained request), not at a negative value. +func TestDeleteInflightProfileThenSetInflightReCreatesAtZero(t *testing.T) { + t.Parallel() + r := NewRegistry() + r.SetInflight("ci", 1) + + r.DeleteInflightProfile("ci") + + // Simulate old-chain profileReleaser.done(): tracker.Current() returns 0 + // after Release() because the last request finished. + r.SetInflight("ci", 0) + + out := renderMetrics(t, r) + // Re-created at 0 is correct and visible; the next reload will delete it. + assertContains(t, out, `sockguard_inflight_requests{profile="ci"} 0`) + + // Verify no negative values appear. + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, `sockguard_inflight_requests{profile="ci"}`) { + if strings.HasSuffix(strings.TrimSpace(line), "-1") { + t.Fatalf("inflight gauge went negative: %s", line) + } + } + } +} + +// TestNilRegistryDeleteInflightIsNoop verifies nil-safety. +func TestNilRegistryDeleteInflightIsNoop(t *testing.T) { + t.Parallel() + var r *Registry + r.DeleteInflightProfile("ci") // must not panic +} + +// TestDeleteInflightProfileConcurrentSetInflightIsRaceSafe hammers concurrent +// SetInflight and DeleteInflightProfile under the race detector to verify no +// data races between the sync.Map Delete and concurrent LoadOrStore/Store pairs. +func TestDeleteInflightProfileConcurrentSetInflightIsRaceSafe(t *testing.T) { + t.Parallel() + r := NewRegistry() + r.SetInflight("ci", 5) + + const goroutines = 8 + const iters = 200 + var wg sync.WaitGroup + wg.Add(goroutines + 1) + + // Concurrent SetInflight callers (simulating old-chain completions). + for i := 0; i < goroutines; i++ { + go func(n int) { + defer wg.Done() + for j := 0; j < iters; j++ { + r.SetInflight("ci", int64(n%3)) + } + }(i) + } + + // Concurrent DeleteInflightProfile caller (simulating reload). + go func() { + defer wg.Done() + for j := 0; j < iters; j++ { + r.DeleteInflightProfile("ci") + } + }() + + wg.Wait() + // After all goroutines finish, the gauge is either present at some + // non-negative value or absent. Neither is a correctness failure. + out := renderMetrics(t, r) + for _, line := range strings.Split(out, "\n") { + if !strings.HasPrefix(line, `sockguard_inflight_requests{profile="ci"}`) { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + val, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + t.Fatalf("non-numeric inflight value: %s", line) + } + if val < 0 { + t.Fatalf("inflight gauge is negative: %s", line) + } + } +} + // TestRegistryConcurrentObserveAndScrape exercises the v0.8.1 lock-free // observation path: many goroutines hammer observe / ObserveThrottle / // ObserveConfigReload / ObserveUpstreamWatchdog while another goroutine diff --git a/app/internal/ownership/middleware.go b/app/internal/ownership/middleware.go index be56f307..d44a2518 100644 --- a/app/internal/ownership/middleware.go +++ b/app/internal/ownership/middleware.go @@ -67,10 +67,23 @@ type upstreamInspector struct { } // Middleware applies owner-label mutation and enforcement for a single proxy -// identity. When Owner is empty, it is a no-op. +// identity. When Owner is empty, it is a no-op. It is the single-local-socket +// shorthand; MiddlewareWithRoundTripper takes the shared upstream transport so +// owner-label inspects follow the same active endpoint as the proxied request. func Middleware(upstreamSocket string, logger *slog.Logger, opts Options) func(http.Handler) http.Handler { + return middlewareWithClient(dockerclient.New(upstreamSocket), logger, opts) +} + +// MiddlewareWithRoundTripper is Middleware over the shared upstream RoundTripper +// (typically an *upstream.Resolver), keeping owner-label inspection coherent +// with the request path under failover. +func MiddlewareWithRoundTripper(rt http.RoundTripper, logger *slog.Logger, opts Options) func(http.Handler) http.Handler { + return middlewareWithClient(dockerclient.NewWithRoundTripper(rt), logger, opts) +} + +func middlewareWithClient(client *http.Client, logger *slog.Logger, opts Options) func(http.Handler) http.Handler { inspector := upstreamInspector{ - client: dockerclient.New(upstreamSocket), + client: client, } cache := inspectcache.New( inspectcache.DefaultTTL, diff --git a/app/internal/proxy/hijack.go b/app/internal/proxy/hijack.go index 7d79cd5a..2968384c 100644 --- a/app/internal/proxy/hijack.go +++ b/app/internal/proxy/hijack.go @@ -2,6 +2,7 @@ package proxy import ( "bufio" + "context" "fmt" "io" "log/slog" @@ -16,6 +17,7 @@ import ( "github.com/codeswhat/sockguard/internal/filter" "github.com/codeswhat/sockguard/internal/httpjson" "github.com/codeswhat/sockguard/internal/logging" + "github.com/codeswhat/sockguard/internal/upstream" ) // hijackBufSize is the buffer size for bidirectional copy on hijacked connections. @@ -112,6 +114,19 @@ func HijackHandler(upstreamSocket string, logger *slog.Logger, next http.Handler }) } +// HijackHandlerWithDialer is HijackHandler over an upstream.Dialer (typically an +// *upstream.Resolver), so the hijack path dials the same active endpoint โ€” local +// socket or remote TCP+TLS โ€” and fails over together with the rest of the proxy. +func HijackHandlerWithDialer(dialer upstream.Dialer, logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isHijackRequest(w, r) { + next.ServeHTTP(w, r) + return + } + handleHijackDialer(w, r, dialer, logger) + }) +} + // isHijackEndpoint returns true if the request targets a Docker API endpoint // that upgrades to a raw TCP stream via 101 Switching Protocols. // @@ -183,6 +198,15 @@ func handleHijack(w http.ResponseWriter, r *http.Request, upstreamSocket string, proxyHijackStreams(session, logger) } +func handleHijackDialer(w http.ResponseWriter, r *http.Request, dialer upstream.Dialer, logger *slog.Logger) { + session, ok := upgradeHijackConnectionDialer(w, r, dialer, logger) + if !ok { + return + } + + proxyHijackStreams(session, logger) +} + func upgradeHijackConnection(w http.ResponseWriter, r *http.Request, upstreamSocket string, logger *slog.Logger) (*hijackSession, bool) { reqPath := r.URL.Path @@ -194,6 +218,33 @@ func upgradeHijackConnection(w http.ResponseWriter, r *http.Request, upstreamSoc return nil, false } + return finishHijackUpgrade(w, r, upstreamConn, logger) +} + +// upgradeHijackConnectionDialer is upgradeHijackConnection over an +// upstream.Dialer: it dials the active endpoint (local or remote TCP+TLS) with +// the same bounded dial timeout, then shares the post-dial upgrade logic. +func upgradeHijackConnectionDialer(w http.ResponseWriter, r *http.Request, dialer upstream.Dialer, logger *slog.Logger) (*hijackSession, bool) { + reqPath := r.URL.Path + + ctx, cancel := context.WithTimeout(context.Background(), hijackDialTimeout) + defer cancel() + upstreamConn, err := dialer.DialContext(ctx, "", "") + if err != nil { + logger.Error("hijack: upstream dial failed", "error", err, "path", reqPath) + writeHijackBadGateway(w, logger, reqPath, "upstream Docker socket unreachable") + return nil, false + } + + return finishHijackUpgrade(w, r, upstreamConn, logger) +} + +// finishHijackUpgrade performs the request write, response read, and 101-upgrade +// finalization shared by the socket and dialer hijack paths once the upstream +// connection is established. +func finishHijackUpgrade(w http.ResponseWriter, r *http.Request, upstreamConn net.Conn, logger *slog.Logger) (*hijackSession, bool) { + reqPath := r.URL.Path + if !writeHijackUpstreamRequest(upstreamConn, w, r, logger) { return nil, false } diff --git a/app/internal/proxy/hijack_test.go b/app/internal/proxy/hijack_test.go index 7457aea6..97a395ae 100644 --- a/app/internal/proxy/hijack_test.go +++ b/app/internal/proxy/hijack_test.go @@ -24,6 +24,7 @@ import ( "github.com/codeswhat/sockguard/internal/httpjson" "github.com/codeswhat/sockguard/internal/logging" "github.com/codeswhat/sockguard/internal/testhelp" + "github.com/codeswhat/sockguard/internal/upstream" ) const wantHijackInactivityTimeout = 10 * time.Minute @@ -2808,22 +2809,19 @@ func TestHijackConstantsArePinned(t *testing.T) { } } -// TestNewProxyTransportTunings pins the IdleConnTimeout on the upstream -// transport and the FlushInterval=-1 on the ReverseProxy. Both are required -// for correct streaming behavior: a non-streaming FlushInterval would buffer -// docker events/logs/attach, and a shortened idle timeout would prematurely -// recycle pooled connections. +// TestNewProxyTransportTunings pins the FlushInterval=-1 on the ReverseProxy +// and that the proxy routes through the shared upstream resolver. FlushInterval +// is required for correct streaming behavior: a non-streaming value would buffer +// docker events/logs/attach. The connection-pool tunings (IdleConnTimeout, etc.) +// now live on the resolver's per-endpoint transport and are pinned in +// internal/upstream's TestEndpoint_NewTransport_PoolTunings. func TestNewProxyTransportTunings(t *testing.T) { rp := NewWithOptions("/tmp/does-not-matter.sock", slog.New(slog.NewTextHandler(io.Discard, nil)), Options{}) if got, want := rp.FlushInterval, time.Duration(-1); got != want { t.Errorf("FlushInterval = %v, want %v (immediate flush for streaming)", got, want) } - tr, ok := rp.Transport.(*http.Transport) - if !ok { - t.Fatalf("Transport type = %T, want *http.Transport", rp.Transport) - } - if got, want := tr.IdleConnTimeout, 90*time.Second; got != want { - t.Errorf("IdleConnTimeout = %v, want %v", got, want) + if _, ok := rp.Transport.(*upstream.Resolver); !ok { + t.Fatalf("Transport type = %T, want *upstream.Resolver", rp.Transport) } } diff --git a/app/internal/proxy/proxy.go b/app/internal/proxy/proxy.go index 07c460a0..bc47dabb 100644 --- a/app/internal/proxy/proxy.go +++ b/app/internal/proxy/proxy.go @@ -4,14 +4,13 @@ import ( "context" "errors" "log/slog" - "net" "net/http" "net/http/httputil" - "time" "github.com/codeswhat/sockguard/internal/httpjson" "github.com/codeswhat/sockguard/internal/logging" "github.com/codeswhat/sockguard/internal/responsefilter" + "github.com/codeswhat/sockguard/internal/upstream" ) const ( @@ -26,30 +25,28 @@ type Options struct { } // NewWithOptions creates a reverse proxy that forwards requests to the upstream -// Docker socket and optionally enforces response-side policy. +// Docker socket and optionally enforces response-side policy. It is the +// single-local-socket shorthand: callers with a plain socket path get a +// one-endpoint resolver. The multi-endpoint/remote path uses NewWithTransport. func NewWithOptions(upstreamSocket string, logger *slog.Logger, opts Options) *httputil.ReverseProxy { - transport := &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", upstreamSocket) - }, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - // Bound the wait for upstream response headers so a Docker daemon that - // accepts the connection but never replies cannot pin a goroutine until - // context cancellation. Streaming endpoints (logs follow, events, stats) - // send headers promptly and then stream the body, so this does not cap - // long-lived responses; hijacked attach/exec-start connections bypass - // this pooled transport entirely. - ResponseHeaderTimeout: 30 * time.Second, - } + return NewWithTransport(upstream.NewSingleSocket(upstreamSocket), logger, opts) +} +// NewWithTransport creates a reverse proxy that forwards requests through rt โ€” +// typically an *upstream.Resolver, which owns endpoint selection, per-endpoint +// connection pooling (MaxIdleConns 100, IdleConnTimeout 90s, ResponseHeader +// timeout 30s, matching the historical single-socket transport), client TLS, +// and automatic failover. Streaming endpoints (logs follow, events, stats) send +// headers promptly and stream the body, so the header timeout does not cap +// long-lived responses; hijacked attach/exec-start connections bypass this +// pooled transport entirely. +func NewWithTransport(rt http.RoundTripper, logger *slog.Logger, opts Options) *httputil.ReverseProxy { return &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL.Scheme = "http" pr.Out.URL.Host = "docker" }, - Transport: transport, + Transport: rt, ModifyResponse: opts.ModifyResponse, FlushInterval: -1, // immediate flush for streaming endpoints ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { diff --git a/app/internal/ratelimit/bench_test.go b/app/internal/ratelimit/bench_test.go index 6b952394..6d073ad5 100644 --- a/app/internal/ratelimit/bench_test.go +++ b/app/internal/ratelimit/bench_test.go @@ -28,7 +28,10 @@ import ( const parallelClientCount = 1000 func BenchmarkLimiterAllowNParallel(b *testing.B) { - l := newLimiterWithClock(1e9, 1e9, time.Now) // effectively unlimited tokens + // 65535 is the packed-design ceiling (MaxPackedBurst): values above it + // truncate in the 16.16 fixed-point encoding and corrupt token counts, so + // "effectively unlimited" sentinels like 1e9 are no longer valid here. + l := newLimiterWithClock(65535, 65535, time.Now) defer l.Stop() // Pre-warm all buckets so we're not measuring cold-path allocation. @@ -57,7 +60,11 @@ func BenchmarkLimiterAllowNParallel(b *testing.B) { // --------------------------------------------------------------------------- func BenchmarkLimiterAllowNHot(b *testing.B) { - l := newLimiterWithClock(1e9, 1e9, time.Now) // effectively unlimited tokens + // 65535 = MaxPackedBurst; larger sentinels truncate in the packed encoding. + // At ~tens of ns per call the bucket drains quickly, so most iterations + // measure the deny branch โ€” which is also the allocation-free path this + // benchmark guards. + l := newLimiterWithClock(65535, 65535, time.Now) defer l.Stop() // Warm the bucket. @@ -93,7 +100,7 @@ func TestLimiterStop_MidFlightRace(t *testing.T) { // Snapshot goroutine count before we start so we can measure the delta. goroutinesBefore := runtime.NumGoroutine() - l := newLimiterWithClock(1e6, 1e6, time.Now) + l := newLimiterWithClock(65535, 65535, time.Now) // Spawn workers that hammer AllowN with rotating client IDs. var ( @@ -144,3 +151,44 @@ func TestLimiterStop_MidFlightRace(t *testing.T) { goroutinesBefore, current, current-goroutinesBefore) } } + +// --------------------------------------------------------------------------- +// Packed-path micro-benchmarks +// +// These benchmarks target the packed atomic.Uint64 token bucket path. +// Run with -benchmem to verify allocs/op = 0. +// +// go test -bench=BenchmarkBucket_AllowNPacked -benchmem ./internal/ratelimit/ +// +// --------------------------------------------------------------------------- + +// BenchmarkBucket_AllowNPacked hammers the packed bucket with a real clock. +// At ~tens of ns per call the initial 65535 tokens drain within the first +// ~65k iterations and refills add only ~65 tokens per elapsed millisecond, so +// the overwhelming majority of iterations (>99%) measure the DENY branch; +// elapsedMS > 0 is observed on well under 1% of calls. That is acceptable for +// this benchmark's purpose โ€” proving both branches of the packed design are +// allocation-free โ€” but the ns/op figure is dominated by denials, not admits. +func BenchmarkBucket_AllowNPacked(b *testing.B) { + bkt := newBucket(65535, 65535, time.Now) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + bkt.AllowN(1) //nolint:errcheck + } +} + +// BenchmarkBucket_AllowNPackedParallel exercises the CAS retry loop under +// concurrent access. As with the serial variant, the bucket spends most of +// the run drained, so this predominantly measures contended CAS on the deny +// branch. Verify allocs/op = 0 with -benchmem. +func BenchmarkBucket_AllowNPackedParallel(b *testing.B) { + bkt := newBucket(65535, 65535, time.Now) + b.ResetTimer() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + bkt.AllowN(1) //nolint:errcheck + } + }) +} diff --git a/app/internal/ratelimit/ratelimit.go b/app/internal/ratelimit/ratelimit.go index 6923c060..d33e8a26 100644 --- a/app/internal/ratelimit/ratelimit.go +++ b/app/internal/ratelimit/ratelimit.go @@ -9,28 +9,11 @@ package ratelimit import ( - "math" "sync" "sync/atomic" "time" ) -// bucketState is the immutable snapshot that the CAS loop swaps atomically. -// Both fields are packed into a single heap allocation that is replaced on -// every successful AllowN transition; the old allocation is reclaimed by GC. -// -// Why not a sync.Pool? Returning the OLD pointer to a pool after a CAS -// succeeds is unsafe: a concurrent reader that already loaded that pointer -// may still be reading its fields when a fresh consumer pulls it out of -// the pool and overwrites them, producing a torn read. Pooling only the -// failed-CAS `next` candidates is sound but saves nothing on the hot path -// because steady-state CAS succeeds on the first try. The 16-byte -// per-admit allocation is left to the GC, which handles it efficiently. -type bucketState struct { - tokens float64 - lastRefillNs int64 // Unix nanoseconds -} - // AnonymousClientID is the bucket key used for requests with no resolved // profile. This ensures anonymous callers cannot bypass limits by skipping // identification. @@ -58,32 +41,97 @@ const ( limiterEvictTTL = 10 * time.Minute ) +// Packed-state constants for the atomic.Uint64 token bucket. +// Encoding: +// +// bits [63:32] uint32 millisecond timestamp mod 2^32 (wraps every ~49.7 days) +// bits [31:0] uint32 16.16 fixed-point token count +// bits [31:16] uint16 integer part (0..65535 tokens) +// bits [15:0] uint16 fractional part (0..65535/65536) +// +// Overflow safety: the worst-case intermediate in refill is +// int64(elapsedMS) * int64(tpsFP). +// With elapsedMS <= int32 max (~24.8 days) and tpsFP <= 65535*65536 = 4,294,901,760, +// the product is at most ~9.2e18, which fits in int64 (max ~9.2e18). +// +// Timestamp wraparound: the uint32 ms counter wraps at ~49.7 days. The signed +// elapsed computation is uint32(nowMS) - uint32(lastMS) reinterpreted as int32. +// Modular subtraction gives the correct signed result as long as the real elapsed +// time is within (-24.8 days, +24.8 days). The eviction TTL is 10 minutes, so +// no bucket lives long enough to encounter the ~24.8-day half-window. Worst case +// (a bucket somehow surviving past 24.8 days idle): one refill cycle is skipped; +// the bucket recovers on the next call. This is benign. +const ( + packedFracBits = 16 + packedFracScale = uint64(1 << packedFracBits) // 65536 + + // MaxPackedBurst is the maximum configurable burst (and tokens_per_second). + // The packed token field is 32 bits of 16.16 fixed-point, so the integer + // part overflows at 65536. The config validator enforces this at startup; + // AllowN does not re-check at runtime. + MaxPackedBurst = float64((1 << 16) - 1) // 65535 +) + +// packState assembles a packed state word from a fixed-point token count and a +// millisecond timestamp. +func packState(tokenFP uint32, ms uint32) uint64 { + return uint64(ms)<<32 | uint64(tokenFP) +} + +// unpackTokenFP extracts the 16.16 fixed-point token count from a packed word. +func unpackTokenFP(w uint64) uint32 { return uint32(w) } //nolint:gosec // G115: intentional truncation to lower 32 bits (token field) + +// unpackMS extracts the millisecond timestamp from a packed word. +func unpackMS(w uint64) uint32 { return uint32(w >> 32) } + // nowFn is the time source used by token buckets. It can be replaced in tests // via newBucketWithClock. type nowFn func() time.Time // bucket is a single per-client token bucket. It is safe for concurrent use. // -// Token state (current count + last-refill timestamp) is held in a -// *bucketState that is swapped via atomic.Pointer CAS so AllowN is -// lock-free on the hot path. lastAccessNs is a separate atomic so eviction -// reads never contend with AllowN writes. +// Token state (current count + last-refill timestamp) is packed into a single +// atomic.Uint64: +// +// bits [63:32] โ€” millisecond timestamp mod 2^32 (wraps every 49.7 days) +// bits [31:0] โ€” 16.16 fixed-point token count (integer in [31:16], fractional in [15:0]) +// +// Packing eliminates the per-admitted-request heap allocation that the former +// atomic.Pointer[bucketState] design incurred. AllowN remains lock-free. +// +// Refill granularity: timestamps are millisecond-precision. Sub-millisecond +// calls see elapsedMS=0 and skip refill; the stored timestamp is not advanced +// until at least 1ms has elapsed since the last refill. This is a behavioral +// change from the nanosecond-precision predecessor: sub-ms traffic patterns +// at very high rates accumulate tokens only at 1ms resolution rather than +// continuously. For all rates the config accepts (max 65535 t/s = 1 token per +// ~15ยตs), this is negligible. +// +// lastAccessNs is a separate atomic so eviction reads never contend with AllowN writes. type bucket struct { - state atomic.Pointer[bucketState] - lastAccessNs atomic.Int64 // Unix nanoseconds; updated on every AllowN - tokensPerSecond float64 - burst float64 - now nowFn + state atomic.Uint64 + lastAccessNs atomic.Int64 // Unix nanoseconds; updated on every AllowN + tpsFP uint64 // tokensPerSecond * packedFracScale, pre-computed + burstFP uint64 // burst * packedFracScale, pre-computed + now nowFn } func newBucket(tokensPerSecond, burst float64, now nowFn) *bucket { t := now() b := &bucket{ - tokensPerSecond: tokensPerSecond, - burst: burst, - now: now, + tpsFP: uint64(tokensPerSecond * float64(packedFracScale)), + burstFP: uint64(burst * float64(packedFracScale)), + now: now, + } + // Guard: tpsFP must be at least 1 to avoid division by zero in retryAfter. + // This handles rates below 1/65536 t/s (~1 token/18h); the effective minimum + // is quantized to 1/65536 t/s. + if b.tpsFP == 0 { + b.tpsFP = 1 } - b.state.Store(&bucketState{tokens: burst, lastRefillNs: t.UnixNano()}) + initialFP := uint32(burst * float64(packedFracScale)) + ms := uint32(t.UnixMilli()) //nolint:gosec // G115: intentional mod-2^32 truncation of ms timestamp (wraps every ~49.7 days) + b.state.Store(packState(initialFP, ms)) b.lastAccessNs.Store(t.UnixNano()) return b } @@ -102,48 +150,75 @@ func (b *bucket) Allow() (ok bool, retryAfter int) { // internal/config rejects that configuration at startup. AllowN does not // re-check it here โ€” defensive logic in a hot path would just hide config bugs. // -// Implementation: lock-free CAS loop. Each iteration reads the current -// *bucketState, computes the next state, and attempts a CAS swap. On CAS -// failure (another goroutine raced us) the loop retries with the fresh value. +// Implementation: lock-free CAS loop. Each iteration reads the current packed +// state, computes the next state, and attempts a CAS swap. On CAS failure +// (another goroutine raced us) the loop retries with the fresh value. // After maxCASRetries unsuccessful swaps the request is conservatively denied; // this is an extremely rare safety-valve โ€” in practice contention resolves // within one or two retries. +// +// retryAfter formula: ceil(deficitFP / tpsFP). Both deficitFP and tpsFP carry +// the same ร—packedFracScale factor, so the quotient is in units of seconds. const maxCASRetries = 100 func (b *bucket) AllowN(cost float64) (ok bool, retryAfter int) { if cost < 1 { cost = 1 } + costFP := uint64(cost * float64(packedFracScale)) nowT := b.now() nowNs := nowT.UnixNano() b.lastAccessNs.Store(nowNs) + nowMS := uint32(nowT.UnixMilli()) //nolint:gosec // G115: intentional mod-2^32 truncation of ms timestamp for i := 0; i < maxCASRetries; i++ { old := b.state.Load() - - // Refill based on elapsed time since last refill. - elapsedSec := float64(nowNs-old.lastRefillNs) / 1e9 - newTokens := old.tokens - newRefillNs := old.lastRefillNs - if elapsedSec > 0 { - newTokens = math.Min(old.tokens+elapsedSec*b.tokensPerSecond, b.burst) - newRefillNs = nowNs + lastMS := unpackMS(old) + tokenFP := uint64(unpackTokenFP(old)) + + // Compute signed elapsed milliseconds via modular subtraction. + // Casting the unsigned difference to int32 handles backwards-clock + // steps (negative elapsed โ†’ no refill) and the uint32 wraparound at + // ~49.7 days (see package-level comment). + elapsedMS := int32(nowMS - lastMS) //nolint:gosec // G115: intentional signed-modular-diff for backwards-clock detection + + var newTokenFP uint64 + var newMS uint32 + if elapsedMS > 0 { + // refill = elapsed_ms * tpsFP / 1000 + // Both elapsed_ms (int32, max ~2.1e9) and tpsFP (max ~4.3e9) fit + // in int64 when multiplied (~9.2e18 < int64 max). + refillFP := uint64(int64(elapsedMS)*int64(b.tpsFP)) / 1000 //nolint:gosec // G115: tpsFP <= 65535*65536 = 4,294,901,760 fits int64; product fits int64 (max ~9.2e18) + newTokenFP = tokenFP + refillFP + if newTokenFP > b.burstFP { + newTokenFP = b.burstFP + } + newMS = nowMS + } else { + newTokenFP = tokenFP + newMS = lastMS } - if newTokens >= cost { - next := &bucketState{tokens: newTokens - cost, lastRefillNs: newRefillNs} + if newTokenFP >= costFP { + remainingFP := newTokenFP - costFP + // Defensive mask: ensures the token bits never spill into the + // timestamp half of the word even if a caller bypasses the + // config validator (e.g., a direct newBucket call with burst>65535). + next := uint64(newMS)<<32 | (uint64(remainingFP) & 0xFFFFFFFF) if b.state.CompareAndSwap(old, next) { return true, 0 } - // CAS lost โ€” retry. + // CAS lost โ€” retry with fresh state. continue } - // Not enough tokens; compute wait time from the refilled amount. - deficit := cost - newTokens - waitSeconds := deficit / b.tokensPerSecond - return false, int(math.Ceil(waitSeconds)) + // Not enough tokens; compute wait in seconds using ceiling division. + // deficitFP and tpsFP both carry ร—packedFracScale, so the quotient is + // in seconds. + deficitFP := costFP - newTokenFP + retrySeconds := int((deficitFP + b.tpsFP - 1) / b.tpsFP) //nolint:gosec // G115: quotient is seconds; burst <= 65535 bounds deficitFP + return false, retrySeconds } // Exhausted retries under extreme contention โ€” deny conservatively. diff --git a/app/internal/reload/diff.go b/app/internal/reload/diff.go index 88ba2ef1..35d4c328 100644 --- a/app/internal/reload/diff.go +++ b/app/internal/reload/diff.go @@ -19,6 +19,8 @@ import ( var ImmutableFields = []string{ "listen", "upstream.socket", + "upstream.endpoints", + "upstream.failover", "log", "health", "metrics", @@ -50,6 +52,16 @@ func ImmutableDiff(oldCfg, newCfg *config.Config) []string { if oldCfg.Upstream.Socket != newCfg.Upstream.Socket { changed = append(changed, "upstream.socket") } + // Endpoints and the failover health loop bind to the long-lived Resolver and + // its background goroutine at startup, so they cannot change under a reload. + // upstream.request_timeout stays mutable: it is rebuilt with the handler + // chain on every reload. + if !reflect.DeepEqual(oldCfg.Upstream.Endpoints, newCfg.Upstream.Endpoints) { + changed = append(changed, "upstream.endpoints") + } + if !reflect.DeepEqual(oldCfg.Upstream.Failover, newCfg.Upstream.Failover) { + changed = append(changed, "upstream.failover") + } if !reflect.DeepEqual(oldCfg.Log, newCfg.Log) { changed = append(changed, "log") } diff --git a/app/internal/reload/diff_test.go b/app/internal/reload/diff_test.go index 1c211ca6..7e9a626f 100644 --- a/app/internal/reload/diff_test.go +++ b/app/internal/reload/diff_test.go @@ -39,6 +39,43 @@ func TestImmutableDiffDetectsUpstreamSocketChange(t *testing.T) { } } +func TestImmutableDiffDetectsUpstreamEndpointsChange(t *testing.T) { + t.Parallel() + a := config.Defaults() + b := a + // Assign a fresh slice rather than appending so a's (shared) backing array + // is not mutated by the test. + b.Upstream.Endpoints = []config.UpstreamEndpoint{{Address: "tcp://dockerd:2376", InsecureAllowPlainTCP: true}} + got := ImmutableDiff(&a, &b) + if !equalUnordered(got, []string{"upstream.endpoints"}) { + t.Fatalf("ImmutableDiff(endpoints change) = %v, want [upstream.endpoints]", got) + } +} + +func TestImmutableDiffDetectsUpstreamFailoverChange(t *testing.T) { + t.Parallel() + a := config.Defaults() + b := a + b.Upstream.Failover.HealthInterval = "10s" + got := ImmutableDiff(&a, &b) + if !equalUnordered(got, []string{"upstream.failover"}) { + t.Fatalf("ImmutableDiff(failover change) = %v, want [upstream.failover]", got) + } +} + +func TestImmutableDiffUpstreamRequestTimeoutIsMutable(t *testing.T) { + t.Parallel() + a := config.Defaults() + b := a + // request_timeout is intentionally NOT immutable โ€” a reload that changes only + // it must produce an empty diff so the new deadline takes effect live. + b.Upstream.RequestTimeout = "30s" + got := ImmutableDiff(&a, &b) + if len(got) != 0 { + t.Fatalf("ImmutableDiff(request_timeout change) = %v, want empty (mutable field)", got) + } +} + func TestImmutableDiffDetectsLogChange(t *testing.T) { t.Parallel() a := config.Defaults() diff --git a/app/internal/upstream/endpoint.go b/app/internal/upstream/endpoint.go new file mode 100644 index 00000000..46c0f685 --- /dev/null +++ b/app/internal/upstream/endpoint.go @@ -0,0 +1,314 @@ +// Package upstream resolves and dials the Docker daemon sockguard proxies to. +// +// Historically the upstream was a single local unix socket dialed inline by +// every consumer (the reverse proxy, the hijack path, the exec inspector, the +// ownership/visibility/client-ACL side channels, and the health monitors). This +// package replaces that hardcoded assumption with a single seam โ€” a Resolver +// over an ordered list of Endpoints โ€” so the upstream can be a remote Docker +// daemon over TCP+TLS, and so a redundant set of endpoints for the same logical +// daemon/swarm can be health-checked with automatic failover. +// +// Every endpoint in a Resolver MUST address the same logical daemon (a swarm +// VIP plus its backing managers, an HA pair behind keepalived, etc.). Container +// IDs, exec session IDs, and owner labels are daemon-local; failing a live +// session over to a genuinely different daemon would surface dangling IDs. The +// Resolver therefore models active/passive redundancy, not cross-daemon +// fan-out. +package upstream + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// Endpoint is one resolved upstream target: either a local unix socket or a +// remote TCP daemon, optionally wrapped in client TLS. +type Endpoint struct { + // Name is a stable identifier used for metrics labels and log fields. For a + // unix socket it is the socket path; for TCP it is host:port. It is never + // empty for a valid endpoint. + Name string + // Network is "unix" or "tcp" โ€” the first argument to net.Dial. + Network string + // Address is the unix socket path or the TCP host:port. It is the second + // argument to net.Dial. + Address string + // TLSConfig is non-nil only for TCP endpoints that negotiate TLS. It is nil + // for unix sockets and for plain-TCP endpoints (which require an explicit + // insecure acknowledgement to construct). + TLSConfig *tls.Config +} + +// IsTLS reports whether the endpoint dials over TLS. +func (e Endpoint) IsTLS() bool { return e.TLSConfig != nil } + +// String renders the endpoint for logs: scheme://address, with a "+tls" suffix +// when TLS is in play. +func (e Endpoint) String() string { + scheme := e.Network + if e.IsTLS() { + scheme += "+tls" + } + return scheme + "://" + e.Address +} + +// EndpointSpec is the parsed, validated configuration for one endpoint before +// its TLS material is loaded. BuildEndpoint turns a spec into an Endpoint. +type EndpointSpec struct { + // Address is a Docker-style upstream address: "unix:///var/run/docker.sock", + // "tcp://host:2376", or a bare path (treated as a unix socket for backward + // compatibility with the legacy upstream.socket field). + Address string + // CAFile verifies the remote daemon's server certificate. Empty falls back + // to the system roots. + CAFile string + // CertFile and KeyFile present a client certificate to the remote daemon + // (mutual TLS). Both must be set together or both empty. + CertFile string + KeyFile string + // ServerName overrides the SNI / certificate hostname verified against the + // daemon's server cert. Empty derives it from the address host. + ServerName string + // InsecureAllowPlainTCP permits a tcp:// endpoint with no TLS material. A + // remote daemon reached over plaintext TCP exposes the full Docker API to + // anyone on the path; this must be set deliberately, mirroring the + // listener-side insecure_allow_plain_tcp acknowledgement. + InsecureAllowPlainTCP bool + // InsecureSkipTLSVerify disables verification of the daemon's server + // certificate. Useful for self-signed homelab daemons; dangerous in + // production because it defeats authentication of the upstream. + InsecureSkipTLSVerify bool + // TLSSystemRoots requests verified TLS using the host's system root CA pool + // and no client certificate โ€” the server-authentication-only case produced + // by DOCKER_TLS_VERIFY with no DOCKER_CERT_PATH. It makes a tcp:// endpoint + // valid without any explicit CA/cert/key material (the CA defaults to the + // system roots). Not exposed as a YAML knob; it only originates from the + // DOCKER_* environment drop-in. + TLSSystemRoots bool +} + +// BuildEndpoint parses spec.Address, loads any TLS material, and returns a +// dialable Endpoint. It returns a descriptive error for every malformed or +// inconsistent spec so config validation can surface the exact problem. +func BuildEndpoint(spec EndpointSpec) (Endpoint, error) { + network, address, err := parseAddress(spec.Address) + if err != nil { + return Endpoint{}, err + } + + switch network { + case "unix": + // TLS material on a unix endpoint is meaningless and almost always a + // copy-paste mistake โ€” reject it rather than silently ignore. + if spec.CertFile != "" || spec.KeyFile != "" || spec.CAFile != "" { + return Endpoint{}, fmt.Errorf("upstream endpoint %q: TLS settings are not valid for a unix socket", spec.Address) + } + return Endpoint{Name: address, Network: "unix", Address: address}, nil + case "tcp": + tlsConfig, err := buildClientTLS(spec, address) + if err != nil { + return Endpoint{}, err + } + return Endpoint{Name: address, Network: "tcp", Address: address, TLSConfig: tlsConfig}, nil + default: + return Endpoint{}, fmt.Errorf("upstream endpoint %q: unsupported scheme %q (use unix:// or tcp://)", spec.Address, network) + } +} + +// ValidateSpec checks a spec's address and TLS-field consistency WITHOUT +// touching the filesystem, so config validation (including the remote +// POST /admin/validate path, where cert files may not exist on the validating +// host) can reject a malformed endpoint without loading its TLS material. +// BuildEndpoint performs the same structural checks and additionally loads the +// referenced files. +func ValidateSpec(spec EndpointSpec) error { + network, address, err := parseAddress(spec.Address) + if err != nil { + return err + } + switch network { + case "unix": + if spec.CertFile != "" || spec.KeyFile != "" || spec.CAFile != "" { + return fmt.Errorf("upstream endpoint %q: TLS settings are not valid for a unix socket", spec.Address) + } + return nil + case "tcp": + if (spec.CertFile == "") != (spec.KeyFile == "") { + return fmt.Errorf("upstream endpoint %q: tls.cert_file and tls.key_file must be set together", spec.Address) + } + hasAnyTLS := spec.CertFile != "" || spec.KeyFile != "" || spec.CAFile != "" || spec.InsecureSkipTLSVerify || spec.TLSSystemRoots + if !hasAnyTLS && !spec.InsecureAllowPlainTCP { + return fmt.Errorf("upstream endpoint %q: TCP requires TLS (set tls.ca_file/cert_file/key_file) or insecure_allow_plain_tcp: true", spec.Address) + } + _ = address + return nil + default: + return fmt.Errorf("upstream endpoint %q: unsupported scheme %q (use unix:// or tcp://)", spec.Address, network) + } +} + +// parseAddress splits a Docker-style upstream address into a (network, address) +// pair. A bare path with no scheme is treated as a unix socket for backward +// compatibility with the legacy upstream.socket field. +func parseAddress(raw string) (network, address string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("upstream endpoint address is empty") + } + + // Bare absolute or relative path with no scheme โ†’ unix socket. + if !strings.Contains(raw, "://") { + if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") { + return "unix", raw, nil + } + return "", "", fmt.Errorf("upstream endpoint %q: address must be a unix path or a unix://, tcp:// URL", raw) + } + + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("upstream endpoint %q: %w", raw, err) + } + + switch u.Scheme { + case "unix": + // unix:///var/run/docker.sock โ†’ Path carries the socket path. A + // host-form unix://relative.sock is rejected as ambiguous. + if u.Host != "" { + return "", "", fmt.Errorf("upstream endpoint %q: unix sockets use an absolute path (unix:///var/run/docker.sock)", raw) + } + if u.Path == "" { + return "", "", fmt.Errorf("upstream endpoint %q: missing socket path", raw) + } + return "unix", u.Path, nil + case "tcp", "http", "https": + if u.Host == "" { + return "", "", fmt.Errorf("upstream endpoint %q: missing host:port", raw) + } + host := u.Host + if u.Port() == "" { + return "", "", fmt.Errorf("upstream endpoint %q: TCP address must include a port (e.g. tcp://host:2376)", raw) + } + return "tcp", host, nil + default: + return "", "", fmt.Errorf("upstream endpoint %q: unsupported scheme %q (use unix:// or tcp://)", raw, u.Scheme) + } +} + +// buildClientTLS constructs the *tls.Config used to dial a remote daemon. It +// returns nil only when plaintext TCP is explicitly acknowledged. +func buildClientTLS(spec EndpointSpec, address string) (*tls.Config, error) { + hasCert := spec.CertFile != "" || spec.KeyFile != "" + hasAnyTLS := hasCert || spec.CAFile != "" || spec.InsecureSkipTLSVerify || spec.TLSSystemRoots + + if !hasAnyTLS { + if spec.InsecureAllowPlainTCP { + return nil, nil + } + return nil, fmt.Errorf("upstream endpoint %q: TCP requires TLS (set tls.ca_file/cert_file/key_file) or insecure_allow_plain_tcp: true", spec.Address) + } + + if (spec.CertFile == "") != (spec.KeyFile == "") { + return nil, fmt.Errorf("upstream endpoint %q: tls.cert_file and tls.key_file must be set together", spec.Address) + } + + serverName := spec.ServerName + if serverName == "" { + // Derive SNI from the host portion of host:port. + if host, _, ok := splitHostPort(address); ok { + serverName = host + } else { + serverName = address + } + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: serverName, + InsecureSkipVerify: spec.InsecureSkipTLSVerify, //nolint:gosec // opt-in, gated behind an explicit acknowledgement + } + + if hasCert { + cert, err := tls.LoadX509KeyPair(spec.CertFile, spec.KeyFile) + if err != nil { + return nil, fmt.Errorf("upstream endpoint %q: loading client certificate: %w", spec.Address, err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + if spec.CAFile != "" { + pem, err := os.ReadFile(spec.CAFile) + if err != nil { + return nil, fmt.Errorf("upstream endpoint %q: reading tls.ca_file: %w", spec.Address, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("upstream endpoint %q: tls.ca_file %q contains no valid PEM certificates", spec.Address, spec.CAFile) + } + tlsConfig.RootCAs = pool + } + + return tlsConfig, nil +} + +// splitHostPort splits host:port without failing on IPv6 literals the way a +// naive strings.Split would. It returns ok=false when no port is present. +func splitHostPort(hostport string) (host, port string, ok bool) { + i := strings.LastIndex(hostport, ":") + if i < 0 { + return hostport, "", false + } + host = hostport[:i] + port = hostport[i+1:] + // Strip brackets from an IPv6 literal: [::1]:2376 โ†’ ::1 + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + return host, port, port != "" +} + +// SpecsFromDockerEnv reads the standard Docker client environment variables +// (DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH) and returns a single +// EndpointSpec when DOCKER_HOST names a TCP daemon, so an operator with a +// working `docker -H tcp://โ€ฆ` setup can point sockguard at it with no YAML. +// It returns ok=false when DOCKER_HOST is unset or names a unix socket (the +// local-socket default already covers that case). +func SpecsFromDockerEnv(getenv func(string) string) (EndpointSpec, bool) { + host := strings.TrimSpace(getenv("DOCKER_HOST")) + if host == "" { + return EndpointSpec{}, false + } + network, _, err := parseAddress(host) + if err != nil || network != "tcp" { + return EndpointSpec{}, false + } + + spec := EndpointSpec{Address: host} + tlsVerify := getenv("DOCKER_TLS_VERIFY") != "" + certPath := strings.TrimSpace(getenv("DOCKER_CERT_PATH")) + if certPath != "" { + spec.CAFile = filepath.Join(certPath, "ca.pem") + spec.CertFile = filepath.Join(certPath, "cert.pem") + spec.KeyFile = filepath.Join(certPath, "key.pem") + } + switch { + case tlsVerify && certPath == "": + // DOCKER_TLS_VERIFY with no DOCKER_CERT_PATH: verify the daemon against + // the system root CAs and present no client cert (server-auth only). + // Without this signal the spec would carry no TLS material and be + // rejected as plain TCP, breaking the documented env drop-in. + spec.TLSSystemRoots = true + case !tlsVerify && certPath == "": + // No verification and no cert material โ†’ plaintext TCP, matching the + // docker CLI when neither TLS env var is set. + spec.InsecureAllowPlainTCP = true + case !tlsVerify && certPath != "": + // Cert material present but verification off โ†’ encrypted, unverified. + spec.InsecureSkipTLSVerify = true + } + // tlsVerify && certPath != "" โ†’ verified mTLS loaded from the cert files, + // no insecure flag needed. + return spec, true +} diff --git a/app/internal/upstream/resolver.go b/app/internal/upstream/resolver.go new file mode 100644 index 00000000..1cd08bb8 --- /dev/null +++ b/app/internal/upstream/resolver.go @@ -0,0 +1,387 @@ +package upstream + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// ErrNoEndpoints is returned by a Resolver that was constructed without any +// endpoints. Config validation prevents this in practice. +var ErrNoEndpoints = errors.New("upstream: no endpoints configured") + +// Dialer is the raw-connection seam used by the hijack path, which bypasses the +// pooled HTTP transport and takes a net.Conn directly. *Resolver implements it. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +const ( + defaultMaxIdleConns = 100 + defaultMaxIdleConnsPerHost = 100 + defaultIdleConnTimeout = 90 * time.Second + defaultResponseHeaderTimeout = 30 * time.Second + defaultProbeInterval = 5 * time.Second + defaultProbeTimeout = 2 * time.Second +) + +// dial establishes a connection to the endpoint. For a TLS endpoint it completes +// the TLS handshake inside the dialer and returns the wrapped *tls.Conn, so every +// consumer can treat the upstream as plain HTTP over an already-encrypted pipe โ€” +// the ReverseProxy rewrites the request scheme to "http", which would otherwise +// suppress transport-level TLS. +func (e Endpoint) dial(ctx context.Context) (net.Conn, error) { + raw, err := (&net.Dialer{}).DialContext(ctx, e.Network, e.Address) + if err != nil { + return nil, err + } + if e.TLSConfig == nil { + return raw, nil + } + tconn := tls.Client(raw, e.TLSConfig) + if err := tconn.HandshakeContext(ctx); err != nil { + _ = raw.Close() + return nil, err + } + return tconn, nil +} + +// newTransport builds the pooled HTTP transport for one endpoint. Pool settings +// match the historical single-socket proxy transport so per-endpoint behavior is +// identical to the pre-multi-host proxy. TLS is handled inside dial, so the +// transport itself carries no TLSClientConfig. +func (e Endpoint) newTransport() *http.Transport { + ep := e + return &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return ep.dial(ctx) + }, + MaxIdleConns: defaultMaxIdleConns, + MaxIdleConnsPerHost: defaultMaxIdleConnsPerHost, + IdleConnTimeout: defaultIdleConnTimeout, + ResponseHeaderTimeout: defaultResponseHeaderTimeout, + } +} + +type endpointState struct { + ep Endpoint + transport *http.Transport + // mu serializes setHealth's swap-and-notify so a flapping endpoint never + // fires OnChange in an order that contradicts the final healthy value. + // Routing reads (healthy/known Load) stay lock-free. + mu sync.Mutex + healthy atomic.Bool + known atomic.Bool + // reprobing gates the asynchronous re-probe demote() launches to at most one + // in-flight goroutine per endpoint, so a dead endpoint under heavy traffic + // cannot spawn a goroutine/FD storm. + reprobing atomic.Bool +} + +// Options configures a Resolver's health loop and observation hooks. +type Options struct { + // Interval is the active health-probe period. Zero uses defaultProbeInterval; + // negative disables continuous probing (a single startup probe still runs). + Interval time.Duration + // Timeout bounds each probe. Zero uses defaultProbeTimeout. + Timeout time.Duration + // Logger receives endpoint up/down transition logs. Nil disables logging. + Logger *slog.Logger + // OnChange is invoked on every endpoint health transition (and on the first + // known result per endpoint), for metrics. It must be non-blocking. + OnChange func(ep Endpoint, healthy bool) + // Probe overrides the default connect-level probe. The default dials the + // endpoint (completing the TLS handshake for TLS endpoints) and closes it. + Probe func(ctx context.Context, ep Endpoint) error +} + +// Resolver routes upstream connections to the first healthy endpoint in an +// ordered list, with automatic failover driven by a background health loop. A +// single-endpoint Resolver (the common case, including the legacy local socket) +// always routes to that endpoint; failover logic is inert. +// +// It implements http.RoundTripper for the reverse proxy and HTTP side channels, +// and exposes DialContext for the raw-conn hijack path. Both demote the active +// endpoint on a connection-level failure so the next request routes elsewhere; +// neither retries the in-flight request, because Docker writes are not idempotent. +type Resolver struct { + states []*endpointState + interval time.Duration + timeout time.Duration + logger *slog.Logger + onChange func(ep Endpoint, healthy bool) + probe func(ctx context.Context, ep Endpoint) error + started atomic.Bool + // baseCtx is the Start context (nil until Start runs). demote's re-probe + // goroutines derive from it so they unwind promptly on shutdown instead of + // outliving the resolver by up to one probe timeout. + baseCtx atomic.Pointer[context.Context] +} + +// New builds a Resolver over the ordered endpoints. The first endpoint is the +// preferred primary; later endpoints are failover targets for the same logical +// daemon. It returns ErrNoEndpoints when endpoints is empty. +func New(endpoints []Endpoint, opts Options) (*Resolver, error) { + if len(endpoints) == 0 { + return nil, ErrNoEndpoints + } + states := make([]*endpointState, len(endpoints)) + for i, ep := range endpoints { + states[i] = &endpointState{ep: ep, transport: ep.newTransport()} + } + + interval := opts.Interval + if interval == 0 { + interval = defaultProbeInterval + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultProbeTimeout + } + probe := opts.Probe + if probe == nil { + probe = defaultProbe + } + + return &Resolver{ + states: states, + interval: interval, + timeout: timeout, + logger: opts.Logger, + onChange: opts.OnChange, + probe: probe, + }, nil +} + +// NewSingleSocket returns a Resolver with one local unix-socket endpoint and no +// continuous health probing โ€” a drop-in for the historical single-socket dial +// path used by the legacy constructors and by tests. Its Active endpoint is +// always the socket, so failover logic stays inert. +func NewSingleSocket(socketPath string) *Resolver { + r, _ := New([]Endpoint{{Name: socketPath, Network: "unix", Address: socketPath}}, Options{Interval: -1}) + return r +} + +// defaultProbe verifies liveness by dialing the endpoint (and completing the TLS +// handshake for TLS endpoints) and closing the connection immediately. +func defaultProbe(ctx context.Context, ep Endpoint) error { + conn, err := ep.dial(ctx) + if err != nil { + return err + } + return conn.Close() +} + +// Endpoints returns the configured endpoints in preference order. +func (r *Resolver) Endpoints() []Endpoint { + out := make([]Endpoint, len(r.states)) + for i, s := range r.states { + out[i] = s.ep + } + return out +} + +// CheckReachable probes every endpoint once, seeding their health state, and +// returns nil when at least one endpoint answers. When all endpoints fail it +// returns an aggregated error naming each unreachable endpoint. This lets a +// multi-endpoint failover set boot as long as one daemon responds, while a +// fully dark upstream still fails fast at startup. +func (r *Resolver) CheckReachable(ctx context.Context) error { + if len(r.states) == 0 { + return ErrNoEndpoints + } + reachable := false + failures := make([]string, 0, len(r.states)) + for _, s := range r.states { + pctx, cancel := context.WithTimeout(ctx, r.timeout) + err := r.probe(pctx, s.ep) + cancel() + r.setHealth(s, err == nil) + if err == nil { + reachable = true + continue + } + failures = append(failures, fmt.Sprintf("%s: %v", s.ep.String(), err)) + } + if reachable { + return nil + } + return fmt.Errorf("no upstream endpoint reachable: %s", strings.Join(failures, "; ")) +} + +// Active returns the endpoint requests currently route to: the first +// known-healthy endpoint, else the first not-yet-probed endpoint, else the +// primary as a last resort so a request is still attempted. +func (r *Resolver) Active() Endpoint { + if s := r.activeState(); s != nil { + return s.ep + } + return Endpoint{} +} + +func (r *Resolver) activeState() *endpointState { + var firstUnknown *endpointState + for _, s := range r.states { + if s.known.Load() && s.healthy.Load() { + return s + } + if firstUnknown == nil && !s.known.Load() { + firstUnknown = s + } + } + if firstUnknown != nil { + return firstUnknown + } + if len(r.states) > 0 { + return r.states[0] + } + return nil +} + +// RoundTrip implements http.RoundTripper, routing the request to the active +// endpoint's pooled transport. A request that fails for a request-scoped reason +// (client disconnect, or the per-request request_timeout deadline firing) does +// NOT demote the endpoint โ€” those say nothing about upstream reachability, and +// demoting on them would flap a healthy primary on every long-running request. +func (r *Resolver) RoundTrip(req *http.Request) (*http.Response, error) { + s := r.activeState() + if s == nil { + return nil, ErrNoEndpoints + } + resp, err := s.transport.RoundTrip(req) + if err != nil && !isRequestScopedError(err) { + r.demote(s) + } + return resp, err +} + +// DialContext dials the active endpoint, returning a raw (TLS-wrapped where +// applicable) net.Conn for the hijack path. The network/address arguments are +// ignored; the endpoint is chosen by health. A dial that exceeds the caller's +// dial deadline DOES demote (a slow/dead endpoint is a reachability signal), +// but an explicit cancellation (context.Canceled) does not. +func (r *Resolver) DialContext(ctx context.Context, _, _ string) (net.Conn, error) { + s := r.activeState() + if s == nil { + return nil, ErrNoEndpoints + } + conn, err := s.ep.dial(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + r.demote(s) + } + return conn, err +} + +// isRequestScopedError reports whether err originates from the request's own +// context (client cancellation or the per-request deadline) rather than an +// upstream-side failure. Such errors must not demote the active endpoint. +func isRequestScopedError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +// demote marks an endpoint unhealthy after a live request/dial failure so the +// next request routes elsewhere. It is a no-op for a single-endpoint resolver +// (there is nowhere to fail over to, so flapping the only endpoint's state would +// just add noise) and triggers an asynchronous re-probe so a transient blip +// recovers without waiting a full interval. The re-probe is gated to one +// in-flight goroutine per endpoint (reprobing CAS) so a dead endpoint under +// heavy traffic cannot spawn a goroutine/FD storm, and it derives from the +// resolver's Start context so it unwinds on shutdown. +func (r *Resolver) demote(s *endpointState) { + if len(r.states) < 2 { + return + } + r.setHealth(s, false) + if !s.reprobing.CompareAndSwap(false, true) { + return + } + go func() { + defer s.reprobing.Store(false) + ctx, cancel := context.WithTimeout(r.reprobeBaseContext(), r.timeout) + defer cancel() + r.setHealth(s, r.probe(ctx, s.ep) == nil) + }() +} + +// reprobeBaseContext returns the resolver's Start context, or context.Background +// when Start has not run yet (the demote path can fire on a request that races +// startup, or in tests that never call Start). +func (r *Resolver) reprobeBaseContext() context.Context { + if p := r.baseCtx.Load(); p != nil { + return *p + } + return context.Background() +} + +// Start launches the background health loop. It is idempotent; the loop exits +// when ctx is canceled. +func (r *Resolver) Start(ctx context.Context) { + if !r.started.CompareAndSwap(false, true) { + return + } + r.baseCtx.Store(&ctx) + go r.loop(ctx) +} + +func (r *Resolver) loop(ctx context.Context) { + r.probeAll(ctx) + if r.interval < 0 { + return + } + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.probeAll(ctx) + } + } +} + +func (r *Resolver) probeAll(ctx context.Context) { + for _, s := range r.states { + if ctx.Err() != nil { + return + } + pctx, cancel := context.WithTimeout(ctx, r.timeout) + err := r.probe(pctx, s.ep) + cancel() + r.setHealth(s, err == nil) + } +} + +func (r *Resolver) setHealth(s *endpointState, healthy bool) { + // Serialize the swap-and-notify so concurrent probes (background loop + a + // demote re-probe) can't fire onChange in an order that contradicts the + // final healthy value. Routing reads stay lock-free on the atomics. + s.mu.Lock() + defer s.mu.Unlock() + was := s.healthy.Swap(healthy) + first := !s.known.Swap(true) + if !first && was == healthy { + return + } + if r.logger != nil { + level := slog.LevelInfo + if !healthy { + level = slog.LevelWarn + } + r.logger.LogAttrs(context.Background(), level, "upstream endpoint health changed", + slog.String("endpoint", s.ep.String()), + slog.Bool("healthy", healthy), + ) + } + if r.onChange != nil { + r.onChange(s.ep, healthy) + } +} diff --git a/app/internal/upstream/upstream_test.go b/app/internal/upstream/upstream_test.go new file mode 100644 index 00000000..6ea28794 --- /dev/null +++ b/app/internal/upstream/upstream_test.go @@ -0,0 +1,1250 @@ +package upstream + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codeswhat/sockguard/internal/testcert" +) + +// โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// tempSocketPath creates a unique path under /tmp safe for a unix socket +// (avoids the 104-byte sun_path limit that t.TempDir() can hit on macOS). +func tempSocketPath(t *testing.T, label string) string { + t.Helper() + f, err := os.CreateTemp("/tmp", "us-"+label+"-*.sock") + if err != nil { + t.Fatalf("create temp socket: %v", err) + } + path := f.Name() + _ = f.Close() + _ = os.Remove(path) + t.Cleanup(func() { _ = os.Remove(path) }) + return path +} + +// startUnixServer starts an HTTP server over a unix socket and returns the +// socket path. The server is shut down via t.Cleanup. +func startUnixServer(t *testing.T, label string, handler http.Handler) string { + t.Helper() + path := tempSocketPath(t, label) + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen unix %s: %v", path, err) + } + srv := &http.Server{Handler: handler} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { + _ = srv.Close() + _ = ln.Close() + }) + return path +} + +// probeAlways returns a probe func that always reports the given error. +func probeAlways(err error) func(context.Context, Endpoint) error { + return func(_ context.Context, _ Endpoint) error { return err } +} + +// โ”€โ”€ parseAddress โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestParseAddress(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input string + wantNetwork string + wantAddress string + wantErr bool + }{ + // valid unix + {name: "unix url", input: "unix:///var/run/docker.sock", wantNetwork: "unix", wantAddress: "/var/run/docker.sock"}, + {name: "bare absolute path", input: "/var/run/docker.sock", wantNetwork: "unix", wantAddress: "/var/run/docker.sock"}, + {name: "bare dot-relative path", input: "./docker.sock", wantNetwork: "unix", wantAddress: "./docker.sock"}, + {name: "bare dot-dot path", input: "../docker.sock", wantNetwork: "unix", wantAddress: "../docker.sock"}, + // valid tcp-family + {name: "tcp url", input: "tcp://host:2376", wantNetwork: "tcp", wantAddress: "host:2376"}, + {name: "http url", input: "http://host:2375", wantNetwork: "tcp", wantAddress: "host:2375"}, + {name: "https url", input: "https://host:2376", wantNetwork: "tcp", wantAddress: "host:2376"}, + // errors + {name: "empty", input: "", wantErr: true}, + {name: "whitespace only", input: " ", wantErr: true}, + {name: "scheme-less non-path", input: "notapath", wantErr: true}, + {name: "unix with host", input: "unix://relative.sock/path", wantErr: true}, + {name: "unix missing path", input: "unix://", wantErr: true}, + {name: "tcp missing port", input: "tcp://myhost", wantErr: true}, + {name: "bad scheme", input: "ftp://host:21", wantErr: true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + net, addr, err := parseAddress(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("parseAddress(%q) expected error, got network=%q addr=%q", tc.input, net, addr) + } + return + } + if err != nil { + t.Fatalf("parseAddress(%q) unexpected error: %v", tc.input, err) + } + if net != tc.wantNetwork { + t.Errorf("network = %q, want %q", net, tc.wantNetwork) + } + if addr != tc.wantAddress { + t.Errorf("address = %q, want %q", addr, tc.wantAddress) + } + }) + } +} + +// โ”€โ”€ ValidateSpec โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestValidateSpec(t *testing.T) { + t.Parallel() + cases := []struct { + name string + spec EndpointSpec + wantErr bool + }{ + // unix โ€” valid + { + name: "unix bare path ok", + spec: EndpointSpec{Address: "/var/run/docker.sock"}, + }, + { + name: "unix url ok", + spec: EndpointSpec{Address: "unix:///var/run/docker.sock"}, + }, + // unix โ€” rejects TLS fields + { + name: "unix with CAFile", + spec: EndpointSpec{Address: "/run/docker.sock", CAFile: "/tmp/ca.pem"}, + wantErr: true, + }, + { + name: "unix with CertFile", + spec: EndpointSpec{Address: "/run/docker.sock", CertFile: "/tmp/cert.pem"}, + wantErr: true, + }, + { + name: "unix with KeyFile", + spec: EndpointSpec{Address: "/run/docker.sock", KeyFile: "/tmp/key.pem"}, + wantErr: true, + }, + // tcp โ€” valid TLS combos + { + name: "tcp with ca only", + spec: EndpointSpec{Address: "tcp://host:2376", CAFile: "/tmp/ca.pem"}, + }, + { + name: "tcp with cert+key", + spec: EndpointSpec{Address: "tcp://host:2376", CertFile: "/tmp/cert.pem", KeyFile: "/tmp/key.pem"}, + }, + { + name: "tcp insecure skip verify", + spec: EndpointSpec{Address: "tcp://host:2376", InsecureSkipTLSVerify: true}, + }, + { + name: "tcp plain insecure acknowledged", + spec: EndpointSpec{Address: "tcp://host:2376", InsecureAllowPlainTCP: true}, + }, + // tcp โ€” errors + { + name: "tcp no tls no plain", + spec: EndpointSpec{Address: "tcp://host:2376"}, + wantErr: true, + }, + { + name: "tcp cert without key", + spec: EndpointSpec{Address: "tcp://host:2376", CertFile: "/tmp/cert.pem"}, + wantErr: true, + }, + { + name: "tcp key without cert", + spec: EndpointSpec{Address: "tcp://host:2376", KeyFile: "/tmp/key.pem"}, + wantErr: true, + }, + // bad address + { + name: "bad address", + spec: EndpointSpec{Address: ""}, + wantErr: true, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateSpec(tc.spec) + if tc.wantErr && err == nil { + t.Fatalf("ValidateSpec(%+v) expected error, got nil", tc.spec) + } + if !tc.wantErr && err != nil { + t.Fatalf("ValidateSpec(%+v) unexpected error: %v", tc.spec, err) + } + }) + } +} + +// โ”€โ”€ BuildEndpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestBuildEndpoint_Unix(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{Address: "/var/run/docker.sock"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.Network != "unix" { + t.Errorf("Network = %q, want %q", ep.Network, "unix") + } + if ep.Address != "/var/run/docker.sock" { + t.Errorf("Address = %q, want %q", ep.Address, "/var/run/docker.sock") + } + if ep.IsTLS() { + t.Error("unix endpoint must not be TLS") + } +} + +func TestBuildEndpoint_UnixWithTLS_Rejected(t *testing.T) { + t.Parallel() + _, err := BuildEndpoint(EndpointSpec{Address: "/run/docker.sock", CAFile: "/tmp/ca.pem"}) + if err == nil { + t.Fatal("expected error for unix+TLS, got nil") + } +} + +func TestBuildEndpoint_PlainTCP(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{Address: "tcp://host:2376", InsecureAllowPlainTCP: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.Network != "tcp" { + t.Errorf("Network = %q, want %q", ep.Network, "tcp") + } + if ep.IsTLS() { + t.Error("plain TCP endpoint must not be TLS") + } +} + +func TestBuildEndpoint_TLSInsecureSkip(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{Address: "tcp://host:2376", InsecureSkipTLSVerify: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ep.IsTLS() { + t.Error("endpoint should be TLS when InsecureSkipTLSVerify is set") + } + if !ep.TLSConfig.InsecureSkipVerify { + t.Error("TLSConfig.InsecureSkipVerify should be true") + } +} + +func TestBuildEndpoint_TLSWithCertFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + bundle, err := testcert.WriteMutualTLSBundle(dir, "127.0.0.1") + if err != nil { + t.Fatalf("write test bundle: %v", err) + } + + ep, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://127.0.0.1:2376", + CAFile: bundle.CAFile, + CertFile: bundle.ClientCertFile, + KeyFile: bundle.ClientKeyFile, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ep.IsTLS() { + t.Error("endpoint should be TLS") + } + if len(ep.TLSConfig.Certificates) != 1 { + t.Errorf("TLSConfig.Certificates len = %d, want 1", len(ep.TLSConfig.Certificates)) + } + if ep.TLSConfig.RootCAs == nil { + t.Error("TLSConfig.RootCAs should not be nil when CAFile is set") + } +} + +func TestBuildEndpoint_MissingCAFile(t *testing.T) { + t.Parallel() + _, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + CAFile: "/nonexistent/ca.pem", + }) + if err == nil { + t.Fatal("expected error for missing CAFile, got nil") + } +} + +func TestBuildEndpoint_MalformedCAFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + caPath := filepath.Join(dir, "bad-ca.pem") + if err := os.WriteFile(caPath, []byte("not a valid PEM certificate"), 0o600); err != nil { + t.Fatalf("write bad CA: %v", err) + } + _, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + CAFile: caPath, + }) + if err == nil { + t.Fatal("expected error for malformed CA PEM, got nil") + } +} + +func TestBuildEndpoint_BadKeyPair(t *testing.T) { + t.Parallel() + dir := t.TempDir() + bundle, err := testcert.WriteMutualTLSBundle(dir, "127.0.0.1") + if err != nil { + t.Fatalf("write test bundle: %v", err) + } + // Pass mismatched files: cert from one bundle, key from another location. + badKeyPath := filepath.Join(dir, "bad.key") + if err := os.WriteFile(badKeyPath, []byte("not a key"), 0o600); err != nil { + t.Fatalf("write bad key: %v", err) + } + _, err = BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + CertFile: bundle.ClientCertFile, + KeyFile: badKeyPath, + }) + if err == nil { + t.Fatal("expected error for bad keypair, got nil") + } +} + +func TestBuildEndpoint_CertWithoutKey(t *testing.T) { + t.Parallel() + _, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + CertFile: "/tmp/cert.pem", + }) + if err == nil { + t.Fatal("expected error when CertFile set without KeyFile") + } +} + +func TestBuildEndpoint_KeyWithoutCert(t *testing.T) { + t.Parallel() + _, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + KeyFile: "/tmp/key.pem", + }) + if err == nil { + t.Fatal("expected error when KeyFile set without CertFile") + } +} + +func TestBuildEndpoint_ServerNameOverride(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://host:2376", + InsecureSkipTLSVerify: true, + ServerName: "overridden.example.com", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.TLSConfig.ServerName != "overridden.example.com" { + t.Errorf("ServerName = %q, want %q", ep.TLSConfig.ServerName, "overridden.example.com") + } +} + +func TestBuildEndpoint_SNIDerivedFromHost(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{ + Address: "tcp://daemon.example.com:2376", + InsecureSkipTLSVerify: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.TLSConfig.ServerName != "daemon.example.com" { + t.Errorf("ServerName = %q, want %q", ep.TLSConfig.ServerName, "daemon.example.com") + } +} + +// โ”€โ”€ Endpoint.String / IsTLS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestEndpoint_StringAndIsTLS(t *testing.T) { + t.Parallel() + cases := []struct { + name string + ep Endpoint + wantStr string + wantIsTLS bool + }{ + { + name: "unix socket", + ep: Endpoint{Name: "/run/docker.sock", Network: "unix", Address: "/run/docker.sock"}, + wantStr: "unix:///run/docker.sock", + wantIsTLS: false, + }, + { + name: "plain tcp", + ep: Endpoint{Name: "host:2375", Network: "tcp", Address: "host:2375"}, + wantStr: "tcp://host:2375", + wantIsTLS: false, + }, + { + name: "tcp with tls", + ep: Endpoint{Name: "host:2376", Network: "tcp", Address: "host:2376", TLSConfig: tlsMinConfig}, + wantStr: "tcp+tls://host:2376", + wantIsTLS: true, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.ep.String(); got != tc.wantStr { + t.Errorf("String() = %q, want %q", got, tc.wantStr) + } + if got := tc.ep.IsTLS(); got != tc.wantIsTLS { + t.Errorf("IsTLS() = %v, want %v", got, tc.wantIsTLS) + } + }) + } +} + +// tlsMinConfig is a minimal non-nil *tls.Config used in tests that need to +// mark an endpoint as TLS without actually negotiating a handshake. +var tlsMinConfig = &tls.Config{MinVersion: tls.VersionTLS12} + +// โ”€โ”€ New / NewSingleSocket โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestNew_NoEndpoints(t *testing.T) { + t.Parallel() + _, err := New(nil, Options{}) + if !errors.Is(err, ErrNoEndpoints) { + t.Fatalf("New(nil) error = %v, want ErrNoEndpoints", err) + } + _, err = New([]Endpoint{}, Options{}) + if !errors.Is(err, ErrNoEndpoints) { + t.Fatalf("New(empty) error = %v, want ErrNoEndpoints", err) + } +} + +func TestNew_SingleEndpoint(t *testing.T) { + t.Parallel() + ep := Endpoint{Name: "/tmp/test.sock", Network: "unix", Address: "/tmp/test.sock"} + r, err := New([]Endpoint{ep}, Options{Probe: probeAlways(nil)}) + if err != nil { + t.Fatalf("New: %v", err) + } + eps := r.Endpoints() + if len(eps) != 1 { + t.Fatalf("Endpoints() len = %d, want 1", len(eps)) + } +} + +func TestNewSingleSocket(t *testing.T) { + t.Parallel() + r := NewSingleSocket("/var/run/docker.sock") + if r == nil { + t.Fatal("NewSingleSocket returned nil") + } + eps := r.Endpoints() + if len(eps) != 1 || eps[0].Network != "unix" || eps[0].Address != "/var/run/docker.sock" { + t.Errorf("unexpected endpoints: %+v", eps) + } +} + +// โ”€โ”€ Resolver.Active and activeState precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_Active_AllUnknown_ReturnsPrimary(t *testing.T) { + t.Parallel() + ep0 := Endpoint{Name: "ep0", Network: "unix", Address: "/tmp/ep0.sock"} + ep1 := Endpoint{Name: "ep1", Network: "unix", Address: "/tmp/ep1.sock"} + r, err := New([]Endpoint{ep0, ep1}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + // No probe has run yet, so all states are unknown. + active := r.Active() + // Should return the first unknown (ep0). + if active.Name != "ep0" { + t.Errorf("Active().Name = %q, want %q", active.Name, "ep0") + } +} + +func TestResolver_Active_KnownHealthyFirst(t *testing.T) { + t.Parallel() + ep0 := Endpoint{Name: "ep0", Network: "unix", Address: "/tmp/ep0.sock"} + ep1 := Endpoint{Name: "ep1", Network: "unix", Address: "/tmp/ep1.sock"} + + // Probe: ep0 unhealthy, ep1 healthy. + callCount := 0 + probe := func(_ context.Context, ep Endpoint) error { + callCount++ + if ep.Name == "ep0" { + return errors.New("down") + } + return nil + } + r, err := New([]Endpoint{ep0, ep1}, Options{Probe: probe, Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := context.Background() + r.Start(ctx) + // Wait for the startup probe (interval=-1 means one probe then stop). + // Poll until both are known. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if r.states[0].known.Load() && r.states[1].known.Load() { + break + } + time.Sleep(5 * time.Millisecond) + } + active := r.Active() + if active.Name != "ep1" { + t.Errorf("Active().Name = %q, want %q after probe marks ep0 unhealthy and ep1 healthy", active.Name, "ep1") + } +} + +// โ”€โ”€ Resolver routing (no real network โ€” fake unix servers) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_RoutesToFirstEndpointWhenBothHealthy(t *testing.T) { + t.Parallel() + body0 := "response-from-ep0" + body1 := "response-from-ep1" + sock0 := startUnixServer(t, "ep0", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body0) + })) + sock1 := startUnixServer(t, "ep1", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body1) + })) + + ep0 := Endpoint{Name: sock0, Network: "unix", Address: sock0} + ep1 := Endpoint{Name: sock1, Network: "unix", Address: sock1} + + // Force both healthy via probe returning nil; mark them known immediately. + r, err := New([]Endpoint{ep0, ep1}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + // Mark both known+healthy directly. + r.setHealth(r.states[0], true) + r.setHealth(r.states[1], true) + + got := doRoundTrip(t, r, sock0) + if got != body0 { + t.Errorf("body = %q, want %q (should route to ep0)", got, body0) + } +} + +func TestResolver_FailoverToSecondWhenFirstUnhealthy(t *testing.T) { + t.Parallel() + body1 := "response-from-ep1" + sock1 := startUnixServer(t, "failover", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body1) + })) + + // ep0 has a path that will never be listened on (already removed by tempSocketPath). + sock0 := tempSocketPath(t, "dead") + ep0 := Endpoint{Name: sock0, Network: "unix", Address: sock0} + ep1 := Endpoint{Name: sock1, Network: "unix", Address: sock1} + + r, err := New([]Endpoint{ep0, ep1}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + // Mark ep0 known+unhealthy, ep1 known+healthy. + r.setHealth(r.states[0], false) + r.setHealth(r.states[1], true) + + got := doRoundTrip(t, r, sock1) + if got != body1 { + t.Errorf("body = %q, want %q (should route to ep1)", got, body1) + } +} + +func TestResolver_DialContext_UsesActiveEndpoint(t *testing.T) { + t.Parallel() + sock := startUnixServer(t, "dial", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "dial-ok") + })) + + ep := Endpoint{Name: sock, Network: "unix", Address: sock} + r, err := New([]Endpoint{ep}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + r.setHealth(r.states[0], true) + + ctx := context.Background() + conn, err := r.DialContext(ctx, "ignored", "ignored") + if err != nil { + t.Fatalf("DialContext: %v", err) + } + _ = conn.Close() +} + +func TestResolver_DialContext_NoEndpoints(t *testing.T) { + t.Parallel() + // Build a valid resolver then empty the states to exercise the nil guard. + ep := Endpoint{Name: "/tmp/x.sock", Network: "unix", Address: "/tmp/x.sock"} + r, err := New([]Endpoint{ep}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + r.states = nil // white-box surgery + _, err = r.DialContext(context.Background(), "", "") + if !errors.Is(err, ErrNoEndpoints) { + t.Fatalf("DialContext with no states: error = %v, want ErrNoEndpoints", err) + } +} + +func TestResolver_RoundTrip_NoEndpoints(t *testing.T) { + t.Parallel() + ep := Endpoint{Name: "/tmp/x.sock", Network: "unix", Address: "/tmp/x.sock"} + r, err := New([]Endpoint{ep}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + r.states = nil + req, _ := http.NewRequest(http.MethodGet, "http://docker/containers/json", nil) + _, err = r.RoundTrip(req) + if !errors.Is(err, ErrNoEndpoints) { + t.Fatalf("RoundTrip with no states: error = %v, want ErrNoEndpoints", err) + } +} + +// โ”€โ”€ demote behavior โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_Demote_TwoEndpoints_FlipsSelection(t *testing.T) { + t.Parallel() + body1 := "ep1-body" + sock1 := startUnixServer(t, "demote-ep1", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body1) + })) + sock0 := tempSocketPath(t, "demote-dead") + ep0 := Endpoint{Name: sock0, Network: "unix", Address: sock0} + ep1 := Endpoint{Name: sock1, Network: "unix", Address: sock1} + + // Probe says ep1 healthy so the re-probe after demote won't flip it back. + probe := func(_ context.Context, ep Endpoint) error { + if ep.Name == sock0 { + return errors.New("still down") + } + return nil + } + r, err := New([]Endpoint{ep0, ep1}, Options{Probe: probe, Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + // Both known healthy to start so ep0 is active. + r.setHealth(r.states[0], true) + r.setHealth(r.states[1], true) + + if r.Active().Name != sock0 { + t.Fatalf("expected ep0 active before demote, got %q", r.Active().Name) + } + + // Demote ep0 directly. + r.demote(r.states[0]) + + // After demote ep0 should be unhealthy, ep1 healthy. + // Poll briefly for the async re-probe goroutine (which will set ep0 to still-down). + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if r.states[0].known.Load() { + break + } + time.Sleep(5 * time.Millisecond) + } + + active := r.Active() + if active.Name != sock1 { + t.Errorf("after demote, Active().Name = %q, want %q", active.Name, sock1) + } +} + +func TestResolver_Demote_SingleEndpoint_IsNoOp(t *testing.T) { + t.Parallel() + ep := Endpoint{Name: "/tmp/sole.sock", Network: "unix", Address: "/tmp/sole.sock"} + r, err := New([]Endpoint{ep}, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + r.setHealth(r.states[0], true) + + // Demote should be a no-op: the single endpoint stays in whatever state it's in. + r.demote(r.states[0]) + + // In a single-endpoint resolver, demote returns early without changing health. + if !r.states[0].healthy.Load() { + t.Error("single-endpoint demote should be a no-op but flipped health to false") + } +} + +// โ”€โ”€ activeState precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestActiveState_Precedence(t *testing.T) { + t.Parallel() + + makeEp := func(name string) Endpoint { + return Endpoint{Name: name, Network: "unix", Address: name} + } + + t.Run("known healthy before unknown", func(t *testing.T) { + t.Parallel() + r, _ := New([]Endpoint{makeEp("a"), makeEp("b")}, Options{Probe: probeAlways(nil), Interval: -1}) + // a is unhealthy and known; b is unknown. + r.states[0].healthy.Store(false) + r.states[0].known.Store(true) + // b remains unknown (zero value). + // activeState should return the first unknown (b) rather than the known-unhealthy (a). + s := r.activeState() + if s.ep.Name != "b" { + t.Errorf("activeState = %q, want %q", s.ep.Name, "b") + } + }) + + t.Run("first unknown before all-known-unhealthy", func(t *testing.T) { + t.Parallel() + r, _ := New([]Endpoint{makeEp("a"), makeEp("b"), makeEp("c")}, Options{Probe: probeAlways(nil), Interval: -1}) + // a unhealthy+known; b unknown; c healthy+known. + r.states[0].healthy.Store(false) + r.states[0].known.Store(true) + // b is zero = unknown. + r.states[2].healthy.Store(true) + r.states[2].known.Store(true) + // c is healthy+known โ€” should win. + s := r.activeState() + if s.ep.Name != "c" { + t.Errorf("activeState = %q, want %q (known-healthy wins)", s.ep.Name, "c") + } + }) + + t.Run("primary as last resort when all unhealthy", func(t *testing.T) { + t.Parallel() + r, _ := New([]Endpoint{makeEp("primary"), makeEp("secondary")}, Options{Probe: probeAlways(nil), Interval: -1}) + r.states[0].healthy.Store(false) + r.states[0].known.Store(true) + r.states[1].healthy.Store(false) + r.states[1].known.Store(true) + s := r.activeState() + if s.ep.Name != "primary" { + t.Errorf("activeState = %q, want primary as last resort", s.ep.Name) + } + }) +} + +// โ”€โ”€ SpecsFromDockerEnv โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestSpecsFromDockerEnv(t *testing.T) { + t.Parallel() + cases := []struct { + name string + env map[string]string + wantOK bool + wantSpec EndpointSpec + }{ + { + name: "DOCKER_HOST unset", + env: map[string]string{}, + wantOK: false, + }, + { + name: "DOCKER_HOST is unix socket", + env: map[string]string{"DOCKER_HOST": "unix:///var/run/docker.sock"}, + wantOK: false, + }, + { + name: "DOCKER_HOST whitespace only", + env: map[string]string{"DOCKER_HOST": " "}, + wantOK: false, + }, + { + name: "tcp plain no TLS verify no cert path", + env: map[string]string{"DOCKER_HOST": "tcp://host:2376"}, + wantOK: true, + wantSpec: EndpointSpec{ + Address: "tcp://host:2376", + InsecureAllowPlainTCP: true, + }, + }, + { + name: "tcp with TLS_VERIFY and cert path", + env: map[string]string{ + "DOCKER_HOST": "tcp://host:2376", + "DOCKER_TLS_VERIFY": "1", + "DOCKER_CERT_PATH": "/certs", + }, + wantOK: true, + wantSpec: EndpointSpec{ + Address: "tcp://host:2376", + CAFile: "/certs/ca.pem", + CertFile: "/certs/cert.pem", + KeyFile: "/certs/key.pem", + }, + }, + { + name: "tcp without TLS_VERIFY but with cert path โ€” insecure skip", + env: map[string]string{ + "DOCKER_HOST": "tcp://host:2376", + "DOCKER_CERT_PATH": "/certs", + }, + wantOK: true, + wantSpec: EndpointSpec{ + Address: "tcp://host:2376", + CAFile: "/certs/ca.pem", + CertFile: "/certs/cert.pem", + KeyFile: "/certs/key.pem", + InsecureSkipTLSVerify: true, + }, + }, + { + name: "tcp with TLS_VERIFY and no cert path", + env: map[string]string{ + "DOCKER_HOST": "tcp://host:2376", + "DOCKER_TLS_VERIFY": "1", + }, + wantOK: true, + wantSpec: EndpointSpec{ + Address: "tcp://host:2376", + // no CA/cert/key โ€” verify against the host's system root CAs. + TLSSystemRoots: true, + }, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + getenv := func(key string) string { return tc.env[key] } + spec, ok := SpecsFromDockerEnv(getenv) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if spec.Address != tc.wantSpec.Address { + t.Errorf("Address = %q, want %q", spec.Address, tc.wantSpec.Address) + } + if spec.CAFile != tc.wantSpec.CAFile { + t.Errorf("CAFile = %q, want %q", spec.CAFile, tc.wantSpec.CAFile) + } + if spec.CertFile != tc.wantSpec.CertFile { + t.Errorf("CertFile = %q, want %q", spec.CertFile, tc.wantSpec.CertFile) + } + if spec.KeyFile != tc.wantSpec.KeyFile { + t.Errorf("KeyFile = %q, want %q", spec.KeyFile, tc.wantSpec.KeyFile) + } + if spec.InsecureAllowPlainTCP != tc.wantSpec.InsecureAllowPlainTCP { + t.Errorf("InsecureAllowPlainTCP = %v, want %v", spec.InsecureAllowPlainTCP, tc.wantSpec.InsecureAllowPlainTCP) + } + if spec.InsecureSkipTLSVerify != tc.wantSpec.InsecureSkipTLSVerify { + t.Errorf("InsecureSkipTLSVerify = %v, want %v", spec.InsecureSkipTLSVerify, tc.wantSpec.InsecureSkipTLSVerify) + } + if spec.TLSSystemRoots != tc.wantSpec.TLSSystemRoots { + t.Errorf("TLSSystemRoots = %v, want %v", spec.TLSSystemRoots, tc.wantSpec.TLSSystemRoots) + } + }) + } +} + +// TestBuildEndpoint_TLSSystemRoots covers the DOCKER_TLS_VERIFY-without-cert-path +// path end to end: a spec carrying only TLSSystemRoots must build a valid TLS +// endpoint that verifies against the host's system roots (RootCAs nil) and +// presents no client certificate, rather than being rejected as plain TCP. +func TestBuildEndpoint_TLSSystemRoots(t *testing.T) { + t.Parallel() + ep, err := BuildEndpoint(EndpointSpec{Address: "tcp://dockerd.internal:2376", TLSSystemRoots: true}) + if err != nil { + t.Fatalf("BuildEndpoint: %v", err) + } + if !ep.IsTLS() { + t.Fatal("endpoint is not TLS, want TLS with system roots") + } + if ep.TLSConfig.RootCAs != nil { + t.Error("RootCAs is non-nil, want nil (use system roots)") + } + if len(ep.TLSConfig.Certificates) != 0 { + t.Error("client certificate present, want none (server-auth only)") + } + if ep.TLSConfig.InsecureSkipVerify { + t.Error("InsecureSkipVerify is true, want false (system roots must verify)") + } + if ep.TLSConfig.ServerName != "dockerd.internal" { + t.Errorf("ServerName = %q, want %q", ep.TLSConfig.ServerName, "dockerd.internal") + } +} + +// TestValidateSpec_TLSSystemRoots confirms the file-free validator accepts the +// system-roots spec (so admin/validate does not reject a DOCKER_TLS_VERIFY env +// drop-in on a host without cert files). +func TestValidateSpec_TLSSystemRoots(t *testing.T) { + t.Parallel() + if err := ValidateSpec(EndpointSpec{Address: "tcp://dockerd.internal:2376", TLSSystemRoots: true}); err != nil { + t.Fatalf("ValidateSpec: %v", err) + } +} + +// โ”€โ”€ Resolver.Start health loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_Start_Idempotent(t *testing.T) { + t.Parallel() + var calls atomic.Int64 + probe := func(_ context.Context, _ Endpoint) error { + calls.Add(1) + return nil + } + ep := Endpoint{Name: "/tmp/loop.sock", Network: "unix", Address: "/tmp/loop.sock"} + r, err := New([]Endpoint{ep}, Options{Probe: probe, Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r.Start(ctx) + r.Start(ctx) // second call must be a no-op + + // Wait briefly for the single startup probe. + time.Sleep(50 * time.Millisecond) + if calls.Load() != 1 { + t.Errorf("probe called %d times after two Start() calls with interval=-1, want 1", calls.Load()) + } +} + +func TestResolver_Start_ContextCancel_StopsLoop(t *testing.T) { + t.Parallel() + var calls atomic.Int64 + probe := func(_ context.Context, _ Endpoint) error { + calls.Add(1) + return nil + } + ep := Endpoint{Name: "/tmp/cancel.sock", Network: "unix", Address: "/tmp/cancel.sock"} + r, err := New([]Endpoint{ep}, Options{ + Probe: probe, + Interval: 10 * time.Millisecond, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + r.Start(ctx) + + // Let at least 2 probe ticks fire. + time.Sleep(50 * time.Millisecond) + cancel() + + snapshot := calls.Load() + if snapshot < 2 { + t.Errorf("expected at least 2 probe calls before cancel, got %d", snapshot) + } + + // After cancel the count should not grow (allow a brief settle). + time.Sleep(30 * time.Millisecond) + after := calls.Load() + if after > snapshot+1 { + t.Errorf("probe still running after ctx cancel: before=%d after=%d", snapshot, after) + } +} + +func TestResolver_Start_OnChange_Fires(t *testing.T) { + t.Parallel() + + type change struct { + ep Endpoint + healthy bool + } + changes := make(chan change, 10) + + ep0 := Endpoint{Name: "ep0", Network: "unix", Address: "/tmp/onchange-ep0.sock"} + ep1 := Endpoint{Name: "ep1", Network: "unix", Address: "/tmp/onchange-ep1.sock"} + + iteration := atomic.Int64{} + probe := func(_ context.Context, ep Endpoint) error { + // First round: ep0 healthy, ep1 unhealthy. + // Second round: ep0 unhealthy, ep1 healthy. + n := iteration.Load() + if n == 0 { + if ep.Name == "ep0" { + return nil + } + return errors.New("down") + } + if ep.Name == "ep0" { + return errors.New("down") + } + return nil + } + + r, err := New([]Endpoint{ep0, ep1}, Options{ + Probe: probe, + Interval: 20 * time.Millisecond, + OnChange: func(ep Endpoint, healthy bool) { + changes <- change{ep: ep, healthy: healthy} + }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + r.Start(ctx) + + // Collect the first two OnChange events (startup probe: ep0 up, ep1 down). + deadline := time.Now().Add(500 * time.Millisecond) + received := 0 + for time.Now().Before(deadline) && received < 2 { + select { + case <-changes: + received++ + default: + time.Sleep(5 * time.Millisecond) + } + } + if received < 2 { + t.Fatalf("expected 2 OnChange events from startup probe, got %d", received) + } + + // Trigger a state flip in the next probe round. + iteration.Add(1) + + // Collect the transition events (ep0 goes down, ep1 comes up). + received = 0 + deadline = time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) && received < 2 { + select { + case <-changes: + received++ + default: + time.Sleep(5 * time.Millisecond) + } + } + if received < 2 { + t.Fatalf("expected 2 OnChange events for state flip, got %d", received) + } +} + +func TestResolver_OnChange_NoFire_WhenSameState(t *testing.T) { + t.Parallel() + var count atomic.Int64 + ep := Endpoint{Name: "ep", Network: "unix", Address: "/tmp/nochange.sock"} + r, err := New([]Endpoint{ep}, Options{ + Probe: probeAlways(nil), // always healthy + Interval: 10 * time.Millisecond, + OnChange: func(_ Endpoint, _ bool) { count.Add(1) }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + r.Start(ctx) + + // Let several probe ticks run. + time.Sleep(80 * time.Millisecond) + cancel() + + // OnChange should fire exactly once: on the first known result. + if count.Load() != 1 { + t.Errorf("OnChange fired %d times, want 1 (only on first-known)", count.Load()) + } +} + +// โ”€โ”€ newTransport pool tunings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestEndpoint_NewTransport_PoolTunings(t *testing.T) { + t.Parallel() + ep := Endpoint{Name: "ep", Network: "unix", Address: "/tmp/pool.sock"} + tr := ep.newTransport() + + if got, want := tr.MaxIdleConns, defaultMaxIdleConns; got != want { + t.Errorf("MaxIdleConns = %d, want %d", got, want) + } + if got, want := tr.MaxIdleConnsPerHost, defaultMaxIdleConnsPerHost; got != want { + t.Errorf("MaxIdleConnsPerHost = %d, want %d", got, want) + } + if got, want := tr.IdleConnTimeout, defaultIdleConnTimeout; got != want { + t.Errorf("IdleConnTimeout = %v, want %v", got, want) + } + if got, want := tr.ResponseHeaderTimeout, defaultResponseHeaderTimeout; got != want { + t.Errorf("ResponseHeaderTimeout = %v, want %v", got, want) + } + // TLS is handled inside dial, so the transport must not carry a TLS config. + if tr.TLSClientConfig != nil { + t.Error("TLSClientConfig is non-nil, want nil (TLS handled inside dial)") + } + if tr.DialContext == nil { + t.Error("DialContext is nil, want the per-endpoint dialer") + } +} + +// โ”€โ”€ CheckReachable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_CheckReachable_AllReachable(t *testing.T) { + t.Parallel() + r, err := New([]Endpoint{ + {Name: "a", Network: "unix", Address: "/tmp/a.sock"}, + {Name: "b", Network: "unix", Address: "/tmp/b.sock"}, + }, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := r.CheckReachable(context.Background()); err != nil { + t.Fatalf("CheckReachable: %v", err) + } + // Both endpoints should be seeded known-healthy. + for _, s := range r.states { + if !s.known.Load() || !s.healthy.Load() { + t.Errorf("endpoint %s: known=%v healthy=%v, want both true", s.ep.Name, s.known.Load(), s.healthy.Load()) + } + } +} + +func TestResolver_CheckReachable_OneReachable_Succeeds(t *testing.T) { + t.Parallel() + // First endpoint down, second up: a failover set must still boot. + probe := func(_ context.Context, ep Endpoint) error { + if ep.Name == "down" { + return errors.New("connection refused") + } + return nil + } + r, err := New([]Endpoint{ + {Name: "down", Network: "unix", Address: "/tmp/down.sock"}, + {Name: "up", Network: "unix", Address: "/tmp/up.sock"}, + }, Options{Probe: probe, Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := r.CheckReachable(context.Background()); err != nil { + t.Fatalf("CheckReachable: %v (want success when one endpoint is up)", err) + } + if r.states[0].healthy.Load() { + t.Error("down endpoint marked healthy, want unhealthy") + } + if !r.states[1].healthy.Load() { + t.Error("up endpoint marked unhealthy, want healthy") + } +} + +func TestResolver_CheckReachable_AllDown_Errors(t *testing.T) { + t.Parallel() + r, err := New([]Endpoint{ + {Name: "a", Network: "unix", Address: "/tmp/a.sock"}, + {Name: "b", Network: "unix", Address: "/tmp/b.sock"}, + }, Options{Probe: probeAlways(errors.New("connection refused")), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + err = r.CheckReachable(context.Background()) + if err == nil { + t.Fatal("CheckReachable: nil error, want failure when all endpoints are down") + } + // Aggregated error should name both unreachable endpoints. + for _, name := range []string{"a", "b"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not mention endpoint %q", err.Error(), name) + } + } +} + +// โ”€โ”€ demote: request-scoped errors must not flap a healthy endpoint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolver_RoundTrip_RequestScopedError_NoDemote(t *testing.T) { + t.Parallel() + cases := []struct { + name string + ctx func() (context.Context, context.CancelFunc) + }{ + { + name: "canceled", + ctx: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx, func() {} + }, + }, + { + name: "deadline exceeded", + ctx: func() (context.Context, context.CancelFunc) { + return context.WithDeadline(context.Background(), time.Unix(0, 0)) + }, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Two endpoints, both seeded healthy. A request-scoped failure on the + // active endpoint must NOT demote it (it says nothing about upstream + // reachability) โ€” otherwise every client cancel / request_timeout + // would flap the primary. + r, err := New([]Endpoint{ + {Name: "a", Network: "unix", Address: "/tmp/reqscoped-a.sock"}, + {Name: "b", Network: "unix", Address: "/tmp/reqscoped-b.sock"}, + }, Options{Probe: probeAlways(nil), Interval: -1}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := r.CheckReachable(context.Background()); err != nil { + t.Fatalf("CheckReachable: %v", err) + } + + ctx, cancel := tc.ctx() + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/containers/json", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if _, rtErr := r.RoundTrip(req); rtErr == nil { + t.Fatal("RoundTrip: nil error, want a context error") + } + if !r.states[0].healthy.Load() { + t.Error("active endpoint was demoted on a request-scoped error, want still healthy") + } + }) + } +} + +// โ”€โ”€ doRoundTrip helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// doRoundTrip sends a GET to http://docker/containers/json through the resolver +// and returns the response body. The request Host is set to "docker" to +// satisfy the http.Transport requirement. +func doRoundTrip(t *testing.T, r *Resolver, _ string) string { + t.Helper() + req, err := http.NewRequest(http.MethodGet, "http://docker/containers/json", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := r.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(body) +} diff --git a/app/internal/visibility/middleware.go b/app/internal/visibility/middleware.go index 0d30f44f..0699b622 100644 --- a/app/internal/visibility/middleware.go +++ b/app/internal/visibility/middleware.go @@ -131,6 +131,13 @@ func Middleware(upstreamSocket string, logger *slog.Logger, opts Options) func(h return middlewareWithDeps(logger, opts, newVisibilityDeps(upstreamSocket)) } +// MiddlewareWithRoundTripper is Middleware over the shared upstream RoundTripper +// (typically an *upstream.Resolver) so visibility inspects follow the same +// active endpoint as the proxied request under failover. +func MiddlewareWithRoundTripper(rt http.RoundTripper, logger *slog.Logger, opts Options) func(http.Handler) http.Handler { + return middlewareWithDeps(logger, opts, newVisibilityDepsClient(dockerclient.NewWithRoundTripper(rt))) +} + func middlewareWithDeps(logger *slog.Logger, opts Options, deps visibilityDeps) func(http.Handler) http.Handler { defaultPolicy, mergedProfilePolicies, ok := compileVisibilityPolicies(logger, opts) if !ok { @@ -486,8 +493,12 @@ func imageItemVisibleByPatterns(raw json.RawMessage, policy *compiledPolicy) (bo } func newVisibilityDeps(upstreamSocket string) visibilityDeps { + return newVisibilityDepsClient(dockerclient.New(upstreamSocket)) +} + +func newVisibilityDepsClient(client *http.Client) visibilityDeps { inspector := upstreamInspector{ - client: dockerclient.New(upstreamSocket), + client: client, } cache := inspectcache.New( inspectcache.DefaultTTL, diff --git a/docs/.snyk b/docs/.snyk new file mode 100644 index 00000000..033e1774 --- /dev/null +++ b/docs/.snyk @@ -0,0 +1,16 @@ +# Snyk (https://snyk.io) policy file โ€” mirror of the root .snyk ignore. +# Snyk resolves this workspace's package.json standalone (no root +# lockfile or npm overrides context), so it reports next's pinned +# postcss 8.4.31 even though overrides force ^8.5.15 and the installed +# tree contains no vulnerable version. +version: v1.25.0 +ignore: + SNYK-JS-POSTCSS-16189065: + - '*': + reason: >- + Not installed: npm overrides pin postcss to ^8.5.15; the + lockfile has no 8.4.31. Snyk manifest-only resolution does + not apply npm overrides. + expires: 2026-09-15T00:00:00.000Z + created: 2026-06-11T00:00:00.000Z +patch: {} diff --git a/docs/assets/codeswhat-logo-original.svg b/docs/assets/codeswhat-logo-original.svg new file mode 100644 index 00000000..8d0fe9d7 --- /dev/null +++ b/docs/assets/codeswhat-logo-original.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index d6248065..e43068b9 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -28,6 +28,18 @@ upstream: socket: /var/run/docker.sock request_timeout: "" # opt-in total per-request deadline (Go duration, e.g. "30s"); empty = disabled + # Remote TCP endpoints with mTLS โ€” when set, socket is ignored. + # List endpoints in priority order; first healthy wins (active/passive failover). + # See Remote Upstreams & Failover for the full guide. + # endpoints: + # - address: tcp://dockerd-a:2376 + # tls: { ca_file: /certs/ca.pem, cert_file: /certs/cert.pem, key_file: /certs/key.pem } + # - address: tcp://dockerd-b:2376 + # tls: { ca_file: /certs/ca.pem, cert_file: /certs/cert.pem, key_file: /certs/key.pem } + # failover: + # health_interval: "5s" + # health_timeout: "2s" + log: level: info # debug, info, warn, error format: json # json, text @@ -76,6 +88,9 @@ request_body: deny_unconfined_seccomp: false # standalone toggle: deny seccomp=unconfined when no allowlist is set allowed_apparmor_profiles: [] # if non-empty, apparmor= profile must be in this list deny_unconfined_apparmor: false # standalone toggle: deny apparmor=unconfined when no allowlist is set + deny_selinux_disable: false # deny label=disable / label:disable SecurityOpt (turns off SELinux confinement) + deny_selinux_label_override: false # deny label=user:/role:/type:/level: SELinux context overrides + deny_unconfined_system_paths: false # deny systempaths=unconfined AND explicit empty MaskedPaths/ReadonlyPaths allow_host_userns: false # deny HostConfig.UsernsMode=host (default) allow_sysctls: false # deny a non-empty HostConfig.Sysctls map (default) allowed_runtimes: [] # allowlist for non-empty HostConfig.Runtime values (e.g. ["runsc", "kata-runtime"]); an empty/unset runtime selects the daemon default and is always permitted @@ -118,6 +133,9 @@ request_body: require_no_new_privileges: false # require ContainerSpec.Privileges.NoNewPrivileges (default off) require_readonly_rootfs: false # require ContainerSpec.ReadOnly=true (default off) require_drop_all_capabilities: false # require ContainerSpec.CapabilityDrop=["ALL"] (default off) + deny_unconfined_seccomp: false # deny ContainerSpec.Privileges.Seccomp.Mode=="unconfined" (default off) + deny_custom_seccomp_profiles: false # deny Seccomp.Mode=="custom" or a bare Profile blob (default off) + deny_unconfined_apparmor: false # deny ContainerSpec.Privileges.AppArmor.Mode=="disabled" (default off) swarm: allow_force_new_cluster: false allow_external_ca: false @@ -185,19 +203,20 @@ The default listener is loopback TCP `127.0.0.1:2375`, which keeps the Docker AP - Plaintext non-loopback TCP is rejected unless you set **both** `listen.insecure_allow_plain_tcp: true` and `listen.insecure_allow_unauthenticated_clients: true`. Both acknowledgments are required โ€” one without the other is rejected โ€” so a single fat-fingered flag cannot expose the listener. That mode is only for legacy compatibility on a private, trusted network. - `health.watchdog.enabled` starts an active upstream socket monitor that checks Docker every `health.watchdog.interval`, logs reachable/unreachable state transitions, and lets `/health` answer from the latest watchdog state instead of waiting for a scrape or probe to discover an outage. The watchdog *dials* the socket โ€” a liveness signal that only proves the socket accepts connections. - `health.readiness.enabled` adds an opt-in `/ready` endpoint (default path `/ready`) that goes one step further than the watchdog: instead of dialing the socket, it issues a real `GET /containers/json?limit=1` against the upstream Docker API every `health.readiness.interval` (per-probe deadline `health.readiness.timeout`). It returns `200` only when the daemon actually answers, and `503` on any transport error or non-2xx โ€” catching the failure mode where the socket stays connectable but request handling has wedged. Point a Kubernetes / load-balancer **readiness** check at `/ready` and a **liveness** check at `/health`. The path must start with `/` and must not collide with `health.path`, `metrics.path`, or `admin.path`; `interval` and `timeout` must be positive durations. The whole `health.*` block (readiness included) is immutable across hot reload โ€” changing it requires a restart. -- `upstream.request_timeout` is opt-in (empty = disabled) and bounds the **total** lifetime of a single proxied request as a Go duration string (e.g. `"30s"`). `ResponseHeaderTimeout` only caps the wait for response *headers*; a daemon that sends headers and then hangs the body โ€” or hangs a heavy read like `GET /containers/json` โ€” can otherwise pin a request indefinitely. When set, an expired finite request aborts its upstream connection and returns `504 Gateway Timeout` (`reason_code=upstream_request_timeout`), distinct from the `502` an unreachable socket yields. Long-lived endpoints are **exempt** so the deadline never severs a legitimately long response: event streams, follow/stream logs and stats, image pull/build/push/load, container export, image get, websocket attach, and the blocking `GET /containers/{id}/wait`; hijacked attach/exec-start connections already bypass it. Unlike `health.*`, this field is reload-mutable (only `upstream.socket` is immutable), so it takes effect on hot reload. Must be a positive duration when non-empty. +- `upstream.endpoints` is the ordered list of remote daemon addresses for TCP+mTLS or unix-socket connections. When non-empty, `upstream.socket` is ignored and sockguard uses the first healthy endpoint in the list (active/passive failover). `endpoints` and `upstream.failover.*` are reload-immutable โ€” changing them requires a restart. See [Remote Upstreams & Failover](/docs/multi-host) for the full guide including HA failover, mTLS setup, insecure opt-ins, and the `DOCKER_*` drop-in path. +- `upstream.request_timeout` is opt-in (empty = disabled) and bounds the **total** lifetime of a single proxied request as a Go duration string (e.g. `"30s"`). `ResponseHeaderTimeout` only caps the wait for response *headers*; a daemon that sends headers and then hangs the body โ€” or hangs a heavy read like `GET /containers/json` โ€” can otherwise pin a request indefinitely. When set, an expired finite request aborts its upstream connection and returns `504 Gateway Timeout` (`reason_code=upstream_request_timeout`), distinct from the `502` an unreachable socket yields. Long-lived endpoints are **exempt** so the deadline never severs a legitimately long response: event streams, follow/stream logs and stats, image pull/build/push/load, container export, image get, websocket attach, and the blocking `GET /containers/{id}/wait`; hijacked attach/exec-start connections already bypass it. Unlike `health.*`, this field is reload-mutable (`upstream.socket`, `upstream.endpoints`, and `upstream.failover` are immutable), so it takes effect on hot reload. Must be a positive duration when non-empty. - `metrics.enabled` is opt-in and serves Prometheus text metrics at `metrics.path` on the same listener. The endpoint is local to Sockguard, is never forwarded to Docker, bypasses Docker API allow rules like `/health`, and remains behind listener security plus `clients.allowed_cidrs`. Every scrape also exports a `sockguard_build_info{version,commit,build_date,go_version}` gauge and a `sockguard_start_time_seconds` gauge for version panels and uptime alerts. When the active watchdog is enabled, metrics also include `sockguard_upstream_socket_up` and `sockguard_upstream_watchdog_checks_total`; when the readiness probe is enabled, they also include `sockguard_upstream_api_up` and `sockguard_upstream_readiness_checks_total`. - `admin.enabled` is opt-in and exposes a single `POST ` endpoint (default `/admin/validate`) that runs the same parse + validate + compile pipeline as the offline `sockguard validate` command against a YAML body in the request payload. Useful as a CI gate before promoting a candidate config to production. Running policy is never mutated. The endpoint rides the main listener, so the listener's CIDR allowlist, mTLS posture, and per-profile rate-limit / concurrency caps all apply. Bodies are hard-capped at `admin.max_request_bytes` (default 512 KiB) via `http.MaxBytesReader` and return `413` on overflow. Non-POST methods return `405` with `Allow: POST`. The response body is a structured JSON report: `{"ok": bool, "rules": int, "profiles": int, "compat_active": bool, "errors": [...]?}`. A failing candidate returns `422` with the validator's per-issue error list; a passing candidate returns `200`. `admin.path` must start with `/` and must not collide with `health.path` or `metrics.path` when those endpoints are also enabled. - `reload.enabled` is opt-in and turns on hot reload of policy at runtime. When on, sockguard watches the loaded config file via `fsnotify` (Linux inotify / macOS kqueue) and also reloads on `SIGHUP`. A burst of editor events (vim's chmod + write + rename + create save dance, for example) is debounced into a single reload by `reload.debounce` (default `"250ms"`). The reload pipeline parses the new file, applies the same Tecnativa-compat env expansion the startup path uses, runs the full validator + rule compiler, and **atomically swaps** the running handler chain on success. In-flight requests at the moment of the swap complete on the previous chain; new requests immediately route through the new one โ€” no connections dropped. On any failure (file unreadable, YAML malformed, validator rejects, compile error) the running policy is preserved untouched. Hot reload is restricted to a reloadable subset of the config. The **immutable** fields โ€” `listen.*`, `upstream.socket`, `log.*`, `health.*`, `metrics.*`, `admin.*`, and `policy_bundle` trust material โ€” are bound at startup to long-lived sockets and goroutines that cannot be replaced from within a running process. A reload that would mutate any of those is refused (the running config stays in place, and the failure is logged with `changed_fields=...`); operators must restart sockguard to apply listener, upstream socket, log sink, health, metrics, or admin changes. Everything else โ€” `rules`, `clients.*`, `response.*`, `request_body.*`, `ownership.*`, `insecure_allow_*` โ€” is rebuilt and atomically applied on every successful reload. Reload outcomes are surfaced as Prometheus metrics: `sockguard_config_reload_total{result="ok|reject_load|reject_validation|reject_immutable|reject_signature"}` counter and a `sockguard_config_reload_last_success_timestamp_seconds` gauge (omitted from scrape output until the first successful reload). **SIGHUP semantics change** when hot reload is on: previously SIGHUP terminated sockguard (Go's default action for unhandled SIGHUP); with `reload.enabled: true` it triggers a reload and never terminates the process. Default is `reload.enabled: false` for backward compatibility โ€” operators who script around SIGHUP-as-shutdown must update their tooling before enabling reload. - W3C trace/log correlation is always on and has no config knob. Sockguard preserves valid incoming `traceparent` trace IDs and sampled flags, replaces the parent span with a proxy-local span ID for the forwarded request, and generates fresh local context when the caller does not send valid trace context. -- `POST /containers/create` bodies are inspected by default. Sockguard blocks `HostConfig.Privileged=true`, `HostConfig.NetworkMode=host`, `HostConfig.PidMode=host`, `HostConfig.IpcMode=host`, `HostConfig.UsernsMode=host`, a non-empty `HostConfig.Sysctls` map (unless `allow_sysctls: true`), a non-empty `HostConfig.Runtime` value not present in `allowed_runtimes` (an empty/unset runtime selects the daemon default and is always permitted โ€” only callers that explicitly request an alternate OCI runtime need an entry), bind mount sources outside `request_body.container_create.allowed_bind_mounts`, `HostConfig.Devices` host paths outside `request_body.container_create.allowed_devices`, `HostConfig.DeviceRequests` (unless explicitly allowed via `allow_device_requests` or `allowed_device_requests`), `HostConfig.DeviceCgroupRules` (unless explicitly allowed via `allow_device_cgroup_rules` or `allowed_device_cgroup_rules`), and any `HostConfig.CapAdd` entry that isn't covered by `allow_all_capabilities` or `allowed_capabilities`. Named volumes still work without allowlist entries because they are not host bind mounts. `allowed_device_requests` is the structured opt-in for GPU passthrough and similar device request policy โ€” each entry must specify a `driver` (exact match, case-insensitive), an `allowed_capabilities` list of capability sets (each request capability set must be a subset of at least one allowlisted set), and an optional `max_count` bound (`-1` means all devices; request `Count: -1` is only allowed when `max_count` is also `-1`); set `allow_device_requests: true` only when you need unrestricted device request access. `allowed_device_cgroup_rules` is the structured opt-in for cgroup device policy โ€” it accepts Docker cgroup rule strings (` : `) with `*` wildcards for major or minor, and denies request wildcards unless the matching allowlist entry also uses a wildcard at that position; set `allow_device_cgroup_rules: true` only when you need unrestricted cgroup device access. Optional opt-in rails enforce `no-new-privileges`, non-root `Config.User`, `HostConfig.ReadonlyRootfs=true`, `HostConfig.CapDrop=["ALL"]`, memory / CPU / PIDs limits, allowlisted seccomp and AppArmor profiles, and required `Config.Labels` keys โ€” all default to off, so a configuration that does not set them keeps prior behavior except for the CapAdd allowlist and host-userns default-deny noted above. `image_trust` adds cosign-backed signature verification: set `mode: enforce` to deny containers whose image lacks a valid signature from one of your `allowed_signing_keys` (PEM public keys) or `allowed_keyless` identities (Fulcio cert chain matched by issuer URL and SAN regex); set `mode: warn` to log failures and allow the request through instead. `require_rekor_inclusion: true` additionally requires a Rekor transparency log entry for keyless bundles. `verify_timeout` controls the per-verification network timeout (default 10s); set to a low value in air-gapped environments to fail fast. Verification resolves the image to its registry manifest digest, fetches the cosign signatures attached to it (the classic `sha256-.sig` tag and OCI 1.1 referrers are both checked), and only accepts a signature whose simple-signing payload binds to that exact digest โ€” so the registry must be reachable from the proxy, and private registries require ambient credentials (a mounted Docker `config.json`). Keyless verification additionally fetches the Sigstore trust root via TUF at startup, which needs network access and a writable TUF cache; if that fetch fails the policy fails closed. Keyed (PEM) verification needs neither network at startup nor a writable cache. Either mode is a no-op when the image reference is empty โ€” Docker refuses to create a container without an image anyway. +- `POST /containers/create` bodies are inspected by default. Sockguard blocks `HostConfig.Privileged=true`, `HostConfig.NetworkMode=host`, `HostConfig.PidMode=host`, `HostConfig.IpcMode=host`, `HostConfig.UsernsMode=host`, a non-empty `HostConfig.Sysctls` map (unless `allow_sysctls: true`), a non-empty `HostConfig.Runtime` value not present in `allowed_runtimes` (an empty/unset runtime selects the daemon default and is always permitted โ€” only callers that explicitly request an alternate OCI runtime need an entry), bind mount sources outside `request_body.container_create.allowed_bind_mounts`, `HostConfig.Devices` host paths outside `request_body.container_create.allowed_devices`, `HostConfig.DeviceRequests` (unless explicitly allowed via `allow_device_requests` or `allowed_device_requests`), `HostConfig.DeviceCgroupRules` (unless explicitly allowed via `allow_device_cgroup_rules` or `allowed_device_cgroup_rules`), and any `HostConfig.CapAdd` entry that isn't covered by `allow_all_capabilities` or `allowed_capabilities`. Named volumes still work without allowlist entries because they are not host bind mounts. `allowed_device_requests` is the structured opt-in for GPU passthrough and similar device request policy โ€” each entry must specify a `driver` (exact match, case-insensitive), an `allowed_capabilities` list of capability sets (each request capability set must be a subset of at least one allowlisted set), and an optional `max_count` bound (`-1` means all devices; request `Count: -1` is only allowed when `max_count` is also `-1`); set `allow_device_requests: true` only when you need unrestricted device request access. `allowed_device_cgroup_rules` is the structured opt-in for cgroup device policy โ€” it accepts Docker cgroup rule strings (` : `) with `*` wildcards for major or minor, and denies request wildcards unless the matching allowlist entry also uses a wildcard at that position; set `allow_device_cgroup_rules: true` only when you need unrestricted cgroup device access. Optional opt-in rails enforce `no-new-privileges`, non-root `Config.User`, `HostConfig.ReadonlyRootfs=true`, `HostConfig.CapDrop=["ALL"]`, memory / CPU / PIDs limits, allowlisted seccomp and AppArmor profiles, and required `Config.Labels` keys โ€” all default to off, so a configuration that does not set them keeps prior behavior except for the CapAdd allowlist and host-userns default-deny noted above. Three further opt-in rails cover the remaining `SecurityOpt` directives: `deny_selinux_disable` denies `label=disable` (and the legacy `label:disable` colon form), which turns off SELinux confinement; `deny_selinux_label_override` denies `label=user:`/`role:`/`type:`/`level:` SELinux context customization; and `deny_unconfined_system_paths` denies `systempaths=unconfined` **and** requests that set `MaskedPaths` or `ReadonlyPaths` to an explicit empty array โ€” the Docker CLI converts `--security-opt systempaths=unconfined` into `MaskedPaths: []` client-side, so a direct API caller can clear the masked-path protections without ever sending the SecurityOpt string; both vectors are blocked. All three default to off (pass-through), preserving existing behavior. `image_trust` adds cosign-backed signature verification: set `mode: enforce` to deny containers whose image lacks a valid signature from one of your `allowed_signing_keys` (PEM public keys) or `allowed_keyless` identities (Fulcio cert chain matched by issuer URL and SAN regex); set `mode: warn` to log failures and allow the request through instead. `require_rekor_inclusion: true` additionally requires a Rekor transparency log entry for keyless bundles. `verify_timeout` controls the per-verification network timeout (default 10s); set to a low value in air-gapped environments to fail fast. Verification resolves the image to its registry manifest digest, fetches the cosign signatures attached to it (the classic `sha256-.sig` tag and OCI 1.1 referrers are both checked), and only accepts a signature whose simple-signing payload binds to that exact digest โ€” so the registry must be reachable from the proxy, and private registries require ambient credentials (a mounted Docker `config.json`). Keyless verification additionally fetches the Sigstore trust root via TUF at startup, which needs network access and a writable TUF cache; if that fetch fails the policy fails closed. Keyed (PEM) verification needs neither network at startup nor a writable cache. Either mode is a no-op when the image reference is empty โ€” Docker refuses to create a container without an image anyway. - `POST /containers/create` also denies five `HostConfig` fields **unconditionally** โ€” no `request_body` setting opts back in: `VolumesFrom`, `UTSMode=host`, a non-empty `CgroupParent`, `GroupAdd`, and `ExtraHosts`. Each one opens a namespace-escape or privilege-escalation path, so it is blocked regardless of policy. - `POST /containers/*/exec` and `POST /exec/*/start` are inspected when `request_body.exec.allowed_commands` is non-empty. Sockguard denies argv vectors that match no allowlist entry โ€” each entry is an argv template whose tokens are sockguard globs (`*` matches a run of non-slash characters, `**` matches any sequence), and a command matches when its token count equals an entry's and every token matches the glob at that position, so an exec carrying a variable argument can be allowlisted without enumerating every literal form. It also denies privileged exec unless `allow_privileged: true`, denies root-user exec unless `allow_root_user: true`, and re-checks `POST /exec/*/start` against Docker's stored exec metadata before execution. Docker exposes exec inspect and exec start as separate API calls, so this start-time check has an unavoidable time-of-check/time-of-use window; keep exec allowlists and client profile assignments narrow. - `POST /images/create` is inspected by default. Sockguard blocks `fromSrc` imports unless `request_body.image_pull.allow_imports: true` and only allows Docker Hub official images unless you set `allow_all_registries: true` or list explicit `allowed_registries`. - `POST /build` is inspected by default. Sockguard blocks remote contexts, `networkmode=host`, and Dockerfiles that contain `RUN` instructions unless you explicitly allow those behaviors under `request_body.build.*`. - `POST /volumes/create` is inspected by default. Sockguard blocks non-local volume drivers and driver options unless you explicitly allow them under `request_body.volume.*`. - `POST /secrets/create` and `POST /configs/create` are inspected by default. Sockguard blocks custom and template drivers unless you explicitly allow them under `request_body.secret.*` and `request_body.config.*`. -- `POST /services/create` and `POST /services/*/update` are inspected by default. Sockguard blocks services that attach the `host` network, blocks bind mounts outside `request_body.service.allowed_bind_mounts`, constrains service images to Docker Hub official images unless you set `request_body.service.allow_all_registries: true` or list explicit `request_body.service.allowed_registries`, enforces the same `allow_all_capabilities` / `allowed_capabilities` capability allowlist and `allow_sysctls` gate as container-create, and applies `image_trust` cosign verification to the service's ContainerSpec image. Opt-in hardening rails mirror the container-create knobs onto the swarm `ContainerSpec`: `require_non_root_user` (denies a root or empty `ContainerSpec.User`), `require_no_new_privileges` (requires `ContainerSpec.Privileges.NoNewPrivileges: true`), `require_readonly_rootfs` (requires `ContainerSpec.ReadOnly: true`), and `require_drop_all_capabilities` (requires `ContainerSpec.CapabilityDrop` to include `ALL`) โ€” all default off, so services that do not set them keep prior behavior. (Swarm has no privileged mode, no per-service namespace sharing, and no runtime/device knobs, so those container-create rails have no service equivalent.) +- `POST /services/create` and `POST /services/*/update` are inspected by default. Sockguard blocks services that attach the `host` network, blocks bind mounts outside `request_body.service.allowed_bind_mounts`, constrains service images to Docker Hub official images unless you set `request_body.service.allow_all_registries: true` or list explicit `request_body.service.allowed_registries`, enforces the same `allow_all_capabilities` / `allowed_capabilities` capability allowlist and `allow_sysctls` gate as container-create, and applies `image_trust` cosign verification to the service's ContainerSpec image. Opt-in hardening rails mirror the container-create knobs onto the swarm `ContainerSpec`: `require_non_root_user` (denies a root or empty `ContainerSpec.User`), `require_no_new_privileges` (requires `ContainerSpec.Privileges.NoNewPrivileges: true`), `require_readonly_rootfs` (requires `ContainerSpec.ReadOnly: true`), and `require_drop_all_capabilities` (requires `ContainerSpec.CapabilityDrop` to include `ALL`) โ€” all default off, so services that do not set them keep prior behavior. Confinement-mode rails complete the parity: `deny_unconfined_seccomp` denies `ContainerSpec.Privileges.Seccomp.Mode: "unconfined"`, `deny_custom_seccomp_profiles` denies `Mode: "custom"` **and** the fail-closed case of a `Seccomp` object carrying a `Profile` blob with no `Mode` (an inline profile the proxy cannot vet), and `deny_unconfined_apparmor` denies `ContainerSpec.Privileges.AppArmor.Mode: "disabled"` (swarm's equivalent of unconfined AppArmor). Note that a custom seccomp profile can encode an allow-everything policy, so operators setting `deny_unconfined_seccomp` should usually set `deny_custom_seccomp_profiles` as well. (Swarm has no privileged mode, no per-service namespace sharing, and no runtime/device knobs, so those container-create rails have no service equivalent; swarm's seccomp/AppArmor settings are mode enums, so the named-profile allowlists from container-create do not apply.) - `POST /swarm/init`, `POST /swarm/join`, and `POST /swarm/update` are inspected by default. Sockguard blocks `ForceNewCluster`, external CA configuration, non-allowlisted join targets, token rotations, manager unlock-key rotations, manager autolock, and signing-CA updates unless you explicitly allow them under `request_body.swarm.*`. - `POST /networks/create`, `POST /networks/*/connect`, and `POST /networks/*/disconnect` are inspected by default. Sockguard blocks custom drivers, swarm/ingress/attachable/config-only controls, custom IPAM, driver options, endpoint static IP/MAC/alias/driver options, and forced disconnects unless explicitly allowed under `request_body.network.*`. - `POST /containers/*/update` is inspected by default. Sockguard blocks restart-policy changes, resource controls, privileged mode, device changes, and capability/security-profile fields unless explicitly allowed under `request_body.container_update.*`. @@ -216,7 +235,7 @@ All request-body policy fields default to the safest value unless noted. List fi | Group | Fields | Default behavior | | --- | --- | --- | -| `container_create` | `allow_privileged`, `allow_host_network`, `allow_host_pid`, `allow_host_ipc`, `allowed_bind_mounts`, `allow_all_devices`, `allowed_devices`, `allow_device_requests`, `allowed_device_requests`, `allow_device_cgroup_rules`, `allowed_device_cgroup_rules`, `require_no_new_privileges`, `require_non_root_user`, `require_readonly_rootfs`, `require_drop_all_capabilities`, `allow_all_capabilities`, `allowed_capabilities`, `require_memory_limit`, `require_cpu_limit`, `require_pids_limit`, `allowed_seccomp_profiles`, `deny_unconfined_seccomp`, `allowed_apparmor_profiles`, `deny_unconfined_apparmor`, `allow_host_userns`, `allow_sysctls`, `allowed_runtimes`, `required_labels`, `image_trust` | Denies privileged containers, host network/PID/IPC/user namespaces, kernel sysctls, non-allowlisted bind sources, non-allowlisted device mappings, device requests, device cgroup rules, and non-allowlisted `CapAdd` entries. `allowed_runtimes` is an allowlist for non-empty `HostConfig.Runtime` values; an empty/unset runtime selects the daemon default and is always permitted, so only callers that select an alternate runtime (e.g. `runsc`, `kata-runtime`) need an entry. `allowed_device_requests` provides per-driver structured policy for `HostConfig.DeviceRequests` (GPU passthrough, etc.) โ€” each entry restricts by driver (exact match), allowed capability sets (request sets must be subsets), and optional `max_count`. `allowed_device_cgroup_rules` provides per-class device cgroup policy without blanket allow. Opt-in rails additionally require no-new-privileges, non-root execution, read-only rootfs, dropped capabilities, memory / CPU / PIDs limits, approved seccomp/AppArmor profiles, and required `Config.Labels` keys. `image_trust` adds cosign signature verification (keyed PEM keys or keyless Fulcio+Rekor) with `warn` (log + allow) or `enforce` (deny on failure) modes; `require_rekor_inclusion` defaults to `true`. `VolumesFrom`, host `UTSMode`, a custom `CgroupParent`, `GroupAdd`, and `ExtraHosts` are denied unconditionally with no opt-out field. | +| `container_create` | `allow_privileged`, `allow_host_network`, `allow_host_pid`, `allow_host_ipc`, `allowed_bind_mounts`, `allow_all_devices`, `allowed_devices`, `allow_device_requests`, `allowed_device_requests`, `allow_device_cgroup_rules`, `allowed_device_cgroup_rules`, `require_no_new_privileges`, `require_non_root_user`, `require_readonly_rootfs`, `require_drop_all_capabilities`, `allow_all_capabilities`, `allowed_capabilities`, `require_memory_limit`, `require_cpu_limit`, `require_pids_limit`, `allowed_seccomp_profiles`, `deny_unconfined_seccomp`, `allowed_apparmor_profiles`, `deny_unconfined_apparmor`, `deny_selinux_disable`, `deny_selinux_label_override`, `deny_unconfined_system_paths`, `allow_host_userns`, `allow_sysctls`, `allowed_runtimes`, `required_labels`, `image_trust` | Denies privileged containers, host network/PID/IPC/user namespaces, kernel sysctls, non-allowlisted bind sources, non-allowlisted device mappings, device requests, device cgroup rules, and non-allowlisted `CapAdd` entries. `allowed_runtimes` is an allowlist for non-empty `HostConfig.Runtime` values; an empty/unset runtime selects the daemon default and is always permitted, so only callers that select an alternate runtime (e.g. `runsc`, `kata-runtime`) need an entry. `allowed_device_requests` provides per-driver structured policy for `HostConfig.DeviceRequests` (GPU passthrough, etc.) โ€” each entry restricts by driver (exact match), allowed capability sets (request sets must be subsets), and optional `max_count`. `allowed_device_cgroup_rules` provides per-class device cgroup policy without blanket allow. Opt-in rails additionally require no-new-privileges, non-root execution, read-only rootfs, dropped capabilities, memory / CPU / PIDs limits, approved seccomp/AppArmor profiles, and required `Config.Labels` keys. `image_trust` adds cosign signature verification (keyed PEM keys or keyless Fulcio+Rekor) with `warn` (log + allow) or `enforce` (deny on failure) modes; `require_rekor_inclusion` defaults to `true`. Opt-in `deny_selinux_disable` / `deny_selinux_label_override` / `deny_unconfined_system_paths` rails cover the SELinux `label=` and `systempaths=` SecurityOpt directives (the last also blocks the direct-API `MaskedPaths: []` / `ReadonlyPaths: []` equivalent). `VolumesFrom`, host `UTSMode`, a custom `CgroupParent`, `GroupAdd`, and `ExtraHosts` are denied unconditionally with no opt-out field. | | `exec` | `allow_privileged`, `allow_root_user`, `allowed_commands` | Denies privileged/root exec and requires an argv allowlist before broad exec rules pass blind-write validation. | | `image_pull` | `allow_imports`, `allow_all_registries`, `allow_official`, `allowed_registries` | Denies `fromSrc` imports and allows Docker Hub official images by default (`allow_official: true`). | | `build` | `allow_remote_context`, `allow_host_network`, `allow_run_instructions` | Denies remote contexts, host-network builds, and Dockerfiles containing `RUN`. | @@ -226,7 +245,7 @@ All request-body policy fields default to the safest value unless noted. List fi | `volume` | `allow_custom_drivers`, `allow_driver_opts` | Denies non-local drivers and driver options. | | `network` | `allow_custom_drivers`, `allow_swarm_scope`, `allow_ingress`, `allow_attachable`, `allow_config_only`, `allow_config_from`, `allow_custom_ipam_drivers`, `allow_custom_ipam_config`, `allow_ipam_options`, `allow_driver_options`, `allow_endpoint_config`, `allow_disconnect_force` | Denies custom drivers, swarm/ingress/attachable/config-only controls, custom IPAM, driver options, endpoint static config, and forced disconnects. | | `secret` / `config` | `allow_custom_drivers`, `allow_template_drivers` | Denies custom and template drivers. | -| `service` | `allow_host_network`, `allowed_bind_mounts`, `allow_all_registries`, `allow_official`, `allowed_registries`, `allow_all_capabilities`, `allowed_capabilities`, `allow_sysctls`, `require_non_root_user`, `require_no_new_privileges`, `require_readonly_rootfs`, `require_drop_all_capabilities`, `image_trust` | Denies host-network services and non-allowlisted bind mounts; allows Docker Hub official images by default (`allow_official: true`). Enforces the same `allowed_capabilities` / `allow_all_capabilities` capability gate, `allow_sysctls` sysctl gate, and `image_trust` cosign verification on the ContainerSpec image as container-create. Opt-in rails mirror the container-create hardening knobs onto the swarm `ContainerSpec`: `require_non_root_user` (`ContainerSpec.User`), `require_no_new_privileges` (`ContainerSpec.Privileges.NoNewPrivileges`), `require_readonly_rootfs` (`ContainerSpec.ReadOnly`), and `require_drop_all_capabilities` (`ContainerSpec.CapabilityDrop` includes `ALL`) โ€” all default off. | +| `service` | `allow_host_network`, `allowed_bind_mounts`, `allow_all_registries`, `allow_official`, `allowed_registries`, `allow_all_capabilities`, `allowed_capabilities`, `allow_sysctls`, `require_non_root_user`, `require_no_new_privileges`, `require_readonly_rootfs`, `require_drop_all_capabilities`, `deny_unconfined_seccomp`, `deny_custom_seccomp_profiles`, `deny_unconfined_apparmor`, `image_trust` | Denies host-network services and non-allowlisted bind mounts; allows Docker Hub official images by default (`allow_official: true`). Enforces the same `allowed_capabilities` / `allow_all_capabilities` capability gate, `allow_sysctls` sysctl gate, and `image_trust` cosign verification on the ContainerSpec image as container-create. Opt-in rails mirror the container-create hardening knobs onto the swarm `ContainerSpec`: `require_non_root_user` (`ContainerSpec.User`), `require_no_new_privileges` (`ContainerSpec.Privileges.NoNewPrivileges`), `require_readonly_rootfs` (`ContainerSpec.ReadOnly`), and `require_drop_all_capabilities` (`ContainerSpec.CapabilityDrop` includes `ALL`) โ€” all default off. Confinement-mode rails: `deny_unconfined_seccomp` (`Privileges.Seccomp.Mode: "unconfined"`), `deny_custom_seccomp_profiles` (`Mode: "custom"`, or a `Profile` blob with no `Mode` โ€” fail-closed), and `deny_unconfined_apparmor` (`Privileges.AppArmor.Mode: "disabled"`) โ€” all default off. | | `swarm` | `allow_force_new_cluster`, `allow_external_ca`, `allowed_join_remote_addrs`, `allow_token_rotation`, `allow_manager_unlock_key_rotation`, `allow_auto_lock_managers`, `allow_signing_ca_update`, `allow_unlock` | Denies unsafe init/join/update controls and swarm unlock. `POST /swarm/join` needs `allowed_join_remote_addrs` before broad join rules pass blind-write validation. | | `node` | `allow_name_change`, `allow_role_change`, `allow_availability_change`, `allow_label_mutation`, `allowed_label_keys` | Denies name, role, availability, and arbitrary label mutations while allowing the configured owner-label key for controlled claims. | | `plugin` | `allow_host_network`, `allow_host_ipc`, `allow_host_pid`, `allow_all_devices`, `allowed_bind_mounts`, `allowed_devices`, `allow_all_capabilities`, `allowed_capabilities`, `allow_all_registries`, `allow_official`, `allowed_registries`, `allowed_set_env_prefixes` | Denies host namespaces, non-allowlisted mounts/devices/capabilities, and non-official registries by default (`allow_official: true`). `POST /plugins/*/set` needs `allowed_set_env_prefixes` before broad set rules pass blind-write validation. | @@ -375,7 +394,11 @@ The bucket refills continuously at `tokens_per_second`; its capacity is `burst` - `tokens_per_second` (required): refill rate; must be `> 0`. - `burst` (optional, default `= tokens_per_second`): bucket capacity. Must be `>= tokens_per_second` when explicitly set. Set to `0` to accept the default - (no burst allowance beyond one token per interval). + (no burst allowance beyond one token per interval). Upper bound: `65535` โ€” + the validator rejects larger values (the token bucket packs its state into a + single atomic word with a 16-bit integer token field). Since + `burst >= tokens_per_second`, the refill rate is implicitly bounded at 65535 + tokens/second as well. ```yaml clients: @@ -693,6 +716,8 @@ with `changed_fields=...`: - `listen.*` โ€” listener address, TLS material, and socket path - `upstream.socket` โ€” upstream Docker socket path +- `upstream.endpoints` and `upstream.failover` โ€” remote endpoint list and + health-probe loop parameters (bound to the long-lived Resolver at startup) - `log.*` โ€” log level, format, and output sink - `health.*` โ€” health endpoint path, watchdog, and readiness probe config - `metrics.*` โ€” metrics endpoint and path @@ -704,9 +729,10 @@ with `changed_fields=...`: re-sign the same YAML without a restart. Everything else โ€” `rules`, `clients.*`, `response.*`, `request_body.*`, -`ownership.*`, `insecure_allow_*`, and `upstream.request_timeout` (only -`upstream.socket` is pinned) โ€” is rebuilt and atomically applied on every -successful reload. +`ownership.*`, `insecure_allow_*`, and `upstream.request_timeout` (of the +upstream block, only `request_timeout` is mutable; `socket`, `endpoints`, and +`failover` are pinned) โ€” is rebuilt and atomically applied on every successful +reload. ### Reload outcomes @@ -882,6 +908,8 @@ even when not enumerated here. | `SOCKGUARD_LISTEN_SOCKET` | `listen.socket` | _(unset)_ | Switches to a unix socket listener. Sockguard hardens the socket to mode `0600` and rejects broader modes. | | `SOCKGUARD_UPSTREAM_SOCKET` | `upstream.socket` | `/var/run/docker.sock` | Path to the real Docker daemon socket Sockguard proxies to. | | `SOCKGUARD_UPSTREAM_REQUEST_TIMEOUT` | `upstream.request_timeout` | `""` | Opt-in total per-request deadline (Go duration, e.g. `30s`). Empty disables it. Finite requests over the deadline return `504`; streaming and long-lived endpoints are exempt. Reload-mutable. | +| `SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_INTERVAL` | `upstream.failover.health_interval` | `""` (resolver default: 5s) | Background health-probe interval per endpoint. Empty uses the resolver default of 5s; a negative value disables continuous probing (failures still detected at request time). Applies only when `upstream.endpoints` is set. Reload-immutable โ€” restart required. | +| `SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_TIMEOUT` | `upstream.failover.health_timeout` | `""` (resolver default: 2s) | Per-probe dial and TLS-handshake timeout. Empty uses the resolver default of 2s. Applies only when `upstream.endpoints` is set. Reload-immutable โ€” restart required. | ### Logging @@ -970,6 +998,9 @@ the gates below are denied before the request reaches Docker. | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_UNCONFINED_SECCOMP` | `request_body.container_create.deny_unconfined_seccomp` | `false` | When no allowlist is set, deny `seccomp=unconfined` specifically. | | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_APPARMOR_PROFILES` | `request_body.container_create.allowed_apparmor_profiles` | _empty_ | If non-empty, `HostConfig.SecurityOpt` `apparmor=` must be in the list. Include `docker-default` to accept the implicit default. | | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_UNCONFINED_APPARMOR` | `request_body.container_create.deny_unconfined_apparmor` | `false` | When no allowlist is set, deny `apparmor=unconfined` specifically. | +| `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_SELINUX_DISABLE` | `request_body.container_create.deny_selinux_disable` | `false` | Deny `label=disable` / `label:disable` SecurityOpt (turns off SELinux confinement). | +| `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_SELINUX_LABEL_OVERRIDE` | `request_body.container_create.deny_selinux_label_override` | `false` | Deny `label=user:`/`role:`/`type:`/`level:` SELinux context overrides. | +| `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_DENY_UNCONFINED_SYSTEM_PATHS`| `request_body.container_create.deny_unconfined_system_paths` | `false` | Deny `systempaths=unconfined` and explicit empty `MaskedPaths`/`ReadonlyPaths` arrays (the CLI-translated direct-API form). | | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOW_HOST_USERNS` | `request_body.container_create.allow_host_userns` | `false` | Allow `HostConfig.UsernsMode=host`. | | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOW_SYSCTLS` | `request_body.container_create.allow_sysctls` | `false` | Allow a non-empty `HostConfig.Sysctls` map (kernel parameter tuning). | | `SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_ALLOWED_RUNTIMES` | `request_body.container_create.allowed_runtimes` | _empty_ | Comma-separated non-empty `HostConfig.Runtime` values to allow (e.g. `runsc,kata-runtime`). An empty/unset runtime field selects the daemon default and is always permitted; only an explicit alternate runtime needs an entry. | @@ -1009,6 +1040,9 @@ multiple entries via the env var; e.g. | `SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_NO_NEW_PRIVILEGES` | `request_body.service.require_no_new_privileges` | `false` | Require `ContainerSpec.Privileges.NoNewPrivileges: true` on service writes. | | `SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_READONLY_ROOTFS` | `request_body.service.require_readonly_rootfs` | `false` | Require `ContainerSpec.ReadOnly: true` on service writes. | | `SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_DROP_ALL_CAPABILITIES`| `request_body.service.require_drop_all_capabilities` | `false` | Require `ContainerSpec.CapabilityDrop` to include `ALL` on service writes. | +| `SOCKGUARD_REQUEST_BODY_SERVICE_DENY_UNCONFINED_SECCOMP` | `request_body.service.deny_unconfined_seccomp` | `false` | Deny `ContainerSpec.Privileges.Seccomp.Mode: "unconfined"` on service writes. | +| `SOCKGUARD_REQUEST_BODY_SERVICE_DENY_CUSTOM_SECCOMP_PROFILES`| `request_body.service.deny_custom_seccomp_profiles` | `false` | Deny `Seccomp.Mode: "custom"`, or a `Profile` blob with no `Mode` (fail-closed). | +| `SOCKGUARD_REQUEST_BODY_SERVICE_DENY_UNCONFINED_APPARMOR` | `request_body.service.deny_unconfined_apparmor` | `false` | Deny `ContainerSpec.Privileges.AppArmor.Mode: "disabled"` on service writes. | | `SOCKGUARD_REQUEST_BODY_SWARM_ALLOWED_JOIN_REMOTE_ADDRS` | `request_body.swarm.allowed_join_remote_addrs` | _empty_ | Comma-separated `host:port` join targets allowed for `POST /swarm/join`. | | `SOCKGUARD_REQUEST_BODY_NODE_ALLOWED_LABEL_KEYS` | `request_body.node.allowed_label_keys` | _empty_ | Comma-separated label keys allowed in `POST /nodes/*/update`. | | `SOCKGUARD_REQUEST_BODY_PLUGIN_ALLOWED_SET_ENV_PREFIXES` | `request_body.plugin.allowed_set_env_prefixes` | _empty_ | Comma-separated `KEY=` prefixes allowed for `POST /plugins/*/set`. | diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index ab9a1052..e9768606 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -4,6 +4,7 @@ "index", "getting-started", "configuration", + "multi-host", "presets", "cis-docker-benchmark", "observability", diff --git a/docs/content/docs/multi-host.mdx b/docs/content/docs/multi-host.mdx new file mode 100644 index 00000000..74d4cee1 --- /dev/null +++ b/docs/content/docs/multi-host.mdx @@ -0,0 +1,187 @@ +--- +title: Remote Upstreams & Failover +description: Connect sockguard to a remote Docker daemon over TCP+mTLS, or configure two endpoints for active/passive HA failover with automatic health probing. +--- + +By default sockguard reaches Docker through a local unix socket. The `upstream.endpoints` block lifts that constraint: you can point sockguard at a remote daemon over TCP+TLS, or list two endpoints so a healthy standby takes over automatically when the primary goes down. + +## When to use this + +- **Single remote daemon** โ€” Docker runs on a different host than sockguard (a build host, a CI worker, a remote VM). You want mTLS between them so the daemon API is not exposed as plaintext on the wire. +- **HA / redundancy** โ€” you have two daemon hosts behind keepalived or a Swarm manager HA pair and want sockguard to stay healthy when one goes down. +- **`docker -H tcp://โ€ฆ` migration** โ€” you already have `DOCKER_HOST` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` set and want zero-config drop-in (see [DOCKER_* environment drop-in](#docker-environment-drop-in) below). + +## Single remote daemon (TCP + mTLS) + +The simplest remote setup: one endpoint, mutual TLS. + +```yaml filename="sockguard.yaml" +upstream: + endpoints: + - address: tcp://dockerd.internal:2376 + tls: + ca_file: /certs/ca.pem # verifies the daemon's server cert + cert_file: /certs/cert.pem # client cert sockguard presents + key_file: /certs/key.pem +``` + +`ca_file` is the CA that issued the daemon's TLS certificate. `cert_file` / `key_file` are the client keypair the daemon uses to authenticate sockguard. This mirrors the standard Docker mTLS setup (`dockerd --tlsverify`). + +When `endpoints` is non-empty, `upstream.socket` is ignored. You cannot mix a local socket fallback with remote endpoints. + +### SNI / hostname override + +By default the hostname for TLS verification is derived from the `address` host. If your cert uses a different name (e.g. a SAN that doesn't match the IP): + +```yaml +upstream: + endpoints: + - address: tcp://10.0.1.5:2376 + tls: + ca_file: /certs/ca.pem + cert_file: /certs/cert.pem + key_file: /certs/key.pem + server_name: dockerd.internal # override SNI and verified hostname +``` + +## HA failover with two endpoints + +List endpoints in priority order. Sockguard picks the first healthy one and routes all traffic through it. If that endpoint fails a health probe or a request dial, it is demoted and the next healthy endpoint takes over. + +```yaml filename="sockguard.yaml" +upstream: + endpoints: + - address: tcp://dockerd-a:2376 + tls: + ca_file: /certs/ca.pem + cert_file: /certs/cert.pem + key_file: /certs/key.pem + - address: tcp://dockerd-b:2376 + tls: + ca_file: /certs/ca.pem + cert_file: /certs/cert.pem + key_file: /certs/key.pem + failover: + health_interval: "5s" # probe period; empty = 5s default; negative disables continuous probing + health_timeout: "2s" # per-probe deadline; empty = 2s default +``` + +### How failover works + +- **Active endpoint** โ€” always the first known-healthy endpoint in list order. `dockerd-a` wins when both are healthy. +- **Health probe** โ€” sockguard dials each endpoint on the `health_interval` (TCP connect + TLS handshake for TLS endpoints). A probe that times out or is refused marks that endpoint unhealthy. +- **On dial failure during a request** โ€” the active endpoint is demoted immediately. The in-flight request fails and the client sees an error. The next request routes to the next healthy endpoint. +- **No automatic retry** โ€” the failing request is not retried. Docker writes are not idempotent, so a silent retry after a connection drop could execute an operation twice. Callers are expected to retry if the operation is safe to repeat. +- **Recovery** โ€” a demoted endpoint is re-probed on the health interval. Once it passes, it resumes its position in the priority order. + +> **Set `health_interval` to a negative value to disable continuous probing.** Sockguard will still detect failures at request time, but will not issue background health probes. Useful when probe traffic to the daemon is undesirable (metered links, audit-heavy environments). + +## Same-daemon constraint + + +All endpoints in the list MUST point to the same logical Docker daemon or Swarm cluster. This is active/passive redundancy โ€” not load balancing or fan-out across different daemons. + +Container IDs, exec sessions, volume state, and sockguard owner labels are daemon-local. Failing a live session from `dockerd-a` to a genuinely different `dockerd-b` would expose the caller to dangling IDs, missing state, and exec sessions that no longer exist. The proxy has no way to detect or compensate for that split. + +Correct use cases: a Swarm manager VIP with two manager IPs behind it, a keepalived HA pair sharing state, two addresses for the same daemon on different interfaces. + +Incorrect use case: two independent Docker hosts running different containers. Use separate sockguard instances for that. + + +## Insecure opt-ins + +Two flags loosen the TLS requirement. Both are explicit acknowledgments of the risk and should only appear in controlled environments. + +### Plaintext TCP (no TLS) + +```yaml +upstream: + endpoints: + - address: tcp://dockerd.internal:2376 + insecure_allow_plain_tcp: true +``` + +`insecure_allow_plain_tcp: true` permits a `tcp://` endpoint with no TLS material at all. The Docker API is sent in plaintext โ€” any host on the path can read or inject requests. Only use this on a private, trusted network with no external exposure. The flag mirrors the same acknowledgment on the listener side (`listen.insecure_allow_plain_tcp`). + +### Skip server certificate verification + +```yaml +upstream: + endpoints: + - address: tcp://dockerd.internal:2376 + tls: + cert_file: /certs/cert.pem + key_file: /certs/key.pem + insecure_skip_tls_verify: true # endpoint-level, a sibling of `tls` +``` + +`insecure_skip_tls_verify: true` skips verification of the daemon's server certificate. Traffic is still encrypted but the daemon's identity is not verified โ€” a man-in-the-middle can present any certificate. Useful for self-signed homelab certs when you control the network and cannot rotate the cert. It is an endpoint-level field (a sibling of `tls`, `address`, and `insecure_allow_plain_tcp`), not a key inside the `tls` block. Prefer providing the correct `ca_file` instead. + +## DOCKER_* environment drop-in + +If you have a working `docker -H tcp://โ€ฆ` setup with the standard Docker client env vars, sockguard picks them up automatically when no `endpoints` are configured in YAML: + +| Environment variable | Effect | +|---|---| +| `DOCKER_HOST=tcp://host:port` | Routes to that TCP address | +| `DOCKER_TLS_VERIFY=1` | Enables TLS verification | +| `DOCKER_CERT_PATH=/path` | Loads `ca.pem`, `cert.pem`, `key.pem` from that directory | + +Precedence: `upstream.endpoints` (YAML) > `DOCKER_HOST` (env) > `upstream.socket` (YAML/default). The env path only activates when `DOCKER_HOST` names a `tcp://` daemon; a `unix://` (or unset) `DOCKER_HOST` falls through to the local-socket default. + +Sockguard follows the same semantics as the Docker CLI, so no YAML acknowledgment is needed for the env drop-in: + +- **`DOCKER_TLS_VERIFY` set + `DOCKER_CERT_PATH`** โ†’ verified mTLS using `ca.pem` / `cert.pem` / `key.pem` from the cert directory. +- **`DOCKER_TLS_VERIFY` unset + `DOCKER_CERT_PATH` set** โ†’ encrypted, but the daemon's server certificate is *not* verified (equivalent to `insecure_skip_tls_verify`), matching the CLI's behavior when verify is off but certs are present. +- **`DOCKER_TLS_VERIFY` unset + no `DOCKER_CERT_PATH`** โ†’ plaintext TCP (equivalent to `insecure_allow_plain_tcp`). The acknowledgment is implicit because your `docker -H` client already talks to that daemon in plaintext. + +This means an existing Docker CLI setup works with zero YAML changes โ€” just point sockguard at the same env vars your client uses. To override any of these, set `upstream.endpoints` in YAML, which takes precedence over the environment. + +## Reload immutability + +`upstream.endpoints` and `upstream.failover` are **reload-immutable**. Adding, removing, or changing endpoints requires a process restart. `upstream.request_timeout` remains reload-mutable and takes effect on hot reload without a restart. + +This matches the behavior of `upstream.socket`, which is also pinned at startup. The upstream transport is bound to long-lived connection pools that cannot be swapped safely from within a running process. + +## Unix socket endpoints + +You can also reference a unix socket explicitly in the `endpoints` list, which is useful when you want the health probing and failover machinery even for a local socket: + +```yaml +upstream: + endpoints: + - address: unix:///var/run/docker.sock + - address: /var/run/docker-secondary.sock # bare path treated as unix:// +``` + +A bare path (starting with `/`) is treated as a `unix://` address. No TLS fields apply to unix endpoints. + +## Full schema reference + +```yaml +upstream: + socket: /var/run/docker.sock # legacy; used only when endpoints is empty + request_timeout: "" # Go duration (e.g. "30s"); empty = disabled; reload-mutable + endpoints: + - address: tcp://dockerd-a:2376 + tls: + ca_file: /certs/ca.pem + cert_file: /certs/cert.pem + key_file: /certs/key.pem + server_name: "" # SNI override; empty = derived from address host + insecure_allow_plain_tcp: false # permit tcp:// with no TLS (plaintext) + insecure_skip_tls_verify: false # skip daemon server-cert verification + - address: tcp://dockerd-b:2376 + tls: { ca_file: /certs/ca.pem, cert_file: /certs/cert.pem, key_file: /certs/key.pem } + failover: + health_interval: "5s" # empty = 5s default; negative = disable continuous probing + health_timeout: "2s" # empty = 2s default +``` + +Per-endpoint fields inside `endpoints` cannot be set via environment variable โ€” list types require YAML. The failover timing fields have env-var equivalents: + +| Variable | YAML field | Default | Description | +|---|---|---|---| +| `SOCKGUARD_UPSTREAM_REQUEST_TIMEOUT` | `upstream.request_timeout` | `""` | Total per-request deadline. Empty disables it. Reload-mutable. | +| `SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_INTERVAL` | `upstream.failover.health_interval` | `""` (resolver default: 5s) | Background probe interval per endpoint. Empty uses the 5s resolver default; negative disables probing. | +| `SOCKGUARD_UPSTREAM_FAILOVER_HEALTH_TIMEOUT` | `upstream.failover.health_timeout` | `""` (resolver default: 2s) | Per-probe dial+TLS-handshake timeout. Empty uses the 2s resolver default. | diff --git a/docs/content/docs/presets.mdx b/docs/content/docs/presets.mdx index 6e7cd73c..93f25314 100644 --- a/docs/content/docs/presets.mdx +++ b/docs/content/docs/presets.mdx @@ -1,6 +1,6 @@ --- title: Presets -description: Ready-made sockguard configs for drydock, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, GitHub Actions and GitLab runners, the CIS Docker Benchmark, and read-only dashboards. +description: Ready-made sockguard configs for drydock, drydock with self-update, Portwing, Portwing with exec, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, GitHub Actions and GitLab runners, the CIS Docker Benchmark, and read-only dashboards. --- Sockguard ships with ready-made config presets for common Docker consumers. All presets are bundled in the container image at `/etc/sockguard/`. @@ -27,6 +27,70 @@ Optimized for the [drydock](https://github.com/CodesWhat/drydock) container upda # explicit HostConfig.Runtime โ€” without it every update would 403 at /containers/create ``` +## Drydock with Self-Update (`drydock-with-selfupdate.yaml`) + +Extends the drydock preset with the exec paths required for drydock's self-update +finalize flow. The finalize step runs a short-lived `docker exec` inside the new +container to POST a completion callback to drydock's internal API. + +**Use for:** drydock deployments where the sockguard proxy is in the self-update +path and you need the finalize callback to complete cleanly. + +```yaml +# Extends drydock.yaml with: +# Allows: POST /containers/{id}/exec, POST /exec/{id}/start, GET /exec/{id}/json +# Exec body inspection: allow_privileged=false, allow_root_user=false +# allowed_commands pins exec to the single finalize entrypoint argv +# If your drydock image runs as root, set allow_root_user: true +``` + +Without this preset (using drydock.yaml), the self-update still completes โ€” the +old container is renamed, the new one starts โ€” but the finalize callback exec is +denied and drydock logs the failure. Use this preset to get a clean finalize. + +## Portwing (`portwing.yaml`) + +Optimized for the [Portwing](https://github.com/CodesWhat/portwing) Docker agent. + +**Use for:** Portwing in the tri-tool topology (sockguard โ†’ Portwing โ†’ drydock), or +any remote Docker agent that needs lifecycle control, image pull, and event streaming. + +```yaml +# Allows: container read (list, inspect, stats, top, changes, logs) +# Allows: container lifecycle (start, stop, restart, kill, rename, update, wait, create, remove) +# Allows: image read, pull, remove +# Allows: network read, volume read, distribution, Swarm service reads +# Denies: exec, build, swarm writes, secrets, plugins, raw archive/export/attach streams +# insecure_allow_read_exfiltration=true โ€” required because GET /containers/*/logs is allowed +# (Portwing's GetContainerLogs()); container logs can carry secrets. Drop the logs rule and this +# flag to harden. /containers/*/archive, /export, /attach stay denied (bulk-exfil paths). +# Response redactions disabled (redact_mount_paths=false, redact_container_env=false, +# redact_network_topology=false) โ€” required for drydock passthrough topology so +# inspect data forwarded to drydock is not corrupted by "" placeholders +# Standalone operators not paired with drydock can re-enable all three redactions +``` + +## Portwing with Exec (`portwing-with-exec.yaml`) + +Extends the Portwing preset with exec support for interactive terminal access through +the Portwing agent, e.g. terminal-over-websocket or drydock-driven exec in edge mode. + +**Use for:** Portwing deployments where exec sessions are needed (interactive terminals, +drydock edge mode exec calls). + +```yaml +# Extends portwing.yaml with: +# Allows: POST /containers/{id}/exec, POST /exec/{id}/start, +# POST /exec/{id}/resize, GET /exec/{id}/json +# Exec body inspection: allow_privileged=false, allow_root_user=true +# (most container workloads run as root; set false + use allowed_commands for +# non-root or command-pinned deployments) +# insecure_allow_body_blind_writes=true โ€” interactive exec can't be pinned to a fixed argv, +# so the body-blind-write guard is bypassed; the allow_privileged/allow_root_user layer still +# applies. insecure_allow_read_exfiltration=true is inherited from portwing.yaml for the +# GET /containers/*/logs rule (see the Portwing section above). +``` + ## Traefik (`traefik.yaml`) Minimal read-only access for Traefik reverse proxy. @@ -119,10 +183,15 @@ socket access to spawn `jobs.*.container` + `jobs.*.services` containers. workflows (i.e. anything from a fork PR or a low-trust org member). ```yaml -# Allows: container lifecycle, exec, attach, image pull, per-job networks, -# named volumes for cache -# Denies: privileged containers, host namespace sharing, bind mounts, -# capability additions, build, swarm, secrets, plugins, raw export +# Allows: container lifecycle, exec, attach, log streaming (GET /containers/*/logs), +# image pull, per-job networks, named volumes for cache +# Denies: privileged containers, privileged exec, host namespace sharing, bind mounts, +# capability additions, build, swarm, secrets, plugins, +# raw tarball export (/containers/*/export, /images/*/get) +# insecure_allow_body_blind_writes=true โ€” exec argv is not command-pinned (workflow steps are +# arbitrary); only privileged exec is denied. insecure_allow_read_exfiltration=true โ€” the runner +# streams job output via the logs and attach APIs. Gate the proxy socket to the runner process +# via clients.unix_peer_profiles or clients.allowed_cidrs to bound both. ``` The preset enforces what a workflow can *do* once admitted. Pair it @@ -139,11 +208,15 @@ to inject job steps. **Use for:** GitLab Runner deployments handling untrusted CI jobs. ```yaml -# Allows: container lifecycle, exec, attach, image pull, per-job networks, -# named volumes -# Denies: privileged containers (even if config.toml asks for them), +# Allows: container lifecycle, exec, attach, log streaming (GET /containers/*/logs), +# image pull, per-job networks, named volumes +# Denies: privileged containers (even if config.toml asks for them), privileged exec, # host namespace sharing, bind mounts, build, swarm, secrets, -# plugins, raw export +# plugins, raw tarball export (/containers/*/export, /images/*/get) +# insecure_allow_body_blind_writes=true โ€” exec argv is not command-pinned; the runner injects +# arbitrary job-step commands. insecure_allow_read_exfiltration=true โ€” job trace output streams +# via the logs and attach APIs. Gate the proxy socket to the runner via clients.unix_peer_profiles +# or clients.allowed_cidrs to bound both. ``` Notable: the preset **deliberately rejects privileged containers** even diff --git a/docs/package.json b/docs/package.json index 58319fa9..cc6edda6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,21 +10,27 @@ }, "dependencies": { "@vercel/analytics": "^2.0.1", - "fumadocs-core": "^16.9.3", - "fumadocs-mdx": "^15.0.10", - "fumadocs-ui": "^16.9.3", - "next": "16.2.7", + "fumadocs-core": "^16.10.3", + "fumadocs-mdx": "^15.0.12", + "fumadocs-ui": "^16.10.3", + "next": "16.2.9", "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { - "@tailwindcss/postcss": "4.3.0", - "@types/mdx": "2.0.13", - "@types/node": "^25.9.1", - "@types/react": "^19.2.16", + "@tailwindcss/postcss": "4.3.1", + "@types/mdx": "2.0.14", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", "@types/react-dom": "19.2.3", "postcss": "^8.5.15", - "tailwindcss": "4.3.0", + "tailwindcss": "4.3.1", "typescript": "^6.0.3" + }, + "overrides": { + "next": { + "postcss": "^8.5.15" + }, + "postcss": "^8.5.15" } } diff --git a/docs/public/sockguard-logo.png b/docs/public/sockguard-logo.png index 4809ae6d..bcb49169 100644 Binary files a/docs/public/sockguard-logo.png and b/docs/public/sockguard-logo.png differ diff --git a/examples/compose/multi-host/README.md b/examples/compose/multi-host/README.md new file mode 100644 index 00000000..31ee638c --- /dev/null +++ b/examples/compose/multi-host/README.md @@ -0,0 +1,45 @@ +# Sockguard + remote Docker daemon (TCP+TLS, active/passive failover) + +**Who this is for:** Teams running a remote Docker daemon or Swarm cluster (e.g. an HA active/standby pair) that want to proxy the Docker API over mTLS with automatic failover, without exposing the raw TCP endpoint to downstream tools. + +**What's exposed:** A unix socket shared via a named volume. The downstream `docker-cli` container connects to `/var/run/sockguard/sockguard.sock` using `DOCKER_HOST`. Sockguard dials the remote daemon over TCP+TLS; downstream tools never see the remote endpoint or its credentials. + +## Security tradeoffs + +| Control | Status | +|---|---| +| sockguard: `read_only`, `cap_drop: ALL`, `no-new-privileges` | Enabled | +| Remote daemon credentials (certs) never reach downstream containers | Yes โ€” certs mounted into sockguard only | +| Exec denied | Yes | +| Build denied | Yes | +| Raw log/archive streams denied | Yes โ€” no `GET /containers/*/logs` or `/export` rules | +| mTLS to remote daemon | Yes โ€” ca/cert/key required in `./certs/` | +| Failover to standby on health failure | Yes โ€” `health_interval: 5s`, `health_timeout: 2s` | + +## Failover semantics + +The `upstream.endpoints` list is an **ordered active/passive failover set**, not a load balancer. Sockguard picks the first healthy endpoint and only promotes the next one when the active endpoint fails its health check. Both endpoints must be the same logical Docker daemon or Swarm cluster (e.g. an HA pair sharing storage). Routing the same client across two independent daemons would break container ID references, exec sessions, and owner-label isolation. + +## Usage + +1. Drop your TLS certificates into `./certs/`: + - `ca.pem` โ€” CA that signed the daemon's server cert + - `cert.pem` โ€” client cert (must be trusted by the daemon) + - `key.pem` โ€” private key for the client cert + +2. Replace `dockerd-primary` and `dockerd-standby` in `sockguard.yaml` with real hostnames or IP addresses. + +3. Start the stack: + +```bash +docker compose up -d +``` + +4. Exec into the `docker-cli` container to verify connectivity: + +```bash +docker compose exec docker-cli docker info +docker compose exec docker-cli docker ps +``` + +Sockguard logs (`format: json`) appear under the `sockguard` service. The `/health` endpoint is available inside the stack at `http://sockguard/health` for external liveness probes. diff --git a/examples/compose/multi-host/docker-compose.yml b/examples/compose/multi-host/docker-compose.yml new file mode 100644 index 00000000..e1a67bac --- /dev/null +++ b/examples/compose/multi-host/docker-compose.yml @@ -0,0 +1,56 @@ +# examples/compose/multi-host/docker-compose.yml +# +# Sockguard + remote Docker daemon over TCP+TLS with active/passive failover. +# +# Topology: +# downstream tool +# โ””โ”€ unix socket (local, filtered) +# โ””โ”€ sockguard +# โ”œโ”€ dockerd-primary:2376 (active) โ† first healthy wins +# โ””โ”€ dockerd-standby:2376 (standby) โ† promoted if primary fails +# +# Both endpoints MUST be the same logical Docker daemon or Swarm (e.g. an +# active/standby HA pair sharing storage). This is ordered failover, not +# load-balancing: container IDs, exec sessions, and owner labels are all +# daemon-local. Routing the same client across two different daemons would +# break them. +# +# mTLS is the standard auth mechanism for Docker over TCP. Sockguard mounts +# the client cert/key and CA read-only; downstream tools see only the local +# unix socket and never touch the remote endpoint or its TLS material. +# +# Bring your own certs: drop ca.pem, cert.pem, and key.pem into ./certs/ +# and replace the service names in sockguard.yaml with real hostnames or IPs. + +services: + sockguard: + image: codeswhat/sockguard:latest + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + volumes: + - ./certs:/certs:ro + - ./sockguard.yaml:/etc/sockguard/sockguard.yaml:ro + - sockguard-socket:/var/run/sockguard + environment: + - SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock + + # Example downstream consumer: a docker CLI container that uses the + # sockguard socket instead of a raw Docker socket. + docker-cli: + image: docker:cli + restart: unless-stopped + depends_on: + - sockguard + volumes: + - sockguard-socket:/var/run/sockguard:ro + environment: + - DOCKER_HOST=unix:///var/run/sockguard/sockguard.sock + # Keep the container alive so you can exec in and run docker commands. + entrypoint: ["sh", "-c", "echo 'sockguard socket ready'; sleep infinity"] + +volumes: + sockguard-socket: diff --git a/examples/compose/multi-host/sockguard.yaml b/examples/compose/multi-host/sockguard.yaml new file mode 100644 index 00000000..fc51badc --- /dev/null +++ b/examples/compose/multi-host/sockguard.yaml @@ -0,0 +1,113 @@ +# examples/compose/multi-host/sockguard.yaml +# +# Standalone example config โ€” not mirroring any shipped preset in app/configs/. +# Demonstrates the upstream.endpoints failover block for remote Docker daemons +# over TCP+TLS. Adjust addresses, cert paths, and rules for your environment. +# +# Both endpoints must point to the same logical daemon or Swarm cluster. +# Sockguard picks the first healthy endpoint and only switches on failure. + +upstream: + endpoints: + - address: tcp://dockerd-primary:2376 + tls: + ca_file: /certs/ca.pem # CA cert that signed the daemon's server cert + cert_file: /certs/cert.pem # client cert for mutual TLS + key_file: /certs/key.pem + - address: tcp://dockerd-standby:2376 + tls: + ca_file: /certs/ca.pem + cert_file: /certs/cert.pem + key_file: /certs/key.pem + failover: + health_interval: "5s" + health_timeout: "2s" + +log: + level: info + format: json + access_log: true + +health: + enabled: true + path: /health + +rules: + # Ping and version โ€” needed by most clients for connection setup. + - match: { method: GET, path: "/_ping" } + action: allow + - match: { method: HEAD, path: "/_ping" } + action: allow + - match: { method: GET, path: "/version" } + action: allow + - match: { method: GET, path: "/info" } + action: allow + + # Events stream โ€” read-only; required by monitoring and compose tools. + - match: { method: GET, path: "/events" } + action: allow + + # Containers โ€” list, inspect, stats, logs. No exec, no archive streams. + - match: { method: GET, path: "/containers/json" } + action: allow + - match: { method: GET, path: "/containers/*/json" } + action: allow + - match: { method: GET, path: "/containers/*/stats" } + action: allow + - match: { method: GET, path: "/containers/*/top" } + action: allow + - match: { method: GET, path: "/containers/*/changes" } + action: allow + + # Container lifecycle writes. + - match: { method: POST, path: "/containers/*/start" } + action: allow + - match: { method: POST, path: "/containers/*/stop" } + action: allow + - match: { method: POST, path: "/containers/*/restart" } + action: allow + - match: { method: POST, path: "/containers/*/kill" } + action: allow + - match: { method: POST, path: "/containers/*/rename" } + action: allow + - match: { method: POST, path: "/containers/*/update" } + action: allow + - match: { method: POST, path: "/containers/*/wait" } + action: allow + - match: { method: DELETE, path: "/containers/*" } + action: allow + - match: { method: POST, path: "/containers/create" } + action: allow + + # Images โ€” inspect, history, pull, remove. No build. + - match: { method: GET, path: "/images/json" } + action: allow + - match: { method: GET, path: "/images/**/json" } + action: allow + - match: { method: GET, path: "/images/**/history" } + action: allow + - match: { method: POST, path: "/images/create" } + action: allow + - match: { method: DELETE, path: "/images/**" } + action: allow + + # Networks and volumes โ€” read-only. + - match: { method: GET, path: "/networks" } + action: allow + - match: { method: GET, path: "/networks/*" } + action: allow + - match: { method: GET, path: "/volumes" } + action: allow + - match: { method: GET, path: "/volumes/*" } + action: allow + + # Swarm service reads โ€” for cluster-aware tooling. + - match: { method: GET, path: "/services" } + action: allow + - match: { method: GET, path: "/services/*" } + action: allow + + # Catch-all deny. Everything not explicitly allowed above is blocked. + - match: { method: "*", path: "/**" } + action: deny + reason: "not allowed by multi-host example policy" diff --git a/examples/compose/portwing/README.md b/examples/compose/portwing/README.md new file mode 100644 index 00000000..11fd2899 --- /dev/null +++ b/examples/compose/portwing/README.md @@ -0,0 +1,42 @@ +# Sockguard + Portwing + +**Who this is for:** Teams running [Portwing](https://github.com/CodesWhat/portwing) as a remote Docker agent and wanting to eliminate the raw Docker socket mount from the Portwing container. + +**What's exposed:** A unix socket shared via a named volume. Portwing connects to `/var/run/sockguard/sockguard.sock` instead of `/var/run/docker.sock`. Port 4000 is the Portwing API. + +## Security tradeoffs + +| Control | Status | +|---|---| +| sockguard: `read_only`, `cap_drop: ALL`, `no-new-privileges` | Enabled | +| No raw socket in Portwing container | Yes โ€” named volume unix socket | +| Exec denied | Yes | +| Build denied | Yes | +| Log streaming allowed; raw archive/export/attach denied | `insecure_allow_read_exfiltration: true` (required for Portwing's `GetContainerLogs()`; `/containers/*/archive`, `/export`, `/attach` stay denied) | +| Image pulls | All registries allowed (Portwing tracks arbitrary images) | +| Bind mounts on container create | Denied unless you add paths to `allowed_bind_mounts` | +| Response redaction (env, mounts, network topology) | Disabled โ€” required for drydock passthrough topology | + +## Redaction note + +The Portwing preset disables `redact_mount_paths`, `redact_container_env`, and `redact_network_topology` because in the tri-tool topology (sockguard โ†’ Portwing โ†’ drydock) Portwing forwards container inspect data to drydock, which uses it to recreate containers during updates. If sockguard redacts those fields, drydock cannot reconstruct the original container spec. + +If you run Portwing in standalone mode without drydock, re-enable redactions by editing `sockguard.yaml`: + +```yaml +response: + redact_mount_paths: true + redact_container_env: true + redact_network_topology: true +``` + +## Usage + +```bash +docker compose up -d +# Portwing API: http://localhost:4000 +``` + +To allowlist bind mounts for containers Portwing recreates, add host paths to `sockguard.yaml` under `request_body.container_create.allowed_bind_mounts`. + +To enable exec sessions (interactive terminal access), switch to `app/configs/portwing-with-exec.yaml`. diff --git a/examples/compose/portwing/docker-compose.yml b/examples/compose/portwing/docker-compose.yml new file mode 100644 index 00000000..71572cc2 --- /dev/null +++ b/examples/compose/portwing/docker-compose.yml @@ -0,0 +1,44 @@ +# examples/compose/portwing/docker-compose.yml +# +# Sockguard + Portwing โ€” unix-socket mode, portwing preset. +# Sockguard writes a filtered unix socket into a shared named volume; +# Portwing reads that socket rather than mounting /var/run/docker.sock. +# +# Portwing needs: container list/inspect/stats, lifecycle (start/stop/ +# restart/kill/rename/update/create/remove), image pull/inspect/remove, +# /events, narrow network/volume/distribution reads, and Swarm service reads. +# The portwing preset covers all of these. Log streaming (GET /containers/*/logs) +# is allowed because Portwing's GetContainerLogs() API needs it, and +# insecure_allow_read_exfiltration: true acknowledges that tradeoff; exec, build, +# secrets, and raw archive/export/attach streams stay denied. + +services: + sockguard: + image: codeswhat/sockguard:latest + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./sockguard.yaml:/etc/sockguard/sockguard.yaml:ro + - sockguard-socket:/var/run/sockguard + environment: + - SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock + + portwing: + image: codeswhat/portwing:latest + restart: unless-stopped + depends_on: + - sockguard + ports: + - "4000:4000" + volumes: + - sockguard-socket:/var/run/sockguard:ro + environment: + - DOCKER_SOCKET=/var/run/sockguard/sockguard.sock + +volumes: + sockguard-socket: diff --git a/examples/compose/portwing/sockguard.yaml b/examples/compose/portwing/sockguard.yaml new file mode 100644 index 00000000..59d701d7 --- /dev/null +++ b/examples/compose/portwing/sockguard.yaml @@ -0,0 +1,112 @@ +# Sockguard config for Portwing โ€” mirrors the shipped portwing preset. +# Copy of app/configs/portwing.yaml; embedded here so the compose stack +# is self-contained. Update both files together if you change rules. +# +# Log streaming: GET /containers/{id}/logs is required by Portwing's +# GetContainerLogs() API. insecure_allow_read_exfiltration: true acknowledges +# that container logs can contain secrets; see app/configs/portwing.yaml for +# the full security-tradeoff rationale. + +upstream: + socket: /var/run/docker.sock + +log: + level: info + format: json + access_log: true + +health: + enabled: true + path: /health + +response: + redact_mount_paths: false + redact_container_env: false + redact_network_topology: false + +insecure_allow_read_exfiltration: true + +request_body: + container_create: + allowed_bind_mounts: [] + allowed_runtimes: + - runc + image_pull: + allow_all_registries: true + +rules: + - match: { method: GET, path: "/_ping" } + action: allow + - match: { method: HEAD, path: "/_ping" } + action: allow + - match: { method: GET, path: "/version" } + action: allow + - match: { method: GET, path: "/info" } + action: allow + - match: { method: GET, path: "/events" } + action: allow + + - match: { method: GET, path: "/containers/json" } + action: allow + - match: { method: GET, path: "/containers/*/json" } + action: allow + - match: { method: GET, path: "/containers/*/logs" } + action: allow + - match: { method: GET, path: "/containers/*/stats" } + action: allow + - match: { method: GET, path: "/containers/*/top" } + action: allow + - match: { method: GET, path: "/containers/*/changes" } + action: allow + + - match: { method: POST, path: "/containers/*/start" } + action: allow + - match: { method: POST, path: "/containers/*/stop" } + action: allow + - match: { method: POST, path: "/containers/*/restart" } + action: allow + - match: { method: POST, path: "/containers/*/kill" } + action: allow + - match: { method: POST, path: "/containers/*/rename" } + action: allow + - match: { method: POST, path: "/containers/*/update" } + action: allow + - match: { method: POST, path: "/containers/*/wait" } + action: allow + - match: { method: DELETE, path: "/containers/*" } + action: allow + - match: { method: POST, path: "/containers/create" } + action: allow + + - match: { method: GET, path: "/images/json" } + action: allow + - match: { method: GET, path: "/images/**/json" } + action: allow + - match: { method: GET, path: "/images/**/history" } + action: allow + - match: { method: POST, path: "/images/create" } + action: allow + - match: { method: DELETE, path: "/images/**" } + action: allow + + - match: { method: GET, path: "/networks" } + action: allow + - match: { method: GET, path: "/networks/*" } + action: allow + + - match: { method: GET, path: "/volumes" } + action: allow + - match: { method: GET, path: "/volumes/*" } + action: allow + + - match: { method: GET, path: "/distribution/**/json" } + action: allow + + - match: { method: GET, path: "/services" } + action: allow + - match: { method: GET, path: "/services/*" } + action: allow + + - match: { method: "*", path: "/**" } + action: deny + reason: "not allowed by portwing preset" diff --git a/package-lock.json b/package-lock.json index aa070680..de9e7c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "docs" ], "devDependencies": { - "@biomejs/biome": "^2.4.16", - "knip": "6.15.0", + "@biomejs/biome": "^2.5.0", + "knip": "6.16.1", "lefthook": "^2.1.9", - "turbo": "^2.9.16" + "turbo": "^2.9.18" }, "engines": { "node": ">=22.0.0" @@ -24,76 +24,24 @@ "hasInstallScript": true, "dependencies": { "@vercel/analytics": "^2.0.1", - "fumadocs-core": "^16.9.3", - "fumadocs-mdx": "^15.0.10", - "fumadocs-ui": "^16.9.3", - "next": "16.2.7", + "fumadocs-core": "^16.10.3", + "fumadocs-mdx": "^15.0.12", + "fumadocs-ui": "^16.10.3", + "next": "16.2.9", "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { - "@tailwindcss/postcss": "4.3.0", - "@types/mdx": "2.0.13", - "@types/node": "^25.9.1", - "@types/react": "^19.2.16", + "@tailwindcss/postcss": "4.3.1", + "@types/mdx": "2.0.14", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", "@types/react-dom": "19.2.3", "postcss": "^8.5.15", - "tailwindcss": "4.3.0", + "tailwindcss": "4.3.1", "typescript": "^6.0.3" } }, - "docs/node_modules/fumadocs-ui": { - "version": "16.9.3", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.9.3.tgz", - "integrity": "sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==", - "license": "MIT", - "dependencies": { - "@fumadocs/tailwind": "0.0.5", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-direction": "^1.1.1", - "@radix-ui/react-navigation-menu": "^1.2.14", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-presence": "^1.1.5", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "class-variance-authority": "^0.7.1", - "lucide-react": "^1.17.0", - "motion": "^12.40.0", - "next-themes": "^0.4.6", - "react-remove-scroll": "^2.7.2", - "rehype-raw": "^7.0.0", - "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.1.0", - "tailwind-merge": "^3.6.0", - "unist-util-visit": "^5.1.0" - }, - "peerDependencies": { - "@takumi-rs/image-response": "*", - "@types/mdx": "*", - "@types/react": "*", - "fumadocs-core": "16.9.3", - "next": "16.x.x", - "react": "^19.2.0", - "react-dom": "^19.2.0" - }, - "peerDependenciesMeta": { - "@takumi-rs/image-response": { - "optional": true - }, - "@types/mdx": { - "optional": true - }, - "@types/react": { - "optional": true - }, - "next": { - "optional": true - } - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -107,9 +55,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", - "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz", + "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -123,20 +71,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.16", - "@biomejs/cli-darwin-x64": "2.4.16", - "@biomejs/cli-linux-arm64": "2.4.16", - "@biomejs/cli-linux-arm64-musl": "2.4.16", - "@biomejs/cli-linux-x64": "2.4.16", - "@biomejs/cli-linux-x64-musl": "2.4.16", - "@biomejs/cli-win32-arm64": "2.4.16", - "@biomejs/cli-win32-x64": "2.4.16" + "@biomejs/cli-darwin-arm64": "2.5.0", + "@biomejs/cli-darwin-x64": "2.5.0", + "@biomejs/cli-linux-arm64": "2.5.0", + "@biomejs/cli-linux-arm64-musl": "2.5.0", + "@biomejs/cli-linux-x64": "2.5.0", + "@biomejs/cli-linux-x64-musl": "2.5.0", + "@biomejs/cli-win32-arm64": "2.5.0", + "@biomejs/cli-win32-x64": "2.5.0" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", - "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==", "cpu": [ "arm64" ], @@ -151,9 +99,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", - "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", + "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==", "cpu": [ "x64" ], @@ -168,9 +116,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", - "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz", + "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==", "cpu": [ "arm64" ], @@ -185,9 +133,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", - "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==", "cpu": [ "arm64" ], @@ -202,9 +150,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", - "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz", + "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==", "cpu": [ "x64" ], @@ -219,9 +167,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", - "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==", "cpu": [ "x64" ], @@ -236,9 +184,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", - "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz", + "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==", "cpu": [ "arm64" ], @@ -253,9 +201,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.16", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", - "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz", + "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==", "cpu": [ "x64" ], @@ -301,9 +249,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -317,9 +265,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -333,9 +281,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -349,9 +297,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -365,9 +313,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -381,9 +329,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -397,9 +345,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -413,9 +361,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -429,9 +377,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -445,9 +393,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -461,9 +409,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -477,9 +425,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -493,9 +441,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -509,9 +457,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -525,9 +473,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -541,9 +489,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -557,9 +505,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -573,9 +521,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -589,9 +537,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -605,9 +553,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -621,9 +569,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -637,9 +585,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -653,9 +601,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -669,9 +617,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -685,9 +633,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -701,9 +649,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -754,6 +702,22 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fuma-translate/react": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@fuma-translate/react/-/react-1.0.2.tgz", + "integrity": "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@fumadocs/tailwind": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@fumadocs/tailwind/-/tailwind-0.0.5.tgz", @@ -1321,13 +1285,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -1339,15 +1303,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz", - "integrity": "sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.7.tgz", - "integrity": "sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", "cpu": [ "arm64" ], @@ -1361,9 +1325,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.7.tgz", - "integrity": "sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", "cpu": [ "x64" ], @@ -1377,9 +1341,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.7.tgz", - "integrity": "sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", "cpu": [ "arm64" ], @@ -1393,9 +1357,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.7.tgz", - "integrity": "sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", "cpu": [ "arm64" ], @@ -1409,9 +1373,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.7.tgz", - "integrity": "sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", "cpu": [ "x64" ], @@ -1425,9 +1389,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.7.tgz", - "integrity": "sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", "cpu": [ "x64" ], @@ -1441,9 +1405,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.7.tgz", - "integrity": "sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", "cpu": [ "arm64" ], @@ -1457,9 +1421,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.7.tgz", - "integrity": "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", "cpu": [ "x64" ], @@ -2105,32 +2069,32 @@ ] }, "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2148,12 +2112,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -2171,19 +2135,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2201,15 +2165,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2226,28 +2190,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2260,9 +2206,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2275,25 +2221,25 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -2310,28 +2256,10 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2344,16 +2272,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2371,9 +2299,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2386,14 +2314,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2411,12 +2339,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2429,25 +2357,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2465,26 +2393,26 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -2501,40 +2429,22 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2552,13 +2462,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2576,13 +2486,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2600,12 +2509,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2622,39 +2531,21 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2672,20 +2563,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2703,9 +2594,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", - "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" @@ -2720,35 +2611,20 @@ } } }, - "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2766,9 +2642,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2781,13 +2657,13 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2800,12 +2676,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2818,12 +2694,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2836,9 +2712,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2851,9 +2727,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2866,12 +2742,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2884,12 +2760,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2902,12 +2778,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -2925,9 +2801,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, "node_modules/@shikijs/core": { @@ -3046,47 +2922,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -3100,9 +2976,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -3116,9 +2992,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -3132,9 +3008,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -3148,9 +3024,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -3164,9 +3040,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], @@ -3180,9 +3056,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], @@ -3196,9 +3072,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], @@ -3212,9 +3088,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], @@ -3228,9 +3104,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3249,7 +3125,7 @@ "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -3257,9 +3133,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -3273,9 +3149,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -3289,22 +3165,22 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", - "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", + "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "postcss": "^8.5.10", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "postcss": "8.5.15", + "tailwindcss": "4.3.1" } }, "node_modules/@turbo/darwin-64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.16.tgz", - "integrity": "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.18.tgz", + "integrity": "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==", "cpu": [ "x64" ], @@ -3316,9 +3192,9 @@ ] }, "node_modules/@turbo/darwin-arm64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.16.tgz", - "integrity": "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.18.tgz", + "integrity": "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ==", "cpu": [ "arm64" ], @@ -3330,9 +3206,9 @@ ] }, "node_modules/@turbo/linux-64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.16.tgz", - "integrity": "sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.18.tgz", + "integrity": "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==", "cpu": [ "x64" ], @@ -3344,9 +3220,9 @@ ] }, "node_modules/@turbo/linux-arm64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.16.tgz", - "integrity": "sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.18.tgz", + "integrity": "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg==", "cpu": [ "arm64" ], @@ -3358,9 +3234,9 @@ ] }, "node_modules/@turbo/windows-64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.16.tgz", - "integrity": "sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.18.tgz", + "integrity": "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==", "cpu": [ "x64" ], @@ -3372,9 +3248,9 @@ ] }, "node_modules/@turbo/windows-arm64": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.16.tgz", - "integrity": "sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.18.tgz", + "integrity": "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA==", "cpu": [ "arm64" ], @@ -3438,9 +3314,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", "license": "MIT" }, "node_modules/@types/ms": { @@ -3450,9 +3326,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3460,9 +3336,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", - "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3534,9 +3410,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3592,9 +3468,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3604,9 +3480,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -3820,9 +3696,9 @@ "link": true }, "node_modules/enhanced-resolve": { - "version": "5.21.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3877,9 +3753,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3889,32 +3765,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-string-regexp": { @@ -4109,9 +3985,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.9.3", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.9.3.tgz", - "integrity": "sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==", + "version": "16.10.3", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.3.tgz", + "integrity": "sha512-xXhqz/fqbN7pLlshJb/B5L+vzMJOmWxoPj7+KMRTa/4A669hKeeCBPpRAiooMqjblWqIRSxLiO02/ds8ltvUPQ==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -4119,15 +3995,15 @@ "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.1.0", - "tinyglobby": "^0.2.16", + "shiki": "^4.2.0", + "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" @@ -4210,9 +4086,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.0.10", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.10.tgz", - "integrity": "sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==", + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.12.tgz", + "integrity": "sha512-R4WenrNQxSKi+QU46Q1cscVWi+S90dj3As4jdN+vgChO2o0TVOj+FFIe3onWM7mglhPj53NxZp/upP+t/ryekQ==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -4220,12 +4096,12 @@ "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", - "tinyexec": "^1.2.2", - "tinyglobby": "^0.2.16", + "tinyexec": "^1.2.4", + "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -4273,6 +4149,59 @@ } } }, + "node_modules/fumadocs-ui": { + "version": "16.10.3", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.3.tgz", + "integrity": "sha512-0aSLdQ73EWoCmYcQYr2uNHlSB/s2fD+NMugtdZF3vC4lqs0MfyOtwnZPyYAskUnXNs6HECly/Hu6oY5JqmlkHg==", + "license": "MIT", + "dependencies": { + "@fuma-translate/react": "^1.0.2", + "@fumadocs/tailwind": "0.0.5", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-collapsible": "^1.1.13", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-direction": "^1.1.2", + "@radix-ui/react-navigation-menu": "^1.2.15", + "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-presence": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.14", + "class-variance-authority": "^0.7.1", + "lucide-react": "^1.17.0", + "motion": "^12.40.0", + "next-themes": "^0.4.6", + "react-remove-scroll": "^2.7.2", + "rehype-raw": "^7.0.0", + "scroll-into-view-if-needed": "^3.1.0", + "shiki": "^4.2.0", + "tailwind-merge": "^3.6.0", + "unist-util-visit": "^5.1.0" + }, + "peerDependencies": { + "@takumi-rs/image-response": "*", + "@types/mdx": "*", + "@types/react": "*", + "fumadocs-core": "16.10.3", + "next": "16.x.x", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "peerDependenciesMeta": { + "@takumi-rs/image-response": { + "optional": true + }, + "@types/mdx": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "next": { + "optional": true + } + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4574,9 +4503,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4586,9 +4525,9 @@ } }, "node_modules/knip": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.15.0.tgz", - "integrity": "sha512-uBaKFEGcu/HG4EY2gWFBMr+fBF43Jftoc2riJX51TKME1Z46C8UQIbNEusenYbEWihphxe2PY0Kns0yPvPYz4A==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.16.1.tgz", + "integrity": "sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==", "dev": true, "funding": [ { @@ -4606,7 +4545,6 @@ "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", - "minimist": "^1.2.8", "oxc-parser": "^0.133.0", "oxc-resolver": "^11.20.0", "picomatch": "^4.0.4", @@ -5048,9 +4986,9 @@ } }, "node_modules/lucide-react": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", - "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", + "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6091,16 +6029,6 @@ ], "license": "MIT" }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", @@ -6167,12 +6095,12 @@ } }, "node_modules/next": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.7.tgz", - "integrity": "sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", "dependencies": { - "@next/env": "16.2.7", + "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -6186,14 +6114,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.7", - "@next/swc-darwin-x64": "16.2.7", - "@next/swc-linux-arm64-gnu": "16.2.7", - "@next/swc-linux-arm64-musl": "16.2.7", - "@next/swc-linux-x64-gnu": "16.2.7", - "@next/swc-linux-x64-musl": "16.2.7", - "@next/swc-win32-arm64-msvc": "16.2.7", - "@next/swc-win32-x64-msvc": "16.2.7", + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { @@ -6229,34 +6157,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -6427,9 +6327,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -6782,9 +6682,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "optional": true, "bin": { @@ -6978,9 +6878,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "license": "MIT" }, "node_modules/tapable": { @@ -7006,9 +6906,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -7048,21 +6948,21 @@ "license": "0BSD" }, "node_modules/turbo": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.16.tgz", - "integrity": "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.18.tgz", + "integrity": "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==", "dev": true, "license": "MIT", "bin": { "turbo": "bin/turbo" }, "optionalDependencies": { - "@turbo/darwin-64": "2.9.16", - "@turbo/darwin-arm64": "2.9.16", - "@turbo/linux-64": "2.9.16", - "@turbo/linux-arm64": "2.9.16", - "@turbo/windows-64": "2.9.16", - "@turbo/windows-arm64": "2.9.16" + "@turbo/darwin-64": "2.9.18", + "@turbo/darwin-arm64": "2.9.18", + "@turbo/linux-64": "2.9.18", + "@turbo/linux-arm64": "2.9.18", + "@turbo/windows-64": "2.9.18", + "@turbo/windows-arm64": "2.9.18" } }, "node_modules/typescript": { @@ -7358,22 +7258,22 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-slot": "^1.2.4", - "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/postcss": "^4.3.1", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "next": "16.2.7", + "lucide-react": "^1.18.0", + "next": "16.2.9", "next-themes": "^0.4.6", "postcss": "^8.5.15", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.0" + "tailwindcss": "^4.3.1" }, "devDependencies": { - "@types/node": "^25.9.1", - "@types/react": "^19.2.16", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", "typescript": "^6.0.3" } } diff --git a/package.json b/package.json index d385b07f..28c9c7dd 100644 --- a/package.json +++ b/package.json @@ -14,16 +14,16 @@ "prepare": "test -d .git && lefthook install || true" }, "devDependencies": { - "@biomejs/biome": "^2.4.16", - "knip": "6.15.0", + "@biomejs/biome": "^2.5.0", + "knip": "6.16.1", "lefthook": "^2.1.9", - "turbo": "^2.9.16" + "turbo": "^2.9.18" }, "overrides": { "next": { - "postcss": "^8.5.14" + "postcss": "^8.5.15" }, - "postcss": "^8.5.14" + "postcss": "^8.5.15" }, "packageManager": "npm@11.13.0", "engines": { diff --git a/sockguard-logo-dark.png b/sockguard-logo-dark.png new file mode 100644 index 00000000..84d5c1b6 Binary files /dev/null and b/sockguard-logo-dark.png differ diff --git a/sockguard-logo.png b/sockguard-logo.png index 4809ae6d..bcb49169 100644 Binary files a/sockguard-logo.png and b/sockguard-logo.png differ diff --git a/website/.snyk b/website/.snyk new file mode 100644 index 00000000..033e1774 --- /dev/null +++ b/website/.snyk @@ -0,0 +1,16 @@ +# Snyk (https://snyk.io) policy file โ€” mirror of the root .snyk ignore. +# Snyk resolves this workspace's package.json standalone (no root +# lockfile or npm overrides context), so it reports next's pinned +# postcss 8.4.31 even though overrides force ^8.5.15 and the installed +# tree contains no vulnerable version. +version: v1.25.0 +ignore: + SNYK-JS-POSTCSS-16189065: + - '*': + reason: >- + Not installed: npm overrides pin postcss to ^8.5.15; the + lockfile has no 8.4.31. Snyk manifest-only resolution does + not apply npm overrides. + expires: 2026-09-15T00:00:00.000Z + created: 2026-06-11T00:00:00.000Z +patch: {} diff --git a/website/package.json b/website/package.json index 6508fc97..8c0d87d1 100644 --- a/website/package.json +++ b/website/package.json @@ -12,22 +12,28 @@ }, "dependencies": { "@radix-ui/react-slot": "^1.2.4", - "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/postcss": "^4.3.1", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "next": "16.2.7", + "lucide-react": "^1.18.0", + "next": "16.2.9", "next-themes": "^0.4.6", "postcss": "^8.5.15", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.0" + "tailwindcss": "^4.3.1" }, "devDependencies": { - "@types/node": "^25.9.1", - "@types/react": "^19.2.16", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", "typescript": "^6.0.3" + }, + "overrides": { + "next": { + "postcss": "^8.5.15" + }, + "postcss": "^8.5.15" } } diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png index 70176f2f..4bc0228c 100644 Binary files a/website/public/apple-touch-icon.png and b/website/public/apple-touch-icon.png differ diff --git a/website/public/favicon-96x96.png b/website/public/favicon-96x96.png index e2f054d3..a7f3f4f0 100644 Binary files a/website/public/favicon-96x96.png and b/website/public/favicon-96x96.png differ diff --git a/website/public/favicon.ico b/website/public/favicon.ico index b918bbe1..d271da58 100644 Binary files a/website/public/favicon.ico and b/website/public/favicon.ico differ diff --git a/website/public/favicon.svg b/website/public/favicon.svg index 35359153..df9d5898 100644 --- a/website/public/favicon.svg +++ b/website/public/favicon.svg @@ -1,3 +1 @@ -RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file + \ No newline at end of file diff --git a/website/public/sockguard-logo.png b/website/public/sockguard-logo.png index 4809ae6d..bcb49169 100644 Binary files a/website/public/sockguard-logo.png and b/website/public/sockguard-logo.png differ diff --git a/website/public/web-app-manifest-192x192.png b/website/public/web-app-manifest-192x192.png index d7be5756..ac23265f 100644 Binary files a/website/public/web-app-manifest-192x192.png and b/website/public/web-app-manifest-192x192.png differ diff --git a/website/public/web-app-manifest-512x512.png b/website/public/web-app-manifest-512x512.png index ccdb5db9..25efee65 100644 Binary files a/website/public/web-app-manifest-512x512.png and b/website/public/web-app-manifest-512x512.png differ diff --git a/website/src/app/data/comparison-rows.ts b/website/src/app/data/comparison-rows.ts index 6117c571..dadb2603 100644 --- a/website/src/app/data/comparison-rows.ts +++ b/website/src/app/data/comparison-rows.ts @@ -72,8 +72,7 @@ export const comparisonRows: ComparisonRow[] = [ wollomatic: "No", elevenNotes: "No", cetusguard: "Yes", - sockguard: "Roadmap (v1.2)", - planned: true, + sockguard: "Yes (TCP + TLS, failover)", }, { feature: "Read-side visibility / redaction", diff --git a/website/src/app/data/features.ts b/website/src/app/data/features.ts index b3a0f65b..93051ee6 100644 --- a/website/src/app/data/features.ts +++ b/website/src/app/data/features.ts @@ -11,6 +11,7 @@ import { Network, RefreshCw, ScanSearch, + Server, Shield, ShieldCheck, SlidersHorizontal, @@ -110,7 +111,7 @@ export const features: Feature[] = [ color: "text-blue-500 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-900/50", description: - "Declarative rules in YAML. Glob patterns for paths, first-match-wins evaluation, and 12 bundled workload presets (drydock, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, read-only, CIS Docker Benchmark, GitHub Actions self-hosted runner, GitLab Runner) plus the default config.", + "Declarative rules in YAML. Glob patterns for paths, first-match-wins evaluation, and 15 bundled workload presets (drydock, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, read-only, CIS Docker Benchmark, GitHub Actions self-hosted runner, GitLab Runner, Portwing, Portwing with exec, Drydock with self-update) plus the default config.", category: "control", }, { @@ -193,4 +194,13 @@ export const features: Feature[] = [ "fsnotify file watch and SIGHUP reload with immutable-field gating โ€” listener, upstream socket, and trust-material fields require a restart. `POST /admin/validate` dry-runs a candidate config without touching the running policy. `GET /admin/policy/version` returns the generation counter, config SHA-256, and verified bundle signer. Optionally binds the admin API to a dedicated listener so admin traffic never traverses the Docker-API filter chain.", category: "operations", }, + { + icon: Server, + title: "Remote Upstreams & Failover", + color: "text-emerald-500 dark:text-emerald-400", + bg: "bg-emerald-100 dark:bg-emerald-900/50", + description: + "Dial a remote Docker daemon over TCP with mutual TLS instead of the local socket. Configure an ordered set of redundant endpoints for the same daemon or swarm node with active health checks and automatic failover.", + category: "operations", + }, ]; diff --git a/website/src/app/layout.tsx b/website/src/app/layout.tsx index 14b71304..0ddce406 100644 --- a/website/src/app/layout.tsx +++ b/website/src/app/layout.tsx @@ -42,12 +42,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - - - - + + + + - + {children} diff --git a/website/src/app/page-data.test.mjs b/website/src/app/page-data.test.mjs index 3a698a39..f9220a9e 100644 --- a/website/src/app/page-data.test.mjs +++ b/website/src/app/page-data.test.mjs @@ -5,7 +5,7 @@ import { comparisonRows } from "./data/comparison-rows.ts"; import { features } from "./data/features.ts"; test("website features live in extracted data modules", () => { - assert.equal(features.length, 18); + assert.equal(features.length, 19); assert.deepEqual( features.map((feature) => feature.title), [ @@ -27,6 +27,7 @@ test("website features live in extracted data modules", () => { "Rate Limits & Concurrency Caps", "Per-Profile Rollout Modes", "Hot-Reload + Admin API", + "Remote Upstreams & Failover", ], ); assert.deepEqual(