feat(server): harden backend with rate limiting, argon2id semaphore, bounded memory, and safe websocket hub
This commit is contained in:
parent
40e3191878
commit
815179a01a
16 changed files with 1292 additions and 263 deletions
17
.env.example
17
.env.example
|
|
@ -1,5 +1,14 @@
|
|||
PORT=8080
|
||||
DATABASE_URL=postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
|
||||
PORT=8787
|
||||
POSTGRES_USER=simpsync
|
||||
POSTGRES_PASSWORD=change-this-postgres-password
|
||||
POSTGRES_DB=simpsync
|
||||
DATABASE_URL=postgres://simpsync:change-this-postgres-password@postgres:5432/simpsync?sslmode=disable
|
||||
SESSION_TTL=720h
|
||||
AUTH_PEPPER=replace-with-a-long-random-secret
|
||||
ALLOWED_ORIGINS=https://sync.example.com
|
||||
# Generate with: openssl rand -hex 32
|
||||
AUTH_PEPPER=generate-at-least-32-bytes-of-random-secret-here
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_USERS=1
|
||||
MAX_BLOBS_PER_USER=50000
|
||||
MAX_DEVICES_PER_USER=10
|
||||
ALLOWED_ORIGINS=https://sync.muriloscolari.me
|
||||
TRUST_LOOPBACK_PROXY=true
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/simpsync ./cmd/ser
|
|||
|
||||
FROM alpine:3.20
|
||||
RUN adduser -D -H -u 10001 app
|
||||
WORKDIR /app
|
||||
USER app
|
||||
COPY --from=build /out/simpsync /simpsync
|
||||
COPY --from=build /src/migrations /migrations
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/simpsync"]
|
||||
COPY --from=build /out/simpsync /app/simpsync
|
||||
COPY --from=build /src/migrations /app/migrations
|
||||
EXPOSE 8787
|
||||
ENTRYPOINT ["/app/simpsync"]
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -1,10 +1,11 @@
|
|||
# SimpSync
|
||||
|
||||
SimpSync is the backend for encrypted SimpMusic synchronization. The server stores
|
||||
opaque encrypted envelopes and synchronization metadata; encryption and key
|
||||
derivation happen in the client.
|
||||
SimpSync is the hardened, zero-knowledge encrypted synchronization backend for SimpMusic.
|
||||
The server stores opaque AES-256-GCM encrypted envelopes and synchronization metadata;
|
||||
key derivation (`PBKDF2-HMAC-SHA256`), keyring wrapping (`DEK` wrapped by Master Password and Recovery Key),
|
||||
and payload encryption happen exclusively on the client.
|
||||
|
||||
## MVP endpoints
|
||||
## Endpoints
|
||||
|
||||
- `GET /healthz`
|
||||
- `POST /v1/auth/register`
|
||||
|
|
@ -12,32 +13,31 @@ derivation happen in the client.
|
|||
- `POST /v1/auth/recover`
|
||||
- `POST /v1/auth/logout`
|
||||
- `GET /v1/devices`
|
||||
- `DELETE /v1/devices/{id}`
|
||||
- `POST /v1/sync`
|
||||
- `GET /v1/ws`
|
||||
|
||||
`authProof` is a client-derived authentication proof, not the master password.
|
||||
The server never receives the master password or plaintext entity data. `envelope`
|
||||
is an opaque, versioned client-side encrypted payload.
|
||||
## Security & Hardening Architecture
|
||||
|
||||
Set `ALLOWED_ORIGINS` to a comma-separated list before using WebSocket clients.
|
||||
The server rejects browser WebSocket origins by default when it is empty.
|
||||
- **Argon2id Concurrency Semaphore**: Limits simultaneous Argon2id password verifications to `2` (capping memory usage at `128 MiB` to prevent OOM DoS attacks).
|
||||
- **Per-IP Token-Bucket Rate Limiting**: Bounded in-memory rate limiter (`maxTrackedIPs = 4096`) on authentication and sync routes with automatic TTL eviction.
|
||||
- **Constant-Time Verification**: Uses `crypto/subtle.ConstantTimeCompare` on decoded Argon2id hashes and domain-separated SHA-256 recovery key digests.
|
||||
- **Registration Lock**: Supports `ALLOW_REGISTRATION=false` and `MAX_USERS=1` so personal self-hosted instances cannot be abused by third parties.
|
||||
- **Non-Blocking WebSocket Hub**: Dedicated `readPump`/`writePump` per client with `SetReadLimit(4096)`, 30s Ping/Pong heartbeats, and per-user connection limits (`MAX_DEVICES_PER_USER=10`).
|
||||
- **Container Hardening**: Runs as non-root (`uid=10001`) on a `read_only: true` filesystem with `cap_drop: [ALL]`, `no-new-privileges`, `mem_limit: 512m`, `pids_limit: 100`, bound strictly to loopback `127.0.0.1:8787:8787` behind Nginx.
|
||||
|
||||
## Development
|
||||
## Deployment with Docker & Nginx
|
||||
|
||||
1. Copy `.env.example` to `.env` and set a strong `AUTH_PEPPER` (`openssl rand -hex 32`) and `POSTGRES_PASSWORD`:
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
2. Configure Nginx using `nginx.conf.example` to proxy `https://sync.muriloscolari.me` to `http://127.0.0.1:8787`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
go test -race -v ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Without PostgreSQL, run the unit tests:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The server applies ordered files from `migrations/` on startup and records
|
||||
completed files in `schema_migration`. Keep migration files append-only.
|
||||
|
||||
The current MVP intentionally does not implement password recovery, conflict
|
||||
merging, or device listing/revocation endpoints yet. Those are required before
|
||||
production deployment.
|
||||
|
|
|
|||
|
|
@ -17,23 +17,47 @@ import (
|
|||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
store, err := database.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
defer store.Pool.Close()
|
||||
|
||||
if err := store.Migrate(ctx, "migrations"); err != nil {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired/revoked device sessions so the table never grows unboundedly.
|
||||
go func() {
|
||||
_ = store.CleanupExpiredSessions(ctx)
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_ = store.CleanupExpiredSessions(cleanupCtx)
|
||||
cleanupCancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hub := syncws.NewHub()
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: syncws.NewHub()}).Routes(),
|
||||
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: hub}).Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
|
@ -41,7 +65,7 @@ func main() {
|
|||
defer shutdownCancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
log.Printf("SimpSync listening on %s", server.Addr)
|
||||
log.Printf("SimpSync listening on %s (registration=%v, maxUsers=%d)", server.Addr, cfg.AllowRegistration, cfg.MaxUsers)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,72 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: simpsync
|
||||
POSTGRES_PASSWORD: simpsync
|
||||
POSTGRES_DB: simpsync
|
||||
POSTGRES_USER: ${POSTGRES_USER:-simpsync}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simpsync}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-simpsync}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./migrations/001_initial.sql:/docker-entrypoint-initdb.d/001_initial.sql:ro
|
||||
networks:
|
||||
- internal-db
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
pids_limit: 128
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U simpsync -d simpsync"]
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-simpsync} -d $${POSTGRES_DB:-simpsync}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
server:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
cpus: 1.0
|
||||
pids_limit: 100
|
||||
environment:
|
||||
PORT: 8080
|
||||
DATABASE_URL: postgres://simpsync:simpsync@postgres:5432/simpsync?sslmode=disable
|
||||
SESSION_TTL: 720h
|
||||
AUTH_PEPPER: ${AUTH_PEPPER:-replace-with-a-long-random-secret}
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-simpsync}:${POSTGRES_PASSWORD:-simpsync}@postgres:5432/${POSTGRES_DB:-simpsync}?sslmode=disable
|
||||
SESSION_TTL: ${SESSION_TTL:-720h}
|
||||
AUTH_PEPPER: ${AUTH_PEPPER}
|
||||
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
|
||||
MAX_USERS: ${MAX_USERS:-1}
|
||||
MAX_BLOBS_PER_USER: ${MAX_BLOBS_PER_USER:-50000}
|
||||
MAX_DEVICES_PER_USER: ${MAX_DEVICES_PER_USER:-10}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sync.muriloscolari.me}
|
||||
TRUST_LOOPBACK_PROXY: ${TRUST_LOOPBACK_PROXY:-true}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal-db
|
||||
- edge
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:${PORT:-8787}:8787"
|
||||
|
||||
networks:
|
||||
internal-db:
|
||||
driver: bridge
|
||||
internal: true
|
||||
edge:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -13,40 +15,100 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
saltSize = 16
|
||||
keySize = 32
|
||||
saltSize = 16
|
||||
keySize = 32
|
||||
minProofLen = 32
|
||||
maxProofLen = 256
|
||||
maxConcurrentArgon = 2
|
||||
argonMemoryKiB = 64 * 1024
|
||||
argonTime = 1
|
||||
argonThreads = 2
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProofInvalid = errors.New("auth proof must be between 32 and 256 bytes")
|
||||
ErrServerBusy = errors.New("authentication subsystem busy")
|
||||
ErrRecoveryInvalid = errors.New("recovery key hash must be between 32 and 256 bytes")
|
||||
// argonSem caps simultaneous Argon2id allocations to 2 * 64 MiB = 128 MiB across the entire process.
|
||||
argonSem = make(chan struct{}, maxConcurrentArgon)
|
||||
)
|
||||
|
||||
func Verifier(proof, pepper string) (string, error) {
|
||||
if len(proof) < 32 {
|
||||
return "", errors.New("auth proof is too short")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return VerifierCtx(ctx, proof, pepper)
|
||||
}
|
||||
|
||||
func VerifierCtx(ctx context.Context, proof, pepper string) (string, error) {
|
||||
if len(proof) < minProofLen || len(proof) > maxProofLen {
|
||||
return "", ErrProofInvalid
|
||||
}
|
||||
salt := make([]byte, saltSize)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
input := []byte(proof + pepper)
|
||||
hash := argon2.IDKey(input, salt, 1, 64*1024, 2, keySize)
|
||||
if err := acquireArgon(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer releaseArgon()
|
||||
|
||||
input := make([]byte, 0, len(proof)+1+len(pepper))
|
||||
input = append(input, proof...)
|
||||
input = append(input, ':')
|
||||
input = append(input, pepper...)
|
||||
hash := argon2.IDKey(input, salt, argonTime, argonMemoryKiB, argonThreads, keySize)
|
||||
return fmt.Sprintf("argon2id$v=19$m=65536,t=1,p=2$%s$%s",
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash)), nil
|
||||
}
|
||||
|
||||
func Verify(proof, encoded, pepper string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return VerifyCtx(ctx, proof, encoded, pepper)
|
||||
}
|
||||
|
||||
func VerifyCtx(ctx context.Context, proof, encoded, pepper string) bool {
|
||||
if len(proof) < minProofLen || len(proof) > maxProofLen {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var salt, expected []byte
|
||||
var err error
|
||||
if salt, err = base64.RawStdEncoding.DecodeString(parts[3]); err != nil {
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil || len(salt) != saltSize {
|
||||
return false
|
||||
}
|
||||
if expected, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(expected) != keySize {
|
||||
return false
|
||||
}
|
||||
hash := argon2.IDKey([]byte(proof+pepper), salt, 1, 64*1024, 2, uint32(len(expected)))
|
||||
return strings.EqualFold(base64.RawStdEncoding.EncodeToString(hash), base64.RawStdEncoding.EncodeToString(expected))
|
||||
if err := acquireArgon(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
defer releaseArgon()
|
||||
|
||||
// Support both domain-separated (proof:pepper) and legacy (proof+pepper) verifiers seamlessly.
|
||||
input := make([]byte, 0, len(proof)+1+len(pepper))
|
||||
input = append(input, proof...)
|
||||
input = append(input, ':')
|
||||
input = append(input, pepper...)
|
||||
hash := argon2.IDKey(input, salt, argonTime, argonMemoryKiB, argonThreads, uint32(len(expected)))
|
||||
if subtle.ConstantTimeCompare(hash, expected) == 1 {
|
||||
return true
|
||||
}
|
||||
legacy := argon2.IDKey([]byte(proof+pepper), salt, argonTime, argonMemoryKiB, argonThreads, uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(legacy, expected) == 1
|
||||
}
|
||||
|
||||
func HashRecoveryKey(raw, pepper string) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if len(trimmed) < 32 || len(trimmed) > 256 {
|
||||
return nil, ErrRecoveryInvalid
|
||||
}
|
||||
digest := sha256.Sum256([]byte("simpsync-recovery-v1:" + trimmed + ":" + pepper))
|
||||
return digest[:], nil
|
||||
}
|
||||
|
||||
func Token() (plain string, hash []byte, err error) {
|
||||
|
|
@ -62,3 +124,16 @@ func Token() (plain string, hash []byte, err error) {
|
|||
func ExpiresAt(now time.Time, ttl time.Duration) time.Time {
|
||||
return now.Add(ttl)
|
||||
}
|
||||
|
||||
func acquireArgon(ctx context.Context) error {
|
||||
select {
|
||||
case argonSem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ErrServerBusy
|
||||
}
|
||||
}
|
||||
|
||||
func releaseArgon() {
|
||||
<-argonSem
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,47 @@
|
|||
package auth
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifierRoundTrip(t *testing.T) {
|
||||
verifier, err := Verifier("proof-derived-from-a-master-password", "pepper")
|
||||
verifier, err := Verifier("proof-derived-from-a-master-password", "pepper-secret-at-least-32-characters")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Verify("proof-derived-from-a-master-password", verifier, "pepper") {
|
||||
if !Verify("proof-derived-from-a-master-password", verifier, "pepper-secret-at-least-32-characters") {
|
||||
t.Fatal("expected verifier to accept the original proof")
|
||||
}
|
||||
if Verify("wrong-proof-derived-from-password", verifier, "pepper") {
|
||||
if Verify("wrong-proof-derived-from-password", verifier, "pepper-secret-at-least-32-characters") {
|
||||
t.Fatal("expected verifier to reject a different proof")
|
||||
}
|
||||
// Ensure Base64 case tampering is rejected (regression test for strings.EqualFold bug).
|
||||
parts := strings.Split(verifier, "$")
|
||||
lastChar := parts[4][len(parts[4])-1]
|
||||
var flipped byte
|
||||
if lastChar >= 'a' && lastChar <= 'z' {
|
||||
flipped = lastChar - 32
|
||||
} else if lastChar >= 'A' && lastChar <= 'Z' {
|
||||
flipped = lastChar + 32
|
||||
}
|
||||
if flipped != 0 {
|
||||
parts[4] = parts[4][:len(parts[4])-1] + string(flipped)
|
||||
tampered := strings.Join(parts, "$")
|
||||
if Verify("proof-derived-from-a-master-password", tampered, "pepper-secret-at-least-32-characters") {
|
||||
t.Fatal("expected case-tampered base64 verifier to be rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProofLengthBounds(t *testing.T) {
|
||||
if _, err := Verifier("too-short", "pepper"); err == nil {
|
||||
t.Fatal("expected short proof to fail")
|
||||
}
|
||||
oversized := strings.Repeat("a", 300)
|
||||
if _, err := Verifier(oversized, "pepper"); err == nil {
|
||||
t.Fatal("expected oversized proof to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsRandomAndHashable(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,40 +1,63 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultInsecurePepper = "replace-with-a-long-random-secret"
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
SessionTTL time.Duration
|
||||
AuthPepper string
|
||||
AllowedOrigins []string
|
||||
Port string
|
||||
DatabaseURL string
|
||||
SessionTTL time.Duration
|
||||
AuthPepper string
|
||||
AllowedOrigins []string
|
||||
AllowRegistration bool
|
||||
MaxUsers int
|
||||
MaxBlobsPerUser int
|
||||
MaxDevicesPerUser int
|
||||
TrustLoopbackProxy bool
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
ttl := 30 * 24 * time.Hour
|
||||
if raw := os.Getenv("SESSION_TTL"); raw != "" {
|
||||
if parsed, err := time.ParseDuration(raw); err == nil {
|
||||
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
|
||||
ttl = parsed
|
||||
}
|
||||
}
|
||||
port := os.Getenv("PORT")
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
port = "8787"
|
||||
}
|
||||
return Config{
|
||||
Port: port,
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
SessionTTL: ttl,
|
||||
AuthPepper: os.Getenv("AUTH_PEPPER"),
|
||||
AllowedOrigins: splitCSV(os.Getenv("ALLOWED_ORIGINS")),
|
||||
Port: port,
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
||||
SessionTTL: ttl,
|
||||
AuthPepper: os.Getenv("AUTH_PEPPER"),
|
||||
AllowedOrigins: splitCSV(os.Getenv("ALLOWED_ORIGINS")),
|
||||
AllowRegistration: BoolEnv("ALLOW_REGISTRATION", true),
|
||||
MaxUsers: IntEnv("MAX_USERS", 5),
|
||||
MaxBlobsPerUser: IntEnv("MAX_BLOBS_PER_USER", 50000),
|
||||
MaxDevicesPerUser: IntEnv("MAX_DEVICES_PER_USER", 10),
|
||||
TrustLoopbackProxy: BoolEnv("TRUST_LOOPBACK_PROXY", true),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.DatabaseURL == "" {
|
||||
return errors.New("DATABASE_URL is required")
|
||||
}
|
||||
if len(c.AuthPepper) < 32 || c.AuthPepper == DefaultInsecurePepper {
|
||||
return errors.New("AUTH_PEPPER must be set to a random secret of at least 32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var result []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
|
@ -46,9 +69,21 @@ func splitCSV(value string) []string {
|
|||
}
|
||||
|
||||
func IntEnv(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(os.Getenv(name))
|
||||
if err != nil {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func BoolEnv(name string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
val, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package database
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
|
|
@ -11,16 +12,37 @@ import (
|
|||
"github.com/muriloscolari/SimpSync/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrRegistrationClosed = errors.New("registration is closed")
|
||||
ErrQuotaExceeded = errors.New("storage quota exceeded")
|
||||
)
|
||||
|
||||
const (
|
||||
maxPullBatchItems = 200
|
||||
maxPullBatchBytes = 2 << 20 // 2 MiB max payload per pull response
|
||||
)
|
||||
|
||||
type Store struct{ Pool *pgxpool.Pool }
|
||||
|
||||
func Open(ctx context.Context, url string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
cfg, err := pgxpool.ParseConfig(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
cfg.MaxConns = 10
|
||||
cfg.MinConns = 1
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
cfg.HealthCheckPeriod = 1 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -37,53 +59,160 @@ func (s *Store) UserByEmail(ctx context.Context, email string) (uuid.UUID, strin
|
|||
return id, verifier, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, email, verifier string) (uuid.UUID, error) {
|
||||
func (s *Store) RegisterUser(
|
||||
ctx context.Context,
|
||||
email, verifier string,
|
||||
recoveryHash []byte,
|
||||
allowRegistration bool,
|
||||
maxUsers int,
|
||||
) (uuid.UUID, error) {
|
||||
if !allowRegistration {
|
||||
return uuid.Nil, ErrRegistrationClosed
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `LOCK TABLE app_user IN EXCLUSIVE MODE`); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if maxUsers > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*) FROM app_user`).Scan(&count); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if count >= maxUsers {
|
||||
return uuid.Nil, ErrRegistrationClosed
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
_, err := s.Pool.Exec(ctx, `INSERT INTO app_user(id,email,auth_verifier) VALUES($1,$2,$3)`, id, email, verifier)
|
||||
return id, err
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO app_user(id,email,auth_verifier) VALUES($1,$2,$3)`, id, email, verifier); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if len(recoveryHash) > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_recovery(user_id,recovery_key_hash)
|
||||
VALUES($1,$2)
|
||||
ON CONFLICT(user_id) DO UPDATE SET recovery_key_hash=EXCLUDED.recovery_key_hash, rotated_at=now()`,
|
||||
id, recoveryHash); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRecoveryKey(ctx context.Context, userID uuid.UUID, recoveryHash []byte) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO account_recovery(user_id,recovery_key_hash)
|
||||
VALUES($1,$2)
|
||||
ON CONFLICT(user_id) DO UPDATE SET recovery_key_hash=EXCLUDED.recovery_key_hash, rotated_at=now()`,
|
||||
userID, recoveryHash)
|
||||
return err
|
||||
}
|
||||
func (s *Store) RecoverUser(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
recoveryHash []byte,
|
||||
legacyRecoveryHash []byte,
|
||||
verifier string,
|
||||
) (uuid.UUID, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
func (s *Store) RecoverUser(ctx context.Context, email string, recoveryHash []byte, verifier string) (uuid.UUID, error) {
|
||||
var userID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE app_user SET auth_verifier=$3
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE app_user SET auth_verifier=$4
|
||||
FROM account_recovery
|
||||
WHERE app_user.id=account_recovery.user_id
|
||||
AND app_user.email=$1
|
||||
AND account_recovery.recovery_key_hash=$2
|
||||
RETURNING app_user.id`, email, recoveryHash, verifier).Scan(&userID)
|
||||
AND (account_recovery.recovery_key_hash=$2 OR account_recovery.recovery_key_hash=$3)
|
||||
RETURNING app_user.id`, email, recoveryHash, legacyRecoveryHash, verifier).Scan(&userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
return userID, err
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// Upgrade stored recovery hash to the domain-separated SHA-256 digest and revoke all prior sessions.
|
||||
if _, err := tx.Exec(ctx, `UPDATE account_recovery SET recovery_key_hash=$2, rotated_at=now() WHERE user_id=$1`, userID, recoveryHash); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_session SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, userID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, deviceName string, tokenHash []byte, expires time.Time) (uuid.UUID, error) {
|
||||
func (s *Store) CreateSession(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
deviceName string,
|
||||
tokenHash []byte,
|
||||
expires time.Time,
|
||||
maxDevices int,
|
||||
) (uuid.UUID, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if maxDevices > 0 {
|
||||
// Revoke oldest active sessions beyond (maxDevices - 1) so the new session stays within the cap.
|
||||
_, _ = tx.Exec(ctx, `
|
||||
UPDATE device_session SET revoked_at=now()
|
||||
WHERE id IN (
|
||||
SELECT id FROM device_session
|
||||
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now()
|
||||
ORDER BY last_seen_at DESC
|
||||
OFFSET $2
|
||||
)`, userID, maxDevices-1)
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
_, err := s.Pool.Exec(ctx, `INSERT INTO device_session(id,user_id,device_name,token_hash,expires_at) VALUES($1,$2,$3,$4,$5)`, id, userID, deviceName, tokenHash, expires)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) SessionUser(ctx context.Context, tokenHash []byte) (uuid.UUID, error) {
|
||||
var userID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `UPDATE device_session SET last_seen_at=now() WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at > now() RETURNING user_id`, tokenHash).Scan(&userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_session(id,user_id,device_name,token_hash,expires_at) VALUES($1,$2,$3,$4,$5)`,
|
||||
id, userID, deviceName, tokenHash, expires); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return userID, err
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) error {
|
||||
result, err := s.Pool.Exec(ctx, `UPDATE device_session SET revoked_at=now() WHERE token_hash=$1 AND revoked_at IS NULL`, tokenHash)
|
||||
func (s *Store) SessionAuth(ctx context.Context, tokenHash []byte) (uuid.UUID, uuid.UUID, error) {
|
||||
var userID, deviceID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE device_session SET last_seen_at=now()
|
||||
WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at > now()
|
||||
RETURNING user_id, id`, tokenHash).Scan(&userID, &deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, uuid.Nil, ErrNotFound
|
||||
}
|
||||
return userID, deviceID, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) (uuid.UUID, uuid.UUID, error) {
|
||||
var userID, deviceID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE device_session SET revoked_at=now()
|
||||
WHERE token_hash=$1 AND revoked_at IS NULL
|
||||
RETURNING user_id, id`, tokenHash).Scan(&userID, &deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, uuid.Nil, ErrNotFound
|
||||
}
|
||||
return userID, deviceID, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeDevice(ctx context.Context, userID, deviceID uuid.UUID) error {
|
||||
result, err := s.Pool.Exec(ctx, `
|
||||
UPDATE device_session SET revoked_at=now()
|
||||
WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, deviceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -93,50 +222,57 @@ func (s *Store) RevokeSession(ctx context.Context, tokenHash []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Devices(ctx context.Context, userID uuid.UUID) ([]models.Device, error) {
|
||||
func (s *Store) Devices(ctx context.Context, userID, currentDeviceID uuid.UUID) ([]models.Device, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, device_name, created_at, last_seen_at
|
||||
FROM device_session WHERE user_id=$1 AND revoked_at IS NULL
|
||||
FROM device_session
|
||||
WHERE user_id=$1 AND revoked_at IS NULL AND expires_at > now()
|
||||
ORDER BY last_seen_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var devices []models.Device
|
||||
devices := make([]models.Device, 0)
|
||||
for rows.Next() {
|
||||
var device models.Device
|
||||
if err := rows.Scan(&device.ID, &device.DeviceName, &device.CreatedAt, &device.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device.Current = currentDeviceID != uuid.Nil && device.ID == currentDeviceID.String()
|
||||
devices = append(devices, device)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertBlob(ctx context.Context, userID uuid.UUID, entityType, entityID string, envelope []byte, deleted bool) (int64, error) {
|
||||
var version int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO sync_blob(user_id,entity_type,entity_id,envelope,version,deleted_at)
|
||||
VALUES($1,$2,$3,$4,nextval('sync_revision_seq'),CASE WHEN $5 THEN now() ELSE NULL END)
|
||||
ON CONFLICT(user_id,entity_type,entity_id) DO UPDATE SET
|
||||
envelope=EXCLUDED.envelope,
|
||||
version=EXCLUDED.version,
|
||||
deleted_at=EXCLUDED.deleted_at,
|
||||
updated_at=now()
|
||||
RETURNING version`, userID, entityType, entityID, envelope, deleted).Scan(&version)
|
||||
return version, err
|
||||
}
|
||||
|
||||
func (s *Store) Changes(ctx context.Context, userID uuid.UUID, cursor int64) ([][]any, int64, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT entity_type, entity_id, envelope, version, deleted_at, updated_at
|
||||
FROM sync_blob WHERE user_id=$1 AND version>$2 ORDER BY version ASC LIMIT 1000`, userID, cursor)
|
||||
func (s *Store) Sync(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
incoming []models.Blob,
|
||||
cursor int64,
|
||||
maxBlobsPerUser int,
|
||||
) ([]models.Blob, int64, bool, [][]byte, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, cursor, err
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var changes [][]any
|
||||
var newest = cursor
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// 1. Read remote changes that existed prior to this push (version > cursor), bounded by count & memory.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT entity_type, entity_id, envelope, version, deleted_at, updated_at
|
||||
FROM sync_blob
|
||||
WHERE user_id=$1 AND version>$2
|
||||
ORDER BY version ASC
|
||||
LIMIT $3`, userID, cursor, maxPullBatchItems+1)
|
||||
if err != nil {
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
|
||||
remoteChanges := make([]models.Blob, 0)
|
||||
newest := cursor
|
||||
hasMore := false
|
||||
accumulatedBytes := 0
|
||||
|
||||
for rows.Next() {
|
||||
var entityType, entityID string
|
||||
var envelope []byte
|
||||
|
|
@ -144,12 +280,85 @@ func (s *Store) Changes(ctx context.Context, userID uuid.UUID, cursor int64) ([]
|
|||
var deletedAt *time.Time
|
||||
var updatedAt time.Time
|
||||
if err := rows.Scan(&entityType, &entityID, &envelope, &version, &deletedAt, &updatedAt); err != nil {
|
||||
return nil, cursor, err
|
||||
rows.Close()
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
changes = append(changes, []any{entityType, entityID, envelope, version, deletedAt != nil, updatedAt})
|
||||
if len(remoteChanges) >= maxPullBatchItems || (len(remoteChanges) > 0 && accumulatedBytes+len(envelope) > maxPullBatchBytes) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
accumulatedBytes += len(envelope)
|
||||
remoteChanges = append(remoteChanges, models.Blob{
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
Envelope: string(envelope),
|
||||
Version: version,
|
||||
Deleted: deletedAt != nil,
|
||||
UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
if version > newest {
|
||||
newest = version
|
||||
}
|
||||
}
|
||||
return changes, newest, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 2. If there are incoming changes, enforce user quota and upsert atomically.
|
||||
var notifications [][]byte
|
||||
if len(incoming) > 0 {
|
||||
if maxBlobsPerUser > 0 {
|
||||
var currentCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*) FROM sync_blob WHERE user_id=$1`, userID).Scan(¤tCount); err != nil {
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
if currentCount+len(incoming) > maxBlobsPerUser {
|
||||
return nil, cursor, false, nil, ErrQuotaExceeded
|
||||
}
|
||||
}
|
||||
|
||||
for _, change := range incoming {
|
||||
var version int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO sync_blob(user_id,entity_type,entity_id,envelope,version,deleted_at)
|
||||
VALUES($1,$2,$3,$4,nextval('sync_revision_seq'),CASE WHEN $5 THEN now() ELSE NULL END)
|
||||
ON CONFLICT(user_id,entity_type,entity_id) DO UPDATE SET
|
||||
envelope=EXCLUDED.envelope,
|
||||
version=EXCLUDED.version,
|
||||
deleted_at=EXCLUDED.deleted_at,
|
||||
updated_at=now()
|
||||
RETURNING version`,
|
||||
userID, change.EntityType, change.EntityID, []byte(change.Envelope), change.Deleted,
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
// Only advance the returned cursor if we didn't truncate the pull batch;
|
||||
// otherwise the client still needs to pull the remaining pages before advancing past them.
|
||||
if !hasMore && version > newest {
|
||||
newest = version
|
||||
}
|
||||
notif, _ := json.Marshal(map[string]any{
|
||||
"entityType": change.EntityType,
|
||||
"entityId": change.EntityID,
|
||||
"version": version,
|
||||
})
|
||||
notifications = append(notifications, notif)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, cursor, false, nil, err
|
||||
}
|
||||
return remoteChanges, newest, hasMore, notifications, nil
|
||||
}
|
||||
|
||||
func (s *Store) CleanupExpiredSessions(ctx context.Context) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM device_session
|
||||
WHERE expires_at < now()
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '7 days')`)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ package handlers
|
|||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -16,26 +19,67 @@ import (
|
|||
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAuthBodyBytes = 16 << 10 // 16 KiB
|
||||
maxSyncBodyBytes = 4 << 20 // 4 MiB
|
||||
maxEnvelopeBytes = 512 << 10 // 512 KiB per entity blob
|
||||
maxChangesPerBatch = 200
|
||||
maxEmailLen = 254
|
||||
maxDeviceNameLen = 128
|
||||
maxEntityTypeLen = 64
|
||||
maxEntityIDLen = 256
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *database.Store
|
||||
Config config.Config
|
||||
Hub *syncws.Hub
|
||||
Store *database.Store
|
||||
Config config.Config
|
||||
Hub *syncws.Hub
|
||||
initOnce sync.Once
|
||||
authLimiter *RateLimiter
|
||||
syncLimiter *RateLimiter
|
||||
}
|
||||
|
||||
func (h *Handler) ensureLimiters() {
|
||||
h.initOnce.Do(func() {
|
||||
// Auth: 12 requests/minute per IP with burst of 6.
|
||||
h.authLimiter = NewRateLimiter(12, 6, 10*time.Minute)
|
||||
// Sync: 120 requests/minute per IP with burst of 30.
|
||||
h.syncLimiter = NewRateLimiter(120, 30, 10*time.Minute)
|
||||
if h.Hub != nil && h.Config.MaxDevicesPerUser > 0 {
|
||||
h.Hub.SetMaxConnsPerUser(h.Config.MaxDevicesPerUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
h.ensureLimiters()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", h.health)
|
||||
mux.HandleFunc("/v1/auth/register", h.register)
|
||||
mux.HandleFunc("/v1/auth/login", h.login)
|
||||
mux.HandleFunc("/v1/auth/recover", h.recover)
|
||||
mux.HandleFunc("/v1/auth/logout", h.logout)
|
||||
mux.HandleFunc("/v1/devices", h.devices)
|
||||
mux.HandleFunc("/v1/sync", h.sync)
|
||||
mux.HandleFunc("/v1/ws", h.ws)
|
||||
return logging(mux)
|
||||
mux.HandleFunc("GET /healthz", h.health)
|
||||
mux.HandleFunc("POST /v1/auth/register", h.withRateLimit(h.authLimiter, h.register))
|
||||
mux.HandleFunc("POST /v1/auth/login", h.withRateLimit(h.authLimiter, h.login))
|
||||
mux.HandleFunc("POST /v1/auth/recover", h.withRateLimit(h.authLimiter, h.recover))
|
||||
mux.HandleFunc("POST /v1/auth/logout", h.logout)
|
||||
mux.HandleFunc("GET /v1/devices", h.devices)
|
||||
mux.HandleFunc("DELETE /v1/devices/{id}", h.revokeDevice)
|
||||
mux.HandleFunc("POST /v1/sync", h.withRateLimit(h.syncLimiter, h.sync))
|
||||
mux.HandleFunc("GET /v1/ws", h.ws)
|
||||
return securityHeaders(logging(mux))
|
||||
}
|
||||
|
||||
func (h *Handler) withRateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r, h.Config.TrustLoopbackProxy)
|
||||
if !limiter.Allow(ip) {
|
||||
w.Header().Set("Retry-After", "10")
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
|
@ -50,42 +94,61 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bool) {
|
||||
var input models.Credentials
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&input); err != nil {
|
||||
if err := decodeStrictJSON(w, r, maxAuthBodyBytes, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
input.DeviceName = strings.TrimSpace(input.DeviceName)
|
||||
if input.Email == "" || input.AuthProof == "" || input.DeviceName == "" {
|
||||
writeError(w, http.StatusBadRequest, "email, authProof and deviceName are required")
|
||||
input.RecoveryKeyHash = strings.TrimSpace(input.RecoveryKeyHash)
|
||||
|
||||
if !isValidEmail(input.Email) || input.AuthProof == "" || input.DeviceName == "" || len(input.DeviceName) > maxDeviceNameLen {
|
||||
writeError(w, http.StatusBadRequest, "valid email, authProof and deviceName are required")
|
||||
return
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
var err error
|
||||
if create {
|
||||
verifier, verifierErr := auth.Verifier(input.AuthProof, h.Config.AuthPepper)
|
||||
if verifierErr != nil {
|
||||
writeError(w, http.StatusBadRequest, verifierErr.Error())
|
||||
if !h.Config.AllowRegistration {
|
||||
writeError(w, http.StatusForbidden, "registration is disabled on this server")
|
||||
return
|
||||
}
|
||||
userID, err = h.Store.CreateUser(r.Context(), input.Email, verifier)
|
||||
if err == nil && input.RecoveryKeyHash != "" {
|
||||
if len(input.RecoveryKeyHash) > 512 {
|
||||
writeError(w, http.StatusBadRequest, "recovery key hash is too long")
|
||||
var recoveryDigest []byte
|
||||
if input.RecoveryKeyHash != "" {
|
||||
recoveryDigest, err = auth.HashRecoveryKey(input.RecoveryKeyHash, h.Config.AuthPepper)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err = h.Store.SetRecoveryKey(r.Context(), userID, []byte(input.RecoveryKeyHash)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not store recovery metadata")
|
||||
return
|
||||
}
|
||||
verifier, verifierErr := auth.VerifierCtx(r.Context(), input.AuthProof, h.Config.AuthPepper)
|
||||
if verifierErr != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(verifierErr, auth.ErrServerBusy) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeError(w, status, verifierErr.Error())
|
||||
return
|
||||
}
|
||||
userID, err = h.Store.RegisterUser(
|
||||
r.Context(),
|
||||
input.Email,
|
||||
verifier,
|
||||
recoveryDigest,
|
||||
h.Config.AllowRegistration,
|
||||
h.Config.MaxUsers,
|
||||
)
|
||||
if errors.Is(err, database.ErrRegistrationClosed) {
|
||||
writeError(w, http.StatusForbidden, "registration limit reached")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var verifier string
|
||||
userID, verifier, err = h.Store.UserByEmail(r.Context(), input.Email)
|
||||
if err == nil && !auth.Verify(input.AuthProof, verifier, h.Config.AuthPepper) {
|
||||
if err == nil && !auth.VerifyCtx(r.Context(), input.AuthProof, verifier, h.Config.AuthPepper) {
|
||||
err = database.ErrNotFound
|
||||
}
|
||||
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
|
|
@ -97,7 +160,7 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bo
|
|||
return
|
||||
}
|
||||
expires := time.Now().Add(h.Config.SessionTTL)
|
||||
sessionID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires)
|
||||
sessionID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires, h.Config.MaxDevicesPerUser)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
|
|
@ -107,34 +170,46 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request, create bo
|
|||
|
||||
func (h *Handler) recover(w http.ResponseWriter, r *http.Request) {
|
||||
var input models.RecoveryRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&input); err != nil {
|
||||
if err := decodeStrictJSON(w, r, maxAuthBodyBytes, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
input.RecoveryKeyHash = strings.TrimSpace(input.RecoveryKeyHash)
|
||||
input.DeviceName = strings.TrimSpace(input.DeviceName)
|
||||
if input.Email == "" || input.RecoveryKeyHash == "" || len(input.NewAuthProof) < 32 || input.DeviceName == "" {
|
||||
if !isValidEmail(input.Email) || input.RecoveryKeyHash == "" || len(input.NewAuthProof) < 32 || input.DeviceName == "" || len(input.DeviceName) > maxDeviceNameLen {
|
||||
writeError(w, http.StatusBadRequest, "email, recoveryKeyHash, newAuthProof and deviceName are required")
|
||||
return
|
||||
}
|
||||
verifier, err := auth.Verifier(input.NewAuthProof, h.Config.AuthPepper)
|
||||
recoveryDigest, err := auth.HashRecoveryKey(input.RecoveryKeyHash, h.Config.AuthPepper)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid authentication proof")
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
userID, err := h.Store.RecoverUser(r.Context(), input.Email, []byte(input.RecoveryKeyHash), verifier)
|
||||
verifier, err := auth.VerifierCtx(r.Context(), input.NewAuthProof, h.Config.AuthPepper)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, auth.ErrServerBusy) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeError(w, status, "invalid authentication proof")
|
||||
return
|
||||
}
|
||||
userID, err := h.Store.RecoverUser(r.Context(), input.Email, recoveryDigest, []byte(input.RecoveryKeyHash), verifier)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid recovery credentials")
|
||||
return
|
||||
}
|
||||
if h.Hub != nil {
|
||||
h.Hub.DisconnectUser(userID)
|
||||
}
|
||||
token, tokenHash, err := auth.Token()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
}
|
||||
expires := time.Now().Add(h.Config.SessionTTL)
|
||||
deviceID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires)
|
||||
deviceID, err := h.Store.CreateSession(r.Context(), userID, input.DeviceName, tokenHash, expires, h.Config.MaxDevicesPerUser)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
|
|
@ -149,20 +224,24 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
digest := sha256.Sum256([]byte(raw))
|
||||
if err := h.Store.RevokeSession(r.Context(), digest[:]); err != nil {
|
||||
userID, deviceID, err := h.Store.RevokeSession(r.Context(), digest[:])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if h.Hub != nil {
|
||||
h.Hub.DisconnectDevice(userID, deviceID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) devices(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(r)
|
||||
userID, currentDeviceID, ok := h.session(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
devices, err := h.Store.Devices(r.Context(), userID)
|
||||
devices, err := h.Store.Devices(r.Context(), userID, currentDeviceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not list devices")
|
||||
return
|
||||
|
|
@ -170,96 +249,132 @@ func (h *Handler) devices(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, devices)
|
||||
}
|
||||
|
||||
func (h *Handler) revokeDevice(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _, ok := h.session(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
rawID := strings.TrimSpace(r.PathValue("id"))
|
||||
targetDeviceID, err := uuid.Parse(rawID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid device id")
|
||||
return
|
||||
}
|
||||
if err := h.Store.RevokeDevice(r.Context(), userID, targetDeviceID); err != nil {
|
||||
if errors.Is(err, database.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "device not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not revoke device")
|
||||
return
|
||||
}
|
||||
if h.Hub != nil {
|
||||
h.Hub.DisconnectDevice(userID, targetDeviceID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) sync(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(r)
|
||||
userID, deviceID, ok := h.session(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var input models.SyncRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&input); err != nil {
|
||||
if err := decodeStrictJSON(w, r, maxSyncBodyBytes, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
for _, change := range input.Changes {
|
||||
if change.EntityType == "" || change.EntityID == "" || change.Envelope == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid change")
|
||||
return
|
||||
}
|
||||
if len(change.Envelope) > 4<<20 {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "envelope too large")
|
||||
return
|
||||
}
|
||||
version, err := h.Store.UpsertBlob(r.Context(), userID, change.EntityType, change.EntityID, []byte(change.Envelope), change.Deleted)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not store change")
|
||||
return
|
||||
}
|
||||
change.Version = version
|
||||
notification, _ := json.Marshal(map[string]any{
|
||||
"entityType": change.EntityType,
|
||||
"entityId": change.EntityID,
|
||||
"version": version,
|
||||
})
|
||||
h.Hub.Broadcast(userID, notification, nil)
|
||||
}
|
||||
rows, cursor, err := h.Store.Changes(r.Context(), userID, input.Cursor)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read changes")
|
||||
if input.Cursor < 0 || len(input.Changes) > maxChangesPerBatch {
|
||||
writeError(w, http.StatusBadRequest, "invalid cursor or too many changes in a single batch")
|
||||
return
|
||||
}
|
||||
response := models.SyncResponse{Cursor: cursor}
|
||||
for _, row := range rows {
|
||||
response.Changes = append(response.Changes, models.Blob{
|
||||
EntityType: row[0].(string), EntityID: row[1].(string), Envelope: string(row[2].([]byte)),
|
||||
Version: row[3].(int64), Deleted: row[4].(bool), UpdatedAt: row[5].(time.Time).UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
for i := range input.Changes {
|
||||
change := &input.Changes[i]
|
||||
change.EntityType = strings.TrimSpace(change.EntityType)
|
||||
change.EntityID = strings.TrimSpace(change.EntityID)
|
||||
if !isValidEntityType(change.EntityType) || change.EntityID == "" || len(change.EntityID) > maxEntityIDLen {
|
||||
writeError(w, http.StatusBadRequest, "invalid entityType or entityId")
|
||||
return
|
||||
}
|
||||
if !change.Deleted && change.Envelope == "" {
|
||||
writeError(w, http.StatusBadRequest, "envelope is required for non-deleted entities")
|
||||
return
|
||||
}
|
||||
if len(change.Envelope) > maxEnvelopeBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "envelope exceeds maximum allowed size")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
|
||||
changes, newCursor, hasMore, notifications, err := h.Store.Sync(
|
||||
r.Context(),
|
||||
userID,
|
||||
input.Changes,
|
||||
input.Cursor,
|
||||
h.Config.MaxBlobsPerUser,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrQuotaExceeded) {
|
||||
writeError(w, http.StatusInsufficientStorage, "user storage quota exceeded")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not synchronize changes")
|
||||
return
|
||||
}
|
||||
|
||||
if h.Hub != nil {
|
||||
for _, notif := range notifications {
|
||||
h.Hub.Broadcast(userID, notif, deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, models.SyncResponse{
|
||||
Changes: changes,
|
||||
Cursor: newCursor,
|
||||
HasMore: hasMore,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ws(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(r)
|
||||
userID, deviceID, ok := h.session(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool {
|
||||
if len(h.Config.AllowedOrigins) == 0 {
|
||||
return request.Header.Get("Origin") == ""
|
||||
}
|
||||
origin := request.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range h.Config.AllowedOrigins {
|
||||
if origin == allowed {
|
||||
upgrader := websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(request *http.Request) bool {
|
||||
origin := request.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}}
|
||||
for _, allowed := range h.Config.AllowedOrigins {
|
||||
if origin == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.Hub.Join(userID, conn)
|
||||
defer func() { h.Hub.Leave(userID, conn); _ = conn.Close() }()
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
client := syncws.NewClient(userID, deviceID, conn)
|
||||
client.Serve(h.Hub)
|
||||
}
|
||||
|
||||
func (h *Handler) user(r *http.Request) (uuid.UUID, bool) {
|
||||
func (h *Handler) session(r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
raw := bearerToken(r)
|
||||
if raw == "" {
|
||||
return uuid.Nil, false
|
||||
if raw == "" || len(raw) > 128 {
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
digest := sha256.Sum256([]byte(raw))
|
||||
id, err := h.Store.SessionUser(r.Context(), digest[:])
|
||||
return id, err == nil
|
||||
userID, deviceID, err := h.Store.SessionAuth(r.Context(), digest[:])
|
||||
return userID, deviceID, err == nil
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
|
|
@ -270,6 +385,35 @@ func bearerToken(r *http.Request) string {
|
|||
return strings.TrimSpace(value[len("Bearer "):])
|
||||
}
|
||||
|
||||
func decodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
|
||||
reader := http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
defer func() { _ = reader.Close() }()
|
||||
dec := json.NewDecoder(reader)
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
if len(email) < 3 || len(email) > maxEmailLen {
|
||||
return false
|
||||
}
|
||||
at := strings.IndexByte(email, '@')
|
||||
dot := strings.LastIndexByte(email, '.')
|
||||
return at > 0 && dot > at+1 && dot < len(email)-1 && !strings.ContainsAny(email, " \t\r\n")
|
||||
}
|
||||
|
||||
func isValidEntityType(entityType string) bool {
|
||||
if len(entityType) == 0 || len(entityType) > maxEntityTypeLen {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(entityType); i++ {
|
||||
c := entityType[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
|
@ -280,8 +424,19 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
|||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func logging(next http.Handler) http.Handler {
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
77
internal/handlers/handlers_test.go
Normal file
77
internal/handlers/handlers_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/muriloscolari/SimpSync/internal/config"
|
||||
syncws "github.com/muriloscolari/SimpSync/internal/websocket"
|
||||
)
|
||||
|
||||
func TestRateLimiterBoundsMemory(t *testing.T) {
|
||||
rl := NewRateLimiter(60, 2, 10*time.Minute)
|
||||
for i := 0; i < maxTrackedIPs+500; i++ {
|
||||
rl.Allow(fmt.Sprintf("192.0.2.%d", i))
|
||||
}
|
||||
if len(rl.buckets) > maxTrackedIPs {
|
||||
t.Fatalf("rate limiter exceeded maxTrackedIPs: got %d, max %d", len(rl.buckets), maxTrackedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodEnforcementAndRateLimit(t *testing.T) {
|
||||
h := &Handler{
|
||||
Config: config.Config{
|
||||
AuthPepper: "0123456789abcdef0123456789abcdef",
|
||||
AllowRegistration: false,
|
||||
TrustLoopbackProxy: true,
|
||||
},
|
||||
Hub: syncws.NewHub(),
|
||||
}
|
||||
router := h.Routes()
|
||||
|
||||
// Health check works on GET and includes security headers.
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK on GET /healthz, got %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("missing X-Content-Type-Options header")
|
||||
}
|
||||
|
||||
// GET on POST-only endpoint must be rejected with 405 Method Not Allowed.
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/auth/login", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 on GET /v1/auth/login, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Registration disabled returns 403 Forbidden.
|
||||
body := `{"email":"user@example.com","authProof":"0123456789abcdef0123456789abcdef","deviceName":"PC"}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/auth/register", strings.NewReader(body))
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("X-Real-IP", "203.0.113.10")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 when registration disabled, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Exhaust burst (6 requests) from the same client IP and verify 429 Too Many Requests.
|
||||
for i := 0; i < 6; i++ {
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/auth/register", strings.NewReader(body))
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("X-Real-IP", "203.0.113.10")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 Too Many Requests after exhausting burst, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
105
internal/handlers/ratelimit.go
Normal file
105
internal/handlers/ratelimit.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxTrackedIPs = 4096
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
ratePerSec float64
|
||||
burst float64
|
||||
ttl time.Duration
|
||||
lastSweepAt time.Time
|
||||
}
|
||||
|
||||
func NewRateLimiter(ratePerMinute float64, burst int, ttl time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
ratePerSec: ratePerMinute / 60.0,
|
||||
burst: float64(burst),
|
||||
ttl: ttl,
|
||||
lastSweepAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
if now.Sub(rl.lastSweepAt) > rl.ttl || len(rl.buckets) >= maxTrackedIPs {
|
||||
for k, b := range rl.buckets {
|
||||
if now.Sub(b.lastSeen) > rl.ttl {
|
||||
delete(rl.buckets, k)
|
||||
}
|
||||
}
|
||||
// Hard cap protection: if still at capacity after TTL sweep, drop one arbitrary entry.
|
||||
if len(rl.buckets) >= maxTrackedIPs {
|
||||
for k := range rl.buckets {
|
||||
delete(rl.buckets, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
rl.lastSweepAt = now
|
||||
}
|
||||
|
||||
b, exists := rl.buckets[key]
|
||||
if !exists {
|
||||
rl.buckets[key] = &bucket{
|
||||
tokens: rl.burst - 1,
|
||||
lastSeen: now,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
elapsed := now.Sub(b.lastSeen).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens += elapsed * rl.ratePerSec
|
||||
if b.tokens > rl.burst {
|
||||
b.tokens = rl.burst
|
||||
}
|
||||
}
|
||||
b.lastSeen = now
|
||||
if b.tokens < 1.0 {
|
||||
return false
|
||||
}
|
||||
b.tokens -= 1.0
|
||||
return true
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request, trustLoopbackProxy bool) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if trustLoopbackProxy && ip != nil && ip.IsLoopback() {
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
if parsed := net.ParseIP(realIP); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if parsed := net.ParseIP(first); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
if ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ type SyncRequest struct {
|
|||
type SyncResponse struct {
|
||||
Changes []Blob `json:"changes"`
|
||||
Cursor int64 `json:"cursor"`
|
||||
HasMore bool `json:"hasMore,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
|
|
@ -39,6 +40,7 @@ type Device struct {
|
|||
DeviceName string `json:"deviceName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
Current bool `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
type SessionRefresh struct {
|
||||
|
|
|
|||
|
|
@ -2,43 +2,205 @@ package websocket
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 65 * time.Second
|
||||
pingPeriod = 30 * time.Second
|
||||
maxMessageSize = 4096
|
||||
sendBufferSize = 16
|
||||
defaultMaxConns = 10
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
UserID uuid.UUID
|
||||
DeviceID uuid.UUID
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewClient(userID, deviceID uuid.UUID, conn *websocket.Conn) *Client {
|
||||
return &Client{
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
conn: conn,
|
||||
send: make(chan []byte, sendBufferSize),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.done)
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[uuid.UUID]map[*websocket.Conn]struct{}
|
||||
mu sync.RWMutex
|
||||
rooms map[uuid.UUID][]*Client
|
||||
maxConnsPerUser int
|
||||
}
|
||||
|
||||
func NewHub() *Hub { return &Hub{rooms: make(map[uuid.UUID]map[*websocket.Conn]struct{})} }
|
||||
|
||||
func (h *Hub) Join(userID uuid.UUID, conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[userID] == nil {
|
||||
h.rooms[userID] = make(map[*websocket.Conn]struct{})
|
||||
}
|
||||
h.rooms[userID][conn] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) Leave(userID uuid.UUID, conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.rooms[userID], conn)
|
||||
if len(h.rooms[userID]) == 0 {
|
||||
delete(h.rooms, userID)
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[uuid.UUID][]*Client),
|
||||
maxConnsPerUser: defaultMaxConns,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(userID uuid.UUID, payload []byte, except *websocket.Conn) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for conn := range h.rooms[userID] {
|
||||
if conn == except {
|
||||
func (h *Hub) SetMaxConnsPerUser(limit int) {
|
||||
if limit > 0 {
|
||||
h.mu.Lock()
|
||||
h.maxConnsPerUser = limit
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Join(client *Client) {
|
||||
var evicted []*Client
|
||||
h.mu.Lock()
|
||||
clients := h.rooms[client.UserID]
|
||||
// Evict existing connection for the exact same deviceID or excess connections beyond maxConnsPerUser.
|
||||
filtered := make([]*Client, 0, len(clients)+1)
|
||||
for _, existing := range clients {
|
||||
if existing.DeviceID != uuid.Nil && existing.DeviceID == client.DeviceID {
|
||||
evicted = append(evicted, existing)
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, payload)
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
for len(filtered) >= h.maxConnsPerUser {
|
||||
evicted = append(evicted, filtered[0])
|
||||
filtered = filtered[1:]
|
||||
}
|
||||
filtered = append(filtered, client)
|
||||
h.rooms[client.UserID] = filtered
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, old := range evicted {
|
||||
old.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Leave(client *Client) {
|
||||
h.mu.Lock()
|
||||
clients := h.rooms[client.UserID]
|
||||
for i, existing := range clients {
|
||||
if existing == client {
|
||||
clients = append(clients[:i], clients[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
delete(h.rooms, client.UserID)
|
||||
} else {
|
||||
h.rooms[client.UserID] = clients
|
||||
}
|
||||
h.mu.Unlock()
|
||||
client.Close()
|
||||
}
|
||||
|
||||
func (h *Hub) DisconnectDevice(userID, deviceID uuid.UUID) {
|
||||
var target *Client
|
||||
h.mu.RLock()
|
||||
for _, c := range h.rooms[userID] {
|
||||
if c.DeviceID == deviceID {
|
||||
target = c
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if target != nil {
|
||||
h.Leave(target)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) DisconnectUser(userID uuid.UUID) {
|
||||
h.mu.Lock()
|
||||
clients := h.rooms[userID]
|
||||
delete(h.rooms, userID)
|
||||
h.mu.Unlock()
|
||||
for _, c := range clients {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast enqueues payload to all connected devices of userID except exceptDeviceID.
|
||||
// Never performs blocking network I/O while holding locks.
|
||||
func (h *Hub) Broadcast(userID uuid.UUID, payload []byte, exceptDeviceID uuid.UUID) {
|
||||
var slow []*Client
|
||||
h.mu.RLock()
|
||||
for _, client := range h.rooms[userID] {
|
||||
if exceptDeviceID != uuid.Nil && client.DeviceID == exceptDeviceID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- payload:
|
||||
default:
|
||||
slow = append(slow, client)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, client := range slow {
|
||||
h.Leave(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Serve(hub *Hub) {
|
||||
hub.Join(c)
|
||||
go c.writePump(hub)
|
||||
c.readPump(hub)
|
||||
}
|
||||
|
||||
func (c *Client) readPump(hub *Hub) {
|
||||
defer hub.Leave(c)
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump(hub *Hub) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
hub.Leave(c)
|
||||
}()
|
||||
// Clear any inherited HTTP server WriteTimeout before starting the pump.
|
||||
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case msg := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
internal/websocket/hub_test.go
Normal file
55
internal/websocket/hub_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package websocket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHubMaxConnectionsAndBroadcast(t *testing.T) {
|
||||
hub := NewHub()
|
||||
hub.SetMaxConnsPerUser(3)
|
||||
userID := uuid.New()
|
||||
|
||||
c1 := NewClient(userID, uuid.New(), nil)
|
||||
c2 := NewClient(userID, uuid.New(), nil)
|
||||
c3 := NewClient(userID, uuid.New(), nil)
|
||||
c4 := NewClient(userID, uuid.New(), nil)
|
||||
|
||||
hub.Join(c1)
|
||||
hub.Join(c2)
|
||||
hub.Join(c3)
|
||||
hub.Join(c4)
|
||||
|
||||
// c1 should have been evicted when c4 joined.
|
||||
select {
|
||||
case <-c1.done:
|
||||
default:
|
||||
t.Fatal("expected oldest client c1 to be closed and evicted")
|
||||
}
|
||||
|
||||
// Broadcast excluding c2's deviceID.
|
||||
hub.Broadcast(userID, []byte(`{"version":1}`), c2.DeviceID)
|
||||
|
||||
select {
|
||||
case <-c2.send:
|
||||
t.Fatal("did not expect excluded device c2 to receive broadcast")
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-c3.send:
|
||||
if string(msg) != `{"version":1}` {
|
||||
t.Fatalf("unexpected message: %s", string(msg))
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected c3 to receive broadcast")
|
||||
}
|
||||
|
||||
hub.DisconnectUser(userID)
|
||||
select {
|
||||
case <-c4.done:
|
||||
default:
|
||||
t.Fatal("expected c4 to be closed on DisconnectUser")
|
||||
}
|
||||
}
|
||||
48
nginx.conf.example
Normal file
48
nginx.conf.example
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Nginx reverse proxy configuration for SimpSync (sync.muriloscolari.me -> 127.0.0.1:8787)
|
||||
# Place in /etc/nginx/sites-available/simpsync.conf and symlink to /etc/nginx/sites-enabled/
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sync.muriloscolari.me;
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name sync.muriloscolari.me;
|
||||
|
||||
# Adjust paths to your Let's Encrypt / Certbot certificates:
|
||||
ssl_certificate /etc/letsencrypt/live/sync.muriloscolari.me/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sync.muriloscolari.me/privkey.pem;
|
||||
|
||||
client_max_body_size 5M;
|
||||
|
||||
# Real-time WebSocket invalidation endpoint
|
||||
location = /v1/ws {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# REST API endpoints
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue