Files
uopi/internal/config/config.go
T
2026-06-22 17:49:14 +02:00

178 lines
5.7 KiB
Go

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. Who
// may *view* the log is governed by the auditor role (see GroupDef).
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
}
// GroupDef defines one access group as [[groups]] in the config file. Each group
// has an optional parent (for nesting) and lists its members by role. Roles form
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
// listed in several role buckets keeps the highest. The built-in "public" group
// (every user is an implicit viewer member) may be configured by name to raise
// specific users globally.
type GroupDef struct {
Name string `toml:"name"`
Parent string `toml:"parent"`
Viewers []string `toml:"viewers"`
Operators []string `toml:"operators"`
LogicEditors []string `toml:"logiceditors"`
Auditors []string `toml:"auditors"`
Admins []string `toml:"admins"`
}
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.
//
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
// all granted via group roles (see GroupDef / [[groups]]). A config with no
// group roles at all is treated as fully open (everyone is admin), so an
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
// {storage_dir}/access.json, that file — not this config — is the source of
// truth for access.
DefaultUser string `toml:"default_user"`
}
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_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_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))
}