Files
uopi/cmd/uopi/main.go
T
2026-06-24 06:12:52 +02:00

350 lines
12 KiB
Go

package main
import (
"context"
"embed"
"flag"
"fmt"
"io/fs"
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/scpi"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/ldapauth"
"github.com/uopi/uopi/internal/pamauth"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web"
)
// The web package's embed.go embeds web/dist at build time.
// This blank import ensures the compiler includes the package.
var _ embed.FS
func main() {
configPath := flag.String("config", "", "path to TOML config file (optional)")
flag.Parse()
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
cfg, err := config.Load(*configPath)
if err != nil {
log.Error("failed to load config", "err", err)
os.Exit(1)
}
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1)
}
store, err := storage.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open interface store", "err", err)
os.Exit(1)
}
aclStore, err := panelacl.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open panel ACL store", "err", err)
os.Exit(1)
}
cfgStore, err := confmgr.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open configuration store", "err", err)
os.Exit(1)
}
webFS, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Error("failed to sub web dist", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
// Enabled by default; disable via [datasource.stub] enabled = false in config.
if cfg.Datasource.Stub.Enabled {
stubDS := stub.New()
if err := stubDS.Connect(ctx); err != nil {
log.Error("stub connect", "err", err)
os.Exit(1)
}
brk.Register(stubDS)
}
// Synthetic data source: computes derived signals from upstream sources via
// configurable DSP pipelines defined in synthetic.json inside the storage dir.
var synthDS *synthetic.Synthetic
if cfg.Datasource.Synthetic.Enabled {
synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err)
synthDS = nil
} else {
brk.Register(synthDS)
}
}
// EPICS Channel Access data source.
// The pure-Go implementation is used by default (no CGo required).
// Build with -tags epics to use the CGo-based libca implementation instead.
if cfg.Datasource.EPICS.Enabled && epics.Available() {
ds := epics.New(
cfg.Datasource.EPICS.CAAddrList,
cfg.Datasource.EPICS.ArchiveURL,
cfg.Datasource.EPICS.ChannelFinderURL,
cfg.Datasource.EPICS.AutoSyncFilter,
cfg.Datasource.EPICS.AutoSyncFromArchiver,
cfg.Datasource.EPICS.PVNames,
)
if err := ds.Connect(ctx); err != nil {
log.Error("epics connect", "err", err)
} else {
brk.Register(ds)
}
}
// PV Access data source.
if cfg.Datasource.PVA.Enabled {
pvaDS := pva.New(cfg.Datasource.PVA.AddrList)
if err := pvaDS.Connect(ctx); err != nil {
log.Error("pva connect", "err", err)
} else {
brk.Register(pvaDS)
}
}
// Modbus TCP data source: polls configured holding/input/coil/discrete
// registers on one or more devices. Disabled unless [datasource.modbus] is
// configured with devices.
if cfg.Datasource.Modbus.Enabled {
mbDS, err := modbus.New(cfg.Datasource.Modbus)
if err != nil {
log.Error("modbus init", "err", err)
} else if err := mbDS.Connect(ctx); err != nil {
log.Error("modbus connect", "err", err)
} else {
brk.Register(mbDS)
context.AfterFunc(ctx, mbDS.Close)
}
}
// SCPI data source: polls instrument channels over raw TCP sockets.
// Disabled unless [datasource.scpi] is configured with instruments.
if cfg.Datasource.SCPI.Enabled {
scpiDS, err := scpi.New(cfg.Datasource.SCPI)
if err != nil {
log.Error("scpi init", "err", err)
} else if err := scpiDS.Connect(ctx); err != nil {
log.Error("scpi connect", "err", err)
} else {
brk.Register(scpiDS)
context.AfterFunc(ctx, scpiDS.Close)
}
}
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
if err != nil {
log.Error("server variables init", "err", err)
os.Exit(1)
}
brk.Register(srvVars)
// Build the role-based access policy from config: each [[groups]] entry lists
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
// parent for nesting. Every user is an implicit viewer of the built-in public
// group; a config with no roles at all is fully open (everyone admin).
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
members := make(map[string]access.Role)
assign := func(users []string, role access.Role) {
for _, u := range users {
members[u] = role
}
}
assign(g.Viewers, access.RoleViewer)
assign(g.Operators, access.RoleOperator)
assign(g.LogicEditors, access.RoleLogic)
assign(g.Auditors, access.RoleAuditor)
assign(g.Admins, access.RoleAdmin)
specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
}
policy := access.New(cfg.Server.DefaultUser, specs)
// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
// which (when present on a later startup) supersedes the TOML access config.
if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
log.Error("failed to load access store", "err", err)
os.Exit(1)
}
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
// When disabled a no-op recorder is used so call sites need no guards.
recorder := audit.Nop()
if cfg.Audit.Enabled {
dbPath := cfg.Audit.DBPath
if dbPath == "" {
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
}
r, err := audit.NewSQLite(dbPath, log)
if err != nil {
log.Error("failed to open audit log", "path", dbPath, "err", err)
os.Exit(1)
}
defer r.Close()
recorder = r
log.Info("audit log enabled", "path", dbPath)
}
// Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
// engine starts all enabled graphs.
ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log)
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
// connected clients (filtered by user/group) and route input responses back
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
// Debug hub: control-logic editors observe live graphs or dry-run unsaved
// edits; the engine pushes per-node execution events through it.
debugHub := server.NewDebugHub(ctrlEngine, log)
ctrlEngine.SetDebugObserver(debugHub)
ctrlEngine.Reload()
// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
// the server can validate browser Negotiate tickets and resolve users directly.
var krbKeytab *keytab.Keytab
if cfg.Server.Kerberos.Enabled {
if cfg.Server.Kerberos.Keytab == "" {
log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
os.Exit(1)
}
kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
if err != nil {
log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
os.Exit(1)
}
krbKeytab = kt
log.Info("native SPNEGO/Kerberos authentication enabled",
"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
}
// Built-in HTTP Basic authentication (optional): challenge the browser with a
// login dialog and validate credentials against either the host PAM stack
// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
// mutually exclusive.
var basicAuthFn func(user, pass string) error
var basicAuthRealm string
enabledAuth := 0
for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
if on {
enabledAuth++
}
}
if enabledAuth > 1 {
log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
os.Exit(1)
}
if cfg.Server.BasicAuth.Enabled {
if !pamauth.Available {
log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
os.Exit(1)
}
service := cfg.Server.BasicAuth.PAMService
if service == "" {
service = "uopi"
}
basicAuthFn = func(user, pass string) error {
return pamauth.Authenticate(service, user, pass)
}
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
}
if cfg.Server.LDAP.Enabled {
ldapAuth, err := ldapauth.New(ldapauth.Config{
URIs: cfg.Server.LDAP.URIs,
SearchBase: cfg.Server.LDAP.SearchBase,
UserAttr: cfg.Server.LDAP.UserAttr,
UserObjectClass: cfg.Server.LDAP.UserObjectClass,
BindDN: cfg.Server.LDAP.BindDN,
BindPassword: cfg.Server.LDAP.BindPassword,
StartTLS: cfg.Server.LDAP.StartTLS,
CACertFile: cfg.Server.LDAP.CACert,
InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
})
if err != nil {
log.Error("ldap auth misconfigured", "err", err)
os.Exit(1)
}
basicAuthFn = ldapAuth.Authenticate
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
}
if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
}
// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
// Basic auth is enabled.
var tlsCert, tlsKey string
if cfg.Server.TLS.Enabled {
if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
os.Exit(1)
}
tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
log.Info("built-in TLS enabled", "cert", tlsCert)
}
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}