Files

453 lines
12 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
)
// ── Incoming message types ────────────────────────────────────────────────────
type inMsg struct {
Type string `json:"type"`
// subscribe / unsubscribe
Signals []sigRef `json:"signals,omitempty"`
// write
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
MaxPoints int `json:"maxPoints,omitempty"`
}
type sigRef struct {
DS string `json:"ds"`
Name string `json:"name"`
}
// ── Outgoing message types ────────────────────────────────────────────────────
type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
// meta
Meta *metaPayload `json:"meta,omitempty"`
// history
Points []histPoint `json:"points,omitempty"`
// error
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type metaPayload struct {
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"`
DriveLow float64 `json:"driveLow,omitempty"`
DriveHigh float64 `json:"driveHigh,omitempty"`
EnumStrings []string `json:"enumStrings,omitempty"`
Writable bool `json:"writable"`
Properties map[string]string `json:"properties,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type histPoint struct {
TS string `json:"ts"`
Value any `json:"value"`
}
// ── Handler ───────────────────────────────────────────────────────────────────
type wsHandler struct {
broker *broker.Broker
log *slog.Logger
// userHeader is the HTTP header from which the per-session end-user identity
// is read (set by a trusted auth proxy). Empty disables per-user identity.
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Capture the end-user identity from the trusted proxy header (if enabled)
// before the connection is upgraded, while the original headers are present.
var user string
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true,
})
if err != nil {
h.log.Error("ws accept failed", "err", err)
return
}
metrics.IncWsConns()
defer metrics.DecWsConns()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
policy: h.policy,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
go func() { defer wg.Done(); c.writeLoop(ctx, cancel) }()
wg.Wait()
// Cancel all subscriptions on disconnect.
c.subsMu.Lock()
for _, cancelSub := range c.subs {
cancelSub()
}
c.subsMu.Unlock()
_ = conn.Close(websocket.StatusNormalClosure, "")
}
// ── wsClient ──────────────────────────────────────────────────────────────────
type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
policy *access.Policy // global access-level enforcement
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
subsMu sync.Mutex
subs map[broker.SignalRef]func() // ref → cancel
}
// readLoop reads incoming WebSocket messages and dispatches them.
// It cancels ctx (and therefore the other goroutines) on any error.
func (c *wsClient) readLoop(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
for {
_, data, err := c.conn.Read(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
c.log.Debug("ws read", "err", err)
}
return
}
c.handleMessage(ctx, data)
}
}
// dispatchLoop converts broker updates to JSON and enqueues them for writing.
func (c *wsClient) dispatchLoop(ctx context.Context) {
for {
select {
case u := <-c.updateCh:
if u.Value.MetaUpdate {
// Datasource refreshed metadata (e.g. after channel connected).
// Re-send the meta message so the client gets accurate type/unit info.
c.sendMeta(ctx, u.Ref)
continue
}
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
}
b, err := json.Marshal(msg)
if err != nil {
continue
}
select {
case c.outCh <- b:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
// writeLoop sends queued messages over the WebSocket connection.
// It cancels ctx on any write error so that readLoop and dispatchLoop exit
// promptly rather than blocking forever waiting for outCh to drain.
func (c *wsClient) writeLoop(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
for {
select {
case msg := <-c.outCh:
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
return
}
metrics.IncMsgOut()
case <-ctx.Done():
return
}
}
}
// handleMessage parses and routes a single incoming client message.
func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
metrics.IncMsgIn()
var msg inMsg
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid JSON: "+err.Error())
return
}
switch msg.Type {
case "subscribe":
c.handleSubscribe(ctx, msg.Signals)
case "unsubscribe":
c.handleUnsubscribe(msg.Signals)
case "write":
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
}
func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
_, already := c.subs[ref]
c.subsMu.Unlock()
if already {
continue
}
cancelSub, err := c.broker.Subscribe(ref, c.updateCh)
if err != nil {
c.sendError(ctx, "SUBSCRIBE_ERROR", err.Error())
continue
}
c.subsMu.Lock()
c.subs[ref] = cancelSub
c.subsMu.Unlock()
// Send metadata in a goroutine: for EPICS signals GetMetadata may block
// for several seconds waiting for the CA channel to connect and for
// ca_pend_io to complete. Running it inline would stall readLoop and
// prevent any further messages from this client from being processed.
go c.sendMeta(ctx, ref)
}
}
func (c *wsClient) handleUnsubscribe(refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
if cancel, ok := c.subs[ref]; ok {
cancel()
delete(c.subs, ref)
}
c.subsMu.Unlock()
}
}
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Enforce the user's global access level: blacklisted read-only / no-access
// users may not write signals, regardless of the channel's own writability.
if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite {
c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "you do not have write access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
// Enforce write permission before touching the value payload.
meta, err := ds.GetMetadata(ctx, msg.Name)
if err != nil {
c.log.Warn("write: GetMetadata failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name)
return
}
if !meta.Writable {
c.log.Warn("write: signal not writable", "ds", msg.DS, "name", msg.Name)
c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name)
return
}
// Decode the JSON value as the appropriate Go type.
var value any
if err := json.Unmarshal(msg.Value, &value); err != nil {
c.log.Warn("write: parse error", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error())
return
}
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user)
metrics.IncWrites()
// 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)
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)
c.sendError(ctx, "WRITE_ERROR", err.Error())
}
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
metrics.IncHistoryReqs()
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
maxPts := msg.MaxPoints
if maxPts <= 0 {
maxPts = 5000
}
vals, err := ds.History(ctx, msg.Name, msg.Start, msg.End, maxPts)
if err != nil {
c.sendError(ctx, "HISTORY_ERROR", err.Error())
return
}
pts := make([]histPoint, len(vals))
for i, v := range vals {
pts[i] = histPoint{
TS: v.Timestamp.UTC().Format(time.RFC3339Nano),
Value: v.Data,
}
}
resp := outMsg{
Type: "history",
DS: msg.DS,
Name: msg.Name,
Points: pts,
}
b, _ := json.Marshal(resp)
select {
case c.outCh <- b:
case <-ctx.Done():
}
}
func (c *wsClient) sendMeta(ctx context.Context, ref broker.SignalRef) {
ds, ok := c.broker.Source(ref.DS)
if !ok {
return
}
meta, err := ds.GetMetadata(ctx, ref.Name)
if err != nil {
return
}
msg := outMsg{
Type: "meta",
DS: ref.DS,
Name: ref.Name,
Meta: metadataToPayload(meta),
}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
default:
}
}
func (c *wsClient) sendError(ctx context.Context, code, message string) {
msg := outMsg{Type: "error", Code: code, Message: message}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
case <-ctx.Done():
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
Unit: m.Unit,
Description: m.Description,
DisplayLow: m.DisplayLow,
DisplayHigh: m.DisplayHigh,
DriveLow: m.DriveLow,
DriveHigh: m.DriveHigh,
EnumStrings: m.EnumStrings,
Writable: m.Writable,
Properties: m.Properties,
Tags: m.Tags,
}
}
func dataTypeName(t datasource.DataType) string {
switch t {
case datasource.TypeFloat64:
return "float64"
case datasource.TypeFloat64Array:
return "float64[]"
case datasource.TypeString:
return "string"
case datasource.TypeInt64:
return "int64"
case datasource.TypeBool:
return "bool"
case datasource.TypeEnum:
return "enum"
default:
return "unknown"
}
}