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"` UI UIConfig `toml:"ui"` // Groups are named sets of users, referenced by panel sharing rules. Groups []GroupDef `toml:"groups"` } // UIConfig carries client-side presentation defaults sent to the browser at // startup (via /api/v1/me). type UIConfig struct { // DefaultZoom is the base UI scale multiplier applied when a browser has no // per-machine zoom override saved. Useful to enlarge the UI by default on // HiDPI screens whose OS scaling is left at 100% (where the browser reports // devicePixelRatio=1 and the UI would otherwise render small). The in-app // A+/A− control still overrides it locally. 0 or unset means 1.0 (no scaling). DefaultZoom float64 `toml:"default_zoom"` } // 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"` // Kerberos enables native SPNEGO authentication (see KerberosConfig). Kerberos KerberosConfig `toml:"kerberos"` // BasicAuth enables built-in HTTP Basic authentication validated against PAM // (see BasicAuthConfig). Mutually exclusive with Kerberos. BasicAuth BasicAuthConfig `toml:"basic_auth"` // LDAP enables built-in HTTP Basic authentication validated against an LDAP // directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps // the static binary. Mutually exclusive with Kerberos and BasicAuth. LDAP LDAPConfig `toml:"ldap"` // TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever // BasicAuth is enabled, since Basic credentials are sent on every request. TLS TLSConfig `toml:"tls"` } // KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so // uopi identifies users directly from their Kerberos ticket, without depending on // a separate auth proxy to set TrustedUserHeader. This is the recommended setup // for browsers like Firefox that do not silently fall back to a proxy default: // uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the // browser performs the SPNEGO handshake, and uopi resolves the user from the // validated ticket (short principal name, realm stripped). That username feeds // the same access pipeline as TrustedUserHeader. // // Browsers must be told to perform SPNEGO for this server's origin (Firefox: // network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or // OS integrated auth). When enabled, any inbound TrustedUserHeader value is // ignored in favour of the Kerberos identity to prevent spoofing. type KerberosConfig struct { Enabled bool `toml:"enabled"` // Keytab is the path to the service keytab holding the HTTP service // principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when // Enabled. Keytab string `toml:"keytab"` // ServicePrincipal optionally selects which principal in the keytab to accept // tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's // entries by default. ServicePrincipal string `toml:"service_principal"` } // BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi // answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for // a username/password, and uopi validates them through the host PAM stack // (/etc/pam.d/). On hosts that are SSSD/LDAP clients this reuses the // users' normal login credentials with no directory configuration in uopi. The // validated username feeds the same access pipeline as TrustedUserHeader. // // PAM support requires a cgo build with the `pam` tag (make backend-pam); the // default fully-static binary cannot validate and will refuse to start with // BasicAuth enabled. Because Basic credentials travel on every request, enable // TLS (see TLSConfig) unless uopi sits on a fully isolated network. type BasicAuthConfig struct { Enabled bool `toml:"enabled"` // PAMService is the PAM service name under /etc/pam.d/ to authenticate // against. Empty defaults to "uopi". PAMService string `toml:"pam_service"` } // LDAPConfig enables uopi's built-in HTTP Basic authentication validated against // an LDAP directory via the "search then bind" pattern — the same flow an // SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it // works in the default fully-static binary (unlike the PAM backend), while still // authenticating users against the same directory the host logs in with. The // validated username feeds the same access pipeline as TrustedUserHeader. Enable // TLS (ldaps:// or StartTLS) so passwords are not sent in clear text. // // Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass → // "posixAccount", empty BindDN → anonymous search. Mutually exclusive with // Kerberos and BasicAuth. type LDAPConfig struct { Enabled bool `toml:"enabled"` // URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g. // "ldaps://ldap.example.com". Required when Enabled. URIs []string `toml:"uri"` // SearchBase is the subtree user entries live under (SSSD ldap_search_base). // Required when Enabled. SearchBase string `toml:"search_base"` // UserAttr is the attribute matched against the login name. Empty → "uid". UserAttr string `toml:"user_attr"` // UserObjectClass restricts the search. Empty → "posixAccount". UserObjectClass string `toml:"user_object_class"` // BindDN / BindPassword optionally authenticate the search (service account). // Empty BindDN performs an anonymous search. BindDN string `toml:"bind_dn"` BindPassword string `toml:"bind_password"` // StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for // ldaps://. StartTLS bool `toml:"start_tls"` // CACert is an optional PEM CA bundle to trust (private CA). CACert string `toml:"ca_cert"` // InsecureSkipVerify disables TLS certificate verification. Testing only. InsecureSkipVerify bool `toml:"insecure_skip_verify"` } // TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for // Basic auth) without a reverse proxy. When Enabled, Cert and Key are required. type TLSConfig struct { Enabled bool `toml:"enabled"` // Cert and Key are paths to the PEM certificate and private key. Cert string `toml:"cert"` Key string `toml:"key"` // RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP // listener on that address that 301-redirects every request to the HTTPS // service. Without it, a browser that connects with http:// gets the opaque // "client sent an HTTP request to an HTTPS server" error instead of being // upgraded. Empty disables the redirector. RedirectFrom string `toml:"redirect_from"` } 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_KERBEROS_ENABLED"); v != "" { cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" { cfg.Server.Kerberos.Keytab = v } if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" { cfg.Server.Kerberos.ServicePrincipal = v } if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" { cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" { cfg.Server.BasicAuth.PAMService = v } if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" { cfg.Server.TLS.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_SERVER_TLS_CERT"); v != "" { cfg.Server.TLS.Cert = v } if v := env("UOPI_SERVER_TLS_KEY"); v != "" { cfg.Server.TLS.Key = v } if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" { cfg.Server.LDAP.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_SERVER_LDAP_URI"); v != "" { cfg.Server.LDAP.URIs = strings.Fields(v) } if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" { cfg.Server.LDAP.SearchBase = v } if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" { cfg.Server.LDAP.BindDN = v } if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" { cfg.Server.LDAP.BindPassword = 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) } if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" { if z, err := strconv.ParseFloat(v, 64); err == nil { cfg.UI.DefaultZoom = z } } } func env(key string) string { return strings.TrimSpace(os.Getenv(key)) }