mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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 <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9ba2bd06c5
commit
060038cb6f
21 files changed
+207
-83
No files matched your search
@@ -5,7 +5,7 @@
|
||||
### Prerequisites
|
||||
- Go 1.21+
|
||||
- PostgreSQL 12+
|
||||
- Redis (optional)
|
||||
- Dragonfly (Redis-compatible cache)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user