diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 3c197cc8c1..8f10b9a0a1 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,9 +1,10 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "7866f390a42da02fa2ce39ffbd26d421c0778f32fc816935c9cf35f614d91598", + "contract_digest": "24920c001e4ac4d8bfc20b983b166a27790c14ce329b7e029123a897c8312933", "links": { "openapi": "/api/v2/openapi.json", - "capabilities": "/api/v2/capabilities" + "capabilities": "/api/v2/capabilities", + "identity": "/api/v2/system/identity" } } diff --git a/contracts/api/v2/fixtures/index.json b/contracts/api/v2/fixtures/index.json index 086935fd3d..893a04aeb0 100644 --- a/contracts/api/v2/fixtures/index.json +++ b/contracts/api/v2/fixtures/index.json @@ -11433,6 +11433,44 @@ "schema": "#/components/schemas/CatalogFacetMatches", "body_file": "search_catalog_facet_ok.json" }, + { + "name": "server_connections_ok", + "operation_id": "getServerConnections", + "scenario": "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running.", + "request": { + "method": "GET", + "path": "/api/v2/system/connections", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"251d40c6578ba4f568f974b66faed17c5fd6ea82251fdc08ebc30e12c2be477f\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerConnectionsDocument", + "body_file": "server_connections_ok.json" + }, + { + "name": "server_identity_ok", + "operation_id": "getServerIdentity", + "scenario": "Discovery before login: the deployment's stable server identity, the same at every address.", + "request": { + "method": "GET", + "path": "/api/v2/system/identity" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerIdentity", + "body_file": "server_identity_ok.json" + }, { "name": "set_file_markers_ok", "operation_id": "setFileMarkers", diff --git a/contracts/api/v2/fixtures/server_connections_ok.json b/contracts/api/v2/fixtures/server_connections_ok.json new file mode 100644 index 0000000000..dbb21bb184 --- /dev/null +++ b/contracts/api/v2/fixtures/server_connections_ok.json @@ -0,0 +1,28 @@ +{ + "revision": "82278f19ff5dc619091bee7b0efe5f375a0b25ec07002a424a5d15efbdc66900", + "state": "available", + "allowed": true, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "current": { + "kind": "default" + }, + "endpoints": [ + { + "kind": "public", + "url": "https://silo.example.test" + }, + { + "kind": "provider", + "provider": "down", + "display_name": "Down Overlay", + "state": "unavailable" + }, + { + "kind": "provider", + "url": "https://silo.overlay.example.test", + "provider": "stub", + "display_name": "Stub Overlay", + "state": "connected" + } + ] +} diff --git a/contracts/api/v2/fixtures/server_identity_ok.json b/contracts/api/v2/fixtures/server_identity_ok.json new file mode 100644 index 0000000000..9ea4b63399 --- /dev/null +++ b/contracts/api/v2/fixtures/server_identity_ok.json @@ -0,0 +1,3 @@ +{ + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 187854435f..4503823165 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -35454,6 +35454,43 @@ ], "type": "object" }, + "ServerAccessPathDefault": { + "additionalProperties": false, + "properties": { + "kind": { + "description": "The public URL, LAN or a reverse proxy", + "enum": [ + "default" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "ServerAccessPathProvider": { + "additionalProperties": false, + "properties": { + "kind": { + "description": "An overlay origin", + "enum": [ + "provider" + ], + "type": "string" + }, + "provider": { + "description": "Provider slug the request came through", + "type": "string" + } + }, + "required": [ + "kind", + "provider" + ], + "type": "object" + }, "ServerCollectionLibrary": { "additionalProperties": false, "properties": { @@ -35502,6 +35539,147 @@ ], "type": "object" }, + "ServerConnectionsDocument": { + "additionalProperties": false, + "properties": { + "allowed": { + "description": "Whether the current principal may use the capability", + "type": "boolean" + }, + "current": { + "description": "How this request reached the server", + "oneOf": [ + { + "$ref": "#/components/schemas/ServerAccessPathDefault" + }, + { + "$ref": "#/components/schemas/ServerAccessPathProvider" + } + ] + }, + "endpoints": { + "description": "Addresses the deployment offers, public first then providers in slug order; empty when no public URL is configured and no provider is installed. Reachability is the client's to test.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/ServerEndpointPublic" + }, + { + "$ref": "#/components/schemas/ServerEndpointProvider" + } + ] + }, + "type": "array" + }, + "revision": { + "description": "Opaque revision of this document", + "type": "string" + }, + "server_id": { + "description": "The same identity getServerIdentity reports", + "type": "string" + }, + "state": { + "description": "Support and configuration state, not health", + "enum": [ + "available", + "disabled", + "not_configured", + "unsupported" + ], + "type": "string" + } + }, + "required": [ + "server_id", + "current", + "endpoints", + "revision", + "state", + "allowed" + ], + "type": "object" + }, + "ServerEndpointProvider": { + "additionalProperties": false, + "properties": { + "display_name": { + "description": "Provider display name from its manifest, for setup help on the receiving device", + "type": "string" + }, + "kind": { + "description": "A network access provider's overlay origin on the API host", + "enum": [ + "provider" + ], + "type": "string" + }, + "provider": { + "description": "Provider slug (e.g. tailscale)", + "type": "string" + }, + "state": { + "description": "Provider state on the API host; only connected carries a url", + "enum": [ + "disconnected", + "awaiting_authorization", + "connecting", + "connected", + "error", + "unavailable" + ], + "type": "string" + }, + "url": { + "description": "scheme://host[:port] clients reach the API at over the overlay; present only while connected", + "type": "string" + } + }, + "required": [ + "kind", + "provider", + "display_name", + "state" + ], + "type": "object" + }, + "ServerEndpointPublic": { + "additionalProperties": false, + "properties": { + "kind": { + "description": "server.public_url as configured", + "enum": [ + "public" + ], + "type": "string" + }, + "url": { + "description": "scheme://host[:port] clients reach the API at", + "type": "string" + } + }, + "required": [ + "kind", + "url" + ], + "type": "object" + }, + "ServerIdentity": { + "additionalProperties": false, + "properties": { + "server_id": { + "description": "Stable identity of this deployment, the same at every address and on every API process; minted once and kept across restarts, hostname changes and database restores. Public and self-asserted: never authorize on it alone.", + "examples": [ + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + ], + "type": "string" + } + }, + "required": [ + "server_id" + ], + "type": "object" + }, "SettingConstraint": { "additionalProperties": false, "properties": { @@ -37377,6 +37555,13 @@ ], "type": "string" }, + "identity": { + "description": "Path of the public server identity document (getServerIdentity)", + "examples": [ + "/api/v2/system/identity" + ], + "type": "string" + }, "openapi": { "description": "Path of the committed OpenAPI artifact", "examples": [ @@ -37387,7 +37572,8 @@ }, "required": [ "openapi", - "capabilities" + "capabilities", + "identity" ], "type": "object" }, @@ -167992,6 +168178,244 @@ "x-silo-service-backed": true } }, + "/api/v2/system/connections": { + "get": { + "operationId": "getServerConnections", + "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerConnectionsDocument" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the addresses this deployment offers to clients: the configured public URL and each installed network access provider with its overlay origin when connected on the API host. available whenever the server identity is readable; a missing public URL is an absent endpoint, never a manufactured one. Node backend addresses, enrollment URLs and admin status are not included.", + "tags": [ + "system" + ], + "x-silo-class": "authenticated", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/system/identity": { + "get": { + "operationId": "getServerIdentity", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerIdentity" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Read the deployment's stable server identity before login, so a client can tell whether two addresses lead to the same server. Self-asserted: pairing and credentials still go through device-login approval.", + "tags": [ + "system" + ], + "x-silo-class": "public", + "x-silo-service-backed": true + } + }, "/api/v2/system/info": { "get": { "operationId": "getSystemInfo", diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index 3f3547e64a..d6a3b54699 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -818,6 +818,12 @@ Clients use capability documents to decide whether a feature exists. The server contract digest are for diagnostics, support, cache identity, and last-resort compatibility messages; they are not substitutes for feature detection. +`links.identity` points at `GET /api/v2/system/identity`, the one public per-deployment value: +a stable `server_id` every API process answers at every address. It is self-asserted and +authorizes nothing; the signed-in `GET /api/v2/system/connections` capability document lists the +addresses the deployment offers. Both are specified in +[server-identity.md](server-identity.md). + Every stable operation remains registered regardless of runtime provider wiring. Route presence means that the operation belongs to the stable contract, not that the current server is configured, authorized, and healthy enough to perform it. Each optional domain exposes a typed capability diff --git a/docs/architecture/server-identity.md b/docs/architecture/server-identity.md new file mode 100644 index 0000000000..57ffeec76b --- /dev/null +++ b/docs/architecture/server-identity.md @@ -0,0 +1,96 @@ +# Server identity and connection discovery + +A phone and a TV can reach one Silo deployment through different addresses: +the configured public URL, a LAN address, or the overlay origin a network +access provider exposes ([network-access.md](network-access.md)). Clients used +to derive a server's identity from its URL, so two addresses looked like two +servers, and companion pairing handed the TV the phone's address whether or +not the TV could reach it. Two `/api/v2` operations fix that. Tracking issue: +Silo-Server/silo-server#1268; client work: Silo-Server/silo-apple#341 and +Silo-Server/silo-android#352. + +## Identity + +`GET /api/v2/system/identity` is public and answers `{"server_id": …}`. +`GET /api/v2/system/info` links to it as `links.identity`, so discovery +stays a fixed build-wide document and the per-deployment value lives next to +it. The ID is: + +- **One per deployment.** A random UUID stored in `server_settings` under + `server.identity_id` (`internal/serveridentity`). Every API process of the + deployment reads the same row, so every address answers the same value. +- **Minted once, atomically.** The first read of a fresh deployment seeds it + with insert-if-absent; concurrent API processes converge on the winner. + Each process caches it for its lifetime because it never changes. +- **Stable across restarts, hostname and URL changes, and database restores.** + All of those keep the deployment the same server. +- **Copied by a database clone.** A clone carries the row, so until it is + changed the clone *is* the same server to every client: clients group it + with the original and may offer its addresses as candidates. That is the + intended reading for a restore or a migrated host. An operator who wants a + clone to be a separate deployment deletes the `server.identity_id` row on + the clone before serving clients; the next read mints a fresh ID. There is + no API to regenerate it. The ID authorizes nothing (below), so a collision + costs a failed pairing attempt at an address that turns out to be the + wrong server, not a credential. +- **Plaintext.** It is public discovery data, and an encrypted row would + become unreadable after a `SECRET_KEY` rotation, which must not change who + a server is. +- **Separate from the other IDs.** The diagnostics installation ID + (`diagnostics.server_instance_id`) keys upload manifests and import + receipts; the Jellyfin-compat `Id` is the configurable + `jellyfin_compat.server_id` on the compatibility surface; `/api/v1/health` + still echoes that compat ID. None of them is this value, and clients must + not key credential storage on it: local credential and settings keys stay + whatever the client chose. + +## The identity authorizes nothing + +The value is self-asserted. Any host can echo a server ID it has seen, so a +matching ID (or a matching display name) never by itself authorizes a +pairing handoff or the sending of credentials to an address. The proof that +two addresses share one backend is the existing device-login flow +(`/api/v2/auth/device/*`): the TV opens a pairing request at the candidate +address, the phone approves it through its own connection, and the TV +collects tokens only if both requests landed in the same store. The ID is a +hint that makes the candidate worth trying and lets clients group saved +servers; the approval is the gate. Profile confirmation and every other +authorization rule are unchanged. + +## Connections + +`GET /api/v2/system/connections` is authenticated (account, no profile) and +is the capability document for this feature: a client that gets +`state: available` can rely on both operations. It reports: + +- `server_id`, the same value as the identity document; +- `current`, the access path the request arrived on (`default` or + `provider` with the slug), read from the ingress middleware so a client + learns which of its saved addresses is an overlay one; +- `endpoints`, the addresses the deployment offers: `server.public_url` as + `kind: public` when configured, then one `kind: provider` entry per + installed provider with its display name and its state on the API host, + carrying a `url` only while connected. + +What it deliberately leaves out: proxy-node backend addresses, provider +enrollment URLs, provider error text and everything else on the admin status. +A missing public URL is a missing endpoint; the server never guesses one from +the request's `Host` and never enables public exposure. Reachability is the +client's to test: a provider that is connected on the server says nothing +about whether a given TV is on that overlay. + +## Client flow + +During companion setup the phone reads the connections document and hands +the TV the server ID plus the candidate endpoints. The TV probes each one +with the public identity operation, keeps those whose ID matches, and opens +device login at the first reachable match. If the overlay address fails, the +provider's display name drives setup help and the public endpoint, when +present, is the explicit fallback. The address that worked is what the TV +saves; the phone's preferred address does not change. Automatic mid-playback +route switching is out of scope. + +## jellycompat + +No change. Jellyfin clients keep the compat `Id`, and the Jellyfin listener is +reached through the same overlay origin the provider exposes for it. diff --git a/docs/network-access-api.md b/docs/network-access-api.md index cfe58616a7..13d693c627 100644 --- a/docs/network-access-api.md +++ b/docs/network-access-api.md @@ -35,6 +35,37 @@ modes where the plugin service is not wired). Provider health is not state; read it from the admin status below. `providers` is always an array. The document carries an `ETag` and answers `If-None-Match` with 304. +## `GET /api/v2/system/connections` + +Authenticated account; no profile required. The client-facing view of the +same providers, for a phone handing a TV an address it can reach: + +```json +{ + "revision": "…", + "state": "available", + "allowed": true, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "current": { "kind": "default" }, + "endpoints": [ + { "kind": "public", "url": "https://silo.example.test" }, + { "kind": "provider", "provider": "tailscale", "display_name": "Tailscale", + "state": "connected", "url": "https://silo.overlay.example.test" } + ] +} +``` + +`server_id` is the deployment identity the public +`GET /api/v2/system/identity` also answers. `current` is the access path this +request arrived on (`provider` with the slug when it came through an overlay +listener). `endpoints` lists `server.public_url` as `kind: public` when it is +configured, then every installed provider in slug order with its state on the +API host; `url` is present only while that provider is connected there. Node +addresses, `auth_url` and error text stay on the admin status below. The +identity is self-asserted: clients pair through device login before trusting +an address. Design and client flow: +[docs/architecture/server-identity.md](architecture/server-identity.md). + ## `GET /api/v2/admin/network-access/{provider}/status` Acting admin. Asks the provider's plugin instance on every host that runs it diff --git a/internal/api/router.go b/internal/api/router.go index c2920769d0..c1522a46dc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -72,6 +72,7 @@ import ( "github.com/Silo-Server/silo-server/internal/scanqueue" "github.com/Silo-Server/silo-server/internal/secret" "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/serveridentity" "github.com/Silo-Server/silo-server/internal/settingscontract" "github.com/Silo-Server/silo-server/internal/streamtelemetry" "github.com/Silo-Server/silo-server/internal/subtitles" @@ -2028,6 +2029,20 @@ func newChiRouter(deps Dependencies) chi.Router { ) v2deps := v2Dependencies(deps, authMiddleware, viewerAccessMiddleware, requireActingAdmin, metadataCurationAccess, markerEditAccess, settingsRepo) v2deps.CompatConnectInfo = compatConnectInfoHandler + // Server identity is public discovery data, so it reads through the raw + // settings repo: a SECRET_KEY rotation must not change who the server is. + if deps.DB != nil { + v2deps.ServerIdentity = serveridentity.New(catalog.NewServerSettingsRepo(deps.DB)) + } + v2deps.ServerConnections = apiv2.ServerConnections{PublicURL: func() string { + if cfg := deps.CurrentConfig(); cfg != nil { + return cfg.Server.PublicURL + } + return "" + }} + if deps.NetworkAccess != nil { + v2deps.ServerConnections.Providers = deps.NetworkAccess.Status + } if deps.OpsLogRepo != nil { v2deps.AdminOperationalLogs = deps.OpsLogRepo } diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index ac01b08687..71f679f1c0 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -713,6 +713,8 @@ POST /api/v2/sync/progress non-media GET /api/v2/sync/progress/capabilities non-media POST /api/v2/sync/progress/snapshots non-media GET /api/v2/sync/progress/snapshots/{snapshot_id} non-media +GET /api/v2/system/connections non-media +GET /api/v2/system/identity non-media GET /api/v2/system/info non-media GET /api/v2/system/setup non-media GET /api/v2/theme/admin-css non-media @@ -1851,6 +1853,8 @@ POST /api/v2/sync/progress non-media GET /api/v2/sync/progress/capabilities non-media POST /api/v2/sync/progress/snapshots non-media GET /api/v2/sync/progress/snapshots/{snapshot_id} non-media +GET /api/v2/system/connections non-media +GET /api/v2/system/identity non-media GET /api/v2/system/info non-media GET /api/v2/system/setup non-media GET /api/v2/theme/admin-css non-media diff --git a/internal/apiv2/document.go b/internal/apiv2/document.go index ddd507d5f5..849a96c783 100644 --- a/internal/apiv2/document.go +++ b/internal/apiv2/document.go @@ -698,6 +698,7 @@ func registerAll(reg *Registry) { registerWebhookReceiver(reg) registerOnboarding(reg) registerSystem(reg) + registerServerIdentity(reg) registerWatchlist(reg) registerWatch(reg) registerOpenAPIDocument(reg) diff --git a/internal/apiv2/document_test.go b/internal/apiv2/document_test.go index 823c8a38e8..cf0c7c018c 100644 --- a/internal/apiv2/document_test.go +++ b/internal/apiv2/document_test.go @@ -153,6 +153,8 @@ func TestGeneratedDocumentStatuses(t *testing.T) { "disconnectNetworkAccess": {http.StatusAccepted: true, http.StatusOK: false, http.StatusNotFound: true, http.StatusRequestTimeout: true}, "getSetupStatus": {http.StatusServiceUnavailable: true}, "getSystemInfo": {http.StatusServiceUnavailable: false}, + "getServerIdentity": {http.StatusOK: true, http.StatusServiceUnavailable: true}, + "getServerConnections": {http.StatusOK: true, http.StatusServiceUnavailable: true}, "getOpenAPIDocument": {http.StatusServiceUnavailable: false}, "getCurrentUser": {http.StatusNotFound: false}, "listProgress": {http.StatusNotFound: true}, diff --git a/internal/apiv2/fixtures_test.go b/internal/apiv2/fixtures_test.go index e33a8c07b8..b774aa51b8 100644 --- a/internal/apiv2/fixtures_test.go +++ b/internal/apiv2/fixtures_test.go @@ -23,6 +23,7 @@ import ( catalogsvc "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/downloads" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/netaccess" "github.com/Silo-Server/silo-server/internal/routeinventory" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -1717,7 +1718,8 @@ func fixtureCases() []fixtureCase { }...) // Request ids are positional: new fixtures append here so committed // fixtures keep their ids. - return append(cases, networkAccessFixtureCases()...) + cases = append(cases, networkAccessFixtureCases()...) + return append(cases, serverIdentityFixtureCases()...) } // fixtureMultipartType is the multipart Content-Type of the avatar fixtures, @@ -1743,6 +1745,10 @@ func profileOwner() map[string]string { return with(bearer(memberToken), "X-Prof func fixtureDeps() Dependencies { deps := pilotDeps(&fakeProgress{entries: progressRows()}, nil) deps.NetworkAccess = newFakeNetworkAccess() + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + fixtureProviders := netaccess.NewStatusCache() + fixtureProviders.Report(netaccess.Status{InstallationID: 7, Provider: "stub", State: netaccess.StateConnected, Origin: "https://silo.overlay.example.test"}) + deps.ServerConnections = ServerConnections{PublicURL: func() string { return "https://silo.example.test" }, Providers: fixtureProviders} deps.SubtitleAIReads = &fakeSubtitleAIReads{} deps.Downloads = &fakeDownloadRegistry{} deps.DownloadCreation = &fakeDownloadCreation{row: &downloads.Download{ID: "entry", ContentID: "movie", MediaFileID: 42, Revision: 1, CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Status: downloads.StatusReady, Quality: downloads.QualityOriginal, EffectiveQuality: downloads.QualityOriginal, Format: downloads.FormatOriginal, DeviceID: "device-one"}, page: downloads.CreatePage{BatchID: "intent", Skipped: []downloads.SkippedDownload{{EpisodeID: "missing", Reason: "no_file"}}}} diff --git a/internal/apiv2/router.go b/internal/apiv2/router.go index 097a097b86..cd6ac09f93 100644 --- a/internal/apiv2/router.go +++ b/internal/apiv2/router.go @@ -135,6 +135,8 @@ type Dependencies struct { PlaybackControlSocket PlaybackControlSocketService EventsCapability EventsCapabilityService NetworkAccess NetworkAccessService + ServerIdentity ServerIdentityService + ServerConnections ServerConnections NotificationDestinationCreate NotificationDestinationCreateService AdminUnmatchedFiles AdminUnmatchedFilesService AdminCatalogImages AdminCatalogImagesService diff --git a/internal/apiv2/system.go b/internal/apiv2/system.go index ad2809ced9..43acf5b6e5 100644 --- a/internal/apiv2/system.go +++ b/internal/apiv2/system.go @@ -28,6 +28,7 @@ type SystemInfo struct { type SystemInfoLinks struct { OpenAPI string `json:"openapi" doc:"Path of the committed OpenAPI artifact" example:"/api/v2/openapi.json"` Capabilities string `json:"capabilities" doc:"Path prefix of the per-domain capability documents" example:"/api/v2/capabilities"` + Identity string `json:"identity" doc:"Path of the public server identity document (getServerIdentity)" example:"/api/v2/system/identity"` } // SystemInfoOutput is the getSystemInfo response. @@ -110,6 +111,7 @@ func getSystemInfo(_ context.Context, _ *struct{}) (*SystemInfoOutput, error) { Links: SystemInfoLinks{ OpenAPI: Prefix + "/openapi.json", Capabilities: Prefix + "/capabilities", + Identity: Prefix + "/system/identity", }, }, }, nil diff --git a/internal/apiv2/system_identity.go b/internal/apiv2/system_identity.go new file mode 100644 index 0000000000..82118097e8 --- /dev/null +++ b/internal/apiv2/system_identity.go @@ -0,0 +1,220 @@ +package apiv2 + +import ( + "context" + "errors" + "net/http" + "reflect" + "sort" + "strings" + + "github.com/danielgtaylor/huma/v2" + + "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/serveridentity" +) + +// Server identity and connection discovery. A phone and a TV can reach one +// deployment through different addresses (public URL, LAN, an overlay +// network fronted by a network access provider); the identity document lets a +// client recognize the deployment behind any of them, and the connections +// document tells a signed-in client which addresses the deployment offers so +// it can hand a reachable one to a device that cannot use its own. +// +// The identity alone authorizes nothing: it is public and any host can echo +// one. Clients pair or send credentials to an alternate address only after +// the existing device-login approval, which proves both addresses share one +// backend, has succeeded there. + +// ServerIdentityService reads the deployment's stable native server identity. +// *serveridentity.Service implements it. +type ServerIdentityService interface { + ServerID(context.Context) (string, error) +} + +// ServerConnections is where the connections document reads the deployment's +// client-facing addresses from: the configured public URL, read live so a +// settings change applies without restart, and the API host's provider +// status cache for overlay origins. Both are optional. +type ServerConnections struct { + PublicURL func() string + Providers *netaccess.StatusCache +} + +// ServerIdentity is the public identity document. +type ServerIdentity struct { + ServerID string `json:"server_id" doc:"Stable identity of this deployment, the same at every address and on every API process; minted once and kept across restarts, hostname changes and database restores. Public and self-asserted: never authorize on it alone." example:"3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35"` +} + +// ServerIdentityOutput is the getServerIdentity response. +type ServerIdentityOutput struct { + CacheControl string `header:"Cache-Control"` + Body ServerIdentity +} + +// Endpoint kinds in the connections document, and the default access path. +const ( + EndpointKindPublic = "public" + EndpointKindProvider = "provider" + AccessPathDefault = "default" + + // publicDiscoveryCache is the reviewed policy of the public discovery + // documents: identical for every caller, revalidated rather than stored. + publicDiscoveryCache = "no-cache" +) + +// ServerEndpoint is one address the deployment offers to clients: a tagged +// union of the public and provider variants below, discriminated by kind. +// The wire struct carries the superset; the schema is the union so a +// validator rejects a public endpoint without a url or a provider endpoint +// without its slug and state. +type ServerEndpoint struct { + Kind string `json:"kind" enum:"public,provider" doc:"public is server.public_url; provider is a network access provider's overlay origin on the API host"` + URL string `json:"url,omitempty" doc:"scheme://host[:port] clients reach the API at; absent for a provider that is not connected on the API host"` + Provider string `json:"provider,omitempty" doc:"Provider slug (e.g. tailscale) for kind provider"` + DisplayName string `json:"display_name,omitempty" doc:"Provider display name from its manifest, for setup help on the receiving device"` + State string `json:"state,omitempty" enum:"disconnected,awaiting_authorization,connecting,connected,error,unavailable" doc:"Provider state on the API host for kind provider; only connected carries a url"` +} + +// ServerEndpointPublic is the configured public URL. +type ServerEndpointPublic struct { + Kind string `json:"kind" enum:"public" doc:"server.public_url as configured"` + URL string `json:"url" doc:"scheme://host[:port] clients reach the API at"` +} + +// ServerEndpointProvider is one installed network access provider on the API +// host. A url is present only while the provider is connected there; a +// connected provider whose origin is not an http(s) origin has none. +type ServerEndpointProvider struct { + Kind string `json:"kind" enum:"provider" doc:"A network access provider's overlay origin on the API host"` + Provider string `json:"provider" doc:"Provider slug (e.g. tailscale)"` + DisplayName string `json:"display_name" doc:"Provider display name from its manifest, for setup help on the receiving device"` + State string `json:"state" enum:"disconnected,awaiting_authorization,connecting,connected,error,unavailable" doc:"Provider state on the API host; only connected carries a url"` + URL string `json:"url,omitempty" doc:"scheme://host[:port] clients reach the API at over the overlay; present only while connected"` +} + +func (ServerEndpoint) Schema(r huma.Registry) *huma.Schema { + return &huma.Schema{OneOf: []*huma.Schema{ + r.Schema(reflect.TypeFor[ServerEndpointPublic](), true, ""), + r.Schema(reflect.TypeFor[ServerEndpointProvider](), true, ""), + }} +} + +// ServerAccessPath is the access path the current request arrived on: a +// tagged union of the default and provider variants, discriminated by kind. +type ServerAccessPath struct { + Kind string `json:"kind" enum:"default,provider" doc:"default for the public URL, LAN or a reverse proxy; provider for an overlay origin"` + Provider string `json:"provider,omitempty" doc:"Provider slug when kind is provider"` +} + +// ServerAccessPathDefault is a request that did not arrive through a provider. +type ServerAccessPathDefault struct { + Kind string `json:"kind" enum:"default" doc:"The public URL, LAN or a reverse proxy"` +} + +// ServerAccessPathProvider is a request that arrived through a provider's +// overlay listener. +type ServerAccessPathProvider struct { + Kind string `json:"kind" enum:"provider" doc:"An overlay origin"` + Provider string `json:"provider" doc:"Provider slug the request came through"` +} + +func (ServerAccessPath) Schema(r huma.Registry) *huma.Schema { + return &huma.Schema{OneOf: []*huma.Schema{ + r.Schema(reflect.TypeFor[ServerAccessPathDefault](), true, ""), + r.Schema(reflect.TypeFor[ServerAccessPathProvider](), true, ""), + }} +} + +// ServerConnectionsDocument is the connections capability document. +type ServerConnectionsDocument struct { + Capability + ServerID string `json:"server_id" doc:"The same identity getServerIdentity reports"` + Current ServerAccessPath `json:"current" doc:"How this request reached the server"` + Endpoints []ServerEndpoint `json:"endpoints" doc:"Addresses the deployment offers, public first then providers in slug order; empty when no public URL is configured and no provider is installed. Reachability is the client's to test."` +} + +// ServerConnectionsOutput is the getServerConnections response. +type ServerConnectionsOutput struct { + Status int + ETag string `header:"ETag"` + CacheControl string `header:"Cache-Control"` + Body ServerConnectionsDocument +} + +func (ServerConnectionsDocument) capabilityState() string { return StateAvailable } + +func registerServerIdentity(reg *Registry) { + Register(reg, Operation{ + Operation: humaOp(http.MethodGet, Prefix+"/system/identity", "getServerIdentity", "system", + "Read the deployment's stable server identity before login, so a client can tell whether two addresses lead to the same server. Self-asserted: pairing and credentials still go through device-login approval."), + Class: ClassPublic, ServiceBacked: true, + }, reg.getServerIdentity) + Register(reg, Operation{ + Operation: humaOp(http.MethodGet, Prefix+"/system/connections", "getServerConnections", "system", + "List the addresses this deployment offers to clients: the configured public URL and each installed network access provider with its overlay origin when connected on the API host. available whenever the server identity is readable; a missing public URL is an absent endpoint, never a manufactured one. Node backend addresses, enrollment URLs and admin status are not included."), + Class: ClassAuthenticated, ServiceBacked: true, + }, reg.getServerConnections) +} + +func (reg *Registry) serverID(ctx context.Context) (string, error) { + if reg.deps.ServerIdentity == nil { + return "", unavailable("server identity") + } + id, err := reg.deps.ServerIdentity.ServerID(ctx) + if err != nil { + if errors.Is(err, serveridentity.ErrUnavailable) { + return "", unavailable("server identity") + } + return "", NewProblem(TypeInternalError, "An unexpected error occurred.") + } + return id, nil +} + +func (reg *Registry) getServerIdentity(ctx context.Context, _ *struct{}) (*ServerIdentityOutput, error) { + id, err := reg.serverID(ctx) + if err != nil { + return nil, err + } + return &ServerIdentityOutput{CacheControl: publicDiscoveryCache, Body: ServerIdentity{ServerID: id}}, nil +} + +func (reg *Registry) getServerConnections(ctx context.Context, _ *CapabilityInput) (*ServerConnectionsOutput, error) { + id, err := reg.serverID(ctx) + if err != nil { + return nil, err + } + doc := ServerConnectionsDocument{ServerID: id, Current: ServerAccessPath{Kind: AccessPathDefault}, Endpoints: []ServerEndpoint{}} + if path := netaccess.PathFromContext(ctx); !path.IsDefault() { + doc.Current = ServerAccessPath{Kind: EndpointKindProvider, Provider: path.Provider} + } + connections := reg.deps.ServerConnections + if connections.PublicURL != nil { + if origin, ok := netaccess.NormalizeOrigin(connections.PublicURL()); ok { + doc.Endpoints = append(doc.Endpoints, ServerEndpoint{Kind: EndpointKindPublic, URL: origin}) + } + } + if reg.deps.NetworkAccess != nil { + providers, err := reg.deps.NetworkAccess.ListNetworkAccessProviders(ctx) + if err != nil { + return nil, serviceProblem(err) + } + sort.Slice(providers, func(i, j int) bool { return providers[i].Provider < providers[j].Provider }) + for _, provider := range providers { + endpoint := ServerEndpoint{Kind: EndpointKindProvider, Provider: provider.Provider, DisplayName: provider.DisplayName, State: netaccess.StateUnavailable} + if status, ok := connections.Providers.ByProvider(provider.Provider); ok { + endpoint.State = status.State + if !publishedNetworkAccessStates[endpoint.State] { + endpoint.State = netaccess.StateError + } + if status.Connected() { + if origin, ok := netaccess.NormalizeOrigin(strings.TrimSpace(status.Origin)); ok { + endpoint.URL = origin + } + } + } + doc.Endpoints = append(doc.Endpoints, endpoint) + } + } + return &ServerConnectionsOutput{Body: doc}, nil +} diff --git a/internal/apiv2/system_identity_fixtures_test.go b/internal/apiv2/system_identity_fixtures_test.go new file mode 100644 index 0000000000..08d5412a8b --- /dev/null +++ b/internal/apiv2/system_identity_fixtures_test.go @@ -0,0 +1,10 @@ +package apiv2 + +import "net/http" + +func serverIdentityFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "server_identity_ok", operationID: "getServerIdentity", scenario: "Discovery before login: the deployment's stable server identity, the same at every address.", method: http.MethodGet, path: Prefix + "/system/identity", status: 200, assertHeaders: []string{"Content-Type", "Cache-Control"}, schema: "#/components/schemas/ServerIdentity"}, + {name: "server_connections_ok", operationID: "getServerConnections", scenario: "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running.", method: http.MethodGet, path: Prefix + "/system/connections", headers: bearer(memberToken), status: 200, assertHeaders: []string{"Content-Type", "Cache-Control", "ETag"}, schema: "#/components/schemas/ServerConnectionsDocument"}, + } +} diff --git a/internal/apiv2/system_identity_test.go b/internal/apiv2/system_identity_test.go new file mode 100644 index 0000000000..1f1931bafd --- /dev/null +++ b/internal/apiv2/system_identity_test.go @@ -0,0 +1,177 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/serveridentity" +) + +type fakeServerIdentity struct { + id string + err error +} + +func (f fakeServerIdentity) ServerID(context.Context) (string, error) { return f.id, f.err } + +const fixtureServerID = "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + +type connectionsDoc struct { + State string `json:"state"` + Allowed bool `json:"allowed"` + ServerID string `json:"server_id"` + Current ServerAccessPath + Endpoints []ServerEndpoint `json:"endpoints"` +} + +func decodeConnections(t *testing.T, rec *httptest.ResponseRecorder) connectionsDoc { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + var doc connectionsDoc + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +func TestGetServerIdentity(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + h := newTestHandler(t, deps) + rec := do(t, h, http.MethodGet, Prefix+"/system/identity", "", nil) + if rec.Code != http.StatusOK || rec.Body.String() != `{"server_id":"`+fixtureServerID+`"}`+"\n" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Cache-Control") != "no-cache" { + t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control")) + } + // Public: credentials and profile headers change nothing, and the + // document links to it from discovery. + rec = do(t, h, http.MethodGet, Prefix+"/system/identity", "", with(bearer(memberToken), "X-Profile-Id", "p-owner")) + if rec.Code != http.StatusOK { + t.Fatal(rec.Code, rec.Body.String()) + } + rec = do(t, h, http.MethodGet, Prefix+"/system/info", "", nil) + var info struct { + Links struct { + Identity string `json:"identity"` + } `json:"links"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil || info.Links.Identity != Prefix+"/system/identity" { + t.Fatalf("links.identity = %q err = %v", info.Links.Identity, err) + } +} + +func TestGetServerIdentityFailures(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.ServerIdentity = nil + requireProblem(t, do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/system/identity", "", nil), TypeDependencyUnavailable) + deps.ServerIdentity = fakeServerIdentity{err: serveridentity.ErrUnavailable} + requireProblem(t, do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/system/identity", "", nil), TypeDependencyUnavailable) + deps.ServerIdentity = fakeServerIdentity{err: errors.New("db down")} + requireProblem(t, do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/system/identity", "", nil), TypeInternalError) + // The connections document depends on the same identity. + requireProblem(t, do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/system/connections", "", bearer(memberToken)), TypeInternalError) +} + +func TestGetServerConnections(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + deps.NetworkAccess = newFakeNetworkAccess() + cache := netaccess.NewStatusCache() + cache.Report(netaccess.Status{InstallationID: 7, Provider: "stub", State: netaccess.StateConnected, Origin: "HTTPS://Silo.Overlay.Example.Test/"}) + publicURL := "https://silo.example.test/" + deps.ServerConnections = ServerConnections{PublicURL: func() string { return publicURL }, Providers: cache} + h := newTestHandler(t, deps) + path := Prefix + "/system/connections" + + requireProblem(t, do(t, h, http.MethodGet, path, "", nil), TypeAuthenticationRequired) + + rec := do(t, h, http.MethodGet, path, "", bearer(memberToken)) + doc := decodeConnections(t, rec) + if doc.State != StateAvailable || !doc.Allowed || doc.ServerID != fixtureServerID { + t.Fatalf("head = %+v", doc) + } + if doc.Current.Kind != AccessPathDefault || doc.Current.Provider != "" { + t.Fatalf("current = %+v", doc.Current) + } + want := []ServerEndpoint{ + {Kind: EndpointKindPublic, URL: "https://silo.example.test"}, + {Kind: EndpointKindProvider, Provider: "down", DisplayName: "Down Overlay", State: netaccess.StateUnavailable}, + {Kind: EndpointKindProvider, Provider: "stub", DisplayName: "Stub Overlay", State: netaccess.StateConnected, URL: "https://silo.overlay.example.test"}, + } + if len(doc.Endpoints) != len(want) { + t.Fatalf("endpoints = %s", rec.Body.String()) + } + for i := range want { + if doc.Endpoints[i] != want[i] { + t.Fatalf("endpoint %d = %+v, want %+v", i, doc.Endpoints[i], want[i]) + } + } + if rec.Header().Get("ETag") == "" || rec.Header().Get("Cache-Control") != cachePrivateNoCache { + t.Fatalf("headers = %v", rec.Header()) + } + etag := rec.Header().Get("ETag") + if rec := do(t, h, http.MethodGet, path, "", with(bearer(memberToken), "If-None-Match", etag)); rec.Code != http.StatusNotModified { + t.Fatalf("revalidation = %d", rec.Code) + } + + // A provider that disconnects loses its url but stays listed; the + // revision moves with it. + cache.Report(netaccess.Status{InstallationID: 7, Provider: "stub", State: netaccess.StateDisconnected, Origin: "https://silo.overlay.example.test"}) + rec = do(t, h, http.MethodGet, path, "", with(bearer(memberToken), "If-None-Match", etag)) + doc = decodeConnections(t, rec) + if doc.Endpoints[2].URL != "" || doc.Endpoints[2].State != netaccess.StateDisconnected { + t.Fatalf("disconnected endpoint = %+v", doc.Endpoints[2]) + } + // A provider state outside the published vocabulary is coerced to error. + cache.Report(netaccess.Status{InstallationID: 7, Provider: "stub", State: "rebooting", Origin: "https://silo.overlay.example.test"}) + if doc = decodeConnections(t, do(t, h, http.MethodGet, path, "", bearer(memberToken))); doc.Endpoints[2].State != netaccess.StateError { + t.Fatalf("coerced state = %+v", doc.Endpoints[2]) + } + + // No public URL: the endpoint is absent, never manufactured. An + // unparsable one is treated the same. + for _, raw := range []string{"", "not a url", "ftp://silo.example.test"} { + publicURL = raw + doc = decodeConnections(t, do(t, h, http.MethodGet, path, "", bearer(memberToken))) + if len(doc.Endpoints) != 2 || doc.Endpoints[0].Kind != EndpointKindProvider { + t.Fatalf("public_url=%q endpoints = %+v", raw, doc.Endpoints) + } + } +} + +func TestGetServerConnectionsCurrentPath(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + h := newTestHandler(t, deps) + r := httptest.NewRequest(http.MethodGet, Prefix+"/system/connections", nil) + r.Header.Set("Authorization", "Bearer "+memberToken) + r = r.WithContext(netaccess.WithPath(r.Context(), netaccess.Path{Provider: "tailscale"})) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + doc := decodeConnections(t, rec) + if doc.Current.Kind != EndpointKindProvider || doc.Current.Provider != "tailscale" { + t.Fatalf("current = %+v", doc.Current) + } + // Nothing wired beyond identity: an empty, still-available document. + if doc.State != StateAvailable || len(doc.Endpoints) != 0 { + t.Fatalf("doc = %+v", doc) + } +} + +func TestGetServerConnectionsProviderListFailure(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + f := newFakeNetworkAccess() + f.listErr = errors.New("manifest store down") + deps.NetworkAccess = f + requireProblem(t, do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/system/connections", "", bearer(memberToken)), TypeInternalError) +} diff --git a/internal/serveridentity/serveridentity.go b/internal/serveridentity/serveridentity.go new file mode 100644 index 0000000000..dcf5bc92b0 --- /dev/null +++ b/internal/serveridentity/serveridentity.go @@ -0,0 +1,116 @@ +// Package serveridentity owns the deployment's native server identity: one +// random ID, minted on first use and stored in server_settings, that every +// API process of a deployment answers with whatever address it was reached +// at. Clients use it to recognize one deployment across its public URL, LAN +// address and overlay-network origins instead of comparing hostnames. +// +// The ID is deliberately separate from the diagnostics installation ID (which +// keys upload manifests and import receipts) and from the Jellyfin-compat +// server ID (a configurable setting on the compatibility surface), so neither +// of those contracts constrains this one. +// +// Lifecycle: the value survives restarts, hostname and URL changes, and a +// database restore, all of which keep the deployment the same server. A +// cloned database carries the same ID to the clone; that is accepted for now +// because the ID authorizes nothing on its own. It is not encrypted: it is +// public discovery data, and an encrypted row would become unreadable after a +// SECRET_KEY rotation, which must not change a server's identity. +package serveridentity + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/google/uuid" +) + +// Key is the server_settings row that holds the identity. +const Key = "server.identity_id" + +// ErrUnavailable reports that no settings store is wired, so no identity can +// be read or minted. +var ErrUnavailable = errors.New("server identity: settings store unavailable") + +// Store is the read/write surface over server_settings the identity needs. +// catalog.ServerSettingsRepo and catalog.EncryptedSettingsRepo satisfy it. +type Store interface { + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key, value string) error +} + +// conditionalStore is the optional insert-if-absent surface. When the store +// offers it, concurrent processes minting at the same moment converge on one +// value instead of overwriting each other. +type conditionalStore interface { + SetIfAbsent(ctx context.Context, key, value string) (bool, error) +} + +// Service reads the identity once and caches it for the process lifetime; the +// value never changes while the deployment lives, so a cached answer is exact. +type Service struct { + store Store + + mu sync.Mutex + id string +} + +// New returns a service over store. A nil store yields ErrUnavailable from +// every read. +func New(store Store) *Service { return &Service{store: store} } + +// ServerID returns the deployment's identity, minting and persisting one on +// the first call of a fresh deployment. +func (s *Service) ServerID(ctx context.Context) (string, error) { + if s == nil || s.store == nil { + return "", ErrUnavailable + } + s.mu.Lock() + defer s.mu.Unlock() + if s.id != "" { + return s.id, nil + } + id, err := Ensure(ctx, s.store) + if err != nil { + return "", err + } + s.id = id + return id, nil +} + +// Ensure returns the stored identity, minting and persisting one when the row +// is absent or empty. With a conditional store the seed is single-writer: +// exactly one minted value lands and every process re-reads the winner. +func Ensure(ctx context.Context, store Store) (string, error) { + if store == nil { + return "", ErrUnavailable + } + existing, err := store.Get(ctx, Key) + if err != nil { + return "", fmt.Errorf("server identity: read: %w", err) + } + if id := strings.TrimSpace(existing); id != "" { + return id, nil + } + minted := uuid.NewString() + conditional, ok := store.(conditionalStore) + if !ok { + if err := store.Set(ctx, Key, minted); err != nil { + return "", fmt.Errorf("server identity: seed: %w", err) + } + return minted, nil + } + if _, err := conditional.SetIfAbsent(ctx, Key, minted); err != nil { + return "", fmt.Errorf("server identity: seed: %w", err) + } + winner, err := store.Get(ctx, Key) + if err != nil { + return "", fmt.Errorf("server identity: read: %w", err) + } + if id := strings.TrimSpace(winner); id != "" { + return id, nil + } + return minted, nil +} diff --git a/internal/serveridentity/serveridentity_test.go b/internal/serveridentity/serveridentity_test.go new file mode 100644 index 0000000000..34bdce5dfb --- /dev/null +++ b/internal/serveridentity/serveridentity_test.go @@ -0,0 +1,141 @@ +package serveridentity + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/google/uuid" +) + +type memStore struct { + mu sync.Mutex + values map[string]string + getErr error + sets int +} + +func (m *memStore) Get(_ context.Context, key string) (string, error) { + if m.getErr != nil { + return "", m.getErr + } + m.mu.Lock() + defer m.mu.Unlock() + return m.values[key], nil +} + +func (m *memStore) Set(_ context.Context, key, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.values == nil { + m.values = map[string]string{} + } + m.values[key] = value + m.sets++ + return nil +} + +// conditionalMemStore adds the insert-if-absent surface the Postgres repo has. +type conditionalMemStore struct{ memStore } + +func (m *conditionalMemStore) SetIfAbsent(_ context.Context, key, value string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.values == nil { + m.values = map[string]string{} + } + if m.values[key] != "" { + return false, nil + } + m.values[key] = value + m.sets++ + return true, nil +} + +func TestEnsureMintsOnceAndKeepsValue(t *testing.T) { + store := &conditionalMemStore{} + first, err := Ensure(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if _, err := uuid.Parse(first); err != nil { + t.Fatalf("minted id %q is not a UUID: %v", first, err) + } + second, err := Ensure(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if second != first || store.sets != 1 { + t.Fatalf("second read = %q (sets %d), want %q once", second, store.sets, first) + } +} + +func TestEnsureConcurrentProcessesConverge(t *testing.T) { + store := &conditionalMemStore{} + const n = 32 + ids := make([]string, n) + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + id, err := Ensure(context.Background(), store) + if err != nil { + t.Error(err) + return + } + ids[i] = id + }() + } + wg.Wait() + for _, id := range ids { + if id != ids[0] { + t.Fatalf("processes disagree: %q vs %q", id, ids[0]) + } + } + if store.sets != 1 { + t.Fatalf("sets = %d, want exactly one winner", store.sets) + } +} + +func TestEnsureFallsBackToSetWithoutConditionalStore(t *testing.T) { + store := &memStore{} + id, err := Ensure(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if store.values[Key] != id || store.sets != 1 { + t.Fatalf("stored %q after %d sets, want %q once", store.values[Key], store.sets, id) + } +} + +func TestEnsureTrimsStoredValue(t *testing.T) { + store := &memStore{values: map[string]string{Key: " existing-id \n"}} + id, err := Ensure(context.Background(), store) + if err != nil || id != "existing-id" || store.sets != 0 { + t.Fatalf("id = %q err = %v sets = %d", id, err, store.sets) + } +} + +func TestServiceCachesAndReportsUnavailable(t *testing.T) { + if _, err := New(nil).ServerID(context.Background()); !errors.Is(err, ErrUnavailable) { + t.Fatalf("nil store err = %v, want ErrUnavailable", err) + } + failing := &memStore{getErr: errors.New("db down")} + svc := New(failing) + if _, err := svc.ServerID(context.Background()); err == nil { + t.Fatal("expected read error") + } + // A failed read caches nothing: the next call retries the store. + failing.getErr = nil + first, err := svc.ServerID(context.Background()) + if err != nil { + t.Fatal(err) + } + failing.getErr = errors.New("db down again") + second, err := svc.ServerID(context.Background()) + if err != nil || second != first { + t.Fatalf("cached read = %q err = %v, want %q", second, err, first) + } +} diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index f27a23bb2a..b39dcf0f18 100644 --- a/web/src/api/v2/operations.ts +++ b/web/src/api/v2/operations.ts @@ -413,6 +413,8 @@ export const v2Operations = { "GET /api/v2/subtitles/{media_file_id}": "listStoredSubtitles", "GET /api/v2/sync/progress/capabilities": "getProgressBootstrapCapabilities", "GET /api/v2/sync/progress/snapshots/{snapshot_id}": "getProgressBootstrapSnapshot", + "GET /api/v2/system/connections": "getServerConnections", + "GET /api/v2/system/identity": "getServerIdentity", "GET /api/v2/system/info": "getSystemInfo", "GET /api/v2/system/setup": "getSetupStatus", "GET /api/v2/theme/admin-css": "getThemeOverrides", diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index cb7a4212c9..45312bfe22 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -9348,6 +9348,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/system/connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the addresses this deployment offers to clients: the configured public URL and each installed network access provider with its overlay origin when connected on the API host. available whenever the server identity is readable; a missing public URL is an absent endpoint, never a manufactured one. Node backend addresses, enrollment URLs and admin status are not included. */ + get: operations["getServerConnections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/system/identity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read the deployment's stable server identity before login, so a client can tell whether two addresses lead to the same server. Self-asserted: pairing and credentials still go through device-login approval. */ + get: operations["getServerIdentity"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/system/info": { parameters: { query?: never; @@ -23412,6 +23446,22 @@ export interface components { */ updated_at: string; }; + ServerAccessPathDefault: { + /** + * @description The public URL, LAN or a reverse proxy + * @enum {string} + */ + kind: "default"; + }; + ServerAccessPathProvider: { + /** + * @description An overlay origin + * @enum {string} + */ + kind: "provider"; + /** @description Provider slug the request came through */ + provider: string; + }; ServerCollectionLibrary: { collections: components["schemas"]["LibraryCollectionCard"][]; /** @@ -23426,6 +23476,68 @@ export interface components { ServerCollectionsOutputBody: { libraries: components["schemas"]["ServerCollectionLibrary"][]; }; + ServerConnectionsDocument: { + /** @description Whether the current principal may use the capability */ + allowed: boolean; + /** @description How this request reached the server */ + current: + | components["schemas"]["ServerAccessPathDefault"] + | components["schemas"]["ServerAccessPathProvider"]; + /** @description Addresses the deployment offers, public first then providers in slug order; empty when no public URL is configured and no provider is installed. Reachability is the client's to test. */ + endpoints: ( + | components["schemas"]["ServerEndpointPublic"] + | components["schemas"]["ServerEndpointProvider"] + )[]; + /** @description Opaque revision of this document */ + revision: string; + /** @description The same identity getServerIdentity reports */ + server_id: string; + /** + * @description Support and configuration state, not health + * @enum {string} + */ + state: "available" | "disabled" | "not_configured" | "unsupported"; + }; + ServerEndpointProvider: { + /** @description Provider display name from its manifest, for setup help on the receiving device */ + display_name: string; + /** + * @description A network access provider's overlay origin on the API host + * @enum {string} + */ + kind: "provider"; + /** @description Provider slug (e.g. tailscale) */ + provider: string; + /** + * @description Provider state on the API host; only connected carries a url + * @enum {string} + */ + state: + | "disconnected" + | "awaiting_authorization" + | "connecting" + | "connected" + | "error" + | "unavailable"; + /** @description scheme://host[:port] clients reach the API at over the overlay; present only while connected */ + url?: string; + }; + ServerEndpointPublic: { + /** + * @description server.public_url as configured + * @enum {string} + */ + kind: "public"; + /** @description scheme://host[:port] clients reach the API at */ + url: string; + }; + ServerIdentity: { + /** + * @description Stable identity of this deployment, the same at every address and on every API process; minted once and kept across restarts, hostname changes and database restores. Public and self-asserted: never authorize on it alone. + * @example 3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35 + */ + server_id: string; + }; SettingConstraint: { /** * @description How the policy narrows the value; one of the settings contract's constraint kinds @@ -24208,6 +24320,11 @@ export interface components { * @example /api/v2/capabilities */ capabilities: string; + /** + * @description Path of the public server identity document (getServerIdentity) + * @example /api/v2/system/identity + */ + identity: string; /** * @description Path of the committed OpenAPI artifact * @example /api/v2/openapi.json @@ -109810,6 +109927,182 @@ export interface operations { }; }; }; + getServerConnections: { + parameters: { + query?: never; + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServerConnectionsDocument"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getServerIdentity: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServerIdentity"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; getSystemInfo: { parameters: { query?: never;