// Package config loads VibeSonic's process configuration from // VIBESONIC_* environment variables. There is no config file: everything // needed to run the server is available via the environment so the same // binary works under systemd or Docker without a wrapper script. package config import ( "fmt" "net/netip" "os" "path/filepath" "strconv" "strings" "time" ) type Config struct { ListenAddr string DataDir string DBPath string MusicDirs []string FFmpegPath string FFprobePath string MPVPath string ScanInterval time.Duration SessionSecret string CookieSecure bool // EnableExternalMetadata opts into looking up MusicBrainz/Cover Art // Archive data for songs/albums that already carry a locally-tagged // MusicBrainz ID. Off by default: VibeSonic must work completely // without Internet access, and this is the only feature that talks // to the outside world. EnableExternalMetadata bool // EnableArtistPhotos opts into looking up an artist photo (fanart.tv, // falling back to Wikidata/Wikimedia Commons) for artists that carry // a locally-tagged MusicBrainz ID. Off by default for the same // offline-first reason as EnableExternalMetadata. EnableArtistPhotos bool // FanartTVAPIKey is an optional personal API key for fanart.tv, // tried before the free Wikidata fallback when EnableArtistPhotos is // on. Empty means "skip fanart.tv". FanartTVAPIKey string // PodcastsAllowPrivateNetworks disables SSRF protection on podcast // feed/enclosure fetching, allowing feeds hosted on loopback/LAN/ // link-local addresses. Off by default: podcast URLs are supplied // by an admin (or, in principle, a compromised feed's redirects) // and must not be usable to probe the server's own internal // network. Only enable this for deployments that intentionally // host podcast feeds on a private network. PodcastsAllowPrivateNetworks bool ExtAuth ExtAuthConfig } // ExtAuthConfig configures optional Navidrome-style externalized // authentication: a trusted reverse proxy authenticates the browser user // and passes their identity in a header. VibeSonic never talks to an // identity provider itself. type ExtAuthConfig struct { Enabled bool TrustedSources []netip.Prefix UserHeader string LogoutURL string AutoCreateUsers bool } // Load reads configuration from the environment, applying defaults for // anything unset. It returns an error only for values that are set but // invalid (e.g. an unparsable duration or CIDR). func Load() (Config, error) { cfg := Config{ ListenAddr: envOr("VIBESONIC_LISTEN_ADDR", ":8080"), DataDir: envOr("VIBESONIC_DATA_DIR", "/var/lib/vibesonic"), FFmpegPath: envOr("VIBESONIC_FFMPEG_PATH", "ffmpeg"), FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"), MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"), SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"), // The built-in server speaks plain HTTP, so Secure cookies cannot be // the default: browsers would discard them during first-run setup. // Deployments terminating TLS at a reverse proxy should opt in. CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", false), EnableExternalMetadata: envBool("VIBESONIC_ENABLE_EXTERNAL_METADATA", false), EnableArtistPhotos: envBool("VIBESONIC_ENABLE_ARTIST_PHOTOS", false), FanartTVAPIKey: os.Getenv("VIBESONIC_FANART_TV_API_KEY"), PodcastsAllowPrivateNetworks: envBool("VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS", false), } cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db")) if dirs := os.Getenv("VIBESONIC_MUSIC_DIRS"); dirs != "" { for _, d := range strings.Split(dirs, ":") { d = strings.TrimSpace(d) if d != "" { cfg.MusicDirs = append(cfg.MusicDirs, d) } } } interval := envOr("VIBESONIC_SCAN_INTERVAL", "1h") d, err := time.ParseDuration(interval) if err != nil { return cfg, fmt.Errorf("VIBESONIC_SCAN_INTERVAL: %w", err) } cfg.ScanInterval = d extauth, err := loadExtAuth() if err != nil { return cfg, err } cfg.ExtAuth = extauth return cfg, nil } func loadExtAuth() (ExtAuthConfig, error) { e := ExtAuthConfig{ Enabled: envBool("VIBESONIC_EXTAUTH_ENABLED", false), UserHeader: envOr("VIBESONIC_EXTAUTH_USER_HEADER", "Remote-User"), LogoutURL: os.Getenv("VIBESONIC_EXTAUTH_LOGOUT_URL"), AutoCreateUsers: envBool("VIBESONIC_EXTAUTH_AUTO_CREATE_USERS", true), } raw := os.Getenv("VIBESONIC_EXTAUTH_TRUSTED_SOURCES") if raw == "" { // No default trusted network: external auth must be explicitly // scoped to a real network before it can trust anything. return e, nil } for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } prefix, err := parseCIDROrIP(part) if err != nil { return e, fmt.Errorf("VIBESONIC_EXTAUTH_TRUSTED_SOURCES: %w", err) } e.TrustedSources = append(e.TrustedSources, prefix) } return e, nil } func parseCIDROrIP(s string) (netip.Prefix, error) { if p, err := netip.ParsePrefix(s); err == nil { return p, nil } addr, err := netip.ParseAddr(s) if err != nil { return netip.Prefix{}, fmt.Errorf("invalid CIDR or IP %q: %w", s, err) } return netip.PrefixFrom(addr, addr.BitLen()), nil } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func envBool(key string, def bool) bool { v := os.Getenv(key) if v == "" { return def } b, err := strconv.ParseBool(v) if err != nil { return def } return b }