Vellum/internal/core/config.go
mithral 795b77b709 Advertise Vellum over mDNS so clients find it without a typed URL
internal/discovery registers three service types on the listen port:
_vellum._tcp for native clients, and _jellyfin._tcp / _subsonic._tcp
because those ecosystems already have clients that browse the LAN. That
last part is the point: Infuse, Jellyfin Media Player and Symfonium can
find Vellum with no client-side work, because they are already looking for
those names.

TXT records carry api, ver, proto, types, tls and (when set) pub, so a
client that resolves one can tell what it found before connecting.

Definitions is a pure function of config and Start only registers what it
returns, so what is advertised is testable without a socket.

Two behaviours worth knowing:
- VELLUM_DISCOVERY=off stops advertising. It accepts off/no/false/0;
  strconv.ParseBool would have silently ignored "off".
- A loopback-only listen address advertises nothing. That is the normal
  shape behind a reverse proxy, and advertising it would put a port in the
  LAN browse list that refuses every client that dials it. Verified
  red-green: defeating the guard fails TestDefinitionsSkipsLoopbackListen.

zeroconf is the only new dependency. It logs to the stdlib logger, so a
host with no multicast-capable interface prints to stderr outside slog.

Not verified: real mDNS on a real LAN. There is no multicast here, so the
wire behaviour of zeroconf is untested - only the definitions it is handed.
2026-10-12 01:04:33 -04:00

228 lines
6.9 KiB
Go

package core
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Config is the complete runtime configuration, loaded from the environment.
// Every field has a working default so the binary runs with no configuration
// at all — important for "podman run" first-run experience.
type Config struct {
// Listen is the address the HTTP server binds, e.g. ":7788".
Listen string
// DataDir holds the SQLite database and persistent state.
DataDir string
// ConfigDir holds derived, regenerable artifacts (thumbnails, page cache).
ConfigDir string
// MediaDirs are read-only library roots. Vellum never writes here.
MediaDirs []string
DBPath string
LogLevel string
LogFormat string
// AdminUser / AdminPassword create the first administrator, but only when
// the users table is empty. Ignored on any established database.
AdminUser string
AdminPassword string
// ComicVineAPIKey enables comic metadata. ComicVine has no keyless tier, so
// the provider is simply absent when this is empty.
ComicVineAPIKey string
// IGDBClientID / IGDBClientSecret enable game metadata via Twitch's
// client-credentials flow. Both must be set for the provider to appear.
IGDBClientID string
IGDBClientSecret string
CookieName string
CookieSecure bool
// PublicURL is the external origin Vellum is reached at, e.g.
// "https://media.example.com". Empty means "not published", which is the
// case for plain-HTTP LAN use. It is the signal that the deployment sits
// behind a TLS-terminating proxy, so it also drives the CookieSecure default.
PublicURL string
// TrustedProxies are the networks allowed to speak for a client in
// X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used.
TrustedProxies []*net.IPNet
// Discovery advertises the server on the LAN over mDNS so clients find it
// without a typed URL.
Discovery bool
SessionTTL time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownGrace time.Duration
}
// LoadConfig reads configuration from the environment and fills in defaults.
// It does not create directories; call Validate for that.
func LoadConfig() (Config, error) {
publicURL := env("VELLUM_PUBLIC_URL", "")
c := Config{
Listen: env("VELLUM_LISTEN", ":7788"),
DataDir: env("VELLUM_DATA_DIR", "./data"),
ConfigDir: env("VELLUM_CONFIG_DIR", "./config"),
MediaDirs: envList("VELLUM_MEDIA_DIRS", nil),
LogLevel: env("VELLUM_LOG_LEVEL", "info"),
LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json
AdminUser: env("VELLUM_ADMIN_USER", ""),
AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""),
ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""),
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
// Secure defaults to true for an https:// PublicURL — a published origin
// is TLS-terminated, so plain-HTTP dev still gets false with it unset.
// VELLUM_COOKIE_SECURE overrides both ways; boot logs a warning when off.
CookieSecure: envBool("VELLUM_COOKIE_SECURE", strings.HasPrefix(publicURL, "https://")),
PublicURL: publicURL,
SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour),
ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
Discovery: !envOff("VELLUM_DISCOVERY"),
}
proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES")
if err != nil {
return Config{}, err
}
c.TrustedProxies = proxies
abs, err := filepath.Abs(c.DataDir)
if err != nil {
return Config{}, fmt.Errorf("resolve data dir: %w", err)
}
c.DataDir = abs
c.DBPath = filepath.Join(abs, "vellum.db")
if abs, err := filepath.Abs(c.ConfigDir); err == nil {
c.ConfigDir = abs
}
return c, nil
}
// EnsureDirs creates the data and config directories if missing.
//
// Separate from Validate because the CLI needs the directories but has no
// interest in the media roots: `vellum user add` must work on a fresh install,
// and must not fail because a library happens to be unmounted.
func (c Config) EnsureDirs() error {
for _, dir := range []string{c.DataDir, c.ConfigDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
return nil
}
// Validate creates the data and config directories if missing and checks that
// the configured media roots exist. A missing media root is an error, not a
// warning: silently scanning nothing is worse than failing loudly at boot.
func (c Config) Validate() error {
if err := c.EnsureDirs(); err != nil {
return err
}
for _, dir := range c.MediaDirs {
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("media dir %s: %w", dir, err)
}
if !info.IsDir() {
return fmt.Errorf("media dir %s: not a directory", dir)
}
}
return nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
// envList parses a colon-separated list, the same separator as $PATH.
func envList(key string, def []string) []string {
v, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(v) == "" {
return def
}
var out []string
for _, part := range strings.Split(v, ":") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
// envCIDRs parses a comma-separated CIDR list, e.g.
// "172.16.0.0/12,127.0.0.1/32". A malformed entry is an error rather than a
// silent skip: dropping a proxy network quietly widens the trust boundary,
// which is exactly the failure this setting exists to prevent.
func envCIDRs(key string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, part := range strings.Split(env(key, ""), ",") {
if part = strings.TrimSpace(part); part == "" {
continue
}
_, n, err := net.ParseCIDR(part)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
out = append(out, n)
}
return out, nil
}
func envDuration(key string, def time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
return def
}
return d
}
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
}
// envOff reads a switch that is ON unless it is explicitly turned off. Unlike
// envBool it accepts the on/off spellings operators actually type, because
// strconv.ParseBool rejects "off" and would silently leave the feature on.
func envOff(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "off", "no", "false", "0":
return true
}
return false
}