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
10 changes: 2 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
| `FMSG_TLS_KEY` | *(optional)* | Path to the TLS private key file (e.g. `/etc/letsencrypt/live/example.com/privkey.pem`). Must be set together with `FMSG_TLS_CERT`. |
| `FMSG_API_PORT` | `443` (TLS) / `8000` (plain) | TCP port to listen on. |
| `FMSG_ID_URL` | `http://127.0.0.1:8080` | Base URL of the fmsgid identity service |
| `FMSG_API_RATE_LIMIT`| `10` | Max sustained requests per second per IP |
| `FMSG_API_RATE_BURST`| `20` | Max burst size for the per-IP rate limiter |
| `FMSG_API_MAX_DATA_SIZE`| `10` | Maximum message data size in megabytes |
| `FMSG_API_MAX_ATTACH_SIZE`| `10` | Maximum attachment file size in megabytes |
| `FMSG_API_MAX_MSG_SIZE`| `20` | Maximum total message size (data + attachments) in megabytes |
Expand Down Expand Up @@ -133,12 +131,8 @@ maximum long-poll duration (60 s) so connections are not dropped prematurely.

All routes are prefixed with `/fmsg` and require a valid `Authorization: Bearer <token>` header.

All routes are subject to per-IP rate limiting. When the limit is exceeded, the
server responds with `429 Too Many Requests`:

```json
{"error": "rate limit exceeded"}
```
Rate limiting is enforced at the host level (e.g. `nftables`) rather than in
the application.

| Method | Path | Description |
| -------- | ------------------------------------------- | ------------------------ |
Expand Down
4 changes: 2 additions & 2 deletions src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
golang.org/x/time v0.15.0
)

require (
Expand Down Expand Up @@ -42,8 +41,9 @@ require (
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
2 changes: 2 additions & 0 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
Expand Down
5 changes: 1 addition & 4 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ func main() {

// Optional configuration with defaults.
idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080")
rateLimit := envOrDefaultInt("FMSG_API_RATE_LIMIT", 10)
rateBurst := envOrDefaultInt("FMSG_API_RATE_BURST", 20)
maxDataSize := int64(envOrDefaultInt("FMSG_API_MAX_DATA_SIZE", 10)) * 1024 * 1024
maxAttachSize := int64(envOrDefaultInt("FMSG_API_MAX_ATTACH_SIZE", 10)) * 1024 * 1024
maxMsgSize := int64(envOrDefaultInt("FMSG_API_MAX_MSG_SIZE", 20)) * 1024 * 1024
Expand Down Expand Up @@ -88,8 +86,7 @@ func main() {
log.Printf("CORS enabled for origins: %s", strings.Join(corsOrigins, ", "))
}

// Global rate limiter.
router.Use(middleware.NewRateLimiter(ctx, float64(rateLimit), rateBurst))
// Global rate limiting is handled by nftables at the host level.

// Instantiate handlers.
msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize)
Expand Down
83 changes: 76 additions & 7 deletions src/middleware/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"log"
"net/http"
"strings"
"sync"
"time"

"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)

// IdentityKey is the Gin context key under which the authenticated user
Expand Down Expand Up @@ -221,28 +223,95 @@ func IsValidAddr(addr string) bool {
return strings.Contains(rest, "@")
}

// fmsgIDClient is a dedicated HTTP client with a bounded timeout so that a
// slow or hung fmsgid never blocks an API request goroutine indefinitely
// (which would otherwise hold the inbound HTTP connection open and exhaust
// the browser's per-host connection limit).
var fmsgIDClient = &http.Client{Timeout: 5 * time.Second}

// fmsgIDCacheTTL is how long a positive fmsgid lookup is cached. Tokens are
// re-validated every time, but the relatively expensive network round-trip to
// fmsgid is short-circuited for this window. Negative results are not cached.
const fmsgIDCacheTTL = 30 * time.Second

type fmsgIDEntry struct {
expires time.Time
code int
acceptingNew bool
}

var fmsgIDCache sync.Map // map[string]fmsgIDEntry, key = addr
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment on fmsgIDCache says the key is addr, but the implementation keys by idURL + "|" + addr. Please update the comment to match reality (and ideally clarify the key format) to avoid misleading future maintainers.

Suggested change
var fmsgIDCache sync.Map // map[string]fmsgIDEntry, key = addr
var fmsgIDCache sync.Map // map[string]fmsgIDEntry, key = idURL + "|" + addr

Copilot uses AI. Check for mistakes.

// fmsgIDGroup coalesces concurrent lookups for the same address so that a
// burst of cache misses (e.g. several browser requests arriving before the
// first response is cached) results in a single upstream fmsgid call.
var fmsgIDGroup singleflight.Group

type fmsgIDResult struct {
code int
acceptingNew bool
}

// checkFmsgID queries the fmsgid service for a user address.
// Returns (statusCode, acceptingNew, error).
// Returns (statusCode, acceptingNew, error). Successful 200 responses are
// cached for fmsgIDCacheTTL to avoid hammering fmsgid when a browser fires
// many concurrent requests with the same JWT. Concurrent cache misses for
// the same address are deduplicated via singleflight.
func checkFmsgID(idURL, addr string) (int, bool, error) {
url := strings.TrimRight(idURL, "/") + "/fmsgid/" + addr
resp, err := http.Get(url) //nolint:gosec // URL constructed from trusted config + validated addr
if v, ok := fmsgIDCache.Load(addr); ok {
entry := v.(fmsgIDEntry)
Comment on lines 260 to +262
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cacheKey := idURL + "|" + addr relies on a string delimiter, which is easy to get wrong and can theoretically collide. Using a comparable struct key (e.g., {idURL, addr}) avoids delimiter issues and also makes it clearer what dimensions are being cached.

Copilot uses AI. Check for mistakes.
if time.Now().Before(entry.expires) {
return entry.code, entry.acceptingNew, nil
}
fmsgIDCache.Delete(addr)
}

v, err, _ := fmsgIDGroup.Do(addr, func() (interface{}, error) {
// Re-check inside the singleflight in case another goroutine just
// populated the cache while we were waiting to enter.
if v, ok := fmsgIDCache.Load(addr); ok {
entry := v.(fmsgIDEntry)
if time.Now().Before(entry.expires) {
return fmsgIDResult{code: entry.code, acceptingNew: entry.acceptingNew}, nil
}
}
return fetchFmsgID(idURL, addr)
})
if err != nil {
return 0, false, err
}
Comment on lines +243 to +282
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fmsgIDCache entries are only deleted on cache hits after expiry. If an address is looked up once and never again, the expired entry will remain in the sync.Map indefinitely, so the cache can grow without bound over the process lifetime. Consider adding periodic cleanup (e.g., a ticker that Ranges and deletes expired entries, similar to the cleanup loop in middleware/ratelimit.go) or switching to a bounded LRU/TTL cache implementation.

Copilot uses AI. Check for mistakes.
Comment on lines +256 to +282
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says caching avoids hammering fmsgid "when a browser fires many concurrent requests", but this implementation does not deduplicate concurrent cache misses—multiple goroutines can still call fmsgid at once before the first response is stored. If reducing burst load is a goal, consider adding per-key request coalescing (e.g., singleflight.Group) so concurrent lookups share one upstream request.

Copilot uses AI. Check for mistakes.
res := v.(fmsgIDResult)
return res.code, res.acceptingNew, nil
}

// fetchFmsgID performs the actual HTTP call to fmsgid and stores positive
// results in the cache.
func fetchFmsgID(idURL, addr string) (fmsgIDResult, error) {
url := strings.TrimRight(idURL, "/") + "/fmsgid/" + addr
resp, err := fmsgIDClient.Get(url) //nolint:gosec // URL constructed from trusted config + validated addr
if err != nil {
Comment on lines 290 to 292
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addr is concatenated directly into the URL path. IsValidAddr currently allows characters like '/', '?', or '#' (it only checks leading '@' and another '@'), so a crafted sub could change the requested path/query on the fmsgid service. Please build the URL using url.JoinPath (or escape the path segment) and/or tighten IsValidAddr to the exact allowed character set so the gosec suppression is justified.

Copilot uses AI. Check for mistakes.
return fmsgIDResult{}, err
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return http.StatusNotFound, false, nil
return fmsgIDResult{code: http.StatusNotFound}, nil
}
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, false, nil
return fmsgIDResult{code: resp.StatusCode}, nil
}

var result struct {
AcceptingNew bool `json:"acceptingNew"`
}
if err := decodeJSON(resp.Body, &result); err != nil {
return http.StatusOK, true, nil // assume accepting if parse fails
return fmsgIDResult{code: http.StatusOK, acceptingNew: true}, nil // assume accepting if parse fails
}
return http.StatusOK, result.AcceptingNew, nil

fmsgIDCache.Store(addr, fmsgIDEntry{
expires: time.Now().Add(fmsgIDCacheTTL),
code: http.StatusOK,
acceptingNew: result.AcceptingNew,
})
return fmsgIDResult{code: http.StatusOK, acceptingNew: result.AcceptingNew}, nil
}
1 change: 1 addition & 0 deletions src/middleware/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ func TestEdDSAMode_Reuse(t *testing.T) {
}

func TestEdDSAMode_FmsgIDUnavailable(t *testing.T) {
fmsgIDCache.Delete("@alice@example.com")
srv := fmsgIDServer(t, http.StatusInternalServerError, false)
defer srv.Close()
priv, jwks := newEdDSAFixture(t)
Expand Down
84 changes: 0 additions & 84 deletions src/middleware/ratelimit.go

This file was deleted.

100 changes: 0 additions & 100 deletions src/middleware/ratelimit_test.go

This file was deleted.

Loading