package config import ( "errors" "fmt" "net/url" "os" "strings" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env/v2" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" ) const envPrefix = "CDROP_" type Config struct { AuthMode string `koanf:"auth_mode"` DevToken string `koanf:"dev_token"` DBPath string `koanf:"db_path"` Listen string `koanf:"listen"` // DeviceTTLHours is the maximum age of a row in `devices` before the // background sweeper deletes it. Brief §2 sets the refresh_token sliding // window at 196h (≈8 days) — devices must not outlive that. Default 196h. DeviceTTLHours int `koanf:"device_ttl_hours"` // OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included). OIDCAuthorizeURL string `koanf:"oidc_authorize_url"` OIDCTokenURL string `koanf:"oidc_token_url"` OIDCJWKSURL string `koanf:"oidc_jwks_url"` OIDCIssuer string `koanf:"oidc_issuer"` // OIDCAudience accepts a comma-separated list. A token passes when its `aud` // matches any one entry — this lets one backend serve several OAuth clients // that carry different audiences (e.g. the web app and the desktop client). // Empty disables audience checking; a single value behaves as before. OIDCAudience string `koanf:"oidc_audience"` OIDCClientID string `koanf:"oidc_client_id"` OIDCClientSecret string `koanf:"oidc_client_secret"` OIDCRedirectURI string `koanf:"oidc_redirect_uri"` OIDCScopes string `koanf:"oidc_scopes"` HS256Secret string `koanf:"hs256_secret"` // SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at // rest in web_sessions (the "passwordless re-login" feature). Any-length // high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only // here (container env), never in the DB file, so an exfiltrated SQLite file // can't be decrypted. Required in prod — refuse to boot without it. SessionSecret string `koanf:"session_secret"` // Shortcut tokens:iOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期 // (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。 // 需配 HS256Secret 才启用,否则签发端点返回 503。 ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"` ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"` // Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/). // When both fields are set, /api/calls/credentials returns short-lived // TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the // endpoint serves a STUN-only fallback so dev / no-CF deploys still work. CFTurnKeyID string `koanf:"cf_turn_key_id"` CFTurnAPIToken string `koanf:"cf_turn_api_token"` // Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是 // 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的 // 后续 PUT 直接刷新 generation 让旧广播取消。 ClipboardMaxBytes int `koanf:"clipboard_max_bytes"` ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"` // ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通 // 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面: // 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。 ClipboardTTLSec int `koanf:"clipboard_ttl_sec"` // Web Push (RFC 8291/8292):浏览器 / PWA 在页面关闭时仍能收到系统通知。 // 公私钥是自签的 VAPID 密钥对(与 Apple/Google 账号无关),用 `just vapid-keygen` // 生成一次后固定——更换会使所有既有订阅失效。两者皆设才启用推送,缺一即整体 // 关闭(订阅端点返回 503,事件回退为「仅在线设备收 SSE」)。Subject 是 VAPID // 要求的联系标识(mailto: 或站点 URL);留空时回退为部署站点 URL。 VAPIDPublicKey string `koanf:"vapid_public_key"` VAPIDPrivateKey string `koanf:"vapid_private_key"` VAPIDSubject string `koanf:"vapid_subject"` // 扫码登录(AUTH.md §4)+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」 // 的会话(扫码批准的设备、受限访客借用)自签短效 access token(HS256,密钥派生自 // SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。 QRLoginEnabled bool `koanf:"qr_login_enabled"` QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"` QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"` QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"` SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"` // StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后 // qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验 // auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。 StepUpEnabled bool `koanf:"step_up_enabled"` StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"` // AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移 // 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。 AccountMatchClaim string `koanf:"account_match_claim"` } // QRLoginOn reports whether scan-login is enabled and the self-signed session // machinery it relies on is keyable (SessionSecret present). Without the secret // cdrop can't sign session tokens, so QR stays off regardless of the flag. func (c *Config) QRLoginOn() bool { return c.QRLoginEnabled && c.SessionSecret != "" } // Load reads config from optional ./config.yaml then overrides with CDROP_* env. // Validates dev/prod invariants; returns error if invariants are violated. func Load() (*Config, error) { k := koanf.New(".") k.Set("auth_mode", "prod") k.Set("db_path", "./cdrop.db") k.Set("listen", ":8080") k.Set("device_ttl_hours", 196) k.Set("oidc_scopes", "openid profile email") k.Set("clipboard_max_bytes", 65536) k.Set("clipboard_debounce_sec", 3) k.Set("shortcut_token_ttl_days", 365) k.Set("shortcut_max_per_user", 10) k.Set("qr_login_enabled", true) k.Set("qr_request_ttl_seconds", 120) k.Set("qr_guest_ttl_seconds", 3600) k.Set("qr_persist_ttl_hours", 168) k.Set("session_token_ttl_seconds", 900) k.Set("account_match_claim", "email") k.Set("step_up_max_age_seconds", 300) if _, err := os.Stat("config.yaml"); err == nil { if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil { return nil, fmt.Errorf("load config.yaml: %w", err) } } envProvider := env.Provider(".", env.Opt{ Prefix: envPrefix, TransformFunc: func(key, value string) (string, any) { return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value }, }) if err := k.Load(envProvider, nil); err != nil { return nil, fmt.Errorf("load env: %w", err) } cfg := &Config{} if err := k.Unmarshal("", cfg); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } func (c *Config) validate() error { switch c.AuthMode { case "dev": if c.DevToken == "" { return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start") } case "prod": // Audience MUST be set in prod (R6). With it empty, RS256 audience // checking is skipped entirely, so ANY token the IdP mints for ANY // application sharing this JWKS would validate here — a user of a // sibling Casdoor app could impersonate a cdrop user. The web + desktop // client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot // without it rather than run wide open. if c.OIDCAudience == "" { return errors.New( "CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " + "(comma-separated OAuth client_ids); refusing to start") } // SessionSecret keys at-rest encryption of browser refresh_tokens. Empty // would either disable passwordless re-login or, worse, tempt a plaintext // fallback; require it so the encryption path is always live in prod. if c.SessionSecret == "" { return errors.New( "CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " + "(keys web-session refresh_token encryption); refusing to start") } default: return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode) } // Web Push is optional, but a half-configured pair is always an operator // mistake: with only one key set, every push send would fail at runtime // while the feature looks "on". Fail fast instead of silently degrading. if (c.VAPIDPublicKey == "") != (c.VAPIDPrivateKey == "") { return errors.New( "CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " + "(run `just vapid-keygen`); set neither to disable Web Push") } return nil } // PushEnabled reports whether Web Push is configured (both VAPID keys present). func (c *Config) PushEnabled() bool { return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != "" } // VAPIDSubjectOrDefault returns the VAPID contact identity. Push services want a // way to reach the operator; the configured value wins, else the deployment's // own origin (derived from the OIDC redirect URI), else the product URL. func (c *Config) VAPIDSubjectOrDefault() string { if c.VAPIDSubject != "" { return c.VAPIDSubject } if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" { return u.Scheme + "://" + u.Host } return "https://drop.commilitia.net" }