Implementing more advanced feature: audit and more
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{} }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user