From fad5c393cf79ae1a7f74e37778da01300b682869 Mon Sep 17 00:00:00 2001 From: dawn Date: Mon, 19 Oct 2026 23:59:42 +0300 Subject: [PATCH] spindle: serve a did:web document behind a signing key the operator brings Signed-off-by: dawn --- docker-compose.yml | 1 + .../content/docs/architecture/identity.mdx | 7 +- .../content/docs/reference/spindle-config.md | 25 +++-- docs/src/content/docs/self-hosting/backups.md | 1 + .../content/docs/spindles/architecture.mdx | 4 +- .../src/content/docs/spindles/self-hosting.md | 21 ++++ localinfra/pc/services/spindle/run | 1 + localinfra/pc/services/spindle/setup | 8 ++ localinfra/spindle.Dockerfile | 8 ++ nix/modules/spindle.nix | 16 ++++ nix/vm.nix | 14 ++- spindle/config/config.go | 4 + spindle/config/config_test.go | 62 +++++++----- spindle/identity/identity.go | 62 ++++++++++++ spindle/identity/identity_test.go | 95 +++++++++++++++++++ spindle/identity_route_test.go | 72 ++++++++++++++ spindle/oidc/issuer.go | 34 +------ spindle/oidc/issuer_test.go | 41 ++------ spindle/pemkey/pemkey.go | 37 ++++++++ spindle/pemkey/pemkey_test.go | 51 ++++++++++ spindle/pemkey/pemkeytest/pemkeytest.go | 41 ++++++++ spindle/run_env_test.go | 21 +--- spindle/server.go | 16 ++++ 23 files changed, 517 insertions(+), 125 deletions(-) create mode 100644 spindle/identity/identity.go create mode 100644 spindle/identity/identity_test.go create mode 100644 spindle/identity_route_test.go create mode 100644 spindle/pemkey/pemkey.go create mode 100644 spindle/pemkey/pemkey_test.go create mode 100644 spindle/pemkey/pemkeytest/pemkeytest.go diff --git a/docker-compose.yml b/docker-compose.yml index 579c7f263..08341549e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -290,6 +290,7 @@ services: environment: SPINDLE_SERVER_HOSTNAME: spindle.tngl.boltless.dev SPINDLE_SERVER_OWNER: did:plc:igzuueb63dytyy3tpaweofuu + SPINDLE_SERVER_KEY_PATH: /var/lib/spindle/key.pem SPINDLE_SERVER_LISTEN_ADDR: 0.0.0.0:6555 SPINDLE_SERVER_METRICS_LISTEN_ADDR: 0.0.0.0:9091 SPINDLE_SERVER_DB_PATH: /var/lib/spindle/spindle.db diff --git a/docs/src/content/docs/architecture/identity.mdx b/docs/src/content/docs/architecture/identity.mdx index 8e66c8e2e..cf6905ad8 100644 --- a/docs/src/content/docs/architecture/identity.mdx +++ b/docs/src/content/docs/architecture/identity.mdx @@ -115,8 +115,11 @@ signing key under `#atproto` and two services, `#tangled_knot` and hostname changes the knot's DID. A spindle's DID is `did:web:` plus `SPINDLE_SERVER_HOSTNAME`. Clients -use it as the audience of service-auth tokens. The spindle's admin is -the DID in `SPINDLE_SERVER_OWNER`. +use it as the audience of service-auth tokens. The spindle serves its +own document at `https:///.well-known/did.json`, with the key +from `SPINDLE_SERVER_KEY_PATH` under `#atproto` and a `#tangled_spindle` +service pointing at `https://`. The spindle's admin is the DID +in `SPINDLE_SERVER_OWNER`. A repository names its spindle by DID in its manifest (`spindle: "did:web:spindle.example.com"`). diff --git a/docs/src/content/docs/reference/spindle-config.md b/docs/src/content/docs/reference/spindle-config.md index aae06cece..2a1db132b 100644 --- a/docs/src/content/docs/reference/spindle-config.md +++ b/docs/src/content/docs/reference/spindle-config.md @@ -10,18 +10,23 @@ at startup by `spindle run` (the default command). There is no config file. The NixOS module (`services.tangled.spindle`) sets these variables from its options. -A minimal standalone spindle needs two variables: +A minimal standalone spindle needs three variables: ```sh +openssl ecparam -name prime256v1 -genkey -noout -out /var/lib/spindle/key.pem export SPINDLE_SERVER_HOSTNAME=spindle.example.com export SPINDLE_SERVER_OWNER=did:plc:yourdid +export SPINDLE_SERVER_KEY_PATH=/var/lib/spindle/key.pem spindle ``` -The spindle's identity is `did:web:`. Startup -fails with a message naming the variable when a required value is -missing or two settings conflict (for example `SPINDLE_OIDC_ISSUER` -without `SPINDLE_OIDC_KEY_PATH`). +The spindle's identity is `did:web:`, and it +serves the matching DID document at `/.well-known/did.json`. The +document's signing key is the one in `SPINDLE_SERVER_KEY_PATH`. Spindle +never generates it, so back it up and keep it. Startup fails with a +message naming the variable when a required value is missing or two +settings conflict (for example `SPINDLE_OIDC_ISSUER` without +`SPINDLE_OIDC_KEY_PATH`). Durations use Go syntax (`5m`, `720h`). Lists are comma-separated. @@ -32,9 +37,10 @@ Durations use Go syntax (`5m`, `720h`). Lists are comma-separated. | `SPINDLE_ROLE` | `standalone` | `standalone` ingests events and runs jobs. `mill` ingests and places jobs on executors but runs nothing. `executor` dials a mill and runs jobs, and ingests nothing. | | `SPINDLE_LOG_FORMAT` | `text` | Log format on stderr: `text` or `json`. | -`standalone` and `mill` require `SPINDLE_SERVER_OWNER`. `executor` -requires `SPINDLE_MILL_URL` and `SPINDLE_MILL_SHARED_SECRET`, and -rejects `SPINDLE_MILL_URL` in the other roles. +`standalone` and `mill` require `SPINDLE_SERVER_OWNER` and +`SPINDLE_SERVER_KEY_PATH`. `executor` requires `SPINDLE_MILL_URL` and +`SPINDLE_MILL_SHARED_SECRET`, and rejects `SPINDLE_MILL_URL` in the other +roles. ## Server @@ -42,7 +48,8 @@ rejects `SPINDLE_MILL_URL` in the other roles. | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `SPINDLE_SERVER_HOSTNAME` | required | Public hostname. Repos select this spindle by hostname. | | `SPINDLE_SERVER_OWNER` | required (standalone, mill) | DID of the spindle owner. The owner manages members, quotas and bans. | -| `SPINDLE_SERVER_LISTEN_ADDR` | `0.0.0.0:6555` | HTTP listener for XRPC, `/_health`, `/mill` and OIDC discovery. | +| `SPINDLE_SERVER_KEY_PATH` | required (standalone, mill) | PEM file with the EC P-256 private key behind `/.well-known/did.json`. Spindle never generates it. | +| `SPINDLE_SERVER_LISTEN_ADDR` | `0.0.0.0:6555` | HTTP listener for XRPC, `/_health`, `/mill`, `/.well-known/did.json` and OIDC discovery. | | `SPINDLE_SERVER_METRICS_LISTEN_ADDR` | `127.0.0.1:9091` | Prometheus metrics listener. | | `SPINDLE_SERVER_DB_PATH` | `spindle.db` | SQLite database: repos, members, pipelines, secrets (sqlite provider), cache metadata, quotas. | | `SPINDLE_SERVER_REPO_DIR` | `repos` | Where `.tangled/workflows` is sparse-checked-out for each repo. | diff --git a/docs/src/content/docs/self-hosting/backups.md b/docs/src/content/docs/self-hosting/backups.md index 06efe1a45..c4cede9ea 100644 --- a/docs/src/content/docs/self-hosting/backups.md +++ b/docs/src/content/docs/self-hosting/backups.md @@ -55,6 +55,7 @@ DID's service endpoint. | `hydrant/` | `SPINDLE_SERVER_HYDRANT_DIR` | Hydrant's backfill database and cursor; spindle backfills every owner again on the next start | | `repos/` | `SPINDLE_SERVER_REPO_DIR` | Sparse clones, refetched on demand | | Logs and artifacts | artifact store (disk dir or S3) | Old logs | +| Identity key | `SPINDLE_SERVER_KEY_PATH` | A new key changes the spindle's `#atproto` key; services that cached the old one reject it until they refetch | | OIDC key | `SPINDLE_OIDC_KEY_PATH` | A new key gets a new key ID; trust policies that pinned the old one need updating | | CI cache | `SPINDLE_CACHE_*` | Workflows run uncached until it refills | diff --git a/docs/src/content/docs/spindles/architecture.mdx b/docs/src/content/docs/spindles/architecture.mdx index df4daa810..1a79175d3 100644 --- a/docs/src/content/docs/spindles/architecture.mdx +++ b/docs/src/content/docs/spindles/architecture.mdx @@ -157,8 +157,8 @@ There are two separate caches: Spindle's identity is `did:web:`, the audience for service-auth calls. It listens on `SPINDLE_SERVER_LISTEN_ADDR` -(default `0.0.0.0:6555`) for `/`, `/_health`, `/xrpc/*`, `/mill` (mill -role) and OIDC discovery, and serves Prometheus metrics on +(default `0.0.0.0:6555`) for `/`, `/_health`, `/.well-known/did.json`, +`/xrpc/*`, `/mill` (mill role) and OIDC discovery, and serves Prometheus metrics on `127.0.0.1:9091`. [Spindle configuration](/reference/spindle-config/) lists every diff --git a/docs/src/content/docs/spindles/self-hosting.md b/docs/src/content/docs/spindles/self-hosting.md index 1dda4390b..ce7b5a32f 100644 --- a/docs/src/content/docs/spindles/self-hosting.md +++ b/docs/src/content/docs/spindles/self-hosting.md @@ -28,6 +28,7 @@ The smallest working setup on NixOS: hostname = "spindle.example.com"; owner = "did:plc:yourdid"; }; + keyPath = "/var/lib/secrets/spindle-key.pem"; # see "Signing key" below artifactStores.s3.bucket = ""; # keep logs on local disk only }; @@ -41,6 +42,25 @@ Put the spindle behind an HTTPS reverse proxy that forwards `https://spindle.example.com` to port 6555, including websocket upgrades (live logs use them). +## Signing key + +A spindle is `did:web:`, and other services check what it +signs against the DID document it serves at `/.well-known/did.json`. +That document carries a signing key you provide, so spindle never +generates one: a missing or unreadable file stops startup. Make an EC +P-256 key once, keep it out of the Nix store, and point +`SPINDLE_SERVER_KEY_PATH` (NixOS: `keyPath`) at it: + +```sh +openssl ecparam -name prime256v1 -genkey -noout -out /var/lib/secrets/spindle-key.pem +chmod 600 /var/lib/secrets/spindle-key.pem +chown spindle /var/lib/secrets/spindle-key.pem # whichever user spindle runs as +``` + +This is a separate file from the OIDC key. Losing it means a new +identity key: services that cached the old document stop trusting the +spindle until they refetch. Executors don't need one. + ## Requirements - **Linux with KVM** for the `microvm` engine: `/dev/kvm`, @@ -287,6 +307,7 @@ reference; the settings most operators touch: | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------- | | `SPINDLE_SERVER_HOSTNAME` | required | Public hostname; identity is `did:web:` | | `SPINDLE_SERVER_OWNER` | required for standalone and mill | Owner DID | +| `SPINDLE_SERVER_KEY_PATH` | required for standalone and mill | PEM EC P-256 key behind `/.well-known/did.json` | | `SPINDLE_SERVER_LISTEN_ADDR` | `0.0.0.0:6555` | HTTP and XRPC | | `SPINDLE_SERVER_METRICS_LISTEN_ADDR` | `127.0.0.1:9091` | Prometheus metrics | | `SPINDLE_SERVER_DB_PATH` | `spindle.db` | SQLite database | diff --git a/localinfra/pc/services/spindle/run b/localinfra/pc/services/spindle/run index 075c6899a..ebff203c3 100755 --- a/localinfra/pc/services/spindle/run +++ b/localinfra/pc/services/spindle/run @@ -5,6 +5,7 @@ mkdir -p "$data/hydrant" "$data/repos" "$data/logs" export SPINDLE_SERVER_HOSTNAME=spindle.tngl.boltless.dev export SPINDLE_SERVER_OWNER=did:plc:igzuueb63dytyy3tpaweofuu +export SPINDLE_SERVER_KEY_PATH=$data/key.pem export SPINDLE_SERVER_LISTEN_ADDR=127.0.0.1:$TNGL_SPINDLE_PORT export SPINDLE_SERVER_METRICS_LISTEN_ADDR=127.0.0.1:$TNGL_SPINDLE_METRICS_PORT export SPINDLE_SERVER_DB_PATH=$data/spindle.db diff --git a/localinfra/pc/services/spindle/setup b/localinfra/pc/services/spindle/setup index bd53fc747..144a32366 100644 --- a/localinfra/pc/services/spindle/setup +++ b/localinfra/pc/services/spindle/setup @@ -8,6 +8,14 @@ if [ -z "${SPINDLE_CMD:-}" ] && [ "$SPINDLE_BIN" = "$SPINDLE_BUILT" ]; then ln -sf "$out/bin/spindle" "$SPINDLE_BUILT" fi +# spindle never makes this key, that's the operator's job +key=$TNGL_STATE/data/spindle/key.pem +if [ ! -f "$key" ]; then + mkdir -p "$(dirname "$key")" + openssl ecparam -name prime256v1 -genkey -noout -out "$key" + chmod 600 "$key" +fi + if profile-on microvm; then arch=$(uname -m) mkdir -p "$SPINDLE_IMAGE_DIR" diff --git a/localinfra/spindle.Dockerfile b/localinfra/spindle.Dockerfile index af49c6341..6cbc1f1b3 100644 --- a/localinfra/spindle.Dockerfile +++ b/localinfra/spindle.Dockerfile @@ -49,6 +49,7 @@ RUN apk add --no-cache \ e2fsprogs \ git \ iproute2 \ + openssl \ qemu-system-x86_64 \ shadow \ slirp4netns \ @@ -69,6 +70,13 @@ set -eu export SPINDLE_SERVER_OWNER="$(cat /shared/owner-did)" : "${SPINDLE_SERVER_OWNER:?set via env or /shared/owner-did}" +# spindle won't make a key, so only dev gets one generated here +if [ "${SPINDLE_SERVER_DEV:-}" = "true" ] && [ -n "${SPINDLE_SERVER_KEY_PATH:-}" ] && [ ! -f "${SPINDLE_SERVER_KEY_PATH}" ]; then + mkdir -p "$(dirname "${SPINDLE_SERVER_KEY_PATH}")" + openssl ecparam -name prime256v1 -genkey -noout -out "${SPINDLE_SERVER_KEY_PATH}" + chmod 600 "${SPINDLE_SERVER_KEY_PATH}" +fi + # executors read their mill token from a file seeded by the mill-tokens # service (command substitution strips the trailing newline) [ -z "${SPINDLE_MILL_SHARED_SECRET:-}" ] && [ -n "${SPINDLE_MILL_TOKEN_FILE:-}" ] && [ -r "${SPINDLE_MILL_TOKEN_FILE}" ] && \ diff --git a/nix/modules/spindle.nix b/nix/modules/spindle.nix index 49abc213e..0dda4cdf6 100644 --- a/nix/modules/spindle.nix +++ b/nix/modules/spindle.nix @@ -597,6 +597,17 @@ in description = "How the main spindle runs."; }; + keyPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/secrets/spindle-key.pem"; + description = '' + PEM file holding the EC P-256 key behind the spindle's did:web document. + Required unless the role is executor. The spindle never generates it, make one with + {command}`openssl ecparam -name prime256v1 -genkey -noout -out spindle-key.pem`. + ''; + }; + executor = mkOption { type = with types; nullOr dedicatedExecutorType; default = null; @@ -773,6 +784,7 @@ in "SPINDLE_MILL_DEBUG_EXECUTOR_PORT=${toString cfg.mill.debugExecutorPort}" "SPINDLE_MILL_MAX_JUMP_CONNECTIONS=${toString cfg.mill.maxJumpConnections}" ] + ++ optionals (cfg.role != "executor") ["SPINDLE_SERVER_KEY_PATH=${toString cfg.keyPath}"] # only the mill mints tokens; the key must not reach executor environments ++ optionals (cfg.role == "mill") [ "SPINDLE_OIDC_ISSUER=${cfg.oidc.issuer}" @@ -907,6 +919,10 @@ in }) cfg.mill.executors ++ [ + { + assertion = cfg.role == "executor" || (cfg.keyPath != null && hasPrefix "/" cfg.keyPath && !hasPrefix "/nix/store/" cfg.keyPath); + message = "services.tangled.spindle.keyPath must be an absolute path outside the Nix store unless role is executor"; + } { assertion = (cfg.role == "executor") == (cfg.executor != null); message = "services.tangled.spindle.executor must be set exactly when role is executor"; diff --git a/nix/vm.nix b/nix/vm.nix index d68216889..27a417a7a 100644 --- a/nix/vm.nix +++ b/nix/vm.nix @@ -148,6 +148,7 @@ in }; services.tangled.spindle = { enable = true; + keyPath = "/var/lib/spindle/key.pem"; server = { owner = envVar "TANGLED_VM_SPINDLE_OWNER"; hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; @@ -235,7 +236,18 @@ in }; in { knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; - spindle = mkDataSyncScripts "/mnt/spindle-data" (dirOf config.services.tangled.spindle.server.dbPath); + spindle = lib.mkMerge [ + (mkDataSyncScripts "/mnt/spindle-data" (dirOf config.services.tangled.spindle.server.dbPath)) + { + # runs after the data sync, so a key that survived an earlier run is kept + preStart = lib.mkAfter '' + if [ ! -f ${config.services.tangled.spindle.keyPath} ]; then + ${lib.getExe pkgs.openssl} ecparam -name prime256v1 -genkey -noout -out ${config.services.tangled.spindle.keyPath} + chmod 600 ${config.services.tangled.spindle.keyPath} + fi + ''; + } + ]; knotmirror.after = ["postgresql.target"]; tap-knotmirror.after = ["postgresql.target"]; gitmirror.after = ["postgresql.target"]; diff --git a/spindle/config/config.go b/spindle/config/config.go index 461ee5c77..20fb44f3d 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -24,6 +24,7 @@ type Server struct { Dev bool `env:"DEV, default=false"` DevExtraHosts []string `env:"DEV_EXTRA_HOSTS"` Owner string `env:"OWNER"` + KeyPath string `env:"KEY_PATH"` // PEM holding the EC P-256 key behind the spindle's did document Secrets Secrets `env:",prefix=SECRETS_"` LogDir string `env:"LOG_DIR, default=/var/log/spindle"` MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time @@ -241,6 +242,9 @@ func (c *Config) validate() error { if c.Mill.URL != "" { return fmt.Errorf("SPINDLE_MILL_URL is set but SPINDLE_ROLE=%s; only an executor dials a mill", c.Role) } + if c.Server.KeyPath == "" { + return fmt.Errorf("SPINDLE_SERVER_KEY_PATH is required when SPINDLE_ROLE=%s (the signing key behind /.well-known/did.json, spindle never generates it: openssl ecparam -name prime256v1 -genkey -noout -out spindle-key.pem)", c.Role) + } case RoleExecutor: if c.Mill.URL == "" { return fmt.Errorf("SPINDLE_ROLE=executor requires SPINDLE_MILL_URL (the mill to dial)") diff --git a/spindle/config/config_test.go b/spindle/config/config_test.go index f524da08d..7f7d78e0e 100644 --- a/spindle/config/config_test.go +++ b/spindle/config/config_test.go @@ -2,15 +2,22 @@ package config import ( "context" + "strings" "testing" "time" "tangled.org/core/spindle/quota" ) -func TestLoadAllowsUnconfiguredMicroVMEngine(t *testing.T) { +func standaloneEnv(t *testing.T) { + t.Helper() t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + t.Setenv("SPINDLE_SERVER_KEY_PATH", "/keys/spindle.pem") +} + +func TestLoadAllowsUnconfiguredMicroVMEngine(t *testing.T) { + standaloneEnv(t) t.Setenv("SPINDLE_MICROVM_PIPELINES_IMAGE_DIR", "") cfg, err := Load(context.Background()) @@ -40,8 +47,7 @@ func TestLoadRequiresOwnerForCoordinatorRoles(t *testing.T) { } func TestLoadRequiresTheOIDCFieldsTogether(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_OIDC_KEY_PATH", "") t.Setenv("SPINDLE_OIDC_KEY_ID", "spindle-oidc") @@ -81,8 +87,7 @@ func TestLoadAllowsExecutorWithoutOwner(t *testing.T) { } func TestLoadRejectsNonPositiveDrainTimeout(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) for _, timeout := range []string{"0s", "-1s"} { t.Run(timeout, func(t *testing.T) { @@ -95,8 +100,7 @@ func TestLoadRejectsNonPositiveDrainTimeout(t *testing.T) { } func TestLoadRejectsInvalidScheduleLimits(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) for _, tc := range []struct{ name, key, value string }{ {"disabled interval", "SPINDLE_SCHEDULE_MINIMUM_INTERVAL", "0s"}, {"sub-minute interval", "SPINDLE_SCHEDULE_MINIMUM_INTERVAL", "30s"}, @@ -114,8 +118,7 @@ func TestLoadRejectsInvalidScheduleLimits(t *testing.T) { } func TestLoadRequiresJumpHostKey(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_ROLE", "mill") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") @@ -125,8 +128,7 @@ func TestLoadRequiresJumpHostKey(t *testing.T) { } func TestLoadRejectsJumpListenerOutsideMill(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_ROLE", "standalone") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") t.Setenv("SPINDLE_MILL_JUMP_HOST_KEY_PATH", "/tmp/jump-host-key") @@ -137,8 +139,7 @@ func TestLoadRejectsJumpListenerOutsideMill(t *testing.T) { } func TestLoadValidatesMaxJumpConnections(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_ROLE", "mill") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") t.Setenv("SPINDLE_MILL_JUMP_HOST_KEY_PATH", "/tmp/jump-host-key") @@ -159,8 +160,7 @@ func TestLoadValidatesMaxJumpConnections(t *testing.T) { } func TestLoadMetricsListenAddr(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) cfg, err := Load(context.Background()) if err != nil { @@ -181,8 +181,7 @@ func TestLoadMetricsListenAddr(t *testing.T) { } func TestLoadTracingConfig(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_TRACING_ENDPOINT", "tempo:4318") t.Setenv("SPINDLE_TRACING_SERVICE_NAME", "spindle-test") t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "0.5") @@ -208,8 +207,7 @@ func TestLoadTracingConfig(t *testing.T) { } func TestLoadTracingConfigValidation(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "1.5") if _, err := Load(context.Background()); err == nil { @@ -361,6 +359,7 @@ func TestLoadQuotaLimitsValidation(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + t.Setenv("SPINDLE_SERVER_KEY_PATH", "/keys/spindle.pem") t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "1.0") for k, v := range tt.envs { @@ -376,8 +375,7 @@ func TestLoadQuotaLimitsValidation(t *testing.T) { } func TestLoadLoggingConfig(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_LOGGING_ENDPOINT", "loki:4318") t.Setenv("SPINDLE_LOGGING_SERVICE_NAME", "spindle-log-test") t.Setenv("SPINDLE_LOGGING_INSECURE", "true") @@ -399,8 +397,7 @@ func TestLoadLoggingConfig(t *testing.T) { } func TestLoadLoggingConfigValidation(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_LOGGING_ENDPOINT", "loki:4318") t.Setenv("SPINDLE_LOGGING_SERVICE_NAME", "") @@ -428,8 +425,7 @@ func TestLoadQuotaDefaultsRejectsNegativeLimits(t *testing.T) { } func TestLoadCacheStoreID(t *testing.T) { - t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") - t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + standaloneEnv(t) t.Setenv("SPINDLE_CACHE_STORE_ID", "shared-cache") cfg, err := Load(context.Background()) @@ -440,3 +436,19 @@ func TestLoadCacheStoreID(t *testing.T) { t.Fatalf("cache store ID = %q, want shared-cache", cfg.Cache.StoreID) } } + +func TestLoadRequiresSigningKeyForCoordinatorRoles(t *testing.T) { + t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") + t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") + t.Setenv("SPINDLE_SERVER_KEY_PATH", "") + + for _, role := range []string{"standalone", "mill"} { + t.Run(role, func(t *testing.T) { + t.Setenv("SPINDLE_ROLE", role) + _, err := Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "SPINDLE_SERVER_KEY_PATH") { + t.Fatalf("Load without a signing key under SPINDLE_ROLE=%s = %v, want a SPINDLE_SERVER_KEY_PATH error", role, err) + } + }) + } +} diff --git a/spindle/identity/identity.go b/spindle/identity/identity.go new file mode 100644 index 000000000..16e0f4473 --- /dev/null +++ b/spindle/identity/identity.go @@ -0,0 +1,62 @@ +// the operator brings the signing key, spindle serves the did document itself +package identity + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/identity" + "tangled.org/core/spindle/pemkey" + "tangled.org/core/xrpc/serviceauth" +) + +const ( + DocumentPath = "/.well-known/did.json" + + serviceID = "#tangled_spindle" + serviceType = "TangledSpindle" +) + +type document struct { + Context []string `json:"@context"` + identity.DIDDocument +} + +// spindle never mints a key, a missing file fails startup +func Load(hostname, keyPath string) (http.HandlerFunc, error) { + key, err := pemkey.Load(keyPath, "spindle signing key") + if err != nil { + return nil, fmt.Errorf("%w (make one with: openssl ecparam -name prime256v1 -genkey -noout -out %s)", err, keyPath) + } + private, err := atcrypto.ParsePrivateBytesP256(key.D.FillBytes(make([]byte, 32))) + if err != nil { + return nil, fmt.Errorf("spindle signing key %s: %w", keyPath, err) + } + signer, err := serviceauth.NewSigner(serviceauth.DidWeb(hostname), private.Multibase()) + if err != nil { + return nil, err + } + + doc := signer.DIDDocument() + doc.Service = []identity.DocService{{ + ID: serviceID, + Type: serviceType, + ServiceEndpoint: "https://" + hostname, + }} + body, err := json.Marshal(document{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + }, + DIDDocument: doc, + }) + if err != nil { + return nil, fmt.Errorf("encoding did document: %w", err) + } + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write(body) + }, nil +} diff --git a/spindle/identity/identity_test.go b/spindle/identity/identity_test.go new file mode 100644 index 000000000..92b23fcef --- /dev/null +++ b/spindle/identity/identity_test.go @@ -0,0 +1,95 @@ +package identity + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "tangled.org/core/spindle/pemkey/pemkeytest" +) + +const testHostname = "spindle.example.com" + +func TestServedDocument(t *testing.T) { + path, key := pemkeytest.WriteP256(t) + serve, err := Load(testHostname, path) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + serve(rec, httptest.NewRequest(http.MethodGet, DocumentPath, nil)) + if got := rec.Header().Get("Content-Type"); got != "application/did+json" { + t.Fatalf("content type = %q", got) + } + + var doc struct { + Context []string `json:"@context"` + ID string `json:"id"` + VerificationMethod []struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyMultibase string `json:"publicKeyMultibase"` + } `json:"verificationMethod"` + Service []struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + + did := "did:web:" + testHostname + if doc.ID != did { + t.Fatalf("id = %q, want %q", doc.ID, did) + } + if len(doc.Context) != 2 || doc.Context[0] != "https://www.w3.org/ns/did/v1" { + t.Fatalf("@context = %v", doc.Context) + } + if len(doc.VerificationMethod) != 1 { + t.Fatalf("verification methods = %+v, want one", doc.VerificationMethod) + } + vm := doc.VerificationMethod[0] + if vm.ID != did+"#atproto" || vm.Type != "Multikey" || vm.Controller != did { + t.Fatalf("verification method = %+v", vm) + } + + public, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase) + if err != nil { + t.Fatal(err) + } + want, err := atcrypto.ParsePrivateBytesP256(key.D.FillBytes(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + wantPublic, err := want.PublicKey() + if err != nil { + t.Fatal(err) + } + if !public.Equal(wantPublic) { + t.Fatal("document key is not the public half of the configured key") + } + + if len(doc.Service) != 1 { + t.Fatalf("services = %+v, want one", doc.Service) + } + svc := doc.Service[0] + if svc.ID != "#tangled_spindle" || svc.Type != "TangledSpindle" || svc.ServiceEndpoint != "https://"+testHostname { + t.Fatalf("service = %+v", svc) + } +} + +func TestLoadFailsWithHowToMakeAKey(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope.pem") + _, err := Load(testHostname, missing) + if err == nil || !strings.Contains(err.Error(), "openssl ecparam -name prime256v1") { + t.Fatalf("err = %v, want a hint with the openssl command", err) + } +} diff --git a/spindle/identity_route_test.go b/spindle/identity_route_test.go new file mode 100644 index 000000000..a5c25c76a --- /dev/null +++ b/spindle/identity_route_test.go @@ -0,0 +1,72 @@ +package spindle + +import ( + "context" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + atidentity "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/idresolver" + "tangled.org/core/spindle/config" + "tangled.org/core/spindle/identity" + "tangled.org/core/spindle/observability" + "tangled.org/core/spindle/pemkey/pemkeytest" +) + +func TestRouterServesADocumentDIDWebResolves(t *testing.T) { + keyPath, key := pemkeytest.WriteP256(t) + const hostname = "spindle.example.com" + id, err := identity.Load(hostname, keyPath) + if err != nil { + t.Fatal(err) + } + s := &Spindle{ + cfg: &config.Config{Role: config.RoleMill}, + l: slog.New(slog.NewTextHandler(io.Discard, nil)), + metrics: observability.NewMetrics(), + res: idresolver.NewMockResolver(idresolver.MockDirectory{}), + identity: id, + } + server := httptest.NewTLSServer(s.Router()) + defer server.Close() + + address := strings.TrimPrefix(server.URL, "https://") + transport := server.Client().Transport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, address) + } + directory := &atidentity.BaseDirectory{ + HTTPClient: http.Client{Transport: transport}, + SkipHandleVerification: true, + } + resolved, err := directory.LookupDID(context.Background(), syntax.DID("did:web:"+hostname)) + if err != nil { + t.Fatal(err) + } + + got, err := resolved.PublicKey() + if err != nil { + t.Fatal(err) + } + private, err := atcrypto.ParsePrivateBytesP256(key.D.FillBytes(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + want, err := private.PublicKey() + if err != nil { + t.Fatal(err) + } + if !got.Equal(want) { + t.Fatal("resolved #atproto key is not the configured key") + } + if svc := resolved.Services["tangled_spindle"]; svc.Type != "TangledSpindle" || svc.URL != "https://"+hostname { + t.Fatalf("tangled_spindle service = %+v", svc) + } +} diff --git a/spindle/oidc/issuer.go b/spindle/oidc/issuer.go index b2b4f8cf1..bcc9d87b6 100644 --- a/spindle/oidc/issuer.go +++ b/spindle/oidc/issuer.go @@ -5,26 +5,23 @@ package oidc import ( "crypto/ecdsa" - "crypto/elliptic" "crypto/rand" "crypto/sha256" - "crypto/x509" "encoding/asn1" "encoding/base64" "encoding/hex" "encoding/json" - "encoding/pem" "errors" "fmt" "math/big" "net/http" "net/url" - "os" "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/spindle/models" + "tangled.org/core/spindle/pemkey" ) // the only two endpoints an issuer serves @@ -111,7 +108,7 @@ func New(opts Options) (*Issuer, error) { return nil, fmt.Errorf("OIDC issuer %q must carry no path, query, or fragment: the endpoints are served from its origin", opts.Issuer) } - key, err := loadPrivateKey(opts.KeyPath) + key, err := pemkey.Load(opts.KeyPath, "OIDC signing key") if err != nil { return nil, err } @@ -286,33 +283,6 @@ func joseSignature(der []byte) ([]byte, error) { return out, nil } -func loadPrivateKey(path string) (*ecdsa.PrivateKey, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading OIDC signing key: %w", err) - } - block, _ := pem.Decode(data) - if block == nil { - return nil, fmt.Errorf("OIDC signing key %s holds no PEM block", path) - } - - key, err := x509.ParseECPrivateKey(block.Bytes) - if err != nil { - parsed, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) - if pkcs8Err != nil { - return nil, fmt.Errorf("parsing OIDC signing key %s: %w", path, err) - } - var ok bool - if key, ok = parsed.(*ecdsa.PrivateKey); !ok { - return nil, fmt.Errorf("OIDC signing key %s is not an EC key", path) - } - } - if key.Curve != elliptic.P256() { - return nil, fmt.Errorf("OIDC signing key %s is on %s, want P-256", path, key.Curve.Params().Name) - } - return key, nil -} - func encode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } diff --git a/spindle/oidc/issuer_test.go b/spindle/oidc/issuer_test.go index 83c3a8e84..206a6fca0 100644 --- a/spindle/oidc/issuer_test.go +++ b/spindle/oidc/issuer_test.go @@ -5,19 +5,18 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/sha256" - "crypto/x509" "encoding/base64" "encoding/json" - "encoding/pem" "errors" "math/big" "net/http" "net/http/httptest" - "os" "path/filepath" "strings" "testing" "time" + + "tangled.org/core/spindle/pemkey/pemkeytest" ) const ( @@ -25,40 +24,12 @@ const ( testAudience = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/tangled/providers/spindle" ) -func testKeyPath(t *testing.T, key *ecdsa.PrivateKey, pkcs8 bool) string { - t.Helper() - var ( - der []byte - blockTy string - err error - ) - if pkcs8 { - der, err = x509.MarshalPKCS8PrivateKey(key) - blockTy = "PRIVATE KEY" - } else { - der, err = x509.MarshalECPrivateKey(key) - blockTy = "EC PRIVATE KEY" - } - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "oidc-key.pem") - pemBytes := pem.EncodeToMemory(&pem.Block{Type: blockTy, Bytes: der}) - if err := os.WriteFile(path, pemBytes, 0o600); err != nil { - t.Fatal(err) - } - return path -} - func newTestIssuer(t *testing.T, keyID string) (*Issuer, *ecdsa.PrivateKey) { t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } + path, key := pemkeytest.WriteP256(t) issuer, err := New(Options{ Issuer: testIssuer, - KeyPath: testKeyPath(t, key, false), + KeyPath: path, KeyID: keyID, }) if err != nil { @@ -72,7 +43,7 @@ func TestNewRejectsBadConfiguration(t *testing.T) { if err != nil { t.Fatal(err) } - p256 := testKeyPath(t, key, true) + p256 := pemkeytest.Write(t, key, true) secp256k1, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { t.Fatal(err) @@ -88,7 +59,7 @@ func TestNewRejectsBadConfiguration(t *testing.T) { {"issuer with a path", Options{Issuer: "https://tokens.spindle.tangled.sh/issuer", KeyPath: p256}}, {"issuer with a query", Options{Issuer: "https://tokens.spindle.tangled.sh?x=1", KeyPath: p256}}, {"missing key file", Options{Issuer: testIssuer, KeyPath: filepath.Join(t.TempDir(), "absent.pem")}}, - {"wrong curve", Options{Issuer: testIssuer, KeyPath: testKeyPath(t, secp256k1, false)}}, + {"wrong curve", Options{Issuer: testIssuer, KeyPath: pemkeytest.Write(t, secp256k1, false)}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/spindle/pemkey/pemkey.go b/spindle/pemkey/pemkey.go new file mode 100644 index 000000000..3aa6985db --- /dev/null +++ b/spindle/pemkey/pemkey.go @@ -0,0 +1,37 @@ +package pemkey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "fmt" + "os" +) + +func Load(path, what string) (*ecdsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", what, err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("%s %s holds no PEM block", what, path) + } + + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + parsed, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return nil, fmt.Errorf("parsing %s %s: %w", what, path, err) + } + var ok bool + if key, ok = parsed.(*ecdsa.PrivateKey); !ok { + return nil, fmt.Errorf("%s %s is not an EC key", what, path) + } + } + if key.Curve != elliptic.P256() { + return nil, fmt.Errorf("%s %s is on %s, want P-256", what, path, key.Curve.Params().Name) + } + return key, nil +} diff --git a/spindle/pemkey/pemkey_test.go b/spindle/pemkey/pemkey_test.go new file mode 100644 index 000000000..996dcbfd3 --- /dev/null +++ b/spindle/pemkey/pemkey_test.go @@ -0,0 +1,51 @@ +package pemkey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "os" + "path/filepath" + "strings" + "testing" + + "tangled.org/core/spindle/pemkey/pemkeytest" +) + +func TestLoadReadsSEC1AndPKCS8(t *testing.T) { + _, key := pemkeytest.WriteP256(t) + + for name, path := range map[string]string{ + "sec1": pemkeytest.Write(t, key, false), + "pkcs8": pemkeytest.Write(t, key, true), + } { + got, err := Load(path, "test key") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !got.Equal(key) { + t.Fatalf("%s: loaded a different key", name) + } + } +} + +func TestLoadRejectsBadKeys(t *testing.T) { + p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + notPEM := filepath.Join(t.TempDir(), "junk") + if err := os.WriteFile(notPEM, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + + for name, tc := range map[string]struct{ path, want string }{ + "missing": {filepath.Join(t.TempDir(), "nope.pem"), "reading test key"}, + "not pem": {notPEM, "holds no PEM block"}, + "wrong curve": {pemkeytest.Write(t, p384, false), "want P-256"}, + } { + if _, err := Load(tc.path, "test key"); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: err = %v, want it to contain %q", name, err, tc.want) + } + } +} diff --git a/spindle/pemkey/pemkeytest/pemkeytest.go b/spindle/pemkey/pemkeytest/pemkeytest.go new file mode 100644 index 000000000..0407c5561 --- /dev/null +++ b/spindle/pemkey/pemkeytest/pemkeytest.go @@ -0,0 +1,41 @@ +// Package pemkeytest writes signing keys to disk for tests. +package pemkeytest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +// Write stores the key as PKCS#8, or SEC1 when pkcs8 is false +func Write(t testing.TB, key *ecdsa.PrivateKey, pkcs8 bool) string { + t.Helper() + marshal, blockType := x509.MarshalECPrivateKey, "EC PRIVATE KEY" + if pkcs8 { + marshal = func(k *ecdsa.PrivateKey) ([]byte, error) { return x509.MarshalPKCS8PrivateKey(k) } + blockType = "PRIVATE KEY" + } + der, err := marshal(key) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "key.pem") + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func WriteP256(t testing.TB) (string, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return Write(t, key, false), key +} diff --git a/spindle/run_env_test.go b/spindle/run_env_test.go index 19bc6f574..d5376dd95 100644 --- a/spindle/run_env_test.go +++ b/spindle/run_env_test.go @@ -2,18 +2,12 @@ package spindle import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" "encoding/base64" "encoding/json" - "encoding/pem" "io" "log/slog" "net/http" "net/http/httptest" - "os" "path/filepath" "strings" "testing" @@ -27,6 +21,7 @@ import ( "tangled.org/core/spindle/models" "tangled.org/core/spindle/observability" "tangled.org/core/spindle/oidc" + "tangled.org/core/spindle/pemkey/pemkeytest" "tangled.org/core/spindle/secrets" ) @@ -40,19 +35,7 @@ const ( func oidcTestIssuer(t *testing.T) *oidc.Issuer { t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - der, err := x509.MarshalECPrivateKey(key) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "oidc-key.pem") - block := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) - if err := os.WriteFile(path, block, 0o600); err != nil { - t.Fatal(err) - } + path, _ := pemkeytest.WriteP256(t) issuer, err := oidc.New(oidc.Options{Issuer: oidcTestIssuerURL, KeyPath: path}) if err != nil { t.Fatal(err) diff --git a/spindle/server.go b/spindle/server.go index ac9dd62ac..0fa3935f4 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -42,6 +42,7 @@ import ( "tangled.org/core/spindle/engines/dummy" "tangled.org/core/spindle/engines/nixery" "tangled.org/core/spindle/feed" + "tangled.org/core/spindle/identity" "tangled.org/core/spindle/mill" "tangled.org/core/spindle/mill/executor" "tangled.org/core/spindle/models" @@ -110,6 +111,7 @@ type Spindle struct { // set only when this spindle hosts the mill or joins one as an executor mill *mill.Mill oidc *oidc.Issuer + identity http.HandlerFunc exec executorClient listCollaborators func(ctx context.Context, knot string, repoDid syntax.DID) ([]syntax.DID, error) listRefRecords func(ctx context.Context, knot string, repoDid syntax.DID) ([]refRecord, error) @@ -174,6 +176,15 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m logger.Info("minting CI ID tokens", "issuer", cfg.OIDC.Issuer, "kid", issuer.KeyID()) } + var id http.HandlerFunc + if cfg.Role != config.RoleExecutor && cfg.Server.KeyPath != "" { + id, err = identity.Load(cfg.Server.Hostname, cfg.Server.KeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load spindle identity: %w", err) + } + logger.Info("serving did document", "did", cfg.Server.Did()) + } + spindle := &Spindle{ db: d, l: logger, @@ -189,6 +200,7 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m jobWake: make(chan struct{}, 1), qm: qm, oidc: issuer, + identity: id, } metrics.AttachDB(ctx, d) diskFallback := "" @@ -788,6 +800,10 @@ func (s *Spindle) Router() http.Handler { mux.Mount("/xrpc", s.XrpcRouter()) } + if s.identity != nil { + mux.Get(identity.DocumentPath, s.identity) + } + if s.oidc != nil { mux.Get(oidc.ConfigurationPath, s.oidc.ServeConfiguration) mux.Get(oidc.JWKSPath, s.oidc.ServeJWKS) -- 2.54.0