From e92453c51f75b26b7e08c0a00a9b81f668483ac7 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Sun, 2 Aug 2026 10:35:53 +1000 Subject: [PATCH] Fix add-to local delivery resolution using caller's domain instead of the instance's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLocalDelivery decided which newly-added recipients were local by parsing the domain out of the requesting identity, not this webapi instance's own domain. That's correct for Send (the owner is always local to their own home server) but wrong for AddRecipients, where any existing participant — including a federated one on a different domain — may add recipients. When a federated participant added a recipient who actually was local to this instance, resolveLocalDelivery treated them as remote and skipped them, while fmsgd's outbound sender also skips local-domain recipients assuming webapi already handled them. Neither side resolved delivery, leaving it stuck pending indefinitely. Adds a required FMSG_DOMAIN env var carrying this instance's own domain, threaded through MessageHandler as LocalDomain and used at both resolveLocalDelivery call sites instead of parsing it from the caller's identity. fmsg-docker's compose files already pass FMSG_DOMAIN to the fmsg-webapi service, so no deployment changes are needed there. Co-Authored-By: Claude Sonnet 5 --- .env.example | 1 + README.md | 3 +++ cmd/fmsg-webapi/main.go | 5 ++++- internal/handlers/messages.go | 22 +++++++++++++++------- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 4a261a1..a50aa13 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ FMSG_DATA_DIR=/var/lib/fmsgd/ +FMSG_DOMAIN=example.com # Production EdDSA JWT verification (uncomment to use JWKS mode). # FMSG_JWT_AUDIENCE is optional; only set it if your identity provider diff --git a/README.md b/README.md index 1d66f9f..73b8832 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o | Variable | Default | Description | | ------------------- | ------------------------ | ------------------------------------------------------- | | `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/var/lib/fmsgd/` | +| `FMSG_DOMAIN` | *(required)* | The fmsg domain this instance serves, e.g. `example.com`. Used to tell local recipients (resolved directly via fmsgid) from federated ones (left to fmsgd), independent of which participant's identity happens to be making the request. | | `FMSG_JWT_JWKS_URL` | *(prod)* | JWKS endpoint for the configured identity provider (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies EdDSA (Ed25519) JWTs. Public keys are fetched and cached, refreshed and looked up by the token's `kid` header. | | `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.example.com/`). Tokens with a different issuer are rejected. This must exactly match the token issuer. | | `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. Leave unset if your identity provider does not issue an `aud` claim. | @@ -199,6 +200,7 @@ by default; override with `FMSG_API_PORT`. ```bash export FMSG_DATA_DIR=/opt/fmsg/data +export FMSG_DOMAIN=example.com export FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json export FMSG_JWT_ISSUER=https://idp.example.com/ export FMSG_JWT_ADDRESS_CLAIM=sub @@ -227,6 +229,7 @@ proxying `https://fmsgapi.example.com/` to `http://127.0.0.1:8000/`). ```bash export FMSG_DATA_DIR=/var/lib/fmsgd/ +export FMSG_DOMAIN=example.com export FMSG_API_TOKEN_ED25519_PRIVATE_KEY=$(openssl rand -base64 32) export PGHOST=localhost export PGUSER=fmsg diff --git a/cmd/fmsg-webapi/main.go b/cmd/fmsg-webapi/main.go index acd2193..be59384 100644 --- a/cmd/fmsg-webapi/main.go +++ b/cmd/fmsg-webapi/main.go @@ -34,6 +34,9 @@ func main() { // Required configuration. dataDir := mustEnv("FMSG_DATA_DIR") + // The domain this instance serves, e.g. "example.com" — used to tell local + // recipients (resolved via fmsgid here) from federated ones (left to fmsgd). + localDomain := mustEnv("FMSG_DOMAIN") // JWT configuration. EdDSA provider JWTs and first-party Ed25519 API // tokens can be enabled independently. @@ -137,7 +140,7 @@ func main() { // Global rate limiting is handled by nftables at the host level. // Instantiate handlers. - msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize, apiStore, idURL) + msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize, apiStore, idURL, localDomain) attHandler := handlers.NewAttachmentHandler(database, dataDir, maxAttachSize, maxMsgSize) // Web Push handler: stores subscriptions and delivers VAPID pushes for diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index d7f45a6..3df5a07 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -34,11 +34,18 @@ type MessageHandler struct { ShortTextSize int SubAccounts *apiauth.Store IDURL string + // LocalDomain is the domain this webapi instance serves (e.g. "example.com"), + // used to decide which recipients are local vs. federated. It must NOT be + // derived from the authenticated caller's own address — a federated + // participant (e.g. @alice@other.example acting on a thread hosted here) + // has a different domain than this instance, but that has no bearing on + // which recipients are local to it. See resolveLocalDelivery. + LocalDomain string } // NewMessageHandler creates a MessageHandler. -func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int, subAccounts *apiauth.Store, idURL string) *MessageHandler { - return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize, SubAccounts: subAccounts, IDURL: idURL} +func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int, subAccounts *apiauth.Store, idURL, localDomain string) *MessageHandler { + return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize, SubAccounts: subAccounts, IDURL: idURL, LocalDomain: localDomain} } // visibleAddrs returns the set of fmsg addresses whose messages the caller @@ -765,10 +772,9 @@ func (h *MessageHandler) Send(c *gin.Context) { // fmsgd's outbound sender skips the local domain entirely, so local // recipients need their delivery status resolved here instead. - _, localDomain := parseAddr(identity) - h.resolveLocalDelivery(ctx, "msg_to", msgID, localDomain, existing.To) + h.resolveLocalDelivery(ctx, "msg_to", msgID, h.LocalDomain, existing.To) for _, b := range existing.AddTo { - h.resolveLocalDelivery(ctx, "msg_add_to", msgID, localDomain, b.To) + h.resolveLocalDelivery(ctx, "msg_add_to", msgID, h.LocalDomain, b.To) } c.JSON(http.StatusOK, gin.H{"id": msgID, "time": now}) @@ -955,9 +961,11 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { // fmsgd only delivers add_to batches once the parent message is sent // (mirroring its own m.time_sent IS NOT NULL gate), and skips the local // domain entirely — so resolve local recipients here for sent messages. + // Note: this must use this instance's own local domain, not the domain of + // whoever called this endpoint — the caller adding recipients may be a + // federated participant on a different domain than the recipients they add. if timeSent != nil { - _, localDomain := parseAddr(identity) - h.resolveLocalDelivery(ctx, "msg_add_to", msgID, localDomain, input.AddTo) + h.resolveLocalDelivery(ctx, "msg_add_to", msgID, h.LocalDomain, input.AddTo) } c.JSON(http.StatusOK, gin.H{"id": msgID, "added": len(input.AddTo)})