350 lines
12 KiB
Go
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)
|
|
}
|
|
}
|