From 060038cb6fb925b0aa767ac87c8c7d5b74dc4409 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 21 Sep 2026 17:24:43 +0200 Subject: [PATCH 1/8] feat: swap Redis for Dragonfly, fix remote installs, self-hosted email-only auth - Replace redis:7-alpine with Dragonfly (Redis-protocol compatible; go-redis client unchanged) in root, infra, and backend-dev compose files. Bounded defaults --proactor_threads/--maxmemory so it boots on small VPSs. - Frontend now defaults to same-origin API calls; nginx proxies /api/* to the backend (WebSocket-aware, docker DNS resolver for upstream re-resolution). Removes the baked localhost:8082 that broke installs reached by IP/domain. - /auth/bootstrap returns configured OAuth providers; the UI hides GitHub/Google buttons unless real credentials exist. Self-hosted stays email/password only - OAuth reserved for a future managed offering. - Sidecar ignores PLACEHOLDER_* OAuth credentials and trusts same-host origins dynamically (Origin host == request Host) so remote installs pass origin checks without config; cross-origin requests still rejected. - install.sh writes ENVIRONMENT=production; COOKIE_SECURE=false in production is now a startup warning instead of fatal (plain-HTTP installs). - Bootstrap failure surfaces an error notice instead of silently rendering the sign-in page. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 21 ++++++-- .github/workflows/ci.yml | 2 +- README.md | 6 ++- app/backend/README_BACKEND.md | 2 +- app/backend/auth/src/auth.js | 38 ++++++++++--- app/backend/cmd/server/main.go | 3 ++ app/backend/docker-compose.yml | 12 ++--- app/backend/internal/api/auth.go | 18 +++++++ app/backend/internal/config/config.go | 5 +- app/backend/internal/config/config_test.go | 9 ++-- app/frontend/Dockerfile | 7 +-- app/frontend/nginx.conf | 22 ++++++++ app/frontend/src/features/auth/pages.tsx | 62 +++++++++++++--------- app/frontend/src/lib/api-client.ts | 3 +- app/frontend/src/lib/auth-client.ts | 13 +++-- app/frontend/vite.config.ts | 10 ++++ docker-compose.yml | 22 ++++---- docs/guides/DOCKER_SETUP.md | 8 +-- docs/guides/README_BACKEND.md | 2 +- infra/docker-compose.yml | 24 +++++---- install.sh | 1 + 21 files changed, 207 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index 80f39c7..b8f6a81 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -# Local development env file. Use .env.prod for production. +# Local development env file. install.sh writes ENVIRONMENT=production for +# self-hosted installs - keep development here only for the checkout workflow. # Domain Configuration DOMAIN=localhost @@ -28,9 +29,12 @@ AUTO_MIGRATE=true MIGRATION_LOCK_TIMEOUT=2m SEED_DATA_ON_START=false -# Redis Configuration +# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility) REDIS_PASSWORD=your_secure_redis_password REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0 +# DRAGONFLY_VERSION=latest +# DRAGONFLY_THREADS=1 +# DRAGONFLY_MAXMEMORY=256mb # Application Configuration # In production this must be a strong value with at least 32 characters. @@ -40,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret # Optional rotation list (comma-separated). When set, this takes precedence. # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret CORS_ORIGINS=http://localhost:3000,http://localhost:8080 -VITE_API_URL=http://localhost:8082 -VITE_AUTH_URL=http://localhost:8082/api/auth +# Optional build-time API URL overrides. Leave empty for the default +# same-origin setup - the frontend nginx proxies /api to the backend, which +# works from any host or IP without rebuilding the image. +# VITE_API_URL=https://api.example.com +# VITE_AUTH_URL=https://api.example.com/api/auth AUTH_PORT=3001 # In production this must be true. COOKIE_SECURE=false @@ -54,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 +# Extra trusted origins (comma-separated). Same-origin traffic is trusted +# automatically, so this only needs entries for cross-origin callers. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 # Optional explicit auth DB settings (recommended when password contains URL special chars) DB_HOST=postgres @@ -64,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:8082 -# OAuth (user auth) +# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed +# offering. Self-hosted installs use email/password only; PLACEHOLDER_* +# values are treated as unset and the buttons stay hidden. GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7ba35..f89d31b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,7 @@ jobs: BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN - docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik + docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik - name: Wait for backend health run: | diff --git a/README.md b/README.md index 40cb476..3e013fe 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ click away. - **Managed databases** — one-click provisioning with backup/restore - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys -- **Auth & security** — Better Auth sessions (email + OAuth), audit logs, vuln scans +- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel ## Quick Start @@ -60,6 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | Installs into `./containr`, generates secrets, pulls the published `:latest` images, and starts the stack — UI at `http://localhost:3000`, API at `http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`. +On first visit you create the owner account (email/password); registration +then closes automatically. Override with env vars: @@ -80,7 +82,7 @@ see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`. ``` app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow -app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar +app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar infra/ Self-hosted compose: Traefik + Cloudflare Tunnel docs/ Guides, OpenAPI spec, design documents ``` diff --git a/app/backend/README_BACKEND.md b/app/backend/README_BACKEND.md index e889063..e9e16a4 100644 --- a/app/backend/README_BACKEND.md +++ b/app/backend/README_BACKEND.md @@ -5,7 +5,7 @@ ### Prerequisites - Go 1.21+ - PostgreSQL 12+ -- Redis (optional) +- Dragonfly (Redis-compatible cache) ### Environment Variables diff --git a/app/backend/auth/src/auth.js b/app/backend/auth/src/auth.js index ae12029..467a831 100644 --- a/app/backend/auth/src/auth.js +++ b/app/backend/auth/src/auth.js @@ -46,10 +46,15 @@ const database = new PostgresDialect({ pool, }); -const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; -const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; -const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; -const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; +const realCredential = (value) => { + const trimmed = (value || '').trim(); + return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : ''; +}; + +const githubClientId = realCredential(env.GITHUB_CLIENT_ID); +const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET); +const googleClientId = realCredential(env.GOOGLE_CLIENT_ID); +const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET); const socialProviders = {}; if (githubClientId && githubClientSecret) { @@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) { }; } -const trustedOrigins = [ +const staticTrustedOrigins = [ ...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS), env.FRONTEND_URL, env.BACKEND_URL, @@ -86,12 +91,33 @@ const trustedOrigins = [ .map((origin) => (origin || '').trim()) .filter(Boolean); +// Self-hosted installs are reached over arbitrary hosts/IPs, so the static +// list above cannot cover them. Trust an Origin only when it matches the +// request's own Host header - that is genuine same-origin traffic (browsers +// cannot forge either header cross-origin). Do NOT consult x-forwarded-host +// here: clients can set it freely. +const resolveTrustedOrigins = (request) => { + const origins = new Set(staticTrustedOrigins); + const host = request?.headers?.get('host'); + const origin = request?.headers?.get('origin'); + if (host && origin) { + try { + if (new URL(origin).host === host) { + origins.add(origin); + } + } catch { + // malformed Origin header - leave untrusted + } + } + return [...origins]; +}; + export const auth = betterAuth({ appName: env.BETTER_AUTH_APP_NAME || 'Containr', baseURL: env.BETTER_AUTH_URL || defaultBackendURL, basePath: '/api/auth', secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN', - trustedOrigins, + trustedOrigins: resolveTrustedOrigins, database, user: { modelName: 'auth_users', diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index 97e0cdb..7c10fcb 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -89,6 +89,9 @@ func main() { // Setup Gin router if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) + if !cfg.CookieSecure { + log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.") + } } router := gin.New() diff --git a/app/backend/docker-compose.yml b/app/backend/docker-compose.yml index d7c02ce..a6aa6b1 100644 --- a/app/backend/docker-compose.yml +++ b/app/backend/docker-compose.yml @@ -14,15 +14,15 @@ services: retries: 10 restart: unless-stopped - redis: - image: redis:7-alpine + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly:latest env_file: - ../../.env.prod - command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"] ports: - "6379:6379" volumes: - - backend_redis_data:/data + - backend_dragonfly_data:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] interval: 10s @@ -41,7 +41,7 @@ services: depends_on: postgres: condition: service_healthy - redis: + dragonfly: condition: service_healthy restart: unless-stopped healthcheck: @@ -52,4 +52,4 @@ services: volumes: backend_postgres_data: - backend_redis_data: + backend_dragonfly_data: diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 8ee42d2..06d4be6 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -120,13 +120,31 @@ func handleAuthBootstrap(c *gin.Context) { mode = "register" } + providers := []string{} + for _, provider := range []string{"GITHUB", "GOOGLE"} { + if oauthProviderConfigured(provider) { + providers = append(providers, strings.ToLower(provider)) + } + } + c.JSON(http.StatusOK, gin.H{ "has_users": count > 0, "user_count": count, "mode": mode, + "providers": providers, }) } +// oauthProviderConfigured reports whether an OAuth provider has real +// credentials. Placeholder values from .env.example do not count, so +// self-hosted installs default to email/password only. +func oauthProviderConfigured(provider string) bool { + id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID")) + secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET")) + return id != "" && secret != "" && + !strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_") +} + type betterAuthLoginUser struct { ID string `json:"id"` Email string `json:"email"` diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go index 2b137df..ffe00d3 100644 --- a/app/backend/internal/config/config.go +++ b/app/backend/internal/config/config.go @@ -215,9 +215,8 @@ func (c *Config) Validate() error { return fmt.Errorf("SEED_DATA_ON_START must be false in production") } - if !c.CookieSecure { - return fmt.Errorf("COOKIE_SECURE must be true in production") - } + // COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is + // reported as a startup warning in main.go rather than a fatal error. if c.BetterAuthEnabled { if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 || diff --git a/app/backend/internal/config/config_test.go b/app/backend/internal/config/config_test.go index e2ca0e4..06b5f12 100644 --- a/app/backend/internal/config/config_test.go +++ b/app/backend/internal/config/config_test.go @@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T) } } -func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { +func TestValidateAllowsInsecureCookieInProduction(t *testing.T) { t.Setenv("ENVIRONMENT", "production") cfg := Config{ JWTSecret: "this-is-a-very-strong-production-secret-123", @@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { CORSCredentials: true, } - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") { - t.Fatalf("expected COOKIE_SECURE validation error, got %v", err) + // Plain-HTTP self-hosted installs are a supported production deployment; + // insecure cookies are allowed and surfaced as a startup warning. + if err := cfg.Validate(); err != nil { + t.Fatalf("expected production config with insecure cookies to validate, got %v", err) } } diff --git a/app/frontend/Dockerfile b/app/frontend/Dockerfile index 246e5be..d02973c 100644 --- a/app/frontend/Dockerfile +++ b/app/frontend/Dockerfile @@ -13,9 +13,10 @@ RUN npm ci # Copy source code COPY . . -# Build-time API URL injection for Vite -ARG VITE_API_URL=http://localhost:8082 -ARG VITE_AUTH_URL=http://localhost:8082/api/auth +# Build-time API URL injection for Vite. Empty = same-origin via the nginx +# /api/ proxy, which keeps the published image usable from any host. +ARG VITE_API_URL= +ARG VITE_AUTH_URL= ENV VITE_API_URL=${VITE_API_URL} ENV VITE_AUTH_URL=${VITE_AUTH_URL} diff --git a/app/frontend/nginx.conf b/app/frontend/nginx.conf index d66de26..894768e 100644 --- a/app/frontend/nginx.conf +++ b/app/frontend/nginx.conf @@ -21,6 +21,12 @@ http { keepalive_timeout 65; types_hash_max_size 2048; + # WebSocket upgrade support + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + # Gzip compression gzip on; gzip_vary on; @@ -51,6 +57,22 @@ http { add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + # Same-origin API proxy - keeps the published image host-agnostic + location /api/ { + resolver 127.0.0.11 valid=10s; + set $backend_upstream http://backend:8080; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $http_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_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + # Handle React Router location / { try_files $uri $uri/ /index.html; diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index 3f71c17..1476200 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -114,6 +114,7 @@ export function SignInPage() { queryKey: ['auth-bootstrap'], queryFn: getAuthBootstrap, }); + const providers = bootstrapQuery.data?.providers ?? []; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -168,10 +169,14 @@ export function SignInPage() { return ( - + 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'} + > {error ? : null} {info ? : null} {bootstrapQuery.isLoading ? : null} + {bootstrapQuery.isError ? : null}