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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ fine-grained authorisation rules based on the user identity they contain.
| ------------------- | ------------------------ | ------------------------------------------------------- |
| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/opt/fmsg/data` |
| `FMSG_API_JWT_SECRET` | *(required)* | HMAC secret used to validate JWT tokens. Prefix with `base64:` to supply a base64-encoded key (e.g. `base64:c2VjcmV0`); otherwise the raw string is used. |
| `FMSG_API_PORT` | `8000` | TCP port the HTTP server listens on |
| `FMSG_TLS_CERT` | *(optional)* | Path to the TLS certificate file (e.g. `/etc/letsencrypt/live/example.com/fullchain.pem`). When set with `FMSG_TLS_KEY`, enables HTTPS on port 443. |
| `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` | `8000` | TCP port for plain HTTP mode (ignored when TLS is enabled) |
| `FMSG_ID_URL` | `http://127.0.0.1:8080` | Base URL of the fmsgid identity service |
| `FMSG_ACME_DIR` | `/var/www/letsencrypt` | Directory containing `.well-known/acme-challenge` for Let's Encrypt certificate renewal (TLS mode only) |

Standard PostgreSQL environment variables (`PGHOST`, `PGPORT`, `PGUSER`,
`PGPASSWORD`, `PGDATABASE`) are used for database connectivity.
Expand All @@ -40,9 +43,17 @@ go test ./...

## Running

### TLS mode (production)

Set `FMSG_TLS_CERT` and `FMSG_TLS_KEY` to enable HTTPS on port `443`. A plain
HTTP server on port `80` serves Let's Encrypt ACME challenges from `FMSG_ACME_DIR`
(default `/var/www/letsencrypt`) and redirects all other requests to HTTPS.

```bash
export FMSG_DATA_DIR=/opt/fmsg/data
export FMSG_API_JWT_SECRET=changeme
export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem
export FMSG_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem
export PGHOST=localhost
export PGUSER=fmsg
export PGPASSWORD=secret
Expand All @@ -52,7 +63,22 @@ cd src
go run .
```

The server starts on port `8000` by default. Override with `FMSG_API_PORT`.
### Plain HTTP mode (development / reverse proxy)

Omit the TLS variables to run a plain HTTP server. Override the port with
`FMSG_API_PORT` (default `8000`).

```bash
export FMSG_DATA_DIR=/opt/fmsg/data
export FMSG_API_JWT_SECRET=changeme
export PGHOST=localhost
export PGUSER=fmsg
export PGPASSWORD=secret
export PGDATABASE=fmsg

cd src
go run .
```

## API Routes

Expand Down
3 changes: 2 additions & 1 deletion src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ go 1.25.0
require (
github.com/appleboy/gin-jwt/v2 v2.10.3
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
golang.org/x/time v0.15.0
)

require (
Expand All @@ -21,7 +23,6 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
41 changes: 37 additions & 4 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"encoding/base64"
"log"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/gin-gonic/gin"
Expand All @@ -24,8 +26,15 @@ func main() {
jwtSecret := mustEnv("FMSG_API_JWT_SECRET")
jwtKey := parseSecret(jwtSecret)

// TLS configuration (optional — omit both to run plain HTTP).
tlsCert := os.Getenv("FMSG_TLS_CERT")
tlsKey := os.Getenv("FMSG_TLS_KEY")
tlsEnabled := tlsCert != "" && tlsKey != ""
if (tlsCert != "") != (tlsKey != "") {
log.Fatal("FMSG_TLS_CERT and FMSG_TLS_KEY must both be set or both be empty")
}

// Optional configuration with defaults.
port := envOrDefault("FMSG_API_PORT", "8000")
idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080")

// Connect to PostgreSQL (uses standard PG* environment variables).
Expand Down Expand Up @@ -69,9 +78,33 @@ func main() {
fmsg.DELETE("/:id/attach/:filename", attHandler.DeleteAttachment)
}

log.Printf("fmsg-webapi starting on :%s", port)
if err = router.Run(":" + port); err != nil {
log.Fatalf("server error: %v", err)
if tlsEnabled {
// Start HTTP server on port 80 for ACME challenges and HTTPS redirect.
acmeDir := envOrDefault("FMSG_ACME_DIR", "/var/www/letsencrypt")
httpRouter := gin.New()
httpRouter.Use(gin.Recovery())
httpRouter.Static("/.well-known/acme-challenge", filepath.Join(acmeDir, ".well-known", "acme-challenge"))
httpRouter.NoRoute(func(c *gin.Context) {
target := "https://" + c.Request.Host + c.Request.RequestURI
c.Redirect(http.StatusMovedPermanently, target)
})
go func() {
if err := http.ListenAndServe(":80", httpRouter); err != nil {
log.Fatalf("HTTP :80 server error: %v", err)
}
}()
log.Println("listening on :80 (ACME + HTTPS redirect)")

log.Println("fmsg-webapi starting on :443")
if err = router.RunTLS(":443", tlsCert, tlsKey); err != nil {
log.Fatalf("server error: %v", err)
}
} else {
port := envOrDefault("FMSG_API_PORT", "8000")
log.Printf("fmsg-webapi starting on :%s (plain HTTP)", port)
if err = router.Run(":" + port); err != nil {
log.Fatalf("server error: %v", err)
}
}
}

Expand Down
84 changes: 84 additions & 0 deletions src/middleware/ratelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package middleware

import (
"context"
"log"
"net/http"
"sync"
"sync/atomic"
"time"

"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)

type visitor struct {
limiter *rate.Limiter
lastSeen atomic.Int64 // UnixNano
}

type rateLimiter struct {
visitors sync.Map
rps rate.Limit
burst int
}

// NewRateLimiter returns Gin middleware that enforces a per-IP token-bucket
// rate limit. rps is the sustained requests-per-second rate and burst is the
// maximum burst size allowed. The cleanup goroutine runs until ctx is cancelled.
func NewRateLimiter(ctx context.Context, rps float64, burst int) gin.HandlerFunc {
rl := &rateLimiter{
rps: rate.Limit(rps),
burst: burst,
}
go rl.cleanup(ctx)
return rl.handler
}

func (rl *rateLimiter) getVisitor(ip string) *rate.Limiter {
now := time.Now().UnixNano()
if val, ok := rl.visitors.Load(ip); ok {
v := val.(*visitor)
v.lastSeen.Store(now)
return v.limiter
}
v := &visitor{limiter: rate.NewLimiter(rl.rps, rl.burst)}
v.lastSeen.Store(now)
if actual, loaded := rl.visitors.LoadOrStore(ip, v); loaded {
v = actual.(*visitor)
v.lastSeen.Store(now)
}
return v.limiter
}

func (rl *rateLimiter) handler(c *gin.Context) {
ip := c.ClientIP()
limiter := rl.getVisitor(ip)
if !limiter.Allow() {
log.Printf("rate limit exceeded: ip=%s", ip)
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}

// cleanup removes visitors that have not been seen for 5 minutes.
func (rl *rateLimiter) cleanup(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now().UnixNano()
rl.visitors.Range(func(key, value any) bool {
v := value.(*visitor)
if now-v.lastSeen.Load() > int64(5*time.Minute) {
rl.visitors.Delete(key)
}
return true
})
}
}
}
Loading