Admin-stored API key with env fallback; scan drive/mail/IMAP uploads. Fail-open if VT down, 422 on malware; migration for virus_scan_status.
386 lines
12 KiB
Go
386 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/secrets"
|
|
)
|
|
|
|
type Config struct {
|
|
Port int
|
|
Domain string
|
|
AppEnv string
|
|
|
|
// Browser clients (web app on another origin than the API gateway).
|
|
CORSAllowedOrigins []string
|
|
|
|
// PostgreSQL
|
|
DatabaseURL string
|
|
|
|
// KeyDB
|
|
KeyDBAddr string
|
|
KeyDBPassword string
|
|
KeyDBDB int
|
|
|
|
// Object Storage (S3-compatible)
|
|
RustFSEndpoint string
|
|
RustFSAccessKey string
|
|
RustFSSecretKey string
|
|
RustFSUseSSL bool
|
|
RustFSRegion string
|
|
|
|
// OIDC
|
|
OIDCIssuer string
|
|
OIDCClientID string
|
|
OIDCClientSecret string
|
|
|
|
// Authentik API (suite app auto-provisioning)
|
|
AuthentikAPIURL string
|
|
AuthentikAPIToken string
|
|
AuthentikPublicHTTPS bool
|
|
|
|
// Suite OIDC clients (Authentik applications)
|
|
NCOIDCClientID string
|
|
NCOIDCClientSecret string
|
|
OnlyOfficeOIDCClientID string
|
|
OnlyOfficeOIDCClientSecret string
|
|
ImmichOIDCClientID string
|
|
ImmichOIDCClientSecret string
|
|
DriveOIDCClientID string
|
|
DriveOIDCClientSecret string
|
|
|
|
// Nextcloud
|
|
NextcloudEnabled bool
|
|
NextcloudURL string
|
|
NextcloudPublicURL string
|
|
NCAdminUser string
|
|
NCAdminPass string
|
|
|
|
// OnlyOffice
|
|
OnlyOfficeEnabled bool
|
|
OnlyOfficeURL string
|
|
OnlyOfficePublicURL string
|
|
OnlyOfficeAPIInternalURL string
|
|
OnlyOfficeJWTSecret string
|
|
UltidPublicURL string
|
|
DrivePublicURL string
|
|
|
|
// Jitsi
|
|
JitsiEnabled bool
|
|
JitsiDomain string
|
|
JitsiAppID string
|
|
JitsiAppSecret string
|
|
JitsiPublicURL string
|
|
|
|
// Immich
|
|
ImmichEnabled bool
|
|
ImmichAPIURL string
|
|
|
|
// Mail
|
|
MailAttachmentsBucket string
|
|
MailSyncInterval time.Duration
|
|
MailOutboxInterval time.Duration
|
|
MailOutboxMaxRetries int
|
|
MailSendRatePerMinute int
|
|
MailSendBurst int
|
|
MailSMTPCircuitFailures int
|
|
MailSMTPCircuitCooldown time.Duration
|
|
MailCredentialKeys string
|
|
MailActiveCredentialKeyID string
|
|
MailWebhookSharedSecret string
|
|
|
|
MailGoogleOAuthClientID string
|
|
MailGoogleOAuthClientSecret string
|
|
MailMicrosoftOAuthClientID string
|
|
MailMicrosoftOAuthSecret string
|
|
MailMicrosoftOAuthTenant string
|
|
MailOAuthRedirectURL string
|
|
MailAppURL string
|
|
|
|
// Secret rotation policy
|
|
SecretRotationMaxAge time.Duration
|
|
OIDCSecretRotatedAt time.Time
|
|
SMTPCredentialKeyRotatedAt time.Time
|
|
WebhookSharedSecretRotatedAt time.Time
|
|
|
|
// Search
|
|
SearchEngine string
|
|
MeilisearchURL string
|
|
MeilisearchKey string
|
|
MeilisearchIndex string
|
|
TypesenseURL string
|
|
TypesenseKey string
|
|
TypesenseCollection string
|
|
|
|
// VirusTotal (optional env fallback for org file_policies.virustotal_api_key)
|
|
VirusTotalAPIKey string
|
|
|
|
// Observability
|
|
HealthNextcloudURL string
|
|
HealthImmichURL string
|
|
HealthJitsiURL string
|
|
HealthHTTPTimeout time.Duration
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
port := 8080
|
|
if v := os.Getenv("ULTID_PORT"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil {
|
|
port = p
|
|
}
|
|
}
|
|
|
|
return &Config{
|
|
Port: port,
|
|
Domain: envOrDefault("DOMAIN", "localhost"),
|
|
AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))),
|
|
CORSAllowedOrigins: parseCSVEnv("ULTID_CORS_ALLOWED_ORIGINS"),
|
|
|
|
DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"),
|
|
KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"),
|
|
KeyDBPassword: secrets.Env("ULTID_KEYDB_PASSWORD"),
|
|
KeyDBDB: envInt("ULTID_KEYDB_DB", 0),
|
|
|
|
RustFSEndpoint: envOrDefault("ULTID_RUSTFS_ENDPOINT", "localhost:9000"),
|
|
RustFSAccessKey: secrets.Env("ULTID_RUSTFS_ACCESS_KEY"),
|
|
RustFSSecretKey: secrets.Env("ULTID_RUSTFS_SECRET_KEY"),
|
|
RustFSUseSSL: envBool("ULTID_RUSTFS_USE_SSL", false),
|
|
RustFSRegion: envOrDefault("ULTID_RUSTFS_REGION", "us-east-1"),
|
|
|
|
OIDCIssuer: os.Getenv("ULTID_OIDC_ISSUER"),
|
|
OIDCClientID: os.Getenv("ULTID_OIDC_CLIENT_ID"),
|
|
OIDCClientSecret: secrets.Env("ULTID_OIDC_CLIENT_SECRET"),
|
|
|
|
AuthentikAPIURL: envOrDefault("AUTHENTIK_API_URL", "http://authentik-server:9000"),
|
|
AuthentikAPIToken: secrets.Env("AUTHENTIK_API_TOKEN"),
|
|
AuthentikPublicHTTPS: authentikPublicHTTPS(),
|
|
|
|
NCOIDCClientID: envOrDefault("NC_OIDC_CLIENT_ID", "ulti-nextcloud"),
|
|
NCOIDCClientSecret: secrets.Env("NC_OIDC_CLIENT_SECRET"),
|
|
OnlyOfficeOIDCClientID: envOrDefault("ONLYOFFICE_OIDC_CLIENT_ID", "ulti-onlyoffice"),
|
|
OnlyOfficeOIDCClientSecret: secrets.Env("ONLYOFFICE_OIDC_CLIENT_SECRET"),
|
|
ImmichOIDCClientID: envOrDefault("IMMICH_OIDC_CLIENT_ID", "ulti-immich"),
|
|
ImmichOIDCClientSecret: secrets.Env("IMMICH_OIDC_CLIENT_SECRET"),
|
|
DriveOIDCClientID: os.Getenv("DRIVE_OIDC_CLIENT_ID"),
|
|
DriveOIDCClientSecret: secrets.Env("DRIVE_OIDC_CLIENT_SECRET"),
|
|
|
|
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
|
|
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
|
|
NextcloudPublicURL: nextcloudPublicURL(),
|
|
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
|
|
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
|
|
|
|
OnlyOfficeEnabled: envBool("ONLYOFFICE_ENABLED", false),
|
|
OnlyOfficeURL: envOrDefault("ONLYOFFICE_URL", "http://onlyoffice"),
|
|
OnlyOfficePublicURL: envOrDefault("ONLYOFFICE_PUBLIC_URL", "http://localhost/office"),
|
|
OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"),
|
|
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
|
|
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
|
DrivePublicURL: drivePublicURL(),
|
|
|
|
JitsiEnabled: envBool("JITSI_ENABLED", true),
|
|
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
|
|
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
|
|
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
|
|
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
|
|
|
|
ImmichEnabled: envBool("IMMICH_ENABLED", true),
|
|
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
|
|
|
|
MailAttachmentsBucket: envOrDefault("MAIL_ATTACHMENTS_BUCKET", "mail-attachments"),
|
|
MailSyncInterval: envDuration("MAIL_SYNC_INTERVAL", 2*time.Minute),
|
|
MailOutboxInterval: envDuration("MAIL_OUTBOX_INTERVAL", 10*time.Second),
|
|
MailOutboxMaxRetries: envInt("MAIL_OUTBOX_MAX_RETRIES", 8),
|
|
MailSendRatePerMinute: envInt("MAIL_SEND_RATE_PER_MINUTE", 30),
|
|
MailSendBurst: envInt("MAIL_SEND_BURST", 10),
|
|
MailSMTPCircuitFailures: envInt("MAIL_SMTP_CIRCUIT_FAILURES", 5),
|
|
MailSMTPCircuitCooldown: envDuration("MAIL_SMTP_CIRCUIT_COOLDOWN", 5*time.Minute),
|
|
MailCredentialKeys: secrets.Env("MAIL_CREDENTIAL_KEYS"),
|
|
MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""),
|
|
MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"),
|
|
|
|
MailGoogleOAuthClientID: os.Getenv("MAIL_GOOGLE_OAUTH_CLIENT_ID"),
|
|
MailGoogleOAuthClientSecret: secrets.Env("MAIL_GOOGLE_OAUTH_CLIENT_SECRET"),
|
|
MailMicrosoftOAuthClientID: os.Getenv("MAIL_MICROSOFT_OAUTH_CLIENT_ID"),
|
|
MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"),
|
|
MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"),
|
|
MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"),
|
|
MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")),
|
|
|
|
SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
|
|
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
|
|
SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"),
|
|
WebhookSharedSecretRotatedAt: envTime("MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT"),
|
|
|
|
SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"),
|
|
MeilisearchURL: os.Getenv("MEILISEARCH_URL"),
|
|
MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"),
|
|
MeilisearchIndex: envOrDefault("MEILISEARCH_INDEX", "ulti"),
|
|
TypesenseURL: os.Getenv("TYPESENSE_URL"),
|
|
TypesenseKey: secrets.Env("TYPESENSE_API_KEY"),
|
|
TypesenseCollection: envOrDefault("TYPESENSE_COLLECTION", "ulti"),
|
|
|
|
VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"),
|
|
|
|
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
|
|
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
|
|
HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))),
|
|
HealthHTTPTimeout: envDuration("HEALTH_HTTP_TIMEOUT", 3*time.Second),
|
|
}, nil
|
|
}
|
|
|
|
func (c *Config) IsProduction() bool {
|
|
return c != nil && c.AppEnv == "production"
|
|
}
|
|
|
|
func (c *Config) ValidateSecretRotation() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
maxAge := c.SecretRotationMaxAge
|
|
if maxAge <= 0 {
|
|
maxAge = 90 * 24 * time.Hour
|
|
}
|
|
|
|
now := time.Now()
|
|
checks := []struct {
|
|
name string
|
|
rotatedAt time.Time
|
|
}{
|
|
{name: "ULTID_OIDC_CLIENT_SECRET", rotatedAt: c.OIDCSecretRotatedAt},
|
|
{name: "MAIL_CREDENTIAL_KEY", rotatedAt: c.SMTPCredentialKeyRotatedAt},
|
|
{name: "MAIL_WEBHOOK_SHARED_SECRET", rotatedAt: c.WebhookSharedSecretRotatedAt},
|
|
}
|
|
|
|
var stale []string
|
|
for _, check := range checks {
|
|
if check.rotatedAt.IsZero() || now.Sub(check.rotatedAt) > maxAge {
|
|
stale = append(stale, check.name)
|
|
}
|
|
}
|
|
|
|
if len(stale) == 0 {
|
|
return nil
|
|
}
|
|
return errors.New("stale or missing rotation metadata for: " + strings.Join(stale, ", "))
|
|
}
|
|
|
|
func envOrDefault(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func parseCSVEnv(key string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func envOrDefaultSecret(key, fallback string) string {
|
|
if v := secrets.Env(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return b
|
|
}
|
|
|
|
func envInt(key string, fallback int) int {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func envDuration(key string, fallback time.Duration) time.Duration {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
func envTime(key string) time.Time {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return time.Time{}
|
|
}
|
|
ts, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return ts
|
|
}
|
|
|
|
func joinURL(base, path string) string {
|
|
return strings.TrimRight(base, "/") + path
|
|
}
|
|
|
|
// nextcloudPublicURL is the external WebDAV base (OVERWRITECLIURL), used for MOVE/COPY Destination headers.
|
|
func nextcloudPublicURL() string {
|
|
if v := strings.TrimSpace(os.Getenv("NC_PUBLIC_URL")); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
domain := envOrDefault("DOMAIN", "localhost")
|
|
proto := envOrDefault("NC_OVERWRITE_PROTOCOL", "http")
|
|
return proto + "://" + domain + "/cloud"
|
|
}
|
|
|
|
// drivePublicURL is the browser-facing UltiDrive base used in public share links.
|
|
func drivePublicURL() string {
|
|
if v := strings.TrimSpace(os.Getenv("DRIVE_PUBLIC_URL")); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return strings.TrimRight(envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), "/") + "/drive"
|
|
}
|
|
|
|
func defaultHealthJitsiURL(publicURL string) string {
|
|
trimmed := strings.TrimRight(publicURL, "/")
|
|
trimmed = strings.TrimSuffix(trimmed, "/meet")
|
|
return trimmed + "/about/health"
|
|
}
|
|
|
|
func authentikPublicHTTPS() bool {
|
|
if envBool("AUTHENTIK_PUBLIC_HTTPS", false) {
|
|
return true
|
|
}
|
|
host := strings.TrimSpace(os.Getenv("AUTHENTIK_HOST"))
|
|
return strings.HasPrefix(strings.ToLower(host), "https://")
|
|
}
|