Improving all side of app
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// DialogHub fans control-logic dialog requests out to connected WebSocket
|
||||
// clients whose identity matches the dialog's user/group filter, and routes
|
||||
// input responses back to the dialog's target server variable.
|
||||
//
|
||||
// It implements controllogic.Notifier; the engine calls Notify when an
|
||||
// action.dialog node runs. Input dialogs are remembered as pending so a later
|
||||
// dialogResponse can be correlated by id and validated against its recipient
|
||||
// filter — this is why panels can write the response target even though direct
|
||||
// srv writes are otherwise gated to control-logic editors.
|
||||
type DialogHub struct {
|
||||
broker *broker.Broker
|
||||
policy *access.Policy
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[*wsClient]struct{}
|
||||
pending map[string]controllogic.Dialog // input dialogs awaiting a response
|
||||
}
|
||||
|
||||
// NewDialogHub builds an empty hub. rec is never nil after construction.
|
||||
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &DialogHub{
|
||||
broker: brk,
|
||||
policy: policy,
|
||||
audit: rec,
|
||||
log: log,
|
||||
clients: map[*wsClient]struct{}{},
|
||||
pending: map[string]controllogic.Dialog{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DialogHub) add(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DialogHub) remove(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// dialogOut is the client-bound JSON form of a dialog request.
|
||||
type dialogOut struct {
|
||||
Type string `json:"type"` // always "dialog"
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements controllogic.Notifier: serialise the dialog and push it to
|
||||
// every connected client matching its user/group filter.
|
||||
func (h *DialogHub) Notify(d controllogic.Dialog) {
|
||||
b, err := json.Marshal(dialogOut{
|
||||
Type: "dialog",
|
||||
ID: d.ID,
|
||||
Kind: d.Kind,
|
||||
Title: d.Title,
|
||||
Message: d.Message,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
|
||||
h.pending[d.ID] = d
|
||||
}
|
||||
var targets []*wsClient
|
||||
for c := range h.clients {
|
||||
if h.matches(c.user, d) {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether user is a recipient of d. An empty user+group filter
|
||||
// targets everyone; otherwise the user must be named or in a named group.
|
||||
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
|
||||
if len(d.Users) == 0 && len(d.Groups) == 0 {
|
||||
return true
|
||||
}
|
||||
if h.policy != nil {
|
||||
user = h.policy.ResolveUser(user)
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
if u == user {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(d.Groups) > 0 && h.policy != nil {
|
||||
groups := map[string]bool{}
|
||||
for _, g := range h.policy.GroupsOf(user) {
|
||||
groups[g] = true
|
||||
}
|
||||
for _, g := range d.Groups {
|
||||
if groups[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel drops a pending input dialog without writing (user dismissed it).
|
||||
func (h *DialogHub) cancel(id string) {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// respond writes an input dialog's response to its target server variable. The
|
||||
// dialog must be pending and the responding client must have been a recipient;
|
||||
// this gate replaces the usual srv write-permission check for sanctioned
|
||||
// control-logic responses.
|
||||
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.pending[id]
|
||||
if ok {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || !h.matches(c.user, d) {
|
||||
return
|
||||
}
|
||||
|
||||
ds, name, ok := parseDialogTarget(d.Target)
|
||||
if !ok || ds == "local" {
|
||||
return
|
||||
}
|
||||
src, ok := h.broker.Source(ds)
|
||||
if !ok {
|
||||
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
|
||||
return
|
||||
}
|
||||
|
||||
ev := audit.Event{
|
||||
Actor: c.user,
|
||||
ActorType: audit.ActorUser,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(value, 'g', -1, 64),
|
||||
Detail: "control logic dialog response",
|
||||
IP: c.ip,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
wctx := datasource.WithUser(ctx, c.user)
|
||||
if err := src.Write(wctx, name, value); err != nil {
|
||||
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
h.audit.Record(ev)
|
||||
}
|
||||
|
||||
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
|
||||
// name (no ':') defaults to the persistent server-variable source "srv".
|
||||
func parseDialogTarget(t string) (ds, name string, ok bool) {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
return "srv", t, true
|
||||
}
|
||||
@@ -28,7 +28,7 @@ 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, rec audit.Recorder, 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, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
@@ -41,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, audit: rec})
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
|
||||
+36
-2
@@ -34,6 +34,9 @@ type inMsg struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
|
||||
// dialogResponse — id of the control-logic dialog being answered.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// history
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
@@ -101,6 +104,8 @@ type wsHandler struct {
|
||||
policy *access.Policy
|
||||
// audit records signal writes (never nil; audit.Nop when disabled).
|
||||
audit audit.Recorder
|
||||
// dialogs fans control-logic dialogs to clients and routes responses.
|
||||
dialogs *DialogHub
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -143,12 +148,19 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ip: clientIP,
|
||||
policy: h.policy,
|
||||
audit: rec,
|
||||
dialogs: h.dialogs,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
log: h.log,
|
||||
}
|
||||
|
||||
// Register for control-logic dialog pushes for this client's identity.
|
||||
if h.dialogs != nil {
|
||||
h.dialogs.add(c)
|
||||
defer h.dialogs.remove(c)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
||||
@@ -174,8 +186,9 @@ type wsClient struct {
|
||||
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)
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -272,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
|
||||
c.handleWrite(ctx, msg)
|
||||
case "history":
|
||||
c.handleHistory(ctx, msg)
|
||||
case "dialogResponse":
|
||||
c.handleDialogResponse(ctx, msg)
|
||||
default:
|
||||
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
||||
}
|
||||
@@ -389,6 +404,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
c.audit.Record(ev)
|
||||
}
|
||||
|
||||
// handleDialogResponse routes the answer to a control-logic input dialog. An
|
||||
// empty/null value means the user dismissed the dialog (drop it without
|
||||
// writing); otherwise the numeric value is written to the dialog's target.
|
||||
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
|
||||
if c.dialogs == nil || msg.ID == "" {
|
||||
return
|
||||
}
|
||||
if len(msg.Value) == 0 || string(msg.Value) == "null" {
|
||||
c.dialogs.cancel(msg.ID)
|
||||
return
|
||||
}
|
||||
var num float64
|
||||
if err := json.Unmarshal(msg.Value, &num); err != nil {
|
||||
c.dialogs.cancel(msg.ID)
|
||||
return
|
||||
}
|
||||
c.dialogs.respond(ctx, c, msg.ID, num)
|
||||
}
|
||||
|
||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
metrics.IncHistoryReqs()
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
|
||||
Reference in New Issue
Block a user