Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions internal/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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)})
Expand Down
Loading