From ef82787f8aae322ca1fa59e8fd5d4fc8fc5691aa Mon Sep 17 00:00:00 2001 From: Charlotte Hartmann Paludo Date: Thu, 9 Jul 2026 12:05:54 +0200 Subject: [PATCH] move from edgelesssys/contrast --- .github/workflows/ci.yml | 37 ++++++ .github/workflows/publish.yml | 43 +++++++ .gitignore | 4 + .golangci.yml | 86 +++++++++++++ LICENSE | 91 ++++++++++++++ README.md | 130 ++++++++++++++++++++ collateral-proxy.yml | 70 +++++++++++ flake.lock | 82 +++++++++++++ flake.nix | 162 ++++++++++++++++++++++++ go.mod | 30 +++++ go.sum | 48 ++++++++ internal/cache/cache.go | 198 ++++++++++++++++++++++++++++++ internal/cache/cache_test.go | 131 ++++++++++++++++++++ internal/proxy/proxy.go | 225 ++++++++++++++++++++++++++++++++++ internal/proxy/proxy_test.go | 157 ++++++++++++++++++++++++ internal/upstream/upstream.go | 63 ++++++++++ main.go | 77 ++++++++++++ overlays/nixpkgs.nix | 16 +++ treefmt.nix | 35 ++++++ version.txt | 1 + 20 files changed, 1686 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 collateral-proxy.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/proxy/proxy.go create mode 100644 internal/proxy/proxy_test.go create mode 100644 internal/upstream/upstream.go create mode 100644 main.go create mode 100644 overlays/nixpkgs.nix create mode 100644 treefmt.nix create mode 100644 version.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..784b374 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 +name: ci +on: + workflow_dispatch: + push: + branches: + - main + pull_request: +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v14 + - name: Run flake checks + run: nix flake check -L + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v14 + - name: Run golangci-lint + run: nix run .#lint + govulncheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v14 + - name: Run govulncheck + run: nix run .#govulncheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..51751a0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 +name: container +on: + workflow_dispatch: + push: + branches: + - main + tags: + - "v*" +permissions: + contents: write + packages: write +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v14 + - name: Log in to ghcr.io + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push versioned tag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: nix run .#push -- "v$(cat version.txt)" + - name: Push latest tag (main only) + if: github.ref == 'refs/heads/main' + run: nix run .#push -- latest + - name: Render manifest and publish release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + nix run .#render-k8s-resources > collateral-proxy.yml + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --generate-notes \ + collateral-proxy.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f27deef --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/result +/result-* +/vendor +.direnv/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a12c0e0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,86 @@ +version: "2" +run: + modules-download-mode: readonly +output: + formats: + tab: + path: stderr +linters: + enable: + # keep-sorted start + - bodyclose + - contextcheck + - copyloopvar + - errchkjson + - errname + - errorlint + - exptostd + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - godot + - intrange + - misspell + - nilerr + - noctx + - nolintlint + - nosprintfhostport + - predeclared + - promlinter + - reassign + - revive + - sloglint + - testifylint + - unconvert + - unparam + - usestdlibvars + - usetesting + - wastedassign + # keep-sorted end + settings: + testifylint: + disable: + - require-error + revive: + rules: + # These are the recommended rules from + # https://github.com/mgechev/revive/blob/v1.9.0/README.md?plain=1#L419-L441 + # without 'package-comments'. + # keep-sorted start + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: unreachable-code + - name: unused-parameter + - name: var-declaration + - name: var-naming + # keep-sorted end + exclusions: + generated: strict + warn-unused: true + presets: + - std-error-handling +issues: + max-issues-per-linter: 0 + max-same-issues: 20 +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: strict diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8a06c06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,91 @@ +Business Source License 1.1 + +Parameters + +Licensor: Edgeless Systems GmbH +Licensed Work: collateral-proxy + The Licensed Work is (c) Edgeless Systems GmbH +Additional Use Grant: None + +Change Date: Four years from the date a MINOR version (SemVer) is published. + +Change License: GNU Affero General Public License Version 3 (AGPL-3.0-only) + +For information about alternative licensing arrangements for the Software, +please visit: https://www.edgeless.systems/enterprise-support + +Notice + +License text copyright (c) 2023 MariaDB plc, All Rights Reserved. +“Business Source License” is a trademark of MariaDB plc. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERBUSL-1.1TED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIBUSL-1.1ATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddd467d --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ + + +# collateral-proxy + +Read-through caching forward proxy for attestation collateral (AMD KDS, Intel PCS, NVIDIA RIM). + +Clients request collateral using the **same paths** the upstream vendors use, but against the proxy's address instead of the vendor host. +The proxy maps the path prefix to the upstream, serves a fresh copy from its cache when it has one, and otherwise fetches, stores, and returns the upstream response. + +### Routing + +Requests are routed to an upstream by path prefix. Only `GET` is accepted (anything else returns `405`). +Unknown paths return `404`. + +| Path prefix | Upstream host | Vendor | +| --------------------- | ------------------------------------------ | ----------------- | +| `/vcek/`, `/vlek/` | `kdsintf.amd.com` | AMD KDS | +| `/sgx/`, `/tdx/` | `api.trustedservices.intel.com` | Intel PCS | +| `/IntelSGX…` | `certificates.trustedservices.intel.com` | Intel SGX Root CA | +| `/v1/rim/` | `rim.attestation.nvidia.com` | NVIDIA RIM | + +For a matched request the proxy reconstructs the upstream URL as `https://?`. +The path and query string are passed through unchanged, only the host (and scheme) are set by the proxy. + +### Request flow + +1. Look up the reconstructed upstream URL in the cache. +2. Fresh cache entry exists: return the cached response directly. +3. Stale cache entry exists: fetch the upstream. + - On success, the response is returned to the caller. + - On upstream failure, return stale entry as fallback. +4. No cache entry exists: fetch the upstream. + - On success, the response is returned to the caller. + - On upstream failure, return `502 Bad Gateway`. + +Relevant response headers are forwarded to the client. +Only `200 OK` responses from upstream are cached. + +### Cache & freshness + +Each entry's freshness lifetime is computed at store time, in priority order: + +1. CRLs: the `nextUpdate` field parsed from the CRL itself. +2. Otherwise the upstream response's `Cache-Control: max-age` (honoring `no-cache` / `no-store` / `must-understand`). +3. Otherwise a default TTL of 1 hour. + +### Endpoints + +- `GET /healthz`: returns `ok`, use as a readiness probe. +- `GET /metrics`: Prometheus metrics: + - `collateral_proxy_requests_total{result, document}`: + - `result` is one of `hit`, `miss`, `stale`, `error`, `rejected` + - `document` is one of `crl`, `ak-cert`, `collateral`, `unknown` + - `collateral_proxy_upstream_responses_total{code, document}`: upstream outcomes by HTTP status code (or `error` when the fetch itself failed). + +## Usage + +### Flags + +| Flag | Default | Description | +| ------------------- | --------------------------- | ----------------------------------------- | +| `-addr` | `:80` | Listen address. | +| `-state-dir` | `/var/lib/collateral-proxy` | Directory for on-disk cache state. | +| `-upstream-timeout` | `10s` | Per-request timeout for upstream fetches. | + +### Running the container + +The published image is `ghcr.io/edgelesssys/collateral-proxy:latest`: + +```sh +docker run -p 8080:80 -v collateral-proxy-state:/var/lib/collateral-proxy ghcr.io/edgelesssys/collateral-proxy:latest +``` + +Mount a persistent volume at `-state-dir` so the cache survives restarts. + +### Deploying on Kubernetes + +Each release attaches a [`collateral-proxy.yaml`](https://github.com/edgelesssys/collateral-proxy/releases/latest/download/collateral-proxy.yml) asset to its [GitHub Release](https://github.com/edgelesssys/collateral-proxy/releases/latest/). + +```sh +curl -fLO https://github.com/edgelesssys/collateral-proxy/releases/latest/download/collateral-proxy.yml +kubectl apply -f collateral-proxy.yml +``` + +### Pointing clients at the proxy + +Clients fetch collateral from the proxy using the vendor's own paths. For example, a VCEK certificate normally fetched from + +``` +https://kdsintf.amd.com/vcek/v1/Milan/?blSPL=...&teeSPL=... +``` + +is instead fetched from the proxy: + +``` +http:///vcek/v1/Milan/?blSPL=...&teeSPL=... +``` + +The proxy preserves the path and query and rewrites only the host, so clients only need their collateral base URL repointed at the proxy. + +## Development + +- Prerequisites: Nix (flakes) and/or a Go toolchain; `direnv`/`.envrc`. +- Build the binary: `nix build .#collateral-proxy`. +- Build the container image: `nix build .#container`. +- Push the image: `nix run .#push -- [tag]` (defaults to the `:dev`). +- Push the image and render the pinned deployment manifest: `nix run .#render-k8s-resources -- [tag]`. +- Format: `nix fmt`. +- Lint: `nix run .#lint`. +- Vuln scan: `nix run .#govulncheck`. +- Run formatters and tests: `nix flake check`. + +## Releasing + +1. Bump version in `version.txt`. + +2. Push to `main` + ```sh + git commit -am "release: v0.X.0" + git push origin release-v0.X.0 + ``` + +3. Open a PR and merge to `main`. + +4. CI running on main publishes `ghcr.io/edgelesssys/collateral-proxy:v0.X.0` and moves `:latest`. + +5. Push the `v0.X.0` tag. CI then publishes a GitHub Release and attaches `collateral-proxy.yaml`, the deployment manifest pinned to `v0.X.0@sha256:`. diff --git a/collateral-proxy.yml b/collateral-proxy.yml new file mode 100644 index 0000000..050c8f1 --- /dev/null +++ b/collateral-proxy.yml @@ -0,0 +1,70 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 +# +# This is a template: the %%pin%% placeholder below is replaced with the +# pinned image reference (ghcr.io/edgelesssys/collateral-proxy:v@sha256:) at release time, +# and the rendered manifest is attached to the GitHub Release as collateral-proxy.yml. +# See `nix run .#render-k8s-resources`. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: collateral-proxy + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: collateral-proxy + serviceName: collateral-proxy + template: + metadata: + labels: + app.kubernetes.io/name: collateral-proxy + spec: + containers: + - args: + - -addr=:80 + - -state-dir=/var/lib/collateral-proxy + image: "%%pin%%" + name: collateral-proxy + ports: + - containerPort: 80 + name: proxy + readinessProbe: + httpGet: + path: /healthz + port: 80 + periodSeconds: 5 + resources: + limits: + memory: 256Mi + requests: + memory: 256Mi + volumeMounts: + - mountPath: /var/lib/collateral-proxy + name: state + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: state + namespace: default + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: collateral-proxy + namespace: default +spec: + ports: + - name: proxy + port: 80 + targetPort: 80 + selector: + app.kubernetes.io/name: collateral-proxy diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f8bab99 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..895f2f4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,162 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + treefmt-nix, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import ./overlays/nixpkgs.nix) ]; + }; + inherit (pkgs) lib; + version = lib.trim (builtins.readFile ./version.txt); + image = "ghcr.io/edgelesssys/collateral-proxy"; + treefmtEval = treefmt-nix.lib.evalModule pkgs ./treefmt.nix; + + collateral-proxy = pkgs.buildGoModule { + pname = "collateral-proxy"; + inherit version; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./go.mod + ./go.sum + (lib.fileset.fileFilter (file: lib.hasSuffix ".go" file.name) ./.) + ]; + }; + proxyVendor = true; + vendorHash = "sha256-GrNc8vmx8p2Cb0FeGyeVKlxYve5KvhZpID/A9MLi/Sw="; + subPackages = [ "." ]; + env.CGO_ENABLED = 0; + ldflags = [ + "-s" + "-X main.version=v${version}" + ]; + # Race detector needs cgo. + preCheck = "export CGO_ENABLED=1"; + checkPhase = '' + runHook preCheck + go test -race ./... + runHook postCheck + ''; + meta = { + description = "Read-through caching forward proxy for attestation collateral (AMD KDS, Intel PCS, NVIDIA RIM)."; + license = lib.licenses.mit; + mainProgram = "collateral-proxy"; + }; + }; + + # OCI image published to ${image}. + container = pkgs.dockerTools.buildImage { + name = "collateral-proxy"; + tag = "v${version}"; + copyToRoot = with pkgs.dockerTools; [ caCertificates ]; + config.Entrypoint = [ (lib.getExe collateral-proxy) ]; + }; + + # Push the container image and echo the pinned reference ${image}:@sha256: to stdout. + push = pkgs.writeShellApplication { + name = "push-collateral-proxy"; + runtimeInputs = with pkgs; [ + crane + gzip + ]; + text = '' + tag="''${1:-dev}" + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + gunzip < "${container}" > "$tmp" + crane push "$tmp" "${image}:$tag" >&2 + digest=$(crane digest "${image}:$tag") + echo "${image}:$tag@$digest" + ''; + }; + + # Push the image and render the deployment manifest to stdout. + render-k8s-resources = pkgs.writeShellApplication { + name = "render-k8s-resources"; + runtimeInputs = [ + push + pkgs.gnugrep + pkgs.gnused + ]; + text = '' + tag="''${1:-v${version}}" + template=${./collateral-proxy.yml} + grep -q '%%pin%%' "$template" + ref=$(push-collateral-proxy "$tag") + sed "s|%%pin%%|$ref|" "$template" + ''; + }; + + # Lint the working tree: `nix run .#lint`. Extra args pass through, e.g. `nix run .#lint -- --fix`. + lint = pkgs.writeShellApplication { + name = "lint"; + runtimeInputs = [ + pkgs.golangci-lint + pkgs.go + ]; + text = ''exec golangci-lint run "$@"''; + }; + + # Scan for known vulnerabilities: `nix run .#govulncheck`. + govulncheck = pkgs.writeShellApplication { + name = "govulncheck"; + runtimeInputs = [ + pkgs.govulncheck + pkgs.go + ]; + text = "exec govulncheck ./..."; + }; + in + { + packages = { + default = collateral-proxy; + inherit + collateral-proxy + container + push + render-k8s-resources + lint + govulncheck + ; + }; + + formatter = treefmtEval.config.build.wrapper; + + # `nix flake check` runs formatters and tests. + checks = { + formatting = treefmtEval.config.build.check self; + # Building the package runs `go test -race ./...` in its checkPhase. + tests = collateral-proxy; + }; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + golangci-lint + gotools + gopls + crane + govulncheck + ]; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5c832c1 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/edgelesssys/collateral-proxy + +go 1.25.6 + +require ( + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.20.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/sys v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2123960 --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +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.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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..56f6de4 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,198 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io/fs" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const defaultTTL = time.Hour + +// Entry is a cached HTTP response. +type Entry struct { + URL string `json:"url"` + Status int `json:"status"` + Header http.Header `json:"header"` + Body []byte `json:"body"` + FreshUntil time.Time `json:"freshUntil"` +} + +// Cache is an in-memory and on-disk cache for upstream HTTP responses. +type Cache struct { + dir string + mu sync.RWMutex + entries map[string]*Entry +} + +// New opens or creates a cache at dir, loading existing entries into memory. +func New(dir string) (*Cache, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + c := &Cache{dir: dir, entries: map[string]*Entry{}} + if err := c.loadAll(); err != nil { + return nil, err + } + return c, nil +} + +// Get returns the entry for url and whether or not it is fresh. +func (c *Cache) Get(url string) (*Entry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[url] + if !ok { + return nil, false + } + return e, time.Now().Before(e.FreshUntil) +} + +// Put stores a response under url, computing freshness from headers / body. +func (c *Cache) Put(url string, status int, header http.Header, body []byte) (*Entry, error) { + e := &Entry{ + URL: url, + Status: status, + Header: header.Clone(), + Body: body, + FreshUntil: time.Now().Add(freshness(status, header, body)), + } + // Hold the lock across the disk write and the in-memory update so concurrent + // Puts can't end up with the disk and the map reflecting different writers. + c.mu.Lock() + defer c.mu.Unlock() + if err := c.writeDisk(e); err != nil { + return nil, err + } + c.entries[url] = e + return e, nil +} + +func (c *Cache) loadAll() error { + return filepath.WalkDir(c.dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + var e Entry + if err := json.NewDecoder(f).Decode(&e); err != nil { + slog.Warn("skipping corrupt cache entry", "path", path, "err", err) + return nil + } + c.entries[e.URL] = &e + return nil + }) +} + +func (c *Cache) writeDisk(e *Entry) error { + path := filepath.Join(c.dir, base64.RawURLEncoding.EncodeToString([]byte(e.URL))+".json") + f, err := os.CreateTemp(c.dir, "tmp-*") + if err != nil { + return err + } + + if err := json.NewEncoder(f).Encode(e); err != nil { + return errors.Join(err, f.Close()) + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) +} + +// understoodStatusCodes are the response status codes whose caching requirements this cache understands, +// as required by the must-understand directive (RFC 9111, Section 5.2.2.2). We only cache successful responses. +var understoodStatusCodes = map[int]struct{}{ + http.StatusOK: {}, +} + +func freshness(status int, header http.Header, body []byte) time.Duration { + if d, ok := crlFreshness(body); ok { + return d + } + if d, ok := cacheControlMaxAge(status, header); ok { + return d + } + return defaultTTL +} + +func cacheControlMaxAge(status int, header http.Header) (time.Duration, bool) { + cc := header.Get("Cache-Control") + if cc == "" { + return 0, false + } + directives := parseCacheControl(cc) + + _, mustUnderstand := directives["must-understand"] + _, understood := understoodStatusCodes[status] + honorMustUnderstand := mustUnderstand && understood + + if _, ok := directives["no-cache"]; ok { + return 0, false + } + if _, ok := directives["no-store"]; ok && !honorMustUnderstand { + return 0, false + } + + maxAge, ok := directives["max-age"] + if !ok { + return 0, false + } + secs, err := strconv.Atoi(maxAge) + if err != nil || secs <= 0 { + return 0, false + } + return time.Duration(secs) * time.Second, true +} + +func parseCacheControl(cc string) map[string]string { + directives := map[string]string{} + for part := range strings.SplitSeq(cc, ",") { + name, value, _ := strings.Cut(strings.TrimSpace(part), "=") + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + directives[name] = strings.Trim(strings.TrimSpace(value), `"`) + } + return directives +} + +func crlFreshness(body []byte) (time.Duration, bool) { + if len(body) == 0 { + return 0, false + } + der := body + if block, _ := pem.Decode(body); block != nil { + der = block.Bytes + } + crl, err := x509.ParseRevocationList(der) + if err != nil || crl.NextUpdate.IsZero() { + return 0, false + } + d := time.Until(crl.NextUpdate) + if d < 0 { + return 0, false + } + return d, true +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..7d26b1d --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCRLFreshness(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCRLSign | x509.KeyUsageCertSign, + IsCA: true, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &key.PublicKey, key) + require.NoError(t, err) + ca, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + crlDER, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: time.Now().Add(-time.Minute), + NextUpdate: time.Now().Add(12 * time.Hour), + }, ca, key) + require.NoError(t, err) + crlPEM := pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crlDER}) + + for _, c := range []struct { + name string + body []byte + }{ + {"der", crlDER}, + {"pem", crlPEM}, + } { + t.Run(c.name, func(t *testing.T) { + d, ok := crlFreshness(c.body) + require.True(t, ok, "expected a CRL-derived freshness") + assert.Greater(t, d, 11*time.Hour) + assert.LessOrEqual(t, d, 12*time.Hour) + }) + } + + _, ok := crlFreshness([]byte("not a crl")) + assert.False(t, ok, "garbage should not be treated as a CRL") +} + +func TestCacheControlMaxAge(t *testing.T) { + cases := []struct { + name string + status int + header string + want time.Duration + ok bool + }{ + {"empty", http.StatusOK, "", 0, false}, + {"max-age 60", http.StatusOK, "max-age=60", 60 * time.Second, true}, + {"public max-age", http.StatusOK, "public, max-age=3600", 3600 * time.Second, true}, + {"no-store", http.StatusOK, "no-store, max-age=60", 0, false}, + {"no-cache", http.StatusOK, "no-cache", 0, false}, + {"zero", http.StatusOK, "max-age=0", 0, false}, + {"garbage", http.StatusOK, "max-age=NaN", 0, false}, + {"must-understand understood", http.StatusOK, "no-store, must-understand, max-age=60", 60 * time.Second, true}, + {"must-understand unknown status", http.StatusNoContent, "no-store, must-understand, max-age=60", 0, false}, + {"must-understand no-cache", http.StatusOK, "no-cache, must-understand, max-age=60", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := http.Header{} + if c.header != "" { + h.Set("Cache-Control", c.header) + } + got, ok := cacheControlMaxAge(c.status, h) + assert.Equal(t, c.ok, ok) + assert.Equal(t, c.want, got) + }) + } +} + +func TestPutGetRoundTrip(t *testing.T) { + dir := t.TempDir() + c, err := New(dir) + require.NoError(t, err) + h := http.Header{} + h.Set("Cache-Control", "max-age=120") + _, err = c.Put("https://example/x", 200, h, []byte("hello")) + require.NoError(t, err) + e, fresh := c.Get("https://example/x") + require.NotNil(t, e) + assert.True(t, fresh) + assert.Equal(t, "hello", string(e.Body)) + + // Reopen from disk. + c2, err := New(dir) + require.NoError(t, err) + e2, fresh2 := c2.Get("https://example/x") + require.NotNil(t, e2) + assert.True(t, fresh2) + assert.Equal(t, "hello", string(e2.Body)) +} + +func TestStaleEntryStillReturned(t *testing.T) { + dir := t.TempDir() + c, err := New(dir) + require.NoError(t, err) + h := http.Header{} + h.Set("Cache-Control", "max-age=1") + e, err := c.Put("https://example/x", 200, h, []byte("hi")) + require.NoError(t, err) + e.FreshUntil = time.Now().Add(-time.Hour) + got, fresh := c.Get("https://example/x") + require.NotNil(t, got, "entry vanished") + assert.False(t, fresh, "expected stale, got fresh") +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..b9df37c --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,225 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package proxy + +import ( + "log/slog" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + + "github.com/edgelesssys/collateral-proxy/internal/cache" + "github.com/edgelesssys/collateral-proxy/internal/upstream" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// metrics holds the Prometheus collectors for a Server, labeled by document and result. +type metrics struct { + // requests counts proxied requests by result (hit, miss, stale, rejected, error). + requests *prometheus.CounterVec + // upstream counts upstream fetch outcomes by HTTP status code, or "error" when the fetch itself failed. + upstream *prometheus.CounterVec +} + +var ( + documentTypes = []string{"crl", "ak-cert", "collateral", "unknown"} + requestResults = []string{"hit", "miss", "stale", "error"} +) + +// newMetrics registers the Prometheus collectors and initializes every known label combination to 0 to improve readability. +func newMetrics(reg prometheus.Registerer) *metrics { + m := &metrics{ + requests: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Namespace: "collateral_proxy", + Name: "requests_total", + Help: "Proxied requests by result and document type.", + }, []string{"result", "document"}), + upstream: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Namespace: "collateral_proxy", + Name: "upstream_responses_total", + Help: `Upstream fetch outcomes by HTTP status code (or "error") and document type.`, + }, []string{"code", "document"}), + } + for _, t := range documentTypes { + for _, r := range requestResults { + m.requests.WithLabelValues(r, t) + } + m.upstream.WithLabelValues("error", t) + } + m.requests.WithLabelValues("rejected", "unknown") + return m +} + +// Server is a read-through caching forward proxy. +type Server struct { + log *slog.Logger + cache *cache.Cache + upstream *upstream.Fetcher + metrics *metrics + metricsHTTP http.Handler +} + +// New constructs a Server. If reg is nil, a fresh registry is created. +func New(log *slog.Logger, c *cache.Cache, u *upstream.Fetcher, reg *prometheus.Registry) *Server { + if reg == nil { + reg = prometheus.NewRegistry() + } + return &Server{ + log: log, + cache: c, + upstream: u, + metrics: newMetrics(reg), + metricsHTTP: promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), + } +} + +// route resolves a request path to the vendor host that serves it and a coarse document type used as a metrics label. +func route(urlPath string) (host, docType string, ok bool) { + switch { + case strings.HasPrefix(urlPath, "/vcek/"), strings.HasPrefix(urlPath, "/vlek/"): + return "kdsintf.amd.com", amdKDSDocType(urlPath), true + case strings.HasPrefix(urlPath, "/sgx/"), strings.HasPrefix(urlPath, "/tdx/"): + return "api.trustedservices.intel.com", intelPCSDocType(urlPath), true + case strings.HasPrefix(urlPath, "/IntelSGX"): + return "certificates.trustedservices.intel.com", intelCertsDocType(urlPath), true + case strings.HasPrefix(urlPath, "/v1/rim/"): + return "rim.attestation.nvidia.com", "collateral", true + default: + return "", "", false + } +} + +// amdHardwareID matches the hex-encoded hardware ID that addresses a VCEK/VLEK certificate. +// The hwID is a fixed-sized 64-byte field/128 hex chars across Milan/Genoa/Turin. +// https://github.com/google/go-sev-guest/blob/33e009a4b5d6ec448cb55d89405276c2140c50c3/abi/abi.go#L65 +// https://github.com/google/go-sev-guest/blob/33e009a4b5d6ec448cb55d89405276c2140c50c3/kds/kds.go#L515 +var amdHardwareID = regexp.MustCompile(`^[0-9a-fA-F]{128}$`) + +// amdKDSDocType classifies AMD KDS paths of the form /{vcek,vlek}/v1/{product}/{resource}. +func amdKDSDocType(urlPath string) string { + switch seg := path.Base(urlPath); { + case seg == "crl": + return "crl" + case seg == "cert_chain": + return "collateral" + case amdHardwareID.MatchString(seg): + return "ak-cert" + default: + return "unknown" + } +} + +// intelPCSDocType classifies Intel PCS paths by their trailing resource. +func intelPCSDocType(urlPath string) string { + switch seg := path.Base(urlPath); seg { + case "pckcrl", "rootcacrl": + return "crl" + case "pckcert": + return "ak-cert" + case "pckcerts", "tcb", "identity": // identity covers qe/qve/tdqe + return "collateral" + default: + return "unknown" + } +} + +// intelCertsDocType classifies the Intel SGX Root CA distribution host. +func intelCertsDocType(urlPath string) string { + if path.Base(urlPath) == "IntelSGXRootCA.der" { + return "crl" + } + return "unknown" +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.metrics.requests.WithLabelValues("rejected", "unknown").Inc() + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + switch r.URL.Path { + case "/healthz": + _, _ = w.Write([]byte("ok")) + return + case "/metrics": + s.metricsHTTP.ServeHTTP(w, r) + return + } + upstreamHost, docType, ok := route(r.URL.Path) + if !ok { + s.metrics.requests.WithLabelValues("rejected", "unknown").Inc() + s.log.Warn("rejecting request for unknown collateral path", "path", r.URL.Path) + http.Error(w, "unknown collateral path", http.StatusNotFound) + return + } + s.serveCollateral(w, r, upstreamHost, docType) +} + +func (s *Server) serveCollateral(w http.ResponseWriter, r *http.Request, upstreamHost, docType string) { + upstreamURL := (&url.URL{ + Scheme: "https", + Host: upstreamHost, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + }).String() + + entry, fresh := s.cache.Get(upstreamURL) + if fresh { + s.metrics.requests.WithLabelValues("hit", docType).Inc() + s.log.Debug("cache hit", "url", upstreamURL) + writeResponse(w, entry.Status, entry.Header, entry.Body) + return + } + + res, err := s.upstream.Get(r.Context(), upstreamURL) + if err != nil { + s.metrics.upstream.WithLabelValues("error", docType).Inc() + if entry != nil { + s.metrics.requests.WithLabelValues("stale", docType).Inc() + s.log.Warn("serving stale on upstream error", "url", upstreamURL, "err", err) + writeResponse(w, entry.Status, entry.Header, entry.Body) + return + } + s.metrics.requests.WithLabelValues("error", docType).Inc() + s.log.Error("upstream fetch failed and no cache entry", "url", upstreamURL, "err", err) + http.Error(w, "upstream unavailable", http.StatusBadGateway) + return + } + + s.metrics.requests.WithLabelValues("miss", docType).Inc() + s.metrics.upstream.WithLabelValues(strconv.Itoa(res.Status), docType).Inc() + s.log.Info("cache miss, fetched upstream", "url", upstreamURL, "status", res.Status) + entry, err = s.cache.Put(upstreamURL, res.Status, res.Header, res.Body) + if err != nil { + s.log.Error("cache write failed", "url", upstreamURL, "err", err) + writeResponse(w, res.Status, res.Header, res.Body) + return + } + writeResponse(w, entry.Status, entry.Header, entry.Body) +} + +// hopByHopHeaders are not forwarded from the upstream response to the client. +var hopByHopHeaders = map[string]struct{}{ + "Connection": {}, + "Transfer-Encoding": {}, + "Content-Length": {}, // set by the ResponseWriter when the body is written +} + +func writeResponse(w http.ResponseWriter, status int, header http.Header, body []byte) { + for k, vs := range header { + if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip { + continue + } + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(status) + _, _ = w.Write(body) +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..eac6c29 --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package proxy + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/edgelesssys/collateral-proxy/internal/cache" + "github.com/edgelesssys/collateral-proxy/internal/upstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// amdHWID is a sample hex-encoded 64-byte AMD hardware ID. +const amdHWID = "" + + "a1b2c3d4e5f60718293a4b5c6d7e8f90" + + "0102030405060708090a0b0c0d0e0f10" + + "1112131415161718191a1b1c1d1e1f20" + + "2122232425262728292a2b2c2d2e2f30" + +func TestRoute(t *testing.T) { + for _, c := range []struct { + path string + host string + docType string + rejected bool + }{ + {path: "/vcek/v1/Milan/" + amdHWID, host: "kdsintf.amd.com", docType: "ak-cert"}, + {path: "/vlek/v1/Milan/" + amdHWID, host: "kdsintf.amd.com", docType: "ak-cert"}, + {path: "/vcek/v1/Milan/crl", host: "kdsintf.amd.com", docType: "crl"}, + {path: "/vcek/v1/Milan/cert_chain", host: "kdsintf.amd.com", docType: "collateral"}, + {path: "/vcek/v1/Milan/not-a-hwid", host: "kdsintf.amd.com", docType: "unknown"}, + {path: "/vcek/v1/Milan/9af1a3beef", host: "kdsintf.amd.com", docType: "unknown"}, // too short to be a hardware ID + + {path: "/sgx/certification/v4/pckcert", host: "api.trustedservices.intel.com", docType: "ak-cert"}, + {path: "/sgx/certification/v4/pckcrl", host: "api.trustedservices.intel.com", docType: "crl"}, + {path: "/sgx/certification/v4/rootcacrl", host: "api.trustedservices.intel.com", docType: "crl"}, + {path: "/sgx/certification/v4/tcb", host: "api.trustedservices.intel.com", docType: "collateral"}, + {path: "/sgx/certification/v4/qe/identity", host: "api.trustedservices.intel.com", docType: "collateral"}, + {path: "/tdx/certification/v4/pckcert", host: "api.trustedservices.intel.com", docType: "ak-cert"}, + {path: "/sgx/certification/v4/something-new", host: "api.trustedservices.intel.com", docType: "unknown"}, + + {path: "/IntelSGXRootCA.der", host: "certificates.trustedservices.intel.com", docType: "crl"}, + {path: "/IntelSGXsomethingelse", host: "certificates.trustedservices.intel.com", docType: "unknown"}, + + {path: "/v1/rim/some-id", host: "rim.attestation.nvidia.com", docType: "collateral"}, + + {path: "/", rejected: true}, + {path: "/evil/path", rejected: true}, + } { + t.Run(c.path, func(t *testing.T) { + host, docType, ok := route(c.path) + if c.rejected { + assert.False(t, ok) + return + } + require.True(t, ok) + assert.Equal(t, c.host, host) + assert.Equal(t, c.docType, docType) + }) + } +} + +func TestReverseProxyCachesAndRoutes(t *testing.T) { + var upstreamHits atomic.Int64 + upstreamSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits.Add(1) + w.Header().Set("Cache-Control", "max-age=3600") + w.Header().Set("X-Test-Header", "passthrough") + _, _ = fmt.Fprintf(w, "vcek bytes for %s", r.URL.Path) + })) + defer upstreamSrv.Close() + upstreamURL, err := url.Parse(upstreamSrv.URL) + require.NoError(t, err) + + dialer := &net.Dialer{} + fetchClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if strings.HasPrefix(addr, "kdsintf.amd.com:") || + strings.HasPrefix(addr, "rim.attestation.nvidia.com:") { + return dialer.DialContext(ctx, network, upstreamURL.Host) + } + return dialer.DialContext(ctx, network, addr) + }, + }, + Timeout: 5 * time.Second, + } + + cch, err := cache.New(t.TempDir()) + require.NoError(t, err) + srv := New(slog.New(slog.DiscardHandler), cch, upstream.New(fetchClient), nil) + + proxySrv := httptest.NewServer(srv) + defer proxySrv.Close() + + client := &http.Client{Timeout: 5 * time.Second} + + body, header := mustGet(t, client, proxySrv.URL+"/vcek/v1/Milan/abc") + assert.Equal(t, "vcek bytes for /vcek/v1/Milan/abc", body) + assert.Equal(t, "passthrough", header.Get("X-Test-Header"), "upstream response header not forwarded") + assert.Equal(t, int64(1), upstreamHits.Load()) + + body2, _ := mustGet(t, client, proxySrv.URL+"/vcek/v1/Milan/abc") + assert.Equal(t, body, body2, "body mismatch on cache hit") + assert.Equal(t, int64(1), upstreamHits.Load(), "second request should be served from cache") + + // NVIDIA RIM is routed to rim.attestation.nvidia.com and cached the same way. + rimBody, _ := mustGet(t, client, proxySrv.URL+"/v1/rim/some-rim-id") + assert.Equal(t, "vcek bytes for /v1/rim/some-rim-id", rimBody) + assert.Equal(t, int64(2), upstreamHits.Load(), "RIM miss should hit upstream") + + _, _ = mustGet(t, client, proxySrv.URL+"/v1/rim/some-rim-id") + assert.Equal(t, int64(2), upstreamHits.Load(), "RIM should be served from cache") +} + +func TestRejectsUnknownPath(t *testing.T) { + cch, err := cache.New(t.TempDir()) + require.NoError(t, err) + srv := New(slog.New(slog.DiscardHandler), cch, upstream.New(&http.Client{Timeout: time.Second}), nil) + proxySrv := httptest.NewServer(srv) + defer proxySrv.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, proxySrv.URL+"/evil/path", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func mustGet(t *testing.T, c *http.Client, url string) (string, http.Header) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + require.NoError(t, err, "build request %s", url) + resp, err := c.Do(req) + require.NoError(t, err, "GET %s", url) + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err, "read body") + require.Equal(t, http.StatusOK, resp.StatusCode, "body=%s", b) + return string(b), resp.Header +} diff --git a/internal/upstream/upstream.go b/internal/upstream/upstream.go new file mode 100644 index 0000000..1a7dbf3 --- /dev/null +++ b/internal/upstream/upstream.go @@ -0,0 +1,63 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package upstream + +import ( + "context" + "fmt" + "io" + "net/http" + + "golang.org/x/sync/singleflight" +) + +// Result is a captured upstream response. +type Result struct { + Status int + Header http.Header + Body []byte +} + +// Fetcher pulls upstream responses. +type Fetcher struct { + client *http.Client + group singleflight.Group +} + +// New returns a Fetcher backed by client. +func New(client *http.Client) *Fetcher { + return &Fetcher{client: client} +} + +// Get fetches the given url. +func (f *Fetcher) Get(ctx context.Context, url string) (*Result, error) { + v, err, _ := f.group.Do(url, func() (any, error) { + return f.doGet(ctx, url) + }) + if err != nil { + return nil, err + } + res, ok := v.(*Result) + if !ok { + return nil, fmt.Errorf("unexpected singleflight result type %T", v) + } + return res, nil +} + +func (f *Fetcher) doGet(ctx context.Context, url string) (*Result, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("upstream fetch: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading upstream body: %w", err) + } + return &Result{Status: resp.StatusCode, Header: resp.Header, Body: body}, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4131c10 --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/edgelesssys/collateral-proxy/internal/cache" + "github.com/edgelesssys/collateral-proxy/internal/proxy" + "github.com/edgelesssys/collateral-proxy/internal/upstream" + "github.com/prometheus/client_golang/prometheus" +) + +var version = "0.0.0-dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var ( + addr = flag.String("addr", ":80", "listen address") + stateDir = flag.String("state-dir", "/var/lib/collateral-proxy", "directory for cache state") + upstreamTimeout = flag.Duration("upstream-timeout", 10*time.Second, "per-request upstream timeout") + ) + flag.Parse() + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + log.Info("collateral-proxy starting", "version", version, "addr", *addr, "stateDir", *stateDir) + + c, err := cache.New(filepath.Join(*stateDir, "cache")) + if err != nil { + return fmt.Errorf("cache init: %w", err) + } + fetcher := upstream.New(&http.Client{Timeout: *upstreamTimeout}) + + httpSrv := &http.Server{ + Addr: *addr, + Handler: proxy.New(log, c, fetcher, prometheus.NewRegistry()), + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { errCh <- httpSrv.ListenAndServe() }() + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + case <-ctx.Done(): + log.Info("shutdown signal received") + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + log.Error("graceful shutdown failed", "err", err) + } + } + return nil +} diff --git a/overlays/nixpkgs.nix b/overlays/nixpkgs.nix new file mode 100644 index 0000000..69ad71f --- /dev/null +++ b/overlays/nixpkgs.nix @@ -0,0 +1,16 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 + +final: prev: + +{ + go_1_26 = prev.go_1_26.overrideAttrs ( + finalAttrs: _prevAttrs: { + version = "1.26.5"; + src = final.fetchurl { + url = "https://go.dev/dl/go${finalAttrs.version}.src.tar.gz"; + hash = "sha256-SVvkvIcXasVnOS5bQRar2YRm0z17SdQedkzMaXay3EI="; + }; + } + ); +} diff --git a/treefmt.nix b/treefmt.nix new file mode 100644 index 0000000..3da271a --- /dev/null +++ b/treefmt.nix @@ -0,0 +1,35 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 + +{ lib, pkgs, ... }: +{ + projectRootFile = "flake.nix"; + programs = { + # keep-sorted start block=true + actionlint.enable = true; + deadnix.enable = true; + gofumpt.enable = true; + keep-sorted.enable = true; + nixfmt.enable = true; + shellcheck.enable = true; + shfmt.enable = true; + statix.enable = true; + yamlfmt.enable = true; + # keep-sorted end + }; + settings.formatter = { + addlicense = { + command = "${lib.getExe pkgs.addlicense}"; + options = [ + "-c=Edgeless Systems GmbH" + "-s=only" + "-l=BUSL-1.1" + ]; + includes = [ + "*.go" + "*.nix" + "*.sh" + ]; + }; + }; +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.1.0