Files
verdande/internal/config/config.go
T
Kristian WindandClaude Opus 5 a38a79e9b6 feat(secrets): tokens og kodeord forlader ikke længere huset med en kopi
Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren,
der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem
fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token.

Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af
dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed,
instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i
samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve
databasen, så filen følger ikke med.

Kun de navngivne felter forsegles — refresh_token, access_token, password,
client_secret. Resten af klatten er det, man læser, når en postkasse driller:
hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke
købe noget og ville koste muligheden for at kigge.

Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses
som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen
konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der
skal have ret om hver eneste række på én gang. Der er en prøve for netop det.

Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder
noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen
er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:18:48 +02:00

197 lines
6.3 KiB
Go

// Package config loads verdande's settings from the environment.
//
// Everything is env-driven because verdande ships as a Rune: the panel hands the
// container an environment and a /data volume, and there is nowhere to put a config
// file that the operator would ever see.
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
// BaseURL is the address verdande answers on from outside. Invite links,
// password resets, ICS feeds and OAuth redirects are all built from it, so it
// has to be the public address, not the listen address.
BaseURL string
Addr string
DataDir string
SessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
SMTP SMTP
// Gmail is the OAuth client the operator registered in Google Cloud. It
// belongs to the instance: one registration, any number of users.
GmailClientID string
GmailClientSecret string
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
// in production, where it means Google.
GmailTokenURL string
GmailAPIURL string
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
Dev bool
}
type SMTP struct {
Host string
Port int
Username string
Password string
From string
// StartTLS is the common case for a self-hosted Mailcow on 587. Port 465 is
// implicit TLS instead and sets this false.
StartTLS bool
Insecure bool
}
// Configured reports whether outbound mail can be sent. Without a host verdande
// still runs — it just surfaces invite links in the UI instead of mailing them.
func (s SMTP) Configured() bool { return s.Host != "" }
func Load() (*Config, error) {
c := &Config{
BaseURL: env("VERDANDE_BASE_URL", "http://localhost:8080"),
Addr: env("VERDANDE_ADDR", ":8080"),
DataDir: env("VERDANDE_DATA_DIR", "/data"),
TrashRetention: 30 * 24 * time.Hour,
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
var err error
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
return nil, err
}
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
return nil, err
}
if c.ResetTTL, err = envDuration("VERDANDE_RESET_TTL", time.Hour); err != nil {
return nil, err
}
port, err := envInt("VERDANDE_SMTP_PORT", 587)
if err != nil {
return nil, err
}
c.SMTP = SMTP{
Host: env("VERDANDE_SMTP_HOST", ""),
Port: port,
Username: env("VERDANDE_SMTP_USER", ""),
Password: env("VERDANDE_SMTP_PASS", ""),
From: env("VERDANDE_SMTP_FROM", "verdande@localhost"),
// Port 465 is implicit TLS from the first byte; everything else negotiates
// with STARTTLS. An operator can still override the guess.
StartTLS: envBool("VERDANDE_SMTP_STARTTLS", port != 465),
Insecure: envBool("VERDANDE_SMTP_INSECURE", false),
}
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
return c, nil
}
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
// GmailRedirectURL has to match what was registered in Google Cloud exactly,
// including the scheme — which is why it is derived from BaseURL rather than
// configured separately and left to drift out of step with it.
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) (int, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a number", key, v)
}
return n, nil
}
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func envDuration(key string, def time.Duration) (time.Duration, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a duration (try 720h)", key, v)
}
return d, nil
}