diff --git a/docker-compose.yml b/docker-compose.yml index d9c9a88b..316a624d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -169,6 +169,7 @@ services: restart: unless-stopped environment: SPINDLE_SERVER_HOSTNAME: spindle.tngl.boltless.dev + SPINDLE_SERVER_ADMIN_PASSWORD: spindle-admin SPINDLE_SERVER_LISTEN_ADDR: 0.0.0.0:6555 SPINDLE_SERVER_DB_PATH: /var/lib/spindle/spindle.db SPINDLE_SERVER_PLC_URL: https://plc.tngl.boltless.dev diff --git a/docs/DOCS.md b/docs/DOCS.md index 4659e14e..574ff1fe 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -1451,6 +1451,7 @@ Spindle is configured using environment variables. The following environment var - `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). - `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). - `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). +- `SPINDLE_SERVER_ADMIN_PASSWORD`: The password for the `/admin` endpoints used by `spindle admin allow|block` to manage members. When unset, the `/admin` endpoints are not mounted at all (default: `""`). - `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). - `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). - `SPINDLE_SERVER_INVITE_ONLY`: A boolean indicating whether only members of this spindle may register repos. When `false`, the spindle accepts repos from anyone on the network (default: `true`). @@ -1541,6 +1542,25 @@ cache (and read from it), configure the cache (prefix Spindle will now start, connect to the Jetstream server, and begin processing pipelines. +### Managing members + +An invite-only spindle (the default, see `SPINDLE_SERVER_INVITE_ONLY`) only +accepts repos whose owner is a member. Members are managed over the `/admin` +endpoints, which authenticate with `SPINDLE_SERVER_ADMIN_PASSWORD`: + +```shell +export SPINDLE_SERVER_ADMIN_PASSWORD="your-admin-password" +spindle admin --url http://localhost:6555 allow did:plc:examplemember +spindle admin --url http://localhost:6555 block did:plc:examplemember +``` + +Blocking keeps the DID on record and denies it, so a blocked DID stays blocked +until you allow it again. + +If `SPINDLE_SERVER_ADMIN_PASSWORD` is unset the spindle still starts, but the +`/admin` endpoints are not mounted (they return 404) and `spindle admin` cannot +be used, so set it if you run an invite-only spindle. + ### Running microVM workflows The microVM engine needs a few extra things on the host, and diff --git a/nix/modules/spindle.nix b/nix/modules/spindle.nix index 04005e52..81669881 100644 --- a/nix/modules/spindle.nix +++ b/nix/modules/spindle.nix @@ -299,7 +299,9 @@ in description = '' Additional environment file as defined in {manpage}`systemd.exec(5)`. - Sensitive secrets such as {env}`AWS_SECRET_ACCESS_KEY`, + Sensitive secrets such as {env}`SPINDLE_SERVER_ADMIN_PASSWORD` + (without it the `/admin` member management endpoints are not mounted), + {env}`AWS_SECRET_ACCESS_KEY`, {env}`AWS_ACCESS_KEY_ID`, {env}`AWS_REGION` may be passed to the service without making them world readable in the nix store. diff --git a/spindle/admin_test.go b/spindle/admin_test.go new file mode 100644 index 00000000..b871cfba --- /dev/null +++ b/spindle/admin_test.go @@ -0,0 +1,85 @@ +package spindle + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "tangled.org/core/idresolver" + "tangled.org/core/spindle/config" +) + +func TestAdminRouterMountedOnlyWhenConfigured(t *testing.T) { + tests := []struct { + name string + password string + want int + }{ + // mounted: rejects the unauthenticated request instead of 404ing + {"configured", "s3cret", http.StatusUnauthorized}, + {"unset password", "", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Spindle{ + l: slog.Default(), + res: idresolver.DefaultResolver("https://plc.test"), + cfg: &config.Config{Server: config.Server{ + AdminPassword: tt.password, + Hostname: "spindle.test", + }}, + } + + req := httptest.NewRequest(http.MethodPost, "/admin/member/allow", nil) + w := httptest.NewRecorder() + s.Router().ServeHTTP(w, req) + + if w.Code != tt.want { + t.Errorf("status = %d, want %d", w.Code, tt.want) + } + }) + } +} + +func TestAdminMiddleware(t *testing.T) { + tests := []struct { + name string + configured string + user, pass string + basicAuth bool + want int + }{ + {"correct password", "s3cret", "admin", "s3cret", true, http.StatusOK}, + {"wrong password", "s3cret", "admin", "nope", true, http.StatusUnauthorized}, + {"wrong user", "s3cret", "root", "s3cret", true, http.StatusUnauthorized}, + {"no credentials", "s3cret", "", "", false, http.StatusUnauthorized}, + // Router() skips the mount when the password is unset, but keep the middleware + // fail-closed too - an empty password must not authenticate anyone. + {"unset password", "", "admin", "", true, http.StatusUnauthorized}, + {"unset password, no credentials", "", "", "", false, http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Spindle{cfg: &config.Config{ + Server: config.Server{AdminPassword: tt.configured}, + }} + + req := httptest.NewRequest(http.MethodPost, "/admin/member/allow", nil) + if tt.basicAuth { + req.SetBasicAuth(tt.user, tt.pass) + } + w := httptest.NewRecorder() + + s.adminMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })).ServeHTTP(w, req) + + if w.Code != tt.want { + t.Errorf("status = %d, want %d", w.Code, tt.want) + } + }) + } +}