diff --git a/README.md b/README.md index 4e2b89a..3614169 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 diff --git a/src/go.mod b/src/go.mod index c3cbb6b..69e0407 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 ( @@ -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 diff --git a/src/go.sum b/src/go.sum index 476e95c..edc7d4d 100644 --- a/src/go.sum +++ b/src/go.sum @@ -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= diff --git a/src/main.go b/src/main.go index 8142bb2..143c295 100644 --- a/src/main.go +++ b/src/main.go @@ -4,7 +4,9 @@ import ( "context" "encoding/base64" "log" + "net/http" "os" + "path/filepath" "strings" "github.com/gin-gonic/gin" @@ -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). @@ -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) + } } } diff --git a/src/middleware/ratelimit.go b/src/middleware/ratelimit.go new file mode 100644 index 0000000..905649f --- /dev/null +++ b/src/middleware/ratelimit.go @@ -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 + }) + } + } +}