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.
228 lines
6.9 KiB
Go
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
|
|
}
|