package config import ( "fmt" "os" "strconv" "strings" "github.com/BurntSushi/toml" ) type Config struct { Server ServerConfig `toml:"server"` Datasource DatasourceConfig `toml:"datasource"` Audit AuditConfig `toml:"audit"` // Groups are named sets of users, referenced by panel sharing rules. Groups []GroupDef `toml:"groups"` } // AuditConfig controls the audit trail. When enabled, every user and automated // action that could affect the controlled system (signal writes, control-logic // changes) is recorded to a SQLite database for later review by audit staff. type AuditConfig struct { Enabled bool `toml:"enabled"` DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db // Readers lists users or group names allowed to view the audit log. Empty // leaves viewing unrestricted (any caller with read access). Anonymous / // trusted-LAN callers are always permitted. Readers []string `toml:"readers"` } // GroupDef is a named set of users defined as [[groups]] in the config file. type GroupDef struct { Name string `toml:"name"` Members []string `toml:"members"` } // BlacklistEntry downgrades a specific user's global access level. Levels: // "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted // with full access. type BlacklistEntry struct { User string `toml:"user"` Level string `toml:"level"` } type ServerConfig struct { Listen string `toml:"listen"` StorageDir string `toml:"storage_dir"` MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited // TrustedUserHeader is the HTTP header from which the end-user identity is // read on each WebSocket connection (set by a trusted reverse proxy doing // authentication). The identity is used for EPICS writes so each session // can act as a different user. Empty disables it (writes use the server // identity). MUST only be enabled when a proxy strips any client-supplied // value, otherwise the header can be spoofed. TrustedUserHeader string `toml:"trusted_user_header"` // DefaultUser is the identity used when the trusted user header is absent or // empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous. DefaultUser string `toml:"default_user"` // Blacklist downgrades specific users' global access level. Everyone not // listed is trusted with full write access. Blacklist []BlacklistEntry `toml:"blacklist"` // LogicEditors optionally restricts who may add or edit panel logic (the // block of interfaces) and server-side control logic. Entries are // usernames or group names. When empty, no restriction applies (any user // with write access may edit logic). Anonymous/trusted-LAN callers are // always permitted. LogicEditors []string `toml:"logic_editors"` } type DatasourceConfig struct { Stub StubConfig `toml:"stub"` EPICS EPICSConfig `toml:"epics"` PVA PVAConfig `toml:"pva"` Synthetic SyntheticConfig `toml:"synthetic"` } type StubConfig struct { Enabled bool `toml:"enabled"` } type EPICSConfig struct { Enabled bool `toml:"enabled"` CAAddrList string `toml:"ca_addr_list"` ArchiveURL string `toml:"archive_url"` ChannelFinderURL string `toml:"channel_finder_url"` AutoSyncFilter string `toml:"auto_sync_filter"` AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"` PVNames []string `toml:"pv_names"` } type PVAConfig struct { Enabled bool `toml:"enabled"` AddrList []string `toml:"addr_list"` } type SyntheticConfig struct { Enabled bool `toml:"enabled"` } func Default() Config { return Config{ Server: ServerConfig{ Listen: ":8080", StorageDir: "./interfaces", }, Datasource: DatasourceConfig{ Stub: StubConfig{Enabled: true}, EPICS: EPICSConfig{Enabled: true}, PVA: PVAConfig{Enabled: true}, Synthetic: SyntheticConfig{Enabled: true}, }, } } // Load reads a TOML config file and applies environment variable overrides. // Environment variables use the prefix UOPI_ with __ as separator, e.g. // UOPI_SERVER_LISTEN=":9090". func Load(path string) (Config, error) { cfg := Default() if path != "" { if _, err := toml.DecodeFile(path, &cfg); err != nil { return cfg, fmt.Errorf("reading config %q: %w", path, err) } } applyEnv(&cfg) return cfg, nil } func applyEnv(cfg *Config) { if v := env("UOPI_SERVER_LISTEN"); v != "" { cfg.Server.Listen = v } if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" { cfg.Server.StorageDir = v } if v := env("UOPI_SERVER_MAX_UPDATE_RATE_HZ"); v != "" { if hz, err := strconv.ParseFloat(v, 64); err == nil { cfg.Server.MaxUpdateRateHz = hz } } if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" { cfg.Server.TrustedUserHeader = v } if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { cfg.Server.DefaultUser = v } if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { cfg.Server.LogicEditors = strings.Fields(v) } if v := env("UOPI_AUDIT_ENABLED"); v != "" { cfg.Audit.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_AUDIT_DB_PATH"); v != "" { cfg.Audit.DBPath = v } if v := env("UOPI_AUDIT_READERS"); v != "" { cfg.Audit.Readers = strings.Fields(v) } if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { cfg.Datasource.EPICS.CAAddrList = v } if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" { cfg.Datasource.EPICS.ArchiveURL = v } if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" { cfg.Datasource.EPICS.ChannelFinderURL = v } if v := env("UOPI_EPICS_AUTO_SYNC_FILTER"); v != "" { cfg.Datasource.EPICS.AutoSyncFilter = v } if v := env("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER"); v != "" { cfg.Datasource.EPICS.AutoSyncFromArchiver = (v == "true" || v == "YES") } if v := env("EPICS_PVA_ADDR_LIST"); v != "" { cfg.Datasource.PVA.AddrList = strings.Fields(v) } } func env(key string) string { return strings.TrimSpace(os.Getenv(key)) }