2026-03-14 20:34:37 +01:00
|
|
|
// Package config provides configuration loading for the OwnCord server.
|
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-24 20:23:40 +01:00
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
2026-03-14 20:34:37 +01:00
|
|
|
"fmt"
|
2026-03-20 12:30:12 +01:00
|
|
|
"log/slog"
|
2026-07-19 08:56:34 +02:00
|
|
|
"net"
|
2026-03-14 20:34:37 +01:00
|
|
|
"os"
|
2026-08-15 20:50:47 +02:00
|
|
|
"slices"
|
2026-03-14 20:34:37 +01:00
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/knadh/koanf/parsers/yaml"
|
|
|
|
|
"github.com/knadh/koanf/providers/env"
|
|
|
|
|
"github.com/knadh/koanf/providers/file"
|
|
|
|
|
"github.com/knadh/koanf/providers/structs"
|
|
|
|
|
"github.com/knadh/koanf/v2"
|
|
|
|
|
goyaml "go.yaml.in/yaml/v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Config holds the full server configuration.
|
|
|
|
|
type Config struct {
|
2026-04-06 09:00:47 +00:00
|
|
|
Server ServerConfig `koanf:"server"`
|
|
|
|
|
Database DatabaseConfig `koanf:"database"`
|
2026-08-15 20:50:47 +02:00
|
|
|
Backup BackupConfig `koanf:"backup"`
|
|
|
|
|
Security SecurityConfig `koanf:"security"`
|
2026-04-06 09:00:47 +00:00
|
|
|
TLS TLSConfig `koanf:"tls"`
|
|
|
|
|
Upload UploadConfig `koanf:"upload"`
|
|
|
|
|
Voice VoiceConfig `koanf:"voice"`
|
|
|
|
|
GitHub GitHubConfig `koanf:"github"`
|
|
|
|
|
EventPersistence EventPersistenceConfig `koanf:"event_persistence"`
|
|
|
|
|
Telemetry TelemetryConfig `koanf:"telemetry"`
|
|
|
|
|
Plugins PluginsConfig `koanf:"plugins"`
|
2026-07-20 13:29:46 +02:00
|
|
|
GIF GIFConfig `koanf:"gif"`
|
2026-07-24 11:06:59 +02:00
|
|
|
Logging LoggingConfig `koanf:"logging"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 15:41:57 +02:00
|
|
|
// LoggingConfig controls server log verbosity. Level gates both stdout and
|
|
|
|
|
// the in-memory ring buffer that backs the admin panel's live log view, so
|
|
|
|
|
// suppressed levels cost nothing anywhere on the hot path.
|
2026-07-24 11:06:59 +02:00
|
|
|
type LoggingConfig struct {
|
2026-07-31 15:41:57 +02:00
|
|
|
// Level is the minimum level logged: "debug" | "info" | "warn" |
|
2026-07-24 11:06:59 +02:00
|
|
|
// "error". Override at runtime without editing config.yaml via the
|
|
|
|
|
// OWNCORD_LOGGING_LEVEL environment variable.
|
|
|
|
|
Level string `koanf:"level"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ParseLevel maps a config log-level string to a slog.Level. It is
|
|
|
|
|
// case-insensitive and treats "" as info. The bool is false for an
|
|
|
|
|
// unrecognised value (in which case slog.LevelInfo is returned and the caller
|
|
|
|
|
// should warn) so a typo doesn't silently disable logging.
|
|
|
|
|
func ParseLevel(s string) (slog.Level, bool) {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
|
|
|
case "debug":
|
|
|
|
|
return slog.LevelDebug, true
|
|
|
|
|
case "", "info":
|
|
|
|
|
return slog.LevelInfo, true
|
|
|
|
|
case "warn", "warning":
|
|
|
|
|
return slog.LevelWarn, true
|
|
|
|
|
case "error":
|
|
|
|
|
return slog.LevelError, true
|
|
|
|
|
default:
|
|
|
|
|
return slog.LevelInfo, false
|
|
|
|
|
}
|
2026-07-20 13:29:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GIFConfig holds the credentials for the server-side GIF (Klipy) proxy.
|
|
|
|
|
//
|
|
|
|
|
// The API key is deliberately server-only: the client never receives it and
|
|
|
|
|
// never talks to api.klipy.com directly, it calls /api/v1/gif/* on its own
|
|
|
|
|
// server instead. An empty APIKey means the feature is OFF — the proxy
|
|
|
|
|
// endpoints answer 503 GIF_DISABLED and the client hides the picker.
|
|
|
|
|
type GIFConfig struct {
|
|
|
|
|
APIKey string `koanf:"api_key"`
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EventPersistenceConfig (Phase B Step 7) controls the tiered event log used
|
|
|
|
|
// for WebSocket reconnection replay.
|
|
|
|
|
type EventPersistenceConfig struct {
|
|
|
|
|
// Enabled toggles cold-storage persistence. When false the server falls
|
|
|
|
|
// back to ring-buffer-only behaviour (Phase A semantics).
|
|
|
|
|
Enabled bool `koanf:"enabled"`
|
|
|
|
|
// RetentionHours is how long persisted events are kept before pruning.
|
|
|
|
|
RetentionHours int `koanf:"retention_hours"`
|
|
|
|
|
// BatchSize is the maximum number of events per persister flush.
|
|
|
|
|
BatchSize int `koanf:"batch_size"`
|
|
|
|
|
// BatchFlushMs is the maximum delay between persister flushes.
|
|
|
|
|
BatchFlushMs int `koanf:"batch_flush_ms"`
|
|
|
|
|
// PrunerIntervalMinutes is how often the pruner goroutine wakes up.
|
|
|
|
|
PrunerIntervalMinutes int `koanf:"pruner_interval_minutes"`
|
2026-08-15 20:50:47 +02:00
|
|
|
// ReplayRingSize is the capacity of the in-memory reconnect replay ring.
|
|
|
|
|
// Reconnects whose gap exceeds it fall to the persisted event log.
|
|
|
|
|
ReplayRingSize int `koanf:"replay_ring_size"`
|
|
|
|
|
// ReplayColdLimit caps how many persisted events a single reconnect may
|
|
|
|
|
// replay; beyond it the client gets a full resync. This is the budget
|
|
|
|
|
// that decides how long a disconnect can be bridged by replay.
|
|
|
|
|
ReplayColdLimit int `koanf:"replay_cold_limit"`
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TelemetryConfig (Phase B Step 8) controls the OpenTelemetry exporter.
|
|
|
|
|
type TelemetryConfig struct {
|
|
|
|
|
// Enabled toggles the OTel SDK. When false the server uses no-op
|
|
|
|
|
// tracer/meter providers and the legacy /metrics endpoint stays the
|
|
|
|
|
// only metrics surface.
|
|
|
|
|
Enabled bool `koanf:"enabled"`
|
|
|
|
|
// Exporter is "none" | "prometheus" | "otlp".
|
|
|
|
|
Exporter string `koanf:"exporter"`
|
|
|
|
|
// OTLPEndpoint is the gRPC endpoint when Exporter == "otlp".
|
|
|
|
|
OTLPEndpoint string `koanf:"otlp_endpoint"`
|
2026-04-07 05:28:53 +00:00
|
|
|
// OTLPInsecure disables TLS for the OTLP gRPC connection. Only set
|
|
|
|
|
// true in development / private-network deployments. Defaults to false
|
|
|
|
|
// (TLS required) to avoid transmitting trace/metric data in plaintext.
|
|
|
|
|
OTLPInsecure bool `koanf:"otlp_insecure"`
|
2026-04-06 09:00:47 +00:00
|
|
|
// ServiceName is the resource service.name attribute.
|
|
|
|
|
ServiceName string `koanf:"service_name"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PluginsConfig (Phase C Step 9) controls the Wazero plugin runtime.
|
|
|
|
|
type PluginsConfig struct {
|
|
|
|
|
// Enabled toggles plugin loading at startup.
|
|
|
|
|
Enabled bool `koanf:"enabled"`
|
|
|
|
|
// Directory is the on-disk directory scanned for plugin packages.
|
|
|
|
|
Directory string `koanf:"directory"`
|
|
|
|
|
// MaxMemoryMB caps a single plugin's WASM linear memory.
|
|
|
|
|
MaxMemoryMB int `koanf:"max_memory_mb"`
|
|
|
|
|
// CPUBudgetMs caps a single plugin invocation's CPU time.
|
|
|
|
|
CPUBudgetMs int `koanf:"cpu_budget_ms"`
|
|
|
|
|
// HTTPAllowlist enumerates host suffixes plugins may reach via host_http.
|
|
|
|
|
HTTPAllowlist []string `koanf:"http_allowlist"`
|
2026-03-14 21:59:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GitHubConfig holds GitHub API settings for update checking.
|
2026-07-18 11:17:29 +02:00
|
|
|
//
|
|
|
|
|
// Owner/Repo point at the public releases repository. Server and client
|
|
|
|
|
// update checks fetch release assets from this repo, so it must stay
|
|
|
|
|
// publicly readable even when the source repository is private.
|
2026-03-14 21:59:58 +01:00
|
|
|
type GitHubConfig struct {
|
|
|
|
|
Token string `koanf:"token"`
|
2026-07-18 11:17:29 +02:00
|
|
|
Owner string `koanf:"owner"`
|
|
|
|
|
Repo string `koanf:"repo"`
|
2026-03-14 21:31:03 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 05:14:52 +01:00
|
|
|
// VoiceConfig holds LiveKit server connection and voice quality settings.
|
2026-03-14 21:31:03 +01:00
|
|
|
type VoiceConfig struct {
|
2026-04-01 11:38:33 +02:00
|
|
|
LiveKitAPIKey string `koanf:"livekit_api_key"` // LiveKit API key
|
|
|
|
|
LiveKitAPISecret string `koanf:"livekit_api_secret"` // LiveKit API secret
|
|
|
|
|
LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880)
|
|
|
|
|
LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start
|
2026-07-31 15:41:57 +02:00
|
|
|
// AutoDownloadLiveKit downloads a pinned, checksum-verified livekit-server
|
|
|
|
|
// release from the official LiveKit GitHub releases into
|
|
|
|
|
// <data_dir>/livekit/ and runs it as the companion process, when no
|
|
|
|
|
// livekit_binary is configured. Fresh installs enable this in the
|
|
|
|
|
// generated config.yaml so voice works out of the box; the compiled-in
|
|
|
|
|
// default stays false so existing configs keep their behaviour.
|
|
|
|
|
AutoDownloadLiveKit bool `koanf:"auto_download_livekit"`
|
|
|
|
|
// LiveKitVersion overrides the pinned livekit-server release version used
|
|
|
|
|
// by auto-download (e.g. "1.13.5"). Empty = the built-in pin.
|
|
|
|
|
LiveKitVersion string `koanf:"livekit_version"`
|
|
|
|
|
NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect
|
2026-07-19 10:50:11 +00:00
|
|
|
// AdvertiseInternalIP makes LiveKit advertise internal (LAN) host candidates
|
|
|
|
|
// in addition to the external node_ip mapping, so clients on the local
|
|
|
|
|
// network can connect while remote clients use the public IP.
|
|
|
|
|
AdvertiseInternalIP bool `koanf:"advertise_internal_ip"`
|
|
|
|
|
Quality string `koanf:"quality"` // low | medium | high
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServerConfig holds HTTP server settings.
|
|
|
|
|
type ServerConfig struct {
|
2026-03-19 12:23:24 +01:00
|
|
|
Port int `koanf:"port"`
|
|
|
|
|
Name string `koanf:"name"`
|
|
|
|
|
DataDir string `koanf:"data_dir"`
|
|
|
|
|
AllowedOrigins []string `koanf:"allowed_origins"`
|
|
|
|
|
TrustedProxies []string `koanf:"trusted_proxies"`
|
|
|
|
|
AdminAllowedCIDRs []string `koanf:"admin_allowed_cidrs"`
|
2026-04-01 13:49:06 +02:00
|
|
|
WAFEnabled bool `koanf:"waf_enabled"` // Enable Coraza WAF (default: false)
|
|
|
|
|
WAFParanoiaLevel int `koanf:"waf_paranoia_level"` // OWASP CRS paranoia level 1-4 (default: 2)
|
2026-07-31 15:41:57 +02:00
|
|
|
// WAFCRSMode selects the OWASP Core Rule Set layer mode when the WAF is
|
|
|
|
|
// enabled: "off" (inline rules only), "detect" (CRS evaluated, matches
|
|
|
|
|
// logged, never blocks) or "block" (CRS anomaly-scoring blocking).
|
|
|
|
|
// Defaults to "detect": chat traffic routinely contains SQL-ish/HTML-ish
|
|
|
|
|
// text the CRS false-positives on, so blocking needs tuning against real
|
|
|
|
|
// traffic first. Unknown values fall back to "detect".
|
|
|
|
|
WAFCRSMode string `koanf:"waf_crs_mode"`
|
2026-08-15 20:50:47 +02:00
|
|
|
// MaxWSConnections caps concurrently connected WebSocket clients; new
|
|
|
|
|
// upgrade requests beyond the cap are refused with 503 before the
|
|
|
|
|
// upgrade. 0 (the default) means unlimited — every connection costs
|
|
|
|
|
// goroutines and buffered send queues, so set a ceiling that matches the
|
|
|
|
|
// host's memory before pointing a large community at it.
|
|
|
|
|
MaxWSConnections int `koanf:"max_ws_connections"`
|
|
|
|
|
// MetricsAllowedCIDRs gates /api/v1/metrics and the Prometheus /metrics
|
|
|
|
|
// exporter separately from the human admin surface, so a central
|
|
|
|
|
// Prometheus scraper can be allowlisted without widening /admin to its
|
|
|
|
|
// network. Empty (default) falls back to AdminAllowedCIDRs.
|
|
|
|
|
MetricsAllowedCIDRs []string `koanf:"metrics_allowed_cidrs"`
|
|
|
|
|
// LiveKitWebhookAllowedCIDRs gates the LiveKit webhook and health
|
|
|
|
|
// endpoints. The webhook already authenticates cryptographically (LiveKit
|
|
|
|
|
// JWT signature over the body hash) — this perimeter is defence-in-depth,
|
|
|
|
|
// and giving it its own key means an externally-hosted LiveKit's IP no
|
|
|
|
|
// longer has to be added to the ADMIN allowlist. Empty (default) falls
|
|
|
|
|
// back to AdminAllowedCIDRs.
|
|
|
|
|
LiveKitWebhookAllowedCIDRs []string `koanf:"livekit_webhook_allowed_cidrs"`
|
2026-08-16 08:25:40 +02:00
|
|
|
// RestartMode selects how a self-restart (update apply, backup restore,
|
|
|
|
|
// setup wizard) hands the process over to its replacement once the server
|
|
|
|
|
// has fully drained:
|
|
|
|
|
// - "supervised": exit cleanly and rely on the process supervisor
|
|
|
|
|
// (systemd Restart=, NSSM AppExit, Docker restart policy) to relaunch.
|
|
|
|
|
// - "spawn": start the replacement binary directly before exiting
|
|
|
|
|
// (unmanaged deployments: console, Task Scheduler).
|
|
|
|
|
// - "auto" (default): "supervised" when a supervisor or container is
|
|
|
|
|
// detected (updater.RunningUnderSupervisor / RunningInContainer),
|
|
|
|
|
// otherwise "spawn".
|
|
|
|
|
// Env override: OWNCORD_SERVER_RESTART_MODE. NSSM deployments must set
|
|
|
|
|
// this to "supervised" — NSSM 2.24 is not auto-detectable.
|
|
|
|
|
RestartMode string `koanf:"restart_mode"`
|
2026-08-15 20:50:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MetricsCIDRs returns the effective allowlist for the metrics surfaces.
|
|
|
|
|
func (s *ServerConfig) MetricsCIDRs() []string {
|
|
|
|
|
if len(s.MetricsAllowedCIDRs) > 0 {
|
|
|
|
|
return s.MetricsAllowedCIDRs
|
|
|
|
|
}
|
|
|
|
|
return s.AdminAllowedCIDRs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LiveKitWebhookCIDRs returns the effective allowlist for the LiveKit
|
|
|
|
|
// webhook/health endpoints.
|
|
|
|
|
func (s *ServerConfig) LiveKitWebhookCIDRs() []string {
|
|
|
|
|
if len(s.LiveKitWebhookAllowedCIDRs) > 0 {
|
|
|
|
|
return s.LiveKitWebhookAllowedCIDRs
|
|
|
|
|
}
|
|
|
|
|
return s.AdminAllowedCIDRs
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DatabaseConfig holds database settings.
|
2026-04-06 07:43:08 +00:00
|
|
|
//
|
2026-08-07 21:20:48 +02:00
|
|
|
// SQLite is the only supported backend. The PostgreSQL scaffolding that once
|
|
|
|
|
// motivated the Type field has been removed (see Server/main.go); the field
|
|
|
|
|
// survives so an explicit "sqlite" keeps working and anything else fails
|
|
|
|
|
// startup with a clear error instead of being silently ignored.
|
2026-03-14 20:34:37 +01:00
|
|
|
type DatabaseConfig struct {
|
2026-07-19 08:15:58 +02:00
|
|
|
// Type selects the database backend. "sqlite" (or empty, which defaults
|
|
|
|
|
// to it) is the only supported value.
|
2026-04-06 07:43:08 +00:00
|
|
|
Type string `koanf:"type"`
|
|
|
|
|
|
2026-07-19 08:15:58 +02:00
|
|
|
// Path is the SQLite database file path.
|
2026-03-14 20:34:37 +01:00
|
|
|
Path string `koanf:"path"`
|
2026-08-15 20:50:47 +02:00
|
|
|
|
|
|
|
|
// MaxReaders bounds the read-only connection pool. 0 (default) keeps the
|
|
|
|
|
// automatic sizing of max(4, NumCPU). Values are clamped to [1, 64] —
|
|
|
|
|
// readers beyond the CPU count mostly buy queueing, not throughput.
|
|
|
|
|
MaxReaders int `koanf:"max_readers"`
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TLSConfig holds TLS/certificate settings.
|
|
|
|
|
type TLSConfig struct {
|
2026-03-15 07:07:59 +01:00
|
|
|
Mode string `koanf:"mode"`
|
|
|
|
|
CertFile string `koanf:"cert_file"`
|
|
|
|
|
KeyFile string `koanf:"key_file"`
|
|
|
|
|
Domain string `koanf:"domain"`
|
|
|
|
|
AcmeCacheDir string `koanf:"acme_cache_dir"`
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UploadConfig holds file upload settings.
|
|
|
|
|
type UploadConfig struct {
|
|
|
|
|
MaxSizeMB int `koanf:"max_size_mb"`
|
|
|
|
|
StorageDir string `koanf:"storage_dir"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 20:50:47 +02:00
|
|
|
// BackupConfig controls where database backups are written. Pointing Dir at
|
|
|
|
|
// another disk (or a mount that is shipped off-host) is the recommended way
|
|
|
|
|
// to keep backups from sharing a single point of failure with the live
|
|
|
|
|
// database and uploads.
|
|
|
|
|
type BackupConfig struct {
|
|
|
|
|
Dir string `koanf:"dir"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SecurityConfig tunes security-adjacent behavior that has safe compiled-in
|
|
|
|
|
// defaults.
|
|
|
|
|
type SecurityConfig struct {
|
|
|
|
|
// AuthRateLimitMultiplier scales the per-IP auth rate limits and failure
|
|
|
|
|
// thresholds (registration, login, TOTP, sensitive endpoints). The
|
|
|
|
|
// defaults assume roughly one person per IP address; a community behind a
|
|
|
|
|
// shared NAT (office, school) hits them collectively. 0 or unset = 1.0;
|
|
|
|
|
// clamped to [0.1, 100].
|
|
|
|
|
AuthRateLimitMultiplier float64 `koanf:"auth_rate_limit_multiplier"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// defaults returns the default configuration.
|
|
|
|
|
func defaults() Config {
|
|
|
|
|
return Config{
|
|
|
|
|
Server: ServerConfig{
|
2026-03-15 07:07:59 +01:00
|
|
|
Port: 8443,
|
|
|
|
|
Name: "OwnCord Server",
|
|
|
|
|
DataDir: "data",
|
2026-03-31 18:46:33 +02:00
|
|
|
AllowedOrigins: []string{},
|
2026-03-15 07:07:59 +01:00
|
|
|
TrustedProxies: []string{},
|
2026-03-19 12:23:24 +01:00
|
|
|
AdminAllowedCIDRs: []string{
|
2026-04-01 11:38:33 +02:00
|
|
|
"127.0.0.0/8", // localhost IPv4
|
|
|
|
|
"::1/128", // localhost IPv6
|
|
|
|
|
"10.0.0.0/8", // private class A
|
|
|
|
|
"172.16.0.0/12", // private class B
|
|
|
|
|
"192.168.0.0/16", // private class C
|
|
|
|
|
"fc00::/7", // IPv6 unique local
|
2026-03-19 12:23:24 +01:00
|
|
|
},
|
2026-08-16 08:25:40 +02:00
|
|
|
WAFCRSMode: "detect",
|
|
|
|
|
RestartMode: "auto",
|
2026-03-14 20:34:37 +01:00
|
|
|
},
|
|
|
|
|
Database: DatabaseConfig{
|
2026-07-19 08:15:58 +02:00
|
|
|
Type: "sqlite",
|
|
|
|
|
Path: "data/chatserver.db",
|
2026-03-14 20:34:37 +01:00
|
|
|
},
|
2026-08-15 20:50:47 +02:00
|
|
|
Backup: BackupConfig{
|
|
|
|
|
Dir: "data/backups",
|
|
|
|
|
},
|
2026-03-14 20:34:37 +01:00
|
|
|
TLS: TLSConfig{
|
2026-03-15 07:07:59 +01:00
|
|
|
Mode: "self_signed",
|
|
|
|
|
CertFile: "data/cert.pem",
|
|
|
|
|
KeyFile: "data/key.pem",
|
|
|
|
|
AcmeCacheDir: "data/acme_certs",
|
2026-03-14 20:34:37 +01:00
|
|
|
},
|
|
|
|
|
Upload: UploadConfig{
|
|
|
|
|
MaxSizeMB: 100,
|
|
|
|
|
StorageDir: "data/uploads",
|
|
|
|
|
},
|
2026-03-14 21:31:03 +01:00
|
|
|
Voice: VoiceConfig{
|
2026-03-24 20:23:40 +01:00
|
|
|
LiveKitURL: "ws://localhost:7880",
|
|
|
|
|
Quality: "medium",
|
2026-03-14 21:31:03 +01:00
|
|
|
},
|
2026-07-18 11:17:29 +02:00
|
|
|
GitHub: GitHubConfig{
|
|
|
|
|
Owner: "J3vb",
|
2026-07-30 16:05:01 +02:00
|
|
|
Repo: "OwnCord",
|
2026-07-18 11:17:29 +02:00
|
|
|
},
|
2026-04-06 09:00:47 +00:00
|
|
|
EventPersistence: EventPersistenceConfig{
|
|
|
|
|
Enabled: true,
|
|
|
|
|
RetentionHours: 24,
|
|
|
|
|
BatchSize: 50,
|
|
|
|
|
BatchFlushMs: 100,
|
|
|
|
|
PrunerIntervalMinutes: 60,
|
2026-08-15 20:50:47 +02:00
|
|
|
ReplayRingSize: 1000,
|
|
|
|
|
ReplayColdLimit: 5000,
|
|
|
|
|
},
|
|
|
|
|
Security: SecurityConfig{
|
|
|
|
|
AuthRateLimitMultiplier: 1.0,
|
2026-04-06 09:00:47 +00:00
|
|
|
},
|
|
|
|
|
Telemetry: TelemetryConfig{
|
|
|
|
|
Enabled: false,
|
|
|
|
|
Exporter: "none",
|
|
|
|
|
ServiceName: "owncord-server",
|
|
|
|
|
},
|
|
|
|
|
Plugins: PluginsConfig{
|
|
|
|
|
Enabled: false,
|
|
|
|
|
Directory: "data/plugins",
|
|
|
|
|
MaxMemoryMB: 64,
|
|
|
|
|
CPUBudgetMs: 100,
|
|
|
|
|
HTTPAllowlist: []string{},
|
|
|
|
|
},
|
2026-07-24 11:06:59 +02:00
|
|
|
Logging: LoggingConfig{
|
|
|
|
|
Level: "info",
|
|
|
|
|
},
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// defaultYAML is the content written when no config file is present.
|
|
|
|
|
const defaultYAML = `# OwnCord Server Configuration
|
|
|
|
|
server:
|
|
|
|
|
port: 8443
|
|
|
|
|
name: "OwnCord Server"
|
|
|
|
|
data_dir: "data"
|
2026-07-31 18:30:31 +02:00
|
|
|
# allowed_origins: [] # browser origins allowed to connect; empty = deny cross-origin.
|
|
|
|
|
# # The OwnCord desktop client is always accepted and needs no entry.
|
2026-07-19 08:56:34 +02:00
|
|
|
# trusted_proxies: [] # CIDRs of the reverse-proxy HOPS only (e.g. ["10.0.0.2/32"]).
|
|
|
|
|
# # Never list client networks here: a range that covers
|
|
|
|
|
# # clients degrades per-client rate limiting and lets
|
|
|
|
|
# # covered clients influence their own rate-limit key.
|
2026-03-19 12:23:24 +01:00
|
|
|
# admin_allowed_cidrs: # CIDRs allowed to access /admin (default: private networks only)
|
|
|
|
|
# - "127.0.0.0/8"
|
|
|
|
|
# - "::1/128"
|
|
|
|
|
# - "10.0.0.0/8"
|
|
|
|
|
# - "172.16.0.0/12"
|
|
|
|
|
# - "192.168.0.0/16"
|
2026-07-31 15:41:57 +02:00
|
|
|
# waf_enabled: false # Coraza WAF (inline rules + OWASP Core Rule Set)
|
|
|
|
|
# waf_paranoia_level: 2 # OWASP CRS paranoia level 1-4
|
|
|
|
|
# waf_crs_mode: "detect" # off | detect | block — CRS layer mode; "detect" logs
|
|
|
|
|
# # CRS matches without blocking (safe default for chat traffic)
|
2026-08-16 08:25:40 +02:00
|
|
|
# restart_mode: "auto" # auto | spawn | supervised — how self-restarts (update,
|
|
|
|
|
# # restore, setup wizard) hand off. "supervised" exits and
|
|
|
|
|
# # lets systemd/NSSM/Docker relaunch; "spawn" starts the
|
|
|
|
|
# # replacement directly; "auto" detects (NSSM users: set
|
|
|
|
|
# # "supervised" explicitly, NSSM is not auto-detectable)
|
2026-03-14 20:34:37 +01:00
|
|
|
|
|
|
|
|
database:
|
2026-07-19 08:15:58 +02:00
|
|
|
type: "sqlite" # "sqlite" is the only supported backend
|
2026-03-14 20:34:37 +01:00
|
|
|
path: "data/chatserver.db"
|
|
|
|
|
|
2026-08-15 20:50:47 +02:00
|
|
|
# backup:
|
|
|
|
|
# dir: "data/backups" # where database backups are written; point at another
|
|
|
|
|
# # disk or an off-host mount so backups don't share a
|
|
|
|
|
# # single point of failure with the live database
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
tls:
|
|
|
|
|
mode: "self_signed" # self_signed, acme, manual, off
|
2026-03-14 22:17:47 +01:00
|
|
|
cert_file: "data/cert.pem"
|
|
|
|
|
key_file: "data/key.pem"
|
2026-03-15 07:07:59 +01:00
|
|
|
domain: "" # required for acme mode (e.g. "chat.example.com")
|
|
|
|
|
acme_cache_dir: "data/acme_certs" # where Let's Encrypt certs are cached
|
2026-03-14 20:34:37 +01:00
|
|
|
|
|
|
|
|
upload:
|
|
|
|
|
max_size_mb: 100
|
|
|
|
|
storage_dir: "data/uploads"
|
2026-03-14 21:59:58 +01:00
|
|
|
|
2026-03-18 23:02:06 +01:00
|
|
|
voice:
|
2026-03-24 20:23:40 +01:00
|
|
|
# livekit_api_key: "" # LiveKit API key (REQUIRED for voice — generate a unique key)
|
|
|
|
|
# livekit_api_secret: "" # LiveKit API secret (REQUIRED, min 32 chars — generate a unique secret)
|
2026-03-20 05:14:52 +01:00
|
|
|
livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL
|
2026-07-31 15:41:57 +02:00
|
|
|
auto_download_livekit: true # download and run livekit-server automatically when
|
|
|
|
|
# no livekit_binary is set (verified against the
|
|
|
|
|
# official LiveKit release checksums; stored in data/livekit/)
|
|
|
|
|
# livekit_version: "" # override the pinned livekit-server version (e.g. "1.13.5")
|
|
|
|
|
# livekit_binary: "" # path to an existing livekit-server binary; set this to
|
|
|
|
|
# # skip auto-download and run your own build
|
2026-03-28 18:23:18 +01:00
|
|
|
# node_ip: "" # public IP for WebRTC media (required for remote users behind NAT)
|
2026-07-19 10:50:11 +00:00
|
|
|
# advertise_internal_ip: false # also advertise LAN IPs so local-network clients can connect
|
2026-03-20 05:14:52 +01:00
|
|
|
# quality: "medium" # low | medium | high
|
2026-03-18 23:02:06 +01:00
|
|
|
|
2026-03-14 21:59:58 +01:00
|
|
|
# github:
|
|
|
|
|
# token: "" # optional: GitHub API token for higher rate limits (5000 req/hr vs 60)
|
2026-04-06 09:49:03 +00:00
|
|
|
|
|
|
|
|
# Phase B Step 7 — cold-tier event log used by the WebSocket reconnect path.
|
|
|
|
|
# When the in-memory ring buffer can't cover a client's last_seq the server
|
|
|
|
|
# falls back to these rows before forcing a full re-sync. Rows older than
|
|
|
|
|
# retention_hours are pruned by a background goroutine.
|
|
|
|
|
# event_persistence:
|
|
|
|
|
# enabled: true # set false to disable cold-tier replay entirely
|
|
|
|
|
# retention_hours: 24 # how long to keep persisted broadcast events
|
|
|
|
|
# batch_size: 50 # flush after this many events buffered
|
|
|
|
|
# batch_flush_ms: 100 # OR after this many milliseconds, whichever first
|
|
|
|
|
# pruner_interval_minutes: 60 # how often the retention pruner runs
|
|
|
|
|
|
|
|
|
|
# Phase B Step 8 — OpenTelemetry exporter. The default build ships a no-op
|
|
|
|
|
# provider; building with -tags otel enables the real SDK.
|
|
|
|
|
# telemetry:
|
|
|
|
|
# enabled: false # master switch
|
|
|
|
|
# exporter: "none" # none | prometheus | otlp
|
|
|
|
|
# otlp_endpoint: "" # required when exporter == "otlp" (host:port of collector)
|
2026-04-07 05:28:53 +00:00
|
|
|
# otlp_insecure: false # set true only for dev/private networks (disables TLS)
|
2026-04-06 09:49:03 +00:00
|
|
|
# service_name: "owncord-server"
|
|
|
|
|
|
|
|
|
|
# Phase C Step 9 — Wazero plugin runtime. Disabled by default so existing
|
|
|
|
|
# operators are unaffected. Plugins live in subdirectories of the configured
|
|
|
|
|
# directory; see Server/plugin/examples/hello for the manifest format.
|
|
|
|
|
# plugins:
|
|
|
|
|
# enabled: false
|
|
|
|
|
# directory: "data/plugins"
|
|
|
|
|
# max_memory_mb: 64 # per-plugin memory cap
|
|
|
|
|
# cpu_budget_ms: 100 # per-invocation CPU budget
|
|
|
|
|
# http_allowlist: [] # hostnames plugins may reach via the http capability
|
2026-07-20 13:29:46 +02:00
|
|
|
|
|
|
|
|
# GIF picker (Klipy). Disabled by default: with no api_key the /api/v1/gif/*
|
|
|
|
|
# endpoints answer 503 GIF_DISABLED and the client hides its GIF button. The
|
|
|
|
|
# key stays on the server — it is never sent to clients.
|
|
|
|
|
# Get a key at https://partner.klipy.com
|
|
|
|
|
# gif:
|
|
|
|
|
# api_key: ""
|
2026-07-24 11:06:59 +02:00
|
|
|
|
2026-07-31 15:41:57 +02:00
|
|
|
# Logging. "level" gates what is logged, to stdout and the admin panel's live
|
|
|
|
|
# log view alike. Override without editing this file via the
|
|
|
|
|
# OWNCORD_LOGGING_LEVEL environment variable.
|
2026-07-24 11:06:59 +02:00
|
|
|
# logging:
|
|
|
|
|
# level: "info" # debug | info | warn | error
|
2026-03-14 20:34:37 +01:00
|
|
|
`
|
|
|
|
|
|
|
|
|
|
// Load reads configuration from the given YAML file path, merging with
|
|
|
|
|
// defaults and environment variable overrides. If the file does not exist,
|
|
|
|
|
// a default config.yaml is written and defaults are returned.
|
|
|
|
|
func Load(cfgPath string) (*Config, error) {
|
|
|
|
|
k := koanf.New(".")
|
|
|
|
|
|
|
|
|
|
// Layer 1: built-in defaults via struct provider.
|
|
|
|
|
def := defaults()
|
|
|
|
|
if err := k.Load(structs.Provider(def, "koanf"), nil); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("loading defaults: %w", err)
|
|
|
|
|
}
|
2026-08-15 20:50:47 +02:00
|
|
|
// The defaults layer's key set is the complete set of keys the config
|
|
|
|
|
// struct can absorb — captured NOW, before the file merges in, so it can
|
|
|
|
|
// serve as the allowlist for the unknown-key warning below. (Capturing
|
|
|
|
|
// after the file load would let the file's own typos into the allowlist.)
|
|
|
|
|
knownKeys := make(map[string]struct{}, len(k.Keys()))
|
|
|
|
|
for _, key := range k.Keys() {
|
|
|
|
|
knownKeys[key] = struct{}{}
|
|
|
|
|
}
|
2026-03-14 20:34:37 +01:00
|
|
|
|
2026-07-31 15:41:57 +02:00
|
|
|
// Layer 2: YAML file (create default if missing). The freshly written
|
|
|
|
|
// default file is loaded like any other so the first boot runs with
|
|
|
|
|
// exactly the configuration the file documents (the generated template
|
|
|
|
|
// enables options — e.g. voice.auto_download_livekit — that the
|
|
|
|
|
// compiled-in defaults deliberately leave off for pre-existing configs).
|
2026-03-14 20:34:37 +01:00
|
|
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
2026-03-24 20:23:40 +01:00
|
|
|
if writeErr := os.WriteFile(cfgPath, []byte(defaultYAML), 0o600); writeErr != nil {
|
2026-03-14 20:34:37 +01:00
|
|
|
return nil, fmt.Errorf("writing default config: %w", writeErr)
|
|
|
|
|
}
|
2026-07-31 15:41:57 +02:00
|
|
|
}
|
|
|
|
|
// Read the file and try to parse it ourselves to detect invalid YAML.
|
|
|
|
|
raw, readErr := os.ReadFile(cfgPath) //nolint:gosec // G304: path from trusted wiring
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr)
|
|
|
|
|
}
|
|
|
|
|
if parseErr := validateYAML(raw); parseErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr)
|
|
|
|
|
}
|
|
|
|
|
if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err)
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-15 20:50:47 +02:00
|
|
|
// Warn (never fail — a newer server must tolerate an older config, and a
|
|
|
|
|
// warning must not brick a working install) about file keys the config
|
|
|
|
|
// struct cannot absorb. Without this, a typo like `admin_alowed_cidrs`
|
|
|
|
|
// silently keeps the default and the operator believes they changed it.
|
|
|
|
|
for _, key := range unknownFileKeys(cfgPath, knownKeys) {
|
|
|
|
|
slog.Warn("config: unknown key ignored — value has NO effect (typo?)",
|
|
|
|
|
"key", key, "file", cfgPath)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// Layer 3: environment variable overrides.
|
|
|
|
|
// OWNCORD_SERVER_PORT -> server.port, OWNCORD_TLS_MODE -> tls.mode, etc.
|
|
|
|
|
envProvider := env.Provider("OWNCORD_", ".", func(s string) string {
|
|
|
|
|
// Strip prefix, lowercase, replace _ with . except within a key segment.
|
|
|
|
|
// OWNCORD_SERVER_PORT -> server.port
|
|
|
|
|
// OWNCORD_DATABASE_PATH -> database.path
|
|
|
|
|
// OWNCORD_UPLOAD_MAX_SIZE_MB -> upload.max_size_mb
|
|
|
|
|
s = strings.TrimPrefix(s, "OWNCORD_")
|
|
|
|
|
s = strings.ToLower(s)
|
|
|
|
|
// Split into at most 2 parts on the first underscore to get
|
|
|
|
|
// section.key. We need smarter splitting because keys can have
|
|
|
|
|
// underscores (e.g. max_size_mb, data_dir, storage_dir).
|
|
|
|
|
return envKeyToKoanf(s)
|
|
|
|
|
})
|
|
|
|
|
if err := k.Load(envProvider, nil); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("loading env vars: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cfg Config
|
|
|
|
|
if err := k.Unmarshal("", &cfg); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("unmarshalling config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 12:23:24 +01:00
|
|
|
// Apply voice defaults for zero-value fields (koanf loses defaults when
|
|
|
|
|
// the YAML section is present but fields are commented out / omitted).
|
2026-03-31 19:00:17 +02:00
|
|
|
if err := applyVoiceDefaults(&cfg.Voice); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("applying voice defaults: %w", err)
|
|
|
|
|
}
|
2026-03-19 12:23:24 +01:00
|
|
|
|
2026-03-20 12:30:12 +01:00
|
|
|
// Warn if using default dev credentials — these are public and insecure.
|
2026-03-24 21:30:23 +01:00
|
|
|
// Clear credentials so downstream consumers (e.g. NewLiveKitClient) see
|
|
|
|
|
// empty values and refuse to start voice.
|
2026-03-24 20:23:40 +01:00
|
|
|
if IsDefaultVoiceCredentials(&cfg.Voice) {
|
|
|
|
|
slog.Warn("using default LiveKit dev credentials — voice will be disabled; set voice.livekit_api_key and voice.livekit_api_secret in config.yaml")
|
2026-03-24 21:30:23 +01:00
|
|
|
cfg.Voice.LiveKitAPIKey = ""
|
|
|
|
|
cfg.Voice.LiveKitAPISecret = ""
|
2026-03-20 12:30:12 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 08:56:34 +02:00
|
|
|
// Invalid CIDR entries are skipped at request time (they must not crash
|
|
|
|
|
// handling), which silently un-trusts a misconfigured proxy — warn once
|
|
|
|
|
// at startup instead. Common mistake: a bare IP without the /32 mask.
|
|
|
|
|
warnInvalidCIDRs("server.trusted_proxies", cfg.Server.TrustedProxies)
|
|
|
|
|
warnInvalidCIDRs("server.admin_allowed_cidrs", cfg.Server.AdminAllowedCIDRs)
|
2026-08-15 20:50:47 +02:00
|
|
|
warnInvalidCIDRs("server.metrics_allowed_cidrs", cfg.Server.MetricsAllowedCIDRs)
|
|
|
|
|
warnInvalidCIDRs("server.livekit_webhook_allowed_cidrs", cfg.Server.LiveKitWebhookAllowedCIDRs)
|
|
|
|
|
|
|
|
|
|
// A customized admin allowlist with no trusted_proxies is a footgun
|
|
|
|
|
// behind any reverse proxy or container network: the check then compares
|
|
|
|
|
// the PROXY'S (or bridge's) address — by construction a private one —
|
|
|
|
|
// instead of the real client's, so the customization silently doesn't do
|
|
|
|
|
// what the operator believes. Warn, don't fail: direct-exposure setups
|
|
|
|
|
// are exactly this shape and are fine.
|
|
|
|
|
if len(cfg.Server.TrustedProxies) == 0 &&
|
|
|
|
|
!slices.Equal(cfg.Server.AdminAllowedCIDRs, defaults().Server.AdminAllowedCIDRs) {
|
|
|
|
|
slog.Warn("config: admin_allowed_cidrs is customized but trusted_proxies is empty — " +
|
|
|
|
|
"behind a reverse proxy or Docker network the allowlist checks the proxy's private " +
|
|
|
|
|
"address, not the real client; set server.trusted_proxies to the proxy hop(s)")
|
|
|
|
|
}
|
2026-07-19 08:56:34 +02:00
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
return &cfg, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 20:50:47 +02:00
|
|
|
// unknownFileKeys parses the config file into its own koanf instance and
|
|
|
|
|
// returns every leaf key that the defaults layer (= the full set of keys the
|
|
|
|
|
// Config struct defines) does not contain. knownKeys must be captured from
|
|
|
|
|
// the defaults layer BEFORE the file merges into it.
|
|
|
|
|
func unknownFileKeys(cfgPath string, knownKeys map[string]struct{}) []string {
|
|
|
|
|
fileK := koanf.New(".")
|
|
|
|
|
if err := fileK.Load(file.Provider(cfgPath), yaml.Parser()); err != nil {
|
|
|
|
|
return nil // the main load already surfaced any parse problem
|
|
|
|
|
}
|
|
|
|
|
var unknown []string
|
|
|
|
|
for _, key := range fileK.Keys() {
|
|
|
|
|
if _, ok := knownKeys[key]; !ok {
|
|
|
|
|
unknown = append(unknown, key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return unknown
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 08:56:34 +02:00
|
|
|
// warnInvalidCIDRs logs a startup warning for each list entry that is not
|
|
|
|
|
// valid CIDR notation.
|
|
|
|
|
func warnInvalidCIDRs(key string, cidrs []string) {
|
|
|
|
|
for _, c := range cidrs {
|
|
|
|
|
if _, _, err := net.ParseCIDR(c); err != nil {
|
|
|
|
|
slog.Warn("config: ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)",
|
|
|
|
|
"key", key, "entry", c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:23:40 +01:00
|
|
|
// defaultLiveKitAPIKey and defaultLiveKitAPISecret are the well-known dev
|
|
|
|
|
// credentials that ship in the default config. They must never be used in
|
|
|
|
|
// production — NewLiveKitClient rejects them.
|
|
|
|
|
const (
|
|
|
|
|
DefaultLiveKitAPIKey = "devkey"
|
2026-04-01 11:38:33 +02:00
|
|
|
DefaultLiveKitAPISecret = "owncord-dev-secret-key-min-32chars" //nolint:gosec // G101: false positive — config key name, not a credential
|
2026-03-24 20:23:40 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// IsDefaultVoiceCredentials returns true when the voice config still uses
|
|
|
|
|
// the well-known default dev credentials shipped in the source code.
|
|
|
|
|
func IsDefaultVoiceCredentials(v *VoiceConfig) bool {
|
|
|
|
|
return v.LiveKitAPIKey == DefaultLiveKitAPIKey ||
|
|
|
|
|
v.LiveKitAPISecret == DefaultLiveKitAPISecret
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// generateRandomKey returns a crypto-random hex string of the given byte length.
|
2026-03-31 19:00:17 +02:00
|
|
|
func generateRandomKey(byteLen int) (string, error) {
|
2026-03-24 20:23:40 +01:00
|
|
|
b := make([]byte, byteLen)
|
|
|
|
|
if _, err := rand.Read(b); err != nil {
|
2026-03-31 19:00:17 +02:00
|
|
|
return "", fmt.Errorf("crypto/rand: %w", err)
|
2026-03-24 20:23:40 +01:00
|
|
|
}
|
2026-03-31 19:00:17 +02:00
|
|
|
return hex.EncodeToString(b), nil
|
2026-03-24 20:23:40 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 12:23:24 +01:00
|
|
|
// applyVoiceDefaults fills in zero-value voice fields with sensible defaults.
|
|
|
|
|
// This guards against the koanf merge behaviour where an empty YAML section
|
|
|
|
|
// overwrites struct defaults with Go zero values.
|
2026-03-24 20:23:40 +01:00
|
|
|
// When API key/secret are empty, unique random credentials are generated
|
|
|
|
|
// so voice works out of the box without shipping known-public defaults.
|
2026-03-31 19:00:17 +02:00
|
|
|
func applyVoiceDefaults(v *VoiceConfig) error {
|
2026-03-20 05:14:52 +01:00
|
|
|
if v.LiveKitAPIKey == "" {
|
2026-03-31 19:00:17 +02:00
|
|
|
key, err := generateRandomKey(8)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("generating LiveKit API key: %w", err)
|
|
|
|
|
}
|
|
|
|
|
v.LiveKitAPIKey = "key-" + key
|
2026-03-26 20:53:09 +01:00
|
|
|
slog.Warn("generated random LiveKit API key — voice tokens will break on restart; set voice.livekit_api_key in config.yaml for stable operation")
|
2026-03-19 12:23:24 +01:00
|
|
|
}
|
2026-03-20 05:14:52 +01:00
|
|
|
if v.LiveKitAPISecret == "" {
|
2026-03-31 19:00:17 +02:00
|
|
|
secret, err := generateRandomKey(32)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("generating LiveKit API secret: %w", err)
|
|
|
|
|
}
|
|
|
|
|
v.LiveKitAPISecret = secret
|
2026-03-26 20:53:09 +01:00
|
|
|
slog.Warn("generated random LiveKit API secret — set voice.livekit_api_secret in config.yaml for stable operation")
|
2026-03-20 05:14:52 +01:00
|
|
|
}
|
|
|
|
|
if v.LiveKitURL == "" {
|
2026-03-24 20:23:40 +01:00
|
|
|
v.LiveKitURL = "ws://localhost:7880"
|
2026-03-19 12:23:24 +01:00
|
|
|
}
|
|
|
|
|
if v.Quality == "" {
|
2026-03-24 20:23:40 +01:00
|
|
|
v.Quality = "medium"
|
2026-03-19 12:23:24 +01:00
|
|
|
}
|
2026-03-31 19:00:17 +02:00
|
|
|
return nil
|
2026-03-19 12:23:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// validateYAML checks that raw bytes are valid YAML.
|
|
|
|
|
func validateYAML(raw []byte) error {
|
2026-03-15 07:07:59 +01:00
|
|
|
var v any
|
2026-03-14 20:34:37 +01:00
|
|
|
return goyaml.Unmarshal(raw, &v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// envKeyToKoanf converts a lower-case env key (without OWNCORD_ prefix) to a
|
|
|
|
|
// koanf dotted path. The first segment (up to the first underscore) is the
|
|
|
|
|
// section; the remainder is the key (with underscores preserved).
|
|
|
|
|
//
|
|
|
|
|
// Examples:
|
|
|
|
|
//
|
|
|
|
|
// server_port -> server.port
|
|
|
|
|
// server_name -> server.name
|
|
|
|
|
// server_data_dir -> server.data_dir
|
|
|
|
|
// database_path -> database.path
|
|
|
|
|
// tls_mode -> tls.mode
|
|
|
|
|
// tls_cert_file -> tls.cert_file
|
|
|
|
|
// upload_max_size_mb -> upload.max_size_mb
|
|
|
|
|
func envKeyToKoanf(s string) string {
|
2026-08-07 21:20:48 +02:00
|
|
|
// event_persistence is the only multi-word section; cutting at the first
|
|
|
|
|
// underscore would produce the dead path event.persistence_* and koanf
|
|
|
|
|
// would drop the documented override silently.
|
|
|
|
|
if rest, ok := strings.CutPrefix(s, "event_persistence_"); ok {
|
|
|
|
|
return "event_persistence." + rest
|
|
|
|
|
}
|
2026-07-29 13:25:46 +02:00
|
|
|
before, after, ok := strings.Cut(s, "_")
|
|
|
|
|
if !ok {
|
2026-03-14 20:34:37 +01:00
|
|
|
return s
|
|
|
|
|
}
|
2026-07-29 13:25:46 +02:00
|
|
|
return before + "." + after
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|