diff --git a/internal/ap/service_actor.go b/internal/ap/service_actor.go index 7923e2c..583dd6f 100644 --- a/internal/ap/service_actor.go +++ b/internal/ap/service_actor.go @@ -40,9 +40,8 @@ type ServiceActor struct { // Key is the actor's RSA private key. Key *rsa.PrivateKey // CreatedAt is when the service key was first provisioned — the - // `published` timestamp of the actor documents (Lemmy's Instance - // protocol REQUIRES published; a zero value falls back to a fixed - // epoch so hand-built test actors still render valid documents). + // `published` timestamp of the actor documents. A zero value means the + // time is unknown, and the field is then omitted rather than invented. CreatedAt time.Time } @@ -180,10 +179,6 @@ func (a *ServiceActor) DocumentJSON() ([]byte, error) { // origin, yielding "{scheme}://{host}/"). func (a *ServiceActor) InstanceActorID() string { return a.BaseURL() + "/" } -// instanceActorFallbackPublished keeps hand-built test actors (zero -// CreatedAt) rendering a valid document — Lemmy requires `published`. -const instanceActorFallbackPublished = "2026-01-01T00:00:00Z" - // InstanceDocumentJSON renders the bridge's INSTANCE actor — the AP face of // the whole deployment, served at the origin apex ("/"). This is a separate // identity from the /actor service actor, and it is load-bearing for @@ -210,10 +205,6 @@ func (a *ServiceActor) InstanceDocumentJSON() ([]byte, error) { if err != nil { return nil, err } - published := instanceActorFallbackPublished - if !a.CreatedAt.IsZero() { - published = a.CreatedAt.UTC().Format(time.RFC3339) - } id := a.InstanceActorID() doc := map[string]any{ "@context": json.RawMessage(serviceActorContext), @@ -224,13 +215,19 @@ func (a *ServiceActor) InstanceDocumentJSON() ([]byte, error) { "summary": "Bridges threadiverse communities into atproto. " + a.BaseURL(), "inbox": a.InboxURL(), "outbox": a.OutboxURL(), - "published": published, "publicKey": map[string]any{ "id": id + "#main-key", "owner": id, "publicKeyPem": string(publicPEM), }, } + // Lemmy requires published, but a provisioning time the bridge does not + // know is a date it would be INVENTING — and every peer caches it. An + // unknown time is omitted instead; only hand-built literals lack one, + // since production loads CreatedAt from the service_keys row. + if !a.CreatedAt.IsZero() { + doc["published"] = a.CreatedAt.UTC().Format(time.RFC3339) + } data, err := json.Marshal(doc) if err != nil { return nil, fmt.Errorf("ap: encode instance actor document: %w", err) diff --git a/internal/ap/service_actor_test.go b/internal/ap/service_actor_test.go index 357dafd..521423f 100644 --- a/internal/ap/service_actor_test.go +++ b/internal/ap/service_actor_test.go @@ -21,12 +21,17 @@ import ( type fakeServiceKeys struct { mu sync.Mutex rows map[string][]byte + // created stamps each row like postgres' DEFAULT now() does. The real + // column is what the instance actor publishes as `published`, so a fake + // that left it zero would let a document render without one and call + // that passing. + created map[string]time.Time // createHook runs inside Create before the insert (to simulate races). createHook func() } func newFakeServiceKeys() *fakeServiceKeys { - return &fakeServiceKeys{rows: map[string][]byte{}} + return &fakeServiceKeys{rows: map[string][]byte{}, created: map[string]time.Time{}} } func (f *fakeServiceKeys) Create(_ context.Context, name string, pem []byte) (*store.ServiceKey, error) { @@ -39,7 +44,8 @@ func (f *fakeServiceKeys) Create(_ context.Context, name string, pem []byte) (*s return nil, errors.NewConflictError("service_key", "name", name) } f.rows[name] = pem - return &store.ServiceKey{ID: 1, Name: name, KeyMaterial: pem}, nil + f.created[name] = time.Now().UTC() + return &store.ServiceKey{ID: 1, Name: name, KeyMaterial: pem, CreatedAt: f.created[name]}, nil } func (f *fakeServiceKeys) Get(_ context.Context, name string) (*store.ServiceKey, error) { @@ -49,7 +55,7 @@ func (f *fakeServiceKeys) Get(_ context.Context, name string) (*store.ServiceKey if !ok { return nil, errors.NewNotFoundError("service_key", name) } - return &store.ServiceKey{ID: 1, Name: name, KeyMaterial: pem}, nil + return &store.ServiceKey{ID: 1, Name: name, KeyMaterial: pem, CreatedAt: f.created[name]}, nil } func TestLoadOrCreateServiceActor_GeneratesThenLoads(t *testing.T) { @@ -194,6 +200,61 @@ func TestInstanceActorDocument(t *testing.T) { "the instance actor reuses the service RSA key (Lemmy reads only publicKeyPem)") } +// TestInstanceActorDocument_PublishedTracksTheKey pins that `published` is +// the service key's real provisioning time, not a constant. +func TestInstanceActorDocument_PublishedTracksTheKey(t *testing.T) { + keys := newFakeServiceKeys() + actor, err := LoadOrCreateServiceActor(context.Background(), keys, "bridge.example", "") + require.NoError(t, err) + require.False(t, actor.CreatedAt.IsZero(), + "the service key row carries a creation time; the actor must adopt it") + + docJSON, err := actor.InstanceDocumentJSON() + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(docJSON, &doc)) + + published, ok := doc["published"].(string) + require.True(t, ok, "published must be present when the key's age is known") + parsed, err := time.Parse(time.RFC3339, published) + require.NoError(t, err) + assert.WithinDuration(t, actor.CreatedAt, parsed, time.Second, + "published must be the key's provisioning time") +} + +// TestInstanceActorDocument_OmitsUnknownPublished retires the hardcoded +// test-shim fallback: an actor whose CreatedAt is unknown must OMIT +// published rather than assert a fixed epoch. A document that states a date +// the bridge does not know is a lie every peer caches; omitting it is +// honest, and the only actors affected are hand-built test literals — +// production loads CreatedAt from the service_keys row. +func TestInstanceActorDocument_OmitsUnknownPublished(t *testing.T) { + key, err := GenerateRSAKey() + require.NoError(t, err) + actor := &ServiceActor{ + ID: "https://bridge.example" + ServiceActorPath, + Hostname: "bridge.example", + Scheme: "https", + Key: key, + // CreatedAt deliberately zero. + } + + docJSON, err := actor.InstanceDocumentJSON() + require.NoError(t, err, "an unknown provisioning time must not fail the render") + assert.NotContains(t, string(docJSON), "2026-01-01", + "no hardcoded epoch may appear in a rendered document") + + var doc map[string]any + require.NoError(t, json.Unmarshal(docJSON, &doc)) + assert.NotContains(t, doc, "published", + "an unknown published date is omitted, never invented") + + // The rest of the document still renders: omitting one unknown field + // must not degrade the identity. + assert.Equal(t, "Application", doc["type"]) + assert.Equal(t, "https://bridge.example/", doc["id"]) +} + func TestServiceActorSigner_RoundTrip(t *testing.T) { keys := newFakeServiceKeys() actor, err := LoadOrCreateServiceActor(context.Background(), keys, "bridge.example", "") diff --git a/internal/personas/hostrouter.go b/internal/personas/hostrouter.go new file mode 100644 index 0000000..f33bfca --- /dev/null +++ b/internal/personas/hostrouter.go @@ -0,0 +1,186 @@ +package personas + +import ( + "bytes" + "net" + "net/http" + "strings" + + "tidepool/internal/errors" +) + +// HostRouterOptions configures NewHostRouter. +type HostRouterOptions struct { + // ServiceHost is BRIDGE_HOSTNAME: the bridge's own surface, including + // every bridged handle's subdomain under it. + ServiceHost string + ServiceHandler http.Handler + // UserHost is AP_USER_ORIGIN's host: the Coves user origin. + UserHost string + UserHandler http.Handler + // DevFallthrough sends unknown Hosts to the service handler instead of + // refusing them. A laptop is reached by IP, tunnel hostname, or whatever + // the tunnel minted this morning; a production deployment is not. + DevFallthrough bool +} + +// NewHostRouter splits one listener between the bridge's service surface and +// the Coves user origin by request Host. +// +// Refusing an unknown Host with 421 is the production posture: this process +// serves an authenticated write surface and an AP inbox, and neither should be +// reachable under a name an attacker chose. Suffix matching is on a LABEL +// BOUNDARY in both directions — "nottdpl.io" is not the service host and +// "tdpl.io.evil.example" is not under it — because a bare substring test here +// would hand an attacker the whole service surface. +func NewHostRouter(opts HostRouterOptions) (http.Handler, error) { + // A nil handler would nil-panic on the first request of whichever + // bucket it was meant to serve, and a missing host cannot classify + // anything: both are startup errors, not runtime surprises. + if opts.ServiceHost == "" { + return nil, errors.NewValidationError("service_host", "must not be empty") + } + if opts.ServiceHandler == nil { + return nil, errors.NewValidationError("service_handler", "must not be nil") + } + if opts.UserHost == "" { + return nil, errors.NewValidationError("user_host", "must not be empty") + } + if opts.UserHandler == nil { + return nil, errors.NewValidationError("user_handler", "must not be nil") + } + + return &hostRouter{ + serviceHost: normalizeHost(opts.ServiceHost), + serviceHandler: opts.ServiceHandler, + userHost: normalizeHost(opts.UserHost), + userHandler: opts.UserHandler, + devFallthrough: opts.DevFallthrough, + }, nil +} + +type hostRouter struct { + serviceHost string + serviceHandler http.Handler + userHost string + userHandler http.Handler + devFallthrough bool +} + +func (h *hostRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := normalizeHost(r.Host) + switch { + case host == h.userHost && h.userHost == h.serviceHost: + // The default dev configuration points BRIDGE_HOSTNAME and + // AP_USER_ORIGIN at the same authority. One Host cannot pick a + // bucket, so the split moves to the path: see serveComposed. + h.serveComposed(w, r) + case host == h.userHost: + h.userHandler.ServeHTTP(w, r) + case h.isServiceHost(host): + h.serviceHandler.ServeHTTP(w, r) + case h.devFallthrough: + h.serviceHandler.ServeHTTP(w, r) + default: + http.Error(w, "unrecognized Host", http.StatusMisdirectedRequest) + } +} + +// isServiceHost reports whether host belongs to the bridge's own surface: +// the configured hostname, any subdomain of it (954 bridged handles resolve +// through those), or an address with no registered name at all — an absent +// Host, "localhost", or a bare IP literal, which is how container +// healthchecks and direct-IP probes arrive. +func (h *hostRouter) isServiceHost(host string) bool { + if host == "" || host == h.serviceHost || strings.HasSuffix(host, "."+h.serviceHost) { + return true + } + name := hostnameOnly(host) + return name == "localhost" || net.ParseIP(name) != nil +} + +// serveComposed runs the user surface first and replaces its 404 with the +// service handler's response. The user response is BUFFERED rather than +// streamed: once a status line has reached the client there is no taking it +// back, so a fallback would append its body to the 404 instead of replacing +// it. +func (h *hostRouter) serveComposed(w http.ResponseWriter, r *http.Request) { + buffered := &bufferedResponse{header: http.Header{}} + h.userHandler.ServeHTTP(buffered, r) + if buffered.status() == http.StatusNotFound { + // The user surface does not serve this path; the service surface + // gets the real writer, so its headers and status are the ones + // that land. + h.serviceHandler.ServeHTTP(w, r) + return + } + buffered.flushTo(w) +} + +// bufferedResponse captures a handler's response so the caller can decide +// whether to send it. +type bufferedResponse struct { + header http.Header + code int + body bytes.Buffer +} + +func (b *bufferedResponse) Header() http.Header { return b.header } + +func (b *bufferedResponse) WriteHeader(code int) { + if b.code == 0 { + b.code = code + } +} + +func (b *bufferedResponse) Write(p []byte) (int, error) { + b.WriteHeader(http.StatusOK) + return b.body.Write(p) +} + +// status is the response's status, defaulting to 200 the way net/http does +// for a handler that wrote nothing at all. +func (b *bufferedResponse) status() int { + if b.code == 0 { + return http.StatusOK + } + return b.code +} + +func (b *bufferedResponse) flushTo(w http.ResponseWriter) { + for key, values := range b.header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(b.status()) + _, _ = w.Write(b.body.Bytes()) +} + +// normalizeHost reduces a Host header to the authority it names: lowercase, +// no trailing dot, and no default port for either scheme. +// +// Both default ports are stripped unconditionally, without consulting r.TLS. +// In production TLS terminates at the proxy and the Go server sees plain HTTP +// carrying the forwarded Host, so a scheme-keyed rule would classify +// "coves.social:443" as an unknown authority precisely where it matters. A +// NON-default port still carries meaning — the dev origin runs on :8091 and +// coves.social:8443 is a different origin, not a sloppy spelling of one. +func normalizeHost(host string) string { + normalized := strings.ToLower(strings.TrimSpace(host)) + for _, defaultPort := range []string{":443", ":80"} { + if trimmed, found := strings.CutSuffix(normalized, defaultPort); found { + normalized = trimmed + break + } + } + return strings.TrimSuffix(normalized, ".") +} + +// hostnameOnly strips a port and IPv6 brackets, leaving the name or address. +func hostnameOnly(host string) string { + if name, _, err := net.SplitHostPort(host); err == nil { + return name + } + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") +} diff --git a/internal/personas/hostrouter_test.go b/internal/personas/hostrouter_test.go new file mode 100644 index 0000000..144803f --- /dev/null +++ b/internal/personas/hostrouter_test.go @@ -0,0 +1,282 @@ +package personas + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const serviceHost = "tdpl.io" + +// marker is a stand-in handler that records what reached it and answers with +// its own name, so a test can tell WHICH surface served a request. Paths in +// notFound answer 404 instead — how the composed handler's fallback is +// exercised. +type marker struct { + name string + calls int + hosts []string + notFound map[string]bool +} + +func newMarker(name string, notFound ...string) *marker { + m := &marker{name: name, notFound: map[string]bool{}} + for _, path := range notFound { + m.notFound[path] = true + } + return m +} + +func (m *marker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.calls++ + m.hosts = append(m.hosts, r.Host) + if m.notFound[r.URL.Path] { + http.NotFound(w, r) + return + } + w.Header().Set("X-Handler", m.name) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(m.name + " " + r.URL.Path)) +} + +// requestOnHost builds a request with an explicit Host. scheme selects +// whether the server sees TLS, which a Host like "coves.social:443" must +// NOT depend on: in production TLS terminates at the proxy and the Go +// server sees plain HTTP carrying whatever Host was forwarded. +func requestOnHost(scheme, host, target string) *http.Request { + req := httptest.NewRequest(http.MethodGet, scheme+"://placeholder.invalid"+target, nil) + req.Host = host + return req +} + +func routeHost(t *testing.T, h http.Handler, scheme, host, target string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, requestOnHost(scheme, host, target)) + return rec +} + +func newTestRouter(t *testing.T, devFallthrough bool) (http.Handler, *marker, *marker) { + t.Helper() + service := newMarker("service") + user := newMarker("user") + router, err := NewHostRouter(HostRouterOptions{ + ServiceHost: serviceHost, + ServiceHandler: service, + UserHost: userHost, + UserHandler: user, + DevFallthrough: devFallthrough, + }) + require.NoError(t, err) + require.NotNil(t, router) + return router, service, user +} + +// TestHostRouter_ServiceBucket: everything the bridge answers on today must +// keep reaching the same handler, byte for byte. The subdomain wildcard is +// the load-bearing one — 954 bridged handles resolve through r.Host. +func TestHostRouter_ServiceBucket(t *testing.T) { + serviceHosts := []struct { + name string + host string + }{ + {"the bridge hostname", serviceHost}, + {"uppercase", "TDPL.IO"}, + {"trailing dot", serviceHost + "."}, + {"default port", serviceHost + ":443"}, + {"bridged handle subdomain", "alice.lemmy-world." + serviceHost}, + {"deep subdomain", "a.b.c." + serviceHost}, + {"localhost", "localhost"}, + {"localhost with port", "localhost:80"}, + {"loopback v4", "127.0.0.1:8080"}, + {"loopback v6", "[::1]:8080"}, + {"public IP literal", "192.0.2.10"}, + {"IPv6 literal", "[2001:db8::1]:443"}, + {"absent Host", ""}, + } + + for _, tc := range serviceHosts { + t.Run(tc.name, func(t *testing.T) { + router, service, user := newTestRouter(t, false) + rec := routeHost(t, router, "http", tc.host, "/healthz") + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, "service", rec.Header().Get("X-Handler")) + assert.Equal(t, 1, service.calls, "Host %q belongs to the service surface", tc.host) + assert.Zero(t, user.calls, "the user surface must not see Host %q", tc.host) + }) + } +} + +// TestHostRouter_HealthcheckReachesService pins the production healthcheck +// exactly: docker's probe sends Host localhost, and a rejection there takes +// the container down. +func TestHostRouter_HealthcheckReachesService(t *testing.T) { + router, service, _ := newTestRouter(t, false) + rec := routeHost(t, router, "http", "localhost:80", "/xrpc/_health") + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, "service /xrpc/_health", rec.Body.String(), + "the service handler must answer byte-identically through the router") + assert.Equal(t, 1, service.calls) +} + +// TestHostRouter_UserBucket: the user origin's Host, in every spelling that +// names the same authority. +func TestHostRouter_UserBucket(t *testing.T) { + for _, tc := range []struct { + name string + scheme string + host string + }{ + {"exact", "https", userHost}, + {"uppercase", "https", "COVES.SOCIAL"}, + {"trailing dot", "https", userHost + "."}, + {"default https port", "https", userHost + ":443"}, + // Behind a TLS-terminating proxy the Go server sees plain HTTP with + // the forwarded Host, so ":443" must normalize without r.TLS. + {"default https port, proxied", "http", userHost + ":443"}, + {"default http port", "http", userHost + ":80"}, + } { + t.Run(tc.name, func(t *testing.T) { + router, service, user := newTestRouter(t, false) + rec := routeHost(t, router, tc.scheme, tc.host, "/.well-known/webfinger") + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, "user", rec.Header().Get("X-Handler"), + "Host %q is the user origin", tc.host) + assert.Equal(t, 1, user.calls) + assert.Zero(t, service.calls, "the service surface must not see the user origin") + }) + } +} + +// TestHostRouter_RejectsUnknownHosts is the production posture: an +// authenticated write/AP service must not expose ANY surface under a Host +// an attacker chose. +func TestHostRouter_RejectsUnknownHosts(t *testing.T) { + for _, tc := range []struct { + name string + host string + }{ + {"unrelated host", "evil.example"}, + // Suffix matching must be on a label boundary in BOTH directions. + {"suffix-adjacent to the service host", "nottdpl.io"}, + {"service host as a prefix label", serviceHost + ".evil.example"}, + {"suffix-adjacent to the user host", "notcoves.social"}, + {"user host as a prefix label", userHost + ".evil.example"}, + // A non-default port is a different authority (the dev origin runs + // on :8091, so ports carry meaning here). + {"user host on another port", userHost + ":8443"}, + // Vanity origins are OUT OF SCOPE for task 13: CreateActorForDID + // only ever seeds the configured origin, so no ap_actors row can + // name another host yet. The router gains an OriginAllowlist seam + // when vanity minting lands; until then a vanity Host is unknown. + {"registered-looking vanity origin", "vanity.example"}, + } { + t.Run(tc.name, func(t *testing.T) { + router, service, user := newTestRouter(t, false) + rec := routeHost(t, router, "https", tc.host, "/healthz") + assert.Equal(t, http.StatusMisdirectedRequest, rec.Code, + "Host %q must be refused in production", tc.host) + assert.Zero(t, service.calls, "a rejected Host must not touch the service surface") + assert.Zero(t, user.calls, "a rejected Host must not touch the user surface") + }) + } +} + +// TestHostRouter_DevFallthrough: the dev escape hatch routes unknown Hosts +// to the service surface (a laptop is reached by IP, tunnel hostname, or +// whatever ngrok minted this morning). +func TestHostRouter_DevFallthrough(t *testing.T) { + router, service, user := newTestRouter(t, true) + + rec := routeHost(t, router, "http", "whatever.ngrok.example", "/healthz") + assert.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, "service", rec.Header().Get("X-Handler")) + assert.Equal(t, 1, service.calls, "dev fallthrough sends unknown Hosts to the service surface") + assert.Zero(t, user.calls) + + // The user origin still wins its own Host with fallthrough on. + rec = routeHost(t, router, "https", userHost, "/.well-known/webfinger") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "user", rec.Header().Get("X-Handler")) + assert.Equal(t, 1, user.calls) +} + +// TestHostRouter_CollidingHosts is the default dev configuration, where +// BRIDGE_HOSTNAME and AP_USER_ORIGIN name the same authority +// (localhost:8091). One Host cannot pick a bucket, so the composition is by +// PATH: the user surface is tried first and a 404 from it falls through to +// the service handler, whose response is the one that reaches the client. +func TestHostRouter_CollidingHosts(t *testing.T) { + const shared = "localhost:8091" + newRouter := func(t *testing.T) (http.Handler, *marker, *marker) { + t.Helper() + // The user surface owns webfinger and the actor paths; everything + // else 404s out of it, exactly as personas.Service does. The service + // surface answers its own routes and 404s the actor path — neither + // side knows that DID, which is how a genuine miss stays a miss. + service := newMarker("service", "/ap/actor/did:plc:missing") + user := newMarker("user", "/xrpc/_health", "/healthz", "/ap/actor/did:plc:missing") + router, err := NewHostRouter(HostRouterOptions{ + ServiceHost: shared, + ServiceHandler: service, + UserHost: shared, + UserHandler: user, + DevFallthrough: true, + }) + require.NoError(t, err) + return router, service, user + } + + t.Run("a user route is served by the user surface", func(t *testing.T) { + router, service, user := newRouter(t) + rec := routeHost(t, router, "http", shared, "/.well-known/webfinger") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "user", rec.Header().Get("X-Handler")) + assert.Equal(t, 1, user.calls) + assert.Zero(t, service.calls, "a path the user surface answered must not be replayed") + }) + + t.Run("a service route falls through cleanly", func(t *testing.T) { + router, service, user := newRouter(t) + rec := routeHost(t, router, "http", shared, "/xrpc/_health") + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, 1, user.calls, "the user surface is tried first") + assert.Equal(t, 1, service.calls) + // The fallback must REPLACE the user surface's 404, not append to + // it: buffering the first response is the only way this works. + assert.Equal(t, "service /xrpc/_health", rec.Body.String()) + assert.Equal(t, "service", rec.Header().Get("X-Handler"), + "the service response's headers must be the ones that land") + assert.NotContains(t, rec.Body.String(), "404 page not found") + }) + + t.Run("neither surface knows the path", func(t *testing.T) { + router, service, user := newRouter(t) + rec := routeHost(t, router, "http", shared, "/ap/actor/did:plc:missing") + assert.Equal(t, http.StatusNotFound, rec.Code, + "a 404 from both surfaces stays a 404") + assert.Equal(t, 1, user.calls) + assert.Equal(t, 1, service.calls) + }) +} + +// TestNewHostRouter_RequiresHandlers: a nil handler would nil-panic on the +// first request of whichever bucket it was meant to serve. +func TestNewHostRouter_RequiresHandlers(t *testing.T) { + _, err := NewHostRouter(HostRouterOptions{ + ServiceHost: serviceHost, + UserHost: userHost, + UserHandler: newMarker("user"), + }) + assert.Error(t, err, "a router without a service handler is unusable") + + _, err = NewHostRouter(HostRouterOptions{ + ServiceHandler: newMarker("service"), + UserHost: userHost, + UserHandler: newMarker("user"), + }) + assert.Error(t, err, "a router without a service host cannot classify anything") +} diff --git a/internal/personas/instance.go b/internal/personas/instance.go new file mode 100644 index 0000000..b0869e4 --- /dev/null +++ b/internal/personas/instance.go @@ -0,0 +1,114 @@ +package personas + +import ( + "net/http" + "time" + + "tidepool/internal/ap" +) + +const ( + nodeInfoDiscoveryPath = "/.well-known/nodeinfo" + nodeInfoSchemaPath = "/nodeinfo/2.0" + nodeInfoSchemaRel = "http://nodeinfo.diaspora.software/ns/schema/2.0" + // nodeInfoSoftwareName is what Lemmy admins allowlist by; it and the + // version mirror the service surface's nodeinfo (ingest/inbox.go), which + // describes the same deployment from its other origin. + nodeInfoSoftwareName = "tidepool" + nodeInfoSoftwareVersion = "0.1.0" + + // instanceOutboxPath is advertised, not served — the same shape the + // service surface's instance actor publishes. Lemmy's Instance parser + // REQUIRES the field but never dereferences it. + instanceOutboxPath = "/ap/outbox" +) + +// handleInstanceActor serves the user origin's instance ("Site") actor at the +// apex. This is load-bearing for inbound federation, not decoration: Lemmy +// resolves every federating peer's instance actor by fetching the peer's +// origin apex, and delivers its send-to-all-instances activities — +// Delete{Person} above all — ONLY to the inbox on that row. An origin with no +// instance actor silently never receives them. +// +// The document republishes the BRIDGE's key: one identity presenting two +// origins, which is also what lets a peer verify signatures from either. Type +// must be exactly "Application" — Lemmy's Instance enum accepts nothing else, +// the opposite of the /actor rule where its Person enum rejects Application. +// +// Content negotiation is deliberately absent: Caddy owns that in production +// (task 18), and a peer that sends ld+json, activity+json, or no Accept at all +// must still get the actor. +func (s *Service) handleInstanceActor(w http.ResponseWriter, r *http.Request) { + if s.serviceActor == nil { + // No bridge identity configured means no key to publish. Lemmy + // tolerates a missing instance actor, and the mint-only wiring has + // no use for one. + http.NotFound(w, r) + return + } + + publicPEM, err := ap.EncodePublicKeyPEM(&s.serviceActor.Key.PublicKey) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // The apex id carries a trailing slash: it is exactly the URL Lemmy + // derives when it clears the path of an object id from this origin. + id := s.userOrigin + "/" + doc := map[string]any{ + "@context": []any{asNamespace, securityNamespace}, + "type": ap.TypeApplication, + "id": id, + "name": s.userHost, + "preferredUsername": s.userHost, + // The inbox is THIS origin's shared inbox, not the bridge's: an + // instance delivery must land where this origin actually listens. + "inbox": s.userOrigin + inboxPath, + "outbox": s.userOrigin + instanceOutboxPath, + "publicKey": map[string]any{ + "id": id + "#main-key", + "owner": id, + "publicKeyPem": string(publicPEM), + }, + } + // published is when the bridge's key was provisioned. An unknown time is + // omitted rather than invented: a date the bridge does not know is a lie + // every peer caches. + if !s.serviceActor.CreatedAt.IsZero() { + doc["published"] = s.serviceActor.CreatedAt.UTC().Format(time.RFC3339) + } + + writeJSON(w, ap.ContentTypeActivityJSON, doc) +} + +// handleNodeInfoDiscovery points at THIS origin's nodeinfo document. Lemmy's +// scheduled task reads it to learn what software a peer runs; it never gates +// federation, but an origin that answers nothing here shows up as an unknown +// implementation in every peer's instance list. +func (s *Service) handleNodeInfoDiscovery(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, "application/json", map[string]any{ + "links": []any{map[string]any{ + "rel": nodeInfoSchemaRel, + "href": s.userOrigin + nodeInfoSchemaPath, + }}, + }) +} + +// handleNodeInfo serves the nodeinfo 2.0 document, mirroring the shape the +// service surface publishes. +func (s *Service) handleNodeInfo(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, "application/json", map[string]any{ + "version": "2.0", + "software": map[string]any{ + "name": nodeInfoSoftwareName, + "version": nodeInfoSoftwareVersion, + }, + "protocols": []any{"activitypub"}, + "services": map[string]any{"inbound": []any{}, "outbound": []any{}}, + // Accounts here are minted from atproto identities, never registered. + "openRegistrations": false, + "usage": map[string]any{"users": map[string]any{}}, + "metadata": map[string]any{}, + }) +} diff --git a/internal/personas/instance_test.go b/internal/personas/instance_test.go new file mode 100644 index 0000000..7811271 --- /dev/null +++ b/internal/personas/instance_test.go @@ -0,0 +1,163 @@ +package personas + +import ( + "database/sql" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/ap" + "tidepool/internal/identity" +) + +// serviceActorCreatedAt is the provisioning time of the bridge's key — the +// instance actor's published timestamp. +var serviceActorCreatedAt = time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + +// newInstanceService builds a Service that knows the bridge's own identity, +// which is what lets the user origin publish an instance actor. +func newInstanceService(t *testing.T, database *sql.DB) (*Service, *ap.ServiceActor) { + t.Helper() + key, err := ap.GenerateRSAKey() + require.NoError(t, err) + bridge := &ap.ServiceActor{ + ID: "https://" + serviceHost + ap.ServiceActorPath, + Hostname: serviceHost, + Scheme: "https", + Key: key, + CreatedAt: serviceActorCreatedAt, + } + custodian, err := identity.NewCustodian(testKEK) + require.NoError(t, err) + svc, err := New(Options{ + DB: database, + Custodian: custodian, + UserOrigin: userOrigin, + ServiceActor: bridge, + }) + require.NoError(t, err) + require.NotNil(t, svc) + return svc, bridge +} + +// TestServeInstanceActor: Lemmy resolves a peer's instance ("Site") actor by +// fetching the origin apex, and delivers send-to-all-instances activities — +// Delete{Person} above all — only to the inbox on that row. A user origin +// with no instance actor silently never receives them. +func TestServeInstanceActor(t *testing.T) { + database := personasTestDB(t) + svc, bridge := newInstanceService(t, database) + + instanceID := userOrigin + "/" + + // Accept is not negotiated here — Caddy owns content negotiation in + // production (task 18), and a peer sending ld+json or nothing at all + // must still get the actor. + for _, accept := range []string{ + ap.ContentTypeActivityJSON, + ap.ContentTypeLDJSON, + "", + } { + header := http.Header{} + if accept != "" { + header.Set("Accept", accept) + } + rec := serveOnUserOrigin(svc, http.MethodGet, "/", header) + require.Equal(t, http.StatusOK, rec.Code, + "GET / with Accept %q must serve the instance actor; body=%s", accept, rec.Body.String()) + assert.Equal(t, ap.ContentTypeActivityJSON, rec.Header().Get("Content-Type")) + + doc := decodeJSON(t, rec) + assert.Equal(t, "Application", doc["type"], + "Lemmy's Instance enum accepts Application and nothing else") + assert.Equal(t, instanceID, doc["id"], + "the id is the origin apex WITH a trailing slash — the URL Lemmy derives") + assert.True(t, contextIncludes(doc["@context"], "https://www.w3.org/ns/activitystreams"), + "@context must name the ActivityStreams namespace, got %v", doc["@context"]) + assert.NotEmpty(t, doc["name"], "Lemmy requires name on the Instance document") + assert.Equal(t, userHost, doc["preferredUsername"], + "the instance actor's username is its host") + assert.Equal(t, userOrigin+"/ap/inbox", doc["inbox"], + "instance deliveries must land on the inbox this origin actually serves") + + outbox, ok := doc["outbox"].(string) + require.True(t, ok, "outbox is required, got %v", doc["outbox"]) + assert.True(t, strings.HasPrefix(outbox, userOrigin+"/"), + "the outbox must live on this origin, got %q", outbox) + + publicKey, ok := doc["publicKey"].(map[string]any) + require.True(t, ok, "publicKey block is required, got %v", doc["publicKey"]) + assert.Equal(t, instanceID+"#main-key", publicKey["id"]) + assert.Equal(t, instanceID, publicKey["owner"]) + pem, ok := publicKey["publicKeyPem"].(string) + require.True(t, ok) + parsed, err := ap.ParsePublicKeyPEM([]byte(pem)) + require.NoError(t, err) + assert.True(t, bridge.Key.PublicKey.Equal(parsed), + "the instance actor republishes the bridge's own key: one identity, two origins") + + published, ok := doc["published"].(string) + require.True(t, ok, "Lemmy requires published, got %v", doc["published"]) + publishedAt, err := time.Parse(time.RFC3339, published) + require.NoError(t, err, "published must be RFC3339, got %q", published) + assert.True(t, publishedAt.Equal(serviceActorCreatedAt), + "published is when the bridge's key was provisioned, got %s", published) + } +} + +// TestServeInstanceActor_Unconfigured: a Service built without the bridge's +// identity has no key to publish, so the apex is simply absent. Lemmy +// tolerates a missing instance actor ("probably not a lemmy instance"), and +// New must not require one — the mint-only wiring has no use for it. +func TestServeInstanceActor_Unconfigured(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + + rec := serveOnUserOrigin(svc, http.MethodGet, "/", + http.Header{"Accept": []string{ap.ContentTypeActivityJSON}}) + assert.Equal(t, http.StatusNotFound, rec.Code, + "without a service actor the origin apex publishes nothing") +} + +// TestServeNodeInfo: Lemmy's scheduled task reads software.name for its +// instance allow/block lists. It never gates federation, but an origin that +// answers nothing here shows up as an unknown implementation. +func TestServeNodeInfo(t *testing.T) { + database := personasTestDB(t) + svc, _ := newInstanceService(t, database) + + discovery := serveOnUserOrigin(svc, http.MethodGet, "/.well-known/nodeinfo", nil) + require.Equal(t, http.StatusOK, discovery.Code, "body=%s", discovery.Body.String()) + assert.Contains(t, discovery.Header().Get("Content-Type"), "application/json") + + doc := decodeJSON(t, discovery) + links, ok := doc["links"].([]any) + require.True(t, ok, "nodeinfo discovery must carry links, got %v", doc["links"]) + require.Len(t, links, 1) + link, ok := links[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "http://nodeinfo.diaspora.software/ns/schema/2.0", link["rel"]) + assert.Equal(t, userOrigin+"/nodeinfo/2.0", link["href"], + "the discovery link must point at THIS origin, not the service origin") + + schema := serveOnUserOrigin(svc, http.MethodGet, "/nodeinfo/2.0", nil) + require.Equal(t, http.StatusOK, schema.Code, "body=%s", schema.Body.String()) + assert.Contains(t, schema.Header().Get("Content-Type"), "application/json") + + info := decodeJSON(t, schema) + assert.Equal(t, "2.0", info["version"]) + software, ok := info["software"].(map[string]any) + require.True(t, ok, "software block is required, got %v", info["software"]) + assert.Equal(t, "tidepool", software["name"], + "Lemmy admins allowlist by this exact string") + assert.NotEmpty(t, software["version"]) + protocols, ok := info["protocols"].([]any) + require.True(t, ok, "protocols is required, got %v", info["protocols"]) + assert.Contains(t, protocols, "activitypub") + assert.Equal(t, false, info["openRegistrations"], + "accounts are minted from atproto identities, never registered here") +} diff --git a/internal/personas/personas.go b/internal/personas/personas.go index c1fe7be..1158c44 100644 --- a/internal/personas/personas.go +++ b/internal/personas/personas.go @@ -40,6 +40,12 @@ type Options struct { // under, e.g. "https://coves.social". It seeds NEW rows only; serving // derives every URL from the stored actor_id. UserOrigin string + // ServiceActor is the bridge's own AP identity. The user origin's + // instance ("Site") actor is the SAME bridge speaking under a second + // origin, so it republishes this actor's RSA key under an id derived + // from UserOrigin; only Key and CreatedAt are read. Optional: without + // it the origin apex serves no instance actor (Lemmy tolerates that). + ServiceActor *ap.ServiceActor } // Service mints and serves Coves user actors. @@ -52,6 +58,9 @@ type Service struct { // normalized_origin they are stored under — the same string Host // routing hands the webfinger endpoint, so a lookup needs no reshaping. userHost string + // serviceActor is the bridge identity the origin apex republishes. Nil + // means the apex publishes nothing. + serviceActor *ap.ServiceActor } // New builds a Service. UserOrigin is parsed once here: the host it yields @@ -67,6 +76,8 @@ func New(opts Options) (*Service, error) { custodian: opts.Custodian, userOrigin: opts.UserOrigin, userHost: host, + + serviceActor: opts.ServiceActor, }, nil } diff --git a/internal/personas/serving.go b/internal/personas/serving.go index 607b84c..606f487 100644 --- a/internal/personas/serving.go +++ b/internal/personas/serving.go @@ -48,6 +48,21 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } s.handleWebFinger(w, r) + case path == "/": + if !isGET(w, r) { + return + } + s.handleInstanceActor(w, r) + case path == nodeInfoDiscoveryPath: + if !isGET(w, r) { + return + } + s.handleNodeInfoDiscovery(w, r) + case path == nodeInfoSchemaPath: + if !isGET(w, r) { + return + } + s.handleNodeInfo(w, r) case strings.HasPrefix(path, actorPathPrefix): rest := strings.TrimPrefix(path, actorPathPrefix) if !isGET(w, r) { @@ -168,7 +183,7 @@ func (s *Service) handleWebFinger(w http.ResponseWriter, r *http.Request) { return } - host := requestHost(r) + host := normalizeHost(r.Host) actor, err := s.lookupResource(r.Context(), resource, host) if err != nil { writeStoreError(w, err) @@ -206,7 +221,7 @@ func (s *Service) lookupResource(ctx context.Context, resource, host string) (*s if err != nil { return nil, err } - if canonicalHost(acctHost) != host { + if normalizeHost(acctHost) != host { return nil, errors.NewNotFoundError("ap_actor", resource) } return s.actors.GetByOriginLocalPart(ctx, host, strings.ToLower(local)) @@ -216,7 +231,7 @@ func (s *Service) lookupResource(ctx context.Context, resource, host string) (*s if err != nil { return nil, errors.NewValidationError("resource", err.Error()) } - if canonicalHost(parsed.Host) != host { + if normalizeHost(parsed.Host) != host { return nil, errors.NewNotFoundError("ap_actor", resource) } did, ok := strings.CutPrefix(parsed.Path, actorPathPrefix) @@ -235,26 +250,6 @@ func (s *Service) lookupResource(ctx context.Context, resource, host string) (*s return actor, nil } -// requestHost is the routed authority in the form ap_actors.normalized_origin -// stores it. The scheme's DEFAULT port is noise and is stripped; any other -// port is part of the authority and stays — dev runs the origin on -// localhost:8091, and coves.social:8443 is a different origin from -// coves.social, not a sloppy spelling of it. -func requestHost(r *http.Request) string { - host := strings.ToLower(strings.TrimSpace(r.Host)) - defaultPort := ":80" - if r.TLS != nil { - defaultPort = ":443" - } - return canonicalHost(strings.TrimSuffix(host, defaultPort)) -} - -// canonicalHost lowercases a host and drops the trailing dot of a -// fully-qualified name, which names the same host. -func canonicalHost(host string) string { - return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") -} - // actorOrigin recovers the scheme+host an actor was minted under from its // stored actor_id, so a vanity-origin actor advertises its own inbox rather // than the configured one. The configured origin is only the fallback for an