Implementing more advanced feature: audit and more

This commit is contained in:
Martino Ferrari
2026-06-19 14:17:46 +02:00
parent 8f6dbcba49
commit 901b87d407
31 changed files with 1669 additions and 569 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
open := New("", nil, groups, nil, nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
@@ -17,7 +17,7 @@ func TestCanEditLogic(t *testing.T) {
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
p := New("", nil, groups, []string{"alice", "ops"}, nil)
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
+100 -5
View File
@@ -8,12 +8,15 @@ import (
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
@@ -31,13 +34,18 @@ type Handler struct {
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
@@ -46,6 +54,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -56,6 +65,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -118,15 +128,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
@@ -490,6 +578,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +669,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -721,6 +811,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -1123,6 +1214,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
@@ -1152,6 +1244,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
@@ -1164,11 +1257,13 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}
+12 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
@@ -50,10 +51,10 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log)
mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -214,7 +215,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` }
var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals)
for _, s := range signals {
@@ -259,7 +262,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected non-empty ID from create")
@@ -303,7 +308,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err)
}
assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` }
var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned)
if cloned.ID == created.ID {
t.Error("clone produced same ID as original")
+65
View File
@@ -0,0 +1,65 @@
// Package audit records system-affecting actions (signal writes by users or the
// control-logic engine, interface and control-logic mutations) to an append-only
// SQLite log that audit staff can query later. It is enabled via [audit] in the
// config; when disabled a no-op Recorder is used so call sites need no guards.
package audit
import "time"
// Actor types distinguish human-initiated actions from automated ones.
const (
ActorUser = "user" // action attributed to an authenticated end-user
ActorSystem = "system" // action performed by the control-logic engine
)
// Outcomes record whether the action succeeded.
const (
OutcomeOK = "ok"
OutcomeError = "error"
)
// Event is a single audit record. Optional fields are omitted from the database
// when empty. Time defaults to the current time when zero.
type Event struct {
Time time.Time `json:"time"`
Actor string `json:"actor"` // username, or graph name for system actions
ActorType string `json:"actorType"` // ActorUser | ActorSystem
Action string `json:"action"` // e.g. "signal.write", "interface.update"
DS string `json:"ds,omitempty"` // data source (signal writes)
Signal string `json:"signal,omitempty"` // signal / target name
Value string `json:"value,omitempty"` // serialised written value
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
IP string `json:"ip,omitempty"` // client address (user actions)
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
}
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
type Filter struct {
Start time.Time
End time.Time
Actor string
Action string
DS string
Signal string
Limit int // <=0 means a default cap is applied
}
// Recorder appends events and answers queries. Implementations must be safe for
// concurrent use and Record must never block the caller for long.
type Recorder interface {
Record(Event)
Query(Filter) ([]Event, error)
Close() error
}
// nopRecorder is used when auditing is disabled. It discards every event.
type nopRecorder struct{}
func (nopRecorder) Record(Event) {}
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
func (nopRecorder) Close() error { return nil }
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
// unconditionally and call Record without checking whether auditing is enabled.
func Nop() Recorder { return nopRecorder{} }
+183
View File
@@ -0,0 +1,183 @@
package audit
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
)
// defaultQueryLimit caps how many rows Query returns when the filter does not
// specify a smaller limit, protecting the API and UI from unbounded result sets.
const defaultQueryLimit = 1000
// writeBuffer is the depth of the async insert queue. When full, Record falls
// back to a synchronous insert so events are never silently dropped.
const writeBuffer = 4096
// sqliteRecorder appends events to a SQLite database. Inserts are normally
// handled by a background goroutine so Record does not block the calling write
// path; the queue has a synchronous fallback to guarantee durability under load.
type sqliteRecorder struct {
db *sql.DB
log *slog.Logger
ch chan Event
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewSQLite opens (creating if needed) the audit database at path and starts the
// background writer. The returned Recorder must be Closed on shutdown.
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
// WAL + a busy timeout let the async writer and synchronous query/fallback
// paths share the file without "database is locked" errors.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init audit schema: %w", err)
}
r := &sqliteRecorder{
db: db,
log: log,
ch: make(chan Event, writeBuffer),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.writeLoop()
return r, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ns INTEGER NOT NULL,
actor TEXT NOT NULL,
actor_type TEXT NOT NULL,
action TEXT NOT NULL,
ds TEXT NOT NULL DEFAULT '',
signal TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
`
func (r *sqliteRecorder) Record(e Event) {
if e.Time.IsZero() {
e.Time = time.Now()
}
if e.Outcome == "" {
e.Outcome = OutcomeOK
}
select {
case <-r.closed:
// Recorder is shutting down; best-effort synchronous insert.
r.insert(e)
case r.ch <- e:
default:
// Queue full: write synchronously rather than drop an audit record.
r.insert(e)
}
}
func (r *sqliteRecorder) writeLoop() {
defer r.wg.Done()
for e := range r.ch {
r.insert(e)
}
}
func (r *sqliteRecorder) insert(e Event) {
_, err := r.db.Exec(
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
)
if err != nil {
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
}
}
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
var where []string
var args []any
if !f.Start.IsZero() {
where = append(where, "ts_ns >= ?")
args = append(args, f.Start.UnixNano())
}
if !f.End.IsZero() {
where = append(where, "ts_ns <= ?")
args = append(args, f.End.UnixNano())
}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.DS != "" {
where = append(where, "ds = ?")
args = append(args, f.DS)
}
if f.Signal != "" {
where = append(where, "signal LIKE ?")
args = append(args, "%"+f.Signal+"%")
}
limit := f.Limit
if limit <= 0 || limit > defaultQueryLimit {
limit = defaultQueryLimit
}
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY ts_ns DESC LIMIT ?"
args = append(args, limit)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
var e Event
var tsNs int64
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
return nil, fmt.Errorf("scan audit row: %w", err)
}
e.Time = time.Unix(0, tsNs)
out = append(out, e)
}
return out, rows.Err()
}
func (r *sqliteRecorder) Close() error {
r.once.Do(func() {
close(r.closed)
close(r.ch)
})
r.wg.Wait()
return r.db.Close()
}
+68
View File
@@ -0,0 +1,68 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestSQLiteRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
base := time.Now()
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
// Flush the async writer.
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
all, err := rec.Query(Filter{})
if err != nil {
t.Fatal("Query:", err)
}
if len(all) != 3 {
t.Fatalf("got %d events, want 3", len(all))
}
// Newest first.
if all[0].Actor != "bob" {
t.Errorf("first actor = %q, want bob", all[0].Actor)
}
byActor, err := rec.Query(Filter{Actor: "alice"})
if err != nil {
t.Fatal("Query actor:", err)
}
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
}
byAction, err := rec.Query(Filter{Action: "signal.write"})
if err != nil {
t.Fatal("Query action:", err)
}
if len(byAction) != 2 {
t.Errorf("action filter returned %d, want 2", len(byAction))
}
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
if err != nil {
t.Fatal("Query start:", err)
}
if len(since) != 1 || since[0].Actor != "bob" {
t.Errorf("time filter = %+v, want one bob event", since)
}
}
+4 -4
View File
@@ -84,10 +84,10 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
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"`
+30 -10
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
)
@@ -25,23 +26,29 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
audit audit.Recorder
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
@@ -227,9 +234,22 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// ── compiled graph ─────────────────────────────────────────────────────────────
@@ -245,11 +265,11 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
+29 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
)
const definitionsFile = "controllogic.json"
@@ -17,16 +18,18 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
type Store struct {
mu sync.RWMutex
path string
items map[string]Graph
mu sync.RWMutex
path string
trashDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
@@ -99,13 +102,32 @@ func (s *Store) Save(g Graph) error {
return s.saveLocked()
}
// Delete removes a graph by id.
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
g, ok := s.items[id]
if !ok {
return ErrNotFound
}
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id)
return s.saveLocked()
}
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
+7 -3
View File
@@ -10,6 +10,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic"
@@ -27,7 +28,10 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// Health check
@@ -37,7 +41,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -45,7 +49,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
+81
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
@@ -96,6 +99,8 @@ type wsHandler struct {
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
// audit records signal writes (never nil; audit.Nop when disabled).
audit audit.Recorder
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -105,6 +110,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
// Resolve to the configured default_user when the header is absent so the
// session carries a real identity (used for audit + EPICS write attribution).
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
clientIP := clientIP(r)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
@@ -120,11 +131,18 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
rec := h.audit
if rec == nil {
rec = audit.Nop()
}
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
ip: clientIP,
policy: h.policy,
audit: rec,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
@@ -155,7 +173,9 @@ type wsClient struct {
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
ip string // client address, for audit attribution
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -307,6 +327,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
// Server variables are read-any but write only for control-logic editors, so a
// panel cannot mutate sequence state unless its user owns control-logic access.
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -340,10 +368,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user)
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: msg.DS,
Signal: msg.Name,
Value: formatAuditValue(value),
IP: c.ip,
Outcome: audit.OutcomeOK,
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
c.audit.Record(ev)
c.sendError(ctx, "WRITE_ERROR", err.Error())
return
}
c.audit.Record(ev)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
@@ -420,6 +463,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
// ── Helpers ───────────────────────────────────────────────────────────────────
// clientIP returns the best-effort client address for audit attribution,
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
// falling back to the TCP peer address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// formatAuditValue renders a written value as a compact string for the audit log.
func formatAuditValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return ""
}
}
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
+32 -5
View File
@@ -474,16 +474,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
}
// Delete removes the interface with the given ID.
// Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
if _, err := os.Stat(s.filePath(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
return err
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
// Move the current file plus every versioned backup (id.vN.xml), preserving
// their original names so a restore is a straight move-back.
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// Clone duplicates an interface, assigning a new unique ID.