349 lines
14 KiB
Go
349 lines
14 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/BurntSushi/toml"
|
||
|
||
"github.com/uopi/uopi/internal/datasource/modbus"
|
||
"github.com/uopi/uopi/internal/datasource/scpi"
|
||
)
|
||
|
||
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/<PAMService>). 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"`
|
||
Modbus modbus.Config `toml:"modbus"`
|
||
SCPI scpi.Config `toml:"scpi"`
|
||
}
|
||
|
||
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))
|
||
}
|