Added ldap and pam authentication
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all frontend backend backend-debug release catools test bench race lint clean run
|
.PHONY: all frontend backend backend-debug backend-pam release catools test bench race lint clean run
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Sources — adding any file here triggers a frontend or backend rebuild #
|
# Sources — adding any file here triggers a frontend or backend rebuild #
|
||||||
@@ -51,6 +51,14 @@ $(CATOOLS): $(GO_SRCS)
|
|||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||||
|
|
||||||
|
# PAM-enabled binary: adds built-in HTTP Basic authentication validated against
|
||||||
|
# the host PAM stack (server.basic_auth). Requires cgo + libpam (-dev headers),
|
||||||
|
# so the resulting binary is NOT fully static — use only when basic_auth is needed.
|
||||||
|
backend-pam: $(FRONTEND_OUT)
|
||||||
|
@mkdir -p dist
|
||||||
|
CGO_ENABLED=1 go build -tags pam -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||||
|
|
||||||
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
|
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
|
||||||
backend-debug: $(FRONTEND_OUT)
|
backend-debug: $(FRONTEND_OUT)
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
- **BUG FIX**:
|
||||||
|
- [x] connecting from firefox always get default user — native SPNEGO/Kerberos auth (`[server.kerberos]`, `internal/server/kerberos.go`): uopi challenges with `401 Negotiate` and resolves the user from the validated ticket instead of relying on a proxy header Firefox never triggers; on non-Kerberos hosts (SSSD/LDAP) use standalone built-in HTTP Basic auth — either validated via PAM (`[server.basic_auth]`, `internal/pamauth`, build with `make backend-pam`) or, keeping the static binary, pure-Go LDAP search-then-bind (`[server.ldap]`, `internal/ldapauth`) — sharing `internal/server/basicauth.go` (which also challenges the page load so the browser shows its login dialog) with optional built-in TLS (`[server.tls]`)
|
||||||
|
- [x] dpi scaling seems not to work on firefox — root font-size now folds in `window.devicePixelRatio` (`applyZoom` = 16×zoom×dpr) with `watchDpr()` re-applying on monitor change
|
||||||
- [ ] **MAJOR** Implement configuration manager:
|
- [ ] **MAJOR** Implement configuration manager:
|
||||||
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
|
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
|
||||||
- with configuration manager user should be able to:
|
- with configuration manager user should be able to:
|
||||||
@@ -75,11 +78,11 @@
|
|||||||
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
|
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
|
||||||
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
|
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
|
||||||
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
|
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
|
||||||
- [ ] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config)
|
- [x] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config). Uniform scope model: each item carries owner + scope ∈ {private,group,global} + scope-groups; empty/unknown scope = global (legacy-safe). Shared Go helper `access.CanSee` (`internal/access/scope.go`) filters list endpoints by the caller; shared frontend `web/src/lib/scope.tsx` provides `bucketOf`/`filterByScope`, the segmented `[Mine | Group ▾ | Global]` `ScopeFilter`, and the `ScopePicker` create/save visibility editor. Visibility is a selector filter, not a hard security boundary (owner always sees own items)
|
||||||
- [ ] panel tree by user / group / global
|
- [x] panel tree by user / group / global — scope bucket derived server-side from the panel ACL (`panelScope` in `internal/api/api.go`: public→global, group grant→group, else→private; unmanaged→global), surfaced on `InterfaceListItem.scope`/`groups`; `InterfaceList.tsx` filters panels by bucket and hides folders with no in-scope descendants; visibility is edited via the existing Share dialog
|
||||||
- [ ] signal tree by user / group / global
|
- [x] signal tree by user / group / global — synthetic `SignalDef` gained a `group` visibility mode + `Groups[]`; `synVisible` (`api.go`) routes it through `access.CanSee`; `SyntheticGraphEditor.tsx` wizard offers panel/user/group/global with group checkboxes
|
||||||
- [ ] config tree by user / group / global
|
- [x] config tree by user / group / global — `ConfigSet`/`ConfigInstance` carry owner+scope+groups (`internal/confmgr`), list endpoints filter via `filterConfigMetas`/`CanSee`, owner preserved across updates; `ConfigManager.tsx` adds `ScopeFilter` + `ScopePicker` to both Sets and Instances managers
|
||||||
- [ ] control sequence by user / group / global
|
- [x] control sequence by user / group / global — control-logic `Graph` gained owner+scope+scopeGroups (`internal/controllogic/model.go`; named `scopeGroups` to avoid the existing cosmetic `Groups []NodeGroup`); `listControlLogic` filters via `CanSee`, create/update stamp/preserve owner; `ControlLogicEditor.tsx` adds `ScopeFilter` + `ScopePicker` (`filterByScope(..., g => g.scopeGroups ?? [])`)
|
||||||
- [ ] Node editors:
|
- [ ] Node editors:
|
||||||
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
|
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
|
||||||
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
|
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
|
||||||
|
|||||||
+90
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/audit"
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
@@ -24,6 +25,8 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/datasource/servervar"
|
"github.com/uopi/uopi/internal/datasource/servervar"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
|
"github.com/uopi/uopi/internal/ldapauth"
|
||||||
|
"github.com/uopi/uopi/internal/pamauth"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
"github.com/uopi/uopi/internal/server"
|
"github.com/uopi/uopi/internal/server"
|
||||||
"github.com/uopi/uopi/internal/storage"
|
"github.com/uopi/uopi/internal/storage"
|
||||||
@@ -220,7 +223,93 @@ func main() {
|
|||||||
ctrlEngine.SetDebugObserver(debugHub)
|
ctrlEngine.SetDebugObserver(debugHub)
|
||||||
ctrlEngine.Reload()
|
ctrlEngine.Reload()
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
|
||||||
|
// the server can validate browser Negotiate tickets and resolve users directly.
|
||||||
|
var krbKeytab *keytab.Keytab
|
||||||
|
if cfg.Server.Kerberos.Enabled {
|
||||||
|
if cfg.Server.Kerberos.Keytab == "" {
|
||||||
|
log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
krbKeytab = kt
|
||||||
|
log.Info("native SPNEGO/Kerberos authentication enabled",
|
||||||
|
"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in HTTP Basic authentication (optional): challenge the browser with a
|
||||||
|
// login dialog and validate credentials against either the host PAM stack
|
||||||
|
// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
|
||||||
|
// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
|
||||||
|
// mutually exclusive.
|
||||||
|
var basicAuthFn func(user, pass string) error
|
||||||
|
var basicAuthRealm string
|
||||||
|
enabledAuth := 0
|
||||||
|
for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
|
||||||
|
if on {
|
||||||
|
enabledAuth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if enabledAuth > 1 {
|
||||||
|
log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if cfg.Server.BasicAuth.Enabled {
|
||||||
|
if !pamauth.Available {
|
||||||
|
log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
service := cfg.Server.BasicAuth.PAMService
|
||||||
|
if service == "" {
|
||||||
|
service = "uopi"
|
||||||
|
}
|
||||||
|
basicAuthFn = func(user, pass string) error {
|
||||||
|
return pamauth.Authenticate(service, user, pass)
|
||||||
|
}
|
||||||
|
basicAuthRealm = "uopi"
|
||||||
|
log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
|
||||||
|
}
|
||||||
|
if cfg.Server.LDAP.Enabled {
|
||||||
|
ldapAuth, err := ldapauth.New(ldapauth.Config{
|
||||||
|
URIs: cfg.Server.LDAP.URIs,
|
||||||
|
SearchBase: cfg.Server.LDAP.SearchBase,
|
||||||
|
UserAttr: cfg.Server.LDAP.UserAttr,
|
||||||
|
UserObjectClass: cfg.Server.LDAP.UserObjectClass,
|
||||||
|
BindDN: cfg.Server.LDAP.BindDN,
|
||||||
|
BindPassword: cfg.Server.LDAP.BindPassword,
|
||||||
|
StartTLS: cfg.Server.LDAP.StartTLS,
|
||||||
|
CACertFile: cfg.Server.LDAP.CACert,
|
||||||
|
InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ldap auth misconfigured", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
basicAuthFn = ldapAuth.Authenticate
|
||||||
|
basicAuthRealm = "uopi"
|
||||||
|
log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
|
||||||
|
}
|
||||||
|
if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
|
||||||
|
log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
|
||||||
|
// Basic auth is enabled.
|
||||||
|
var tlsCert, tlsKey string
|
||||||
|
if cfg.Server.TLS.Enabled {
|
||||||
|
if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
|
||||||
|
log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
|
||||||
|
log.Info("built-in TLS enabled", "cert", tlsCert)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.UI.DefaultZoom, log)
|
||||||
|
|
||||||
if err := srv.Start(ctx); err != nil {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
+97
-1
@@ -384,7 +384,39 @@ instance's `setId` in its `Meta`.
|
|||||||
|
|
||||||
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
|
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
|
||||||
identity is read per request from `server.trusted_user_header` (with a `default_user`
|
identity is read per request from `server.trusted_user_header` (with a `default_user`
|
||||||
fallback) and stored on the request context. Access is **role-based** through group
|
fallback) and stored on the request context. Alternatively, **native SPNEGO/Kerberos**
|
||||||
|
authentication (`[server.kerberos]`) lets uopi identify users directly from their Kerberos
|
||||||
|
ticket without a reverse proxy: `internal/server/kerberos.go` wraps the REST and WebSocket
|
||||||
|
handlers, challenges API requests with `401 WWW-Authenticate: Negotiate`, validates the
|
||||||
|
ticket against the configured service keytab (`github.com/jcmturner/gokrb5`), and writes the
|
||||||
|
short principal name (realm stripped) into the same `userHeader` the access pipeline reads
|
||||||
|
— so downstream identity resolution is identical. Any client-supplied header value is
|
||||||
|
discarded before validation to prevent spoofing. WebSocket upgrades (where browsers cannot
|
||||||
|
attach an `Authorization` header) validate proactively-sent credentials best-effort and
|
||||||
|
otherwise fall back to `default_user`. Browsers must be configured to perform SPNEGO for the
|
||||||
|
server origin (Firefox: `network.negotiate-auth.trusted-uris`), which fixes the case where
|
||||||
|
Firefox would otherwise resolve to `default_user`. A third standalone option is **built-in
|
||||||
|
HTTP Basic authentication** (`[server.basic_auth]`, mutually exclusive with Kerberos):
|
||||||
|
`internal/server/basicauth.go` challenges with `401 WWW-Authenticate: Basic` and validates
|
||||||
|
credentials against the host PAM stack (`internal/pamauth`, `/etc/pam.d/<pam_service>`), so
|
||||||
|
on an SSSD/LDAP-joined host users authenticate with their normal login password and no
|
||||||
|
directory schema is needed in uopi. Successful logins are memoised in a short-TTL salted-hash
|
||||||
|
cache (`credCache`) to avoid a PAM round-trip per request, and the validated username is
|
||||||
|
written into the same `userHeader`. PAM requires a cgo build (`make backend-pam`, build tag
|
||||||
|
`pam`); the default static binary uses a stub that refuses Basic auth. As a **pure-Go**
|
||||||
|
alternative that keeps the fully-static (`CGO_ENABLED=0`) binary, `[server.ldap]`
|
||||||
|
(`internal/ldapauth`) validates the same Basic credentials against an LDAP directory with a
|
||||||
|
"search then bind" — anonymously (or via a service `bind_dn`) locate the user entry under
|
||||||
|
`search_base`, then bind as its DN with the supplied password — mirroring an SSSD/LDAP
|
||||||
|
client (defaults: `user_attr=uid`, `user_object_class=posixAccount`). Empty passwords are
|
||||||
|
rejected before any bind to avoid LDAP "unauthenticated bind", and the login name is
|
||||||
|
filter-escaped against injection. The challenge front-end is shared: both PAM and LDAP feed
|
||||||
|
the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's
|
||||||
|
native login dialog actually appears (a background `fetch('/me')` 401 does not prompt).
|
||||||
|
Because Basic credentials are sent on every request, uopi can terminate **TLS** itself
|
||||||
|
(`[server.tls]` → `ListenAndServeTLS`) without a reverse proxy. The four identity sources
|
||||||
|
(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are
|
||||||
|
mutually exclusive where they overlap. Access is **role-based** through group
|
||||||
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
|
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
|
||||||
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
|
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
|
||||||
capability is the highest role across all their memberships. Roles map to capabilities as:
|
capability is the highest role across all their memberships. Roles map to capabilities as:
|
||||||
@@ -425,6 +457,39 @@ opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-gr
|
|||||||
assignment with an effective-role badge), Groups (nesting + per-member roles), and
|
assignment with an effective-role badge), Groups (nesting + per-member roles), and
|
||||||
Server-stats tabs.
|
Server-stats tabs.
|
||||||
|
|
||||||
|
#### Visibility scope (selector-tree filtering)
|
||||||
|
|
||||||
|
Every user-owned, list-able object — panels, synthetic signals, config sets/instances, and
|
||||||
|
control-logic graphs — carries a uniform **visibility scope** so each selector tree can be
|
||||||
|
filtered by **Mine / Group / Global**. The model is `owner` (stamped server-side from the
|
||||||
|
trusted identity on create, immutable across updates) plus a scope token
|
||||||
|
`∈ {private, group, global}` and, for group scope, a list of group names. An empty or
|
||||||
|
unknown token resolves to **global**, so legacy objects with no scope stay visible to
|
||||||
|
everyone. This is a *visibility filter*, not a hard security boundary: an owner always sees
|
||||||
|
their own objects regardless of scope, and the per-panel ACL (§3.10) remains the real
|
||||||
|
access-control mechanism for panels.
|
||||||
|
|
||||||
|
The shared backend helper is `access.CanSee(user, owner, scope, itemGroups, userGroups)`
|
||||||
|
(`internal/access/scope.go`), with a `(*Policy).CanSee` method that resolves the caller's
|
||||||
|
groups via `GroupsOf`. Each list endpoint filters its results through it:
|
||||||
|
`internal/confmgr` stores `owner/scope/groups` on sets and instances (filtered by
|
||||||
|
`filterConfigMetas`); synthetic `SignalDef` gained a `group` visibility mode routed through
|
||||||
|
`synVisible`; control-logic `Graph` carries `owner/scope/scopeGroups` (named to avoid the
|
||||||
|
pre-existing cosmetic `Groups []NodeGroup`) filtered in `listControlLogic`. Panels reuse the
|
||||||
|
existing ACL rather than a new field: `panelScope` (`internal/api/api.go`) derives the
|
||||||
|
bucket from the ACL record (public → global, a group grant → group, otherwise → private;
|
||||||
|
unmanaged → global) and surfaces it on `InterfaceListItem.scope`/`groups`.
|
||||||
|
|
||||||
|
The shared frontend lib is `web/src/lib/scope.tsx`: `bucketOf` assigns an item to exactly
|
||||||
|
one bucket (owned → mine, else group-scoped → group, else global), `filterByScope` narrows a
|
||||||
|
list to the active bucket (with an optional groups-accessor for control-logic's
|
||||||
|
`scopeGroups`), `ScopeFilter` is the segmented `[Mine | Group ▾ | Global]` selector shown
|
||||||
|
above each tree (the Group segment carries a combo to pick a group when the user is in
|
||||||
|
several), and `ScopePicker` is the create/save visibility editor. `ConfigManager.tsx`,
|
||||||
|
`SyntheticGraphEditor.tsx`, and `ControlLogicEditor.tsx` use the picker to set scope on save;
|
||||||
|
panel visibility is instead edited through the existing Share dialog. `InterfaceList.tsx`
|
||||||
|
applies the filter to its folder tree, hiding folders that have no in-scope descendant.
|
||||||
|
|
||||||
### 3.11 Configuration Manager
|
### 3.11 Configuration Manager
|
||||||
|
|
||||||
The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed
|
The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed
|
||||||
@@ -534,6 +599,7 @@ Both edit and view modes support mouse-drag panel resizing:
|
|||||||
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
|
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
|
||||||
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
|
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
|
||||||
- **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
|
- **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
|
||||||
|
- The root font-size also folds in `window.devicePixelRatio` (`applyZoom` → `16 × zoom × dpr`) so high-DPI displays auto-scale the UI by default; Firefox in particular does not enlarge the root px on its own, so the DPR must be applied explicitly. `watchDpr()` re-applies the zoom when the ratio changes (e.g. the window moves to a differently-scaled monitor).
|
||||||
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
|
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
|
||||||
|
|
||||||
### 4.8 Lua Editor (`LuaEditor.tsx`)
|
### 4.8 Lua Editor (`LuaEditor.tsx`)
|
||||||
@@ -622,6 +688,36 @@ storage_dir = "./interfaces"
|
|||||||
trusted_user_header = "" # header carrying the proxy-authenticated user
|
trusted_user_header = "" # header carrying the proxy-authenticated user
|
||||||
default_user = "" # identity when the header is absent (LAN/dev)
|
default_user = "" # identity when the header is absent (LAN/dev)
|
||||||
|
|
||||||
|
# Native SPNEGO/Kerberos auth — alternative to a proxy; identifies users from
|
||||||
|
# their Kerberos ticket. Recommended when some browsers (e.g. Firefox) would
|
||||||
|
# otherwise fall through to default_user.
|
||||||
|
# [server.kerberos]
|
||||||
|
# enabled = true
|
||||||
|
# keytab = "/etc/uopi/http.keytab" # HTTP/host@REALM service key
|
||||||
|
# service_principal = "HTTP/host.example.com" # optional; empty = keytab default
|
||||||
|
|
||||||
|
# Built-in HTTP Basic auth (PAM) — standalone, mutually exclusive with Kerberos.
|
||||||
|
# Requires a PAM build: `make backend-pam`. Enable TLS below for production.
|
||||||
|
# [server.basic_auth]
|
||||||
|
# enabled = true
|
||||||
|
# pam_service = "uopi" # /etc/pam.d/<name>; empty = "uopi"
|
||||||
|
|
||||||
|
# Built-in HTTP Basic auth (LDAP) — pure-Go, works in the static binary; search
|
||||||
|
# then bind against the directory. Mutually exclusive with kerberos/basic_auth.
|
||||||
|
# [server.ldap]
|
||||||
|
# enabled = true
|
||||||
|
# uri = ["ldaps://ldap.example.com"]
|
||||||
|
# search_base = "dc=example,dc=com"
|
||||||
|
# user_attr = "uid" # "sAMAccountName" for AD; empty = uid
|
||||||
|
# bind_dn = "" # empty = anonymous search
|
||||||
|
|
||||||
|
# Built-in TLS/HTTPS — terminate HTTPS without a reverse proxy. Recommended
|
||||||
|
# whenever basic_auth is enabled. Both cert and key required when enabled.
|
||||||
|
# [server.tls]
|
||||||
|
# enabled = true
|
||||||
|
# cert = "/etc/uopi/tls/cert.pem"
|
||||||
|
# key = "/etc/uopi/tls/key.pem"
|
||||||
|
|
||||||
# Role-based access through group memberships. Roles (low→high):
|
# Role-based access through group memberships. Roles (low→high):
|
||||||
# viewer < operator < logiceditor < auditor < admin
|
# viewer < operator < logiceditor < auditor < admin
|
||||||
# Effective capability = highest role across all memberships. The built-in
|
# Effective capability = highest role across all memberships. The built-in
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
|||||||
@@ -1,32 +1,97 @@
|
|||||||
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
|
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
|
||||||
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
|
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||||
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package access
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Visibility scope tokens shared by user-owned, filterable objects across uopi
|
||||||
|
// (config sets/instances, control-logic graphs, synthetic signals). They drive
|
||||||
|
// the per-tree "Mine / Group / Global" selector. Storage carries the scope as a
|
||||||
|
// plain string so each subsystem can embed it in its own JSON without depending
|
||||||
|
// on this package's types.
|
||||||
|
const (
|
||||||
|
// ScopePrivate: visible only to the owner.
|
||||||
|
ScopePrivate = "private"
|
||||||
|
// ScopeGroup: visible to the owner and members of any listed group.
|
||||||
|
ScopeGroup = "group"
|
||||||
|
// ScopeGlobal: visible to everyone. This is also the legacy default — an
|
||||||
|
// empty/unknown scope is treated as global so objects created before scopes
|
||||||
|
// existed stay visible to all.
|
||||||
|
ScopeGlobal = "global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanSee reports whether user (a member of userGroups) may see an object with the
|
||||||
|
// given owner, scope and itemGroups. An empty or unrecognised scope is treated as
|
||||||
|
// global, so legacy objects without a scope remain visible to everyone.
|
||||||
|
//
|
||||||
|
// This is a visibility filter for selector trees, not a hard security boundary:
|
||||||
|
// it governs which objects are offered in listings, and intentionally always
|
||||||
|
// shows an object to its owner regardless of scope.
|
||||||
|
func CanSee(user, owner, scope string, itemGroups, userGroups []string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||||
|
case ScopePrivate:
|
||||||
|
return owner != "" && owner == user
|
||||||
|
case ScopeGroup:
|
||||||
|
if owner != "" && owner == user {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, g := range itemGroups {
|
||||||
|
if slices.Contains(userGroups, g) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default: // global / empty / unknown
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSee reports whether user may see an object with the given owner, scope and
|
||||||
|
// itemGroups, resolving the user's group memberships from the policy. It is the
|
||||||
|
// convenience wrapper list handlers use.
|
||||||
|
func (p *Policy) CanSee(user, owner, scope string, itemGroups []string) bool {
|
||||||
|
return CanSee(user, owner, scope, itemGroups, p.GroupsOf(user))
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package access
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCanSee(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user string
|
||||||
|
owner string
|
||||||
|
scope string
|
||||||
|
itemGroups []string
|
||||||
|
userGroups []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"global visible to anyone", "bob", "alice", ScopeGlobal, nil, nil, true},
|
||||||
|
{"empty scope = global", "bob", "alice", "", nil, nil, true},
|
||||||
|
{"unknown scope = global", "bob", "alice", "weird", nil, nil, true},
|
||||||
|
{"private hidden from others", "bob", "alice", ScopePrivate, nil, nil, false},
|
||||||
|
{"private visible to owner", "alice", "alice", ScopePrivate, nil, nil, true},
|
||||||
|
{"private with empty owner hidden", "bob", "", ScopePrivate, nil, nil, false},
|
||||||
|
{"group visible to member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"ops"}, true},
|
||||||
|
{"group hidden from non-member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"eng"}, false},
|
||||||
|
{"group visible to owner even if not a member", "alice", "alice", ScopeGroup, []string{"ops"}, nil, true},
|
||||||
|
{"group with multiple item groups", "bob", "alice", ScopeGroup, []string{"ops", "eng"}, []string{"eng"}, true},
|
||||||
|
{"case-insensitive scope token", "bob", "alice", "PRIVATE", nil, nil, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := CanSee(c.user, c.owner, c.scope, c.itemGroups, c.userGroups); got != c.want {
|
||||||
|
t.Errorf("CanSee(%q,%q,%q,%v,%v) = %v, want %v",
|
||||||
|
c.user, c.owner, c.scope, c.itemGroups, c.userGroups, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyCanSeeResolvesGroups(t *testing.T) {
|
||||||
|
p := New("", []GroupSpec{{Name: "ops", Members: map[string]Role{"bob": RoleOperator}}})
|
||||||
|
// bob is a member of ops; a group-scoped item shared with ops is visible.
|
||||||
|
if !p.CanSee("bob", "alice", ScopeGroup, []string{"ops"}) {
|
||||||
|
t.Errorf("expected bob to see an ops-scoped item")
|
||||||
|
}
|
||||||
|
// carol is in no group; the same item is hidden.
|
||||||
|
if p.CanSee("carol", "alice", ScopeGroup, []string{"ops"}) {
|
||||||
|
t.Errorf("expected carol not to see an ops-scoped item")
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-16
@@ -44,12 +44,13 @@ type Handler struct {
|
|||||||
audit audit.Recorder // never nil; audit.Nop when disabled
|
audit audit.Recorder // never nil; audit.Nop when disabled
|
||||||
channelFinderURL string // empty if not configured
|
channelFinderURL string // empty if not configured
|
||||||
archiverURL string // empty if not configured
|
archiverURL string // empty if not configured
|
||||||
|
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||||
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
|
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
|
||||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
rec = audit.Nop()
|
rec = audit.Nop()
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
|
|||||||
audit: rec,
|
audit: rec,
|
||||||
channelFinderURL: channelFinderURL,
|
channelFinderURL: channelFinderURL,
|
||||||
archiverURL: archiverURL,
|
archiverURL: archiverURL,
|
||||||
|
uiDefaultZoom: uiDefaultZoom,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +194,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
if groups == nil {
|
if groups == nil {
|
||||||
groups = []string{}
|
groups = []string{}
|
||||||
}
|
}
|
||||||
|
defaultZoom := h.uiDefaultZoom
|
||||||
|
if defaultZoom <= 0 {
|
||||||
|
defaultZoom = 1.0
|
||||||
|
}
|
||||||
jsonOK(w, map[string]any{
|
jsonOK(w, map[string]any{
|
||||||
"user": user,
|
"user": user,
|
||||||
"level": h.policy.Level(user).String(),
|
"level": h.policy.Level(user).String(),
|
||||||
@@ -199,6 +205,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"canEditLogic": h.policy.CanEditLogic(user),
|
"canEditLogic": h.policy.CanEditLogic(user),
|
||||||
"canViewAudit": h.policy.CanViewAudit(user),
|
"canViewAudit": h.policy.CanViewAudit(user),
|
||||||
"canAdmin": h.policy.CanAdmin(user),
|
"canAdmin": h.policy.CanAdmin(user),
|
||||||
|
"defaultZoom": defaultZoom,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
if dsName == "synthetic" && h.synthetic != nil {
|
if dsName == "synthetic" && h.synthetic != nil {
|
||||||
user := caller(r)
|
user := caller(r)
|
||||||
panel := r.URL.Query().Get("panel")
|
panel := r.URL.Query().Get("panel")
|
||||||
|
userGroups := h.policy.GroupsOf(user)
|
||||||
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||||
return synVisible(d, user, panel)
|
return synVisible(d, user, panel, userGroups)
|
||||||
})
|
})
|
||||||
out := make([]signalInfo, len(metas))
|
out := make([]signalInfo, len(metas))
|
||||||
for i, m := range metas {
|
for i, m := range metas {
|
||||||
@@ -398,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// synVisible reports whether a synthetic signal should be listed for the given
|
// synVisible reports whether a synthetic signal should be listed for the given
|
||||||
// caller while editing the given panel. An empty Visibility is treated as
|
// caller (a member of userGroups) while editing the given panel. An empty
|
||||||
// "global" so legacy definitions remain visible everywhere.
|
// Visibility is treated as "global" so legacy definitions remain visible
|
||||||
func synVisible(d synthetic.SignalDef, user, panel string) bool {
|
// everywhere.
|
||||||
|
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
|
||||||
switch d.Visibility {
|
switch d.Visibility {
|
||||||
case "user":
|
case "user":
|
||||||
return user != "" && d.Owner == user
|
return user != "" && d.Owner == user
|
||||||
|
case "group":
|
||||||
|
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
|
||||||
case "panel":
|
case "panel":
|
||||||
return panel != "" && d.Panel == panel
|
return panel != "" && d.Panel == panel
|
||||||
default: // "global" or legacy empty
|
default: // "global" or legacy empty
|
||||||
@@ -431,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user := caller(r)
|
user := caller(r)
|
||||||
panel := r.URL.Query().Get("panel")
|
panel := r.URL.Query().Get("panel")
|
||||||
|
userGroups := h.policy.GroupsOf(user)
|
||||||
|
|
||||||
var out []signalInfo
|
var out []signalInfo
|
||||||
for _, ds := range sources {
|
for _, ds := range sources {
|
||||||
var metas []datasource.Metadata
|
var metas []datasource.Metadata
|
||||||
if ds.Name() == "synthetic" && h.synthetic != nil {
|
if ds.Name() == "synthetic" && h.synthetic != nil {
|
||||||
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||||
return synVisible(d, user, panel)
|
return synVisible(d, user, panel, userGroups)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
@@ -593,10 +605,33 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
|||||||
// render sharing affordances and filter the visible set.
|
// render sharing affordances and filter the visible set.
|
||||||
type interfaceListItem struct {
|
type interfaceListItem struct {
|
||||||
storage.InterfaceMeta
|
storage.InterfaceMeta
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
Order float64 `json:"order,omitempty"`
|
Order float64 `json:"order,omitempty"`
|
||||||
Perm string `json:"perm"`
|
Perm string `json:"perm"`
|
||||||
|
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
|
||||||
|
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelScope derives a uniform visibility token (and, for group scope, the
|
||||||
|
// shared group names) from a panel's ACL record so the selector tree can bucket
|
||||||
|
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
|
||||||
|
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
|
||||||
|
// with one or more user-groups is group-scoped; everything else is private.
|
||||||
|
func panelScope(acl *panelacl.PanelACL) (string, []string) {
|
||||||
|
if acl == nil || acl.Public != "" {
|
||||||
|
return access.ScopeGlobal, nil
|
||||||
|
}
|
||||||
|
var groups []string
|
||||||
|
for _, g := range acl.Grants {
|
||||||
|
if g.Kind == "group" {
|
||||||
|
groups = append(groups, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(groups) > 0 {
|
||||||
|
return access.ScopeGroup, groups
|
||||||
|
}
|
||||||
|
return access.ScopePrivate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -613,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue // hide panels the caller cannot see
|
continue // hide panels the caller cannot see
|
||||||
}
|
}
|
||||||
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
|
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
|
||||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
acl := h.acl.GetPanel(m.ID)
|
||||||
|
if acl != nil {
|
||||||
item.Owner = acl.Owner
|
item.Owner = acl.Owner
|
||||||
item.Folder = acl.Folder
|
item.Folder = acl.Folder
|
||||||
item.Order = acl.Order
|
item.Order = acl.Order
|
||||||
}
|
}
|
||||||
|
item.Scope, item.Groups = panelScope(acl)
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
h.log.Info("list interfaces", "count", len(out))
|
h.log.Info("list interfaces", "count", len(out))
|
||||||
@@ -1548,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ── Control logic ──────────────────────────────────────────────────────────────
|
// ── Control logic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.ctrlLogic == nil {
|
if h.ctrlLogic == nil {
|
||||||
jsonOK(w, []any{})
|
jsonOK(w, []any{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
graphs := h.ctrlLogic.List()
|
user := caller(r)
|
||||||
if graphs == nil {
|
graphs := []controllogic.Graph{}
|
||||||
graphs = []controllogic.Graph{}
|
for _, g := range h.ctrlLogic.List() {
|
||||||
|
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
|
||||||
|
graphs = append(graphs, g)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
jsonOK(w, graphs)
|
jsonOK(w, graphs)
|
||||||
}
|
}
|
||||||
@@ -1588,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.ID = genID("cl")
|
g.ID = genID("cl")
|
||||||
|
g.Owner = caller(r) // stamp ownership from the trusted identity
|
||||||
if err := h.ctrlLogic.Save(g); err != nil {
|
if err := h.ctrlLogic.Save(g); err != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -1608,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if _, err := h.ctrlLogic.Get(id); err != nil {
|
prev, err := h.ctrlLogic.Get(id)
|
||||||
|
if err != nil {
|
||||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.ID = id
|
g.ID = id
|
||||||
|
g.Owner = prev.Owner // owner is immutable across revisions
|
||||||
if err := h.ctrlLogic.Save(g); err != nil {
|
if err := h.ctrlLogic.Save(g); err != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
return srv, func() {
|
return srv, func() {
|
||||||
@@ -485,7 +485,7 @@ func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner := http.NewServeMux()
|
inner := http.NewServeMux()
|
||||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
|
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+23
-4
@@ -37,7 +37,7 @@ func configStatus(err error) int {
|
|||||||
|
|
||||||
// ── config sets ─────────────────────────────────────────────────────────────
|
// ── config sets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.configEnabled(w) {
|
if !h.configEnabled(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
|||||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonOK(w, sets)
|
jsonOK(w, h.filterConfigMetas(r, sets))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -92,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if prev, err := h.cfg.GetSet(id); err == nil {
|
||||||
|
set.Owner = prev.Owner // owner is immutable across revisions
|
||||||
|
}
|
||||||
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
|
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, configStatus(err), err.Error())
|
jsonError(w, configStatus(err), err.Error())
|
||||||
@@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
|
|||||||
|
|
||||||
// ── config instances ────────────────────────────────────────────────────────
|
// ── config instances ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.configEnabled(w) {
|
if !h.configEnabled(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
|||||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonOK(w, insts)
|
jsonOK(w, h.filterConfigMetas(r, insts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterConfigMetas drops entries the caller may not see per their scope
|
||||||
|
// (private/group/global). Owner always sees their own; empty scope = global.
|
||||||
|
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
|
||||||
|
user := caller(r)
|
||||||
|
out := make([]confmgr.Meta, 0, len(metas))
|
||||||
|
for _, m := range metas {
|
||||||
|
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if prev, err := h.cfg.GetInstance(id); err == nil {
|
||||||
|
inst.Owner = prev.Owner // owner is immutable across revisions
|
||||||
|
}
|
||||||
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
|
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, configStatus(err), err.Error())
|
jsonError(w, configStatus(err), err.Error())
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ type Config struct {
|
|||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Datasource DatasourceConfig `toml:"datasource"`
|
Datasource DatasourceConfig `toml:"datasource"`
|
||||||
Audit AuditConfig `toml:"audit"`
|
Audit AuditConfig `toml:"audit"`
|
||||||
|
UI UIConfig `toml:"ui"`
|
||||||
// Groups are named sets of users, referenced by panel sharing rules.
|
// Groups are named sets of users, referenced by panel sharing rules.
|
||||||
Groups []GroupDef `toml:"groups"`
|
Groups []GroupDef `toml:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UIConfig carries client-side presentation defaults sent to the browser at
|
||||||
|
// startup (via /api/v1/me).
|
||||||
|
type UIConfig struct {
|
||||||
|
// DefaultZoom is the base UI scale multiplier applied when a browser has no
|
||||||
|
// per-machine zoom override saved. Useful to enlarge the UI by default on
|
||||||
|
// HiDPI screens whose OS scaling is left at 100% (where the browser reports
|
||||||
|
// devicePixelRatio=1 and the UI would otherwise render small). The in-app
|
||||||
|
// A+/A− control still overrides it locally. 0 or unset means 1.0 (no scaling).
|
||||||
|
DefaultZoom float64 `toml:"default_zoom"`
|
||||||
|
}
|
||||||
|
|
||||||
// AuditConfig controls the audit trail. When enabled, every user and automated
|
// AuditConfig controls the audit trail. When enabled, every user and automated
|
||||||
// action that could affect the controlled system (signal writes, control-logic
|
// action that could affect the controlled system (signal writes, control-logic
|
||||||
// changes) is recorded to a SQLite database for later review by audit staff. Who
|
// changes) is recorded to a SQLite database for later review by audit staff. Who
|
||||||
@@ -65,6 +77,116 @@ type ServerConfig struct {
|
|||||||
// {storage_dir}/access.json, that file — not this config — is the source of
|
// {storage_dir}/access.json, that file — not this config — is the source of
|
||||||
// truth for access.
|
// truth for access.
|
||||||
DefaultUser string `toml:"default_user"`
|
DefaultUser string `toml:"default_user"`
|
||||||
|
|
||||||
|
// Kerberos enables native SPNEGO authentication (see KerberosConfig).
|
||||||
|
Kerberos KerberosConfig `toml:"kerberos"`
|
||||||
|
|
||||||
|
// BasicAuth enables built-in HTTP Basic authentication validated against PAM
|
||||||
|
// (see BasicAuthConfig). Mutually exclusive with Kerberos.
|
||||||
|
BasicAuth BasicAuthConfig `toml:"basic_auth"`
|
||||||
|
|
||||||
|
// LDAP enables built-in HTTP Basic authentication validated against an LDAP
|
||||||
|
// directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps
|
||||||
|
// the static binary. Mutually exclusive with Kerberos and BasicAuth.
|
||||||
|
LDAP LDAPConfig `toml:"ldap"`
|
||||||
|
|
||||||
|
// TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever
|
||||||
|
// BasicAuth is enabled, since Basic credentials are sent on every request.
|
||||||
|
TLS TLSConfig `toml:"tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so
|
||||||
|
// uopi identifies users directly from their Kerberos ticket, without depending on
|
||||||
|
// a separate auth proxy to set TrustedUserHeader. This is the recommended setup
|
||||||
|
// for browsers like Firefox that do not silently fall back to a proxy default:
|
||||||
|
// uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the
|
||||||
|
// browser performs the SPNEGO handshake, and uopi resolves the user from the
|
||||||
|
// validated ticket (short principal name, realm stripped). That username feeds
|
||||||
|
// the same access pipeline as TrustedUserHeader.
|
||||||
|
//
|
||||||
|
// Browsers must be told to perform SPNEGO for this server's origin (Firefox:
|
||||||
|
// network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or
|
||||||
|
// OS integrated auth). When enabled, any inbound TrustedUserHeader value is
|
||||||
|
// ignored in favour of the Kerberos identity to prevent spoofing.
|
||||||
|
type KerberosConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// Keytab is the path to the service keytab holding the HTTP service
|
||||||
|
// principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when
|
||||||
|
// Enabled.
|
||||||
|
Keytab string `toml:"keytab"`
|
||||||
|
// ServicePrincipal optionally selects which principal in the keytab to accept
|
||||||
|
// tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's
|
||||||
|
// entries by default.
|
||||||
|
ServicePrincipal string `toml:"service_principal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi
|
||||||
|
// answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for
|
||||||
|
// a username/password, and uopi validates them through the host PAM stack
|
||||||
|
// (/etc/pam.d/<PAMService>). On hosts that are SSSD/LDAP clients this reuses the
|
||||||
|
// users' normal login credentials with no directory configuration in uopi. The
|
||||||
|
// validated username feeds the same access pipeline as TrustedUserHeader.
|
||||||
|
//
|
||||||
|
// PAM support requires a cgo build with the `pam` tag (make backend-pam); the
|
||||||
|
// default fully-static binary cannot validate and will refuse to start with
|
||||||
|
// BasicAuth enabled. Because Basic credentials travel on every request, enable
|
||||||
|
// TLS (see TLSConfig) unless uopi sits on a fully isolated network.
|
||||||
|
type BasicAuthConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// PAMService is the PAM service name under /etc/pam.d/ to authenticate
|
||||||
|
// against. Empty defaults to "uopi".
|
||||||
|
PAMService string `toml:"pam_service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAPConfig enables uopi's built-in HTTP Basic authentication validated against
|
||||||
|
// an LDAP directory via the "search then bind" pattern — the same flow an
|
||||||
|
// SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it
|
||||||
|
// works in the default fully-static binary (unlike the PAM backend), while still
|
||||||
|
// authenticating users against the same directory the host logs in with. The
|
||||||
|
// validated username feeds the same access pipeline as TrustedUserHeader. Enable
|
||||||
|
// TLS (ldaps:// or StartTLS) so passwords are not sent in clear text.
|
||||||
|
//
|
||||||
|
// Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass →
|
||||||
|
// "posixAccount", empty BindDN → anonymous search. Mutually exclusive with
|
||||||
|
// Kerberos and BasicAuth.
|
||||||
|
type LDAPConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g.
|
||||||
|
// "ldaps://ldap.example.com". Required when Enabled.
|
||||||
|
URIs []string `toml:"uri"`
|
||||||
|
// SearchBase is the subtree user entries live under (SSSD ldap_search_base).
|
||||||
|
// Required when Enabled.
|
||||||
|
SearchBase string `toml:"search_base"`
|
||||||
|
// UserAttr is the attribute matched against the login name. Empty → "uid".
|
||||||
|
UserAttr string `toml:"user_attr"`
|
||||||
|
// UserObjectClass restricts the search. Empty → "posixAccount".
|
||||||
|
UserObjectClass string `toml:"user_object_class"`
|
||||||
|
// BindDN / BindPassword optionally authenticate the search (service account).
|
||||||
|
// Empty BindDN performs an anonymous search.
|
||||||
|
BindDN string `toml:"bind_dn"`
|
||||||
|
BindPassword string `toml:"bind_password"`
|
||||||
|
// StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for
|
||||||
|
// ldaps://.
|
||||||
|
StartTLS bool `toml:"start_tls"`
|
||||||
|
// CACert is an optional PEM CA bundle to trust (private CA).
|
||||||
|
CACert string `toml:"ca_cert"`
|
||||||
|
// InsecureSkipVerify disables TLS certificate verification. Testing only.
|
||||||
|
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for
|
||||||
|
// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required.
|
||||||
|
type TLSConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// Cert and Key are paths to the PEM certificate and private key.
|
||||||
|
Cert string `toml:"cert"`
|
||||||
|
Key string `toml:"key"`
|
||||||
|
// RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP
|
||||||
|
// listener on that address that 301-redirects every request to the HTTPS
|
||||||
|
// service. Without it, a browser that connects with http:// gets the opaque
|
||||||
|
// "client sent an HTTP request to an HTTPS server" error instead of being
|
||||||
|
// upgraded. Empty disables the redirector.
|
||||||
|
RedirectFrom string `toml:"redirect_from"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatasourceConfig struct {
|
type DatasourceConfig struct {
|
||||||
@@ -146,6 +268,45 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||||
cfg.Server.DefaultUser = v
|
cfg.Server.DefaultUser = v
|
||||||
}
|
}
|
||||||
|
if v := env("UOPI_SERVER_KERBEROS_ENABLED"); v != "" {
|
||||||
|
cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES")
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" {
|
||||||
|
cfg.Server.Kerberos.Keytab = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" {
|
||||||
|
cfg.Server.Kerberos.ServicePrincipal = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" {
|
||||||
|
cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES")
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" {
|
||||||
|
cfg.Server.BasicAuth.PAMService = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" {
|
||||||
|
cfg.Server.TLS.Enabled = (v == "true" || v == "YES")
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_TLS_CERT"); v != "" {
|
||||||
|
cfg.Server.TLS.Cert = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_TLS_KEY"); v != "" {
|
||||||
|
cfg.Server.TLS.Key = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" {
|
||||||
|
cfg.Server.LDAP.Enabled = (v == "true" || v == "YES")
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_LDAP_URI"); v != "" {
|
||||||
|
cfg.Server.LDAP.URIs = strings.Fields(v)
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" {
|
||||||
|
cfg.Server.LDAP.SearchBase = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" {
|
||||||
|
cfg.Server.LDAP.BindDN = v
|
||||||
|
}
|
||||||
|
if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" {
|
||||||
|
cfg.Server.LDAP.BindPassword = v
|
||||||
|
}
|
||||||
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
|
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
|
||||||
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
||||||
}
|
}
|
||||||
@@ -170,6 +331,11 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||||
}
|
}
|
||||||
|
if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" {
|
||||||
|
if z, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
cfg.UI.DefaultZoom = z
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func env(key string) string {
|
func env(key string) string {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ type ConfigSet struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||||
|
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
|
||||||
Parameters []Parameter `json:"parameters"`
|
Parameters []Parameter `json:"parameters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ type ConfigInstance struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||||
|
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
|
||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ type Meta struct {
|
|||||||
// rules without the flag. Lets the rule list show enabled/disabled status
|
// rules without the flag. Lets the rule list show enabled/disabled status
|
||||||
// without a GET per rule.
|
// without a GET per rule.
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
// Owner/Scope/Groups carry the visibility metadata so list handlers can filter
|
||||||
|
// by the caller without a GET per object. Empty scope = global (legacy-safe).
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionMeta describes a single persisted revision.
|
// VersionMeta describes a single persisted revision.
|
||||||
@@ -87,9 +92,12 @@ type header struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
SetID string `json:"setId"`
|
SetID string `json:"setId"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateID(id string) error {
|
func validateID(id string) error {
|
||||||
@@ -154,7 +162,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled})
|
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ type Graph struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
|
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
|
||||||
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
||||||
|
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||||
|
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||||
|
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
Wires []Wire `json:"wires"`
|
Wires []Wire `json:"wires"`
|
||||||
Groups []NodeGroup `json:"groups,omitempty"`
|
Groups []NodeGroup `json:"groups,omitempty"`
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ type SignalDef struct {
|
|||||||
// Visibility controls who sees this signal in the signal tree:
|
// Visibility controls who sees this signal in the signal tree:
|
||||||
// "global" — listed in every panel's edit mode
|
// "global" — listed in every panel's edit mode
|
||||||
// "user" — listed in every panel owned by Owner
|
// "user" — listed in every panel owned by Owner
|
||||||
|
// "group" — listed for Owner and members of any group in Groups
|
||||||
// "panel" — listed only when editing the bound Panel
|
// "panel" — listed only when editing the bound Panel
|
||||||
// An empty value is treated as "global" for backward compatibility with
|
// An empty value is treated as "global" for backward compatibility with
|
||||||
// definitions created before this field existed.
|
// definitions created before this field existed.
|
||||||
Visibility string `json:"visibility,omitempty"`
|
Visibility string `json:"visibility,omitempty"`
|
||||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
Groups []string `json:"groups,omitempty"` // groups for "group" visibility
|
||||||
|
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||||
|
|
||||||
// Version and Tag implement git-style revisioning. Version is bumped on
|
// Version and Tag implement git-style revisioning. Version is bumped on
|
||||||
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
|
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Package ldapauth validates a username/password pair against an LDAP directory
|
||||||
|
// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client
|
||||||
|
// would: connect to the directory, find the user's entry under the configured
|
||||||
|
// search base, then attempt a bind as that entry's DN with the supplied password.
|
||||||
|
//
|
||||||
|
// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it
|
||||||
|
// speaks LDAP over the wire with no cgo, uopi keeps its fully-static
|
||||||
|
// (CGO_ENABLED=0) release binary while still authenticating against the same
|
||||||
|
// directory the host logs in with. It feeds the same HTTP Basic pipeline
|
||||||
|
// (internal/server/basicauth.go) as PAM.
|
||||||
|
//
|
||||||
|
// Unlike PAM it only verifies the password; it does not run the rest of the PAM
|
||||||
|
// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally
|
||||||
|
// sufficient.
|
||||||
|
package ldapauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the LDAP authenticator. URIs and SearchBase are required; the
|
||||||
|
// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous
|
||||||
|
// search, RFC2307 schema) works out of the box.
|
||||||
|
type Config struct {
|
||||||
|
// URIs are the directory endpoints, tried in order until one connects, e.g.
|
||||||
|
// "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's
|
||||||
|
// ldap_uri.
|
||||||
|
URIs []string
|
||||||
|
// SearchBase is the subtree under which user entries are searched. Mirrors
|
||||||
|
// SSSD's ldap_search_base.
|
||||||
|
SearchBase string
|
||||||
|
// UserAttr is the attribute matched against the login name. Empty defaults to
|
||||||
|
// "uid" (SSSD's ldap_user_name default for RFC2307).
|
||||||
|
UserAttr string
|
||||||
|
// UserObjectClass restricts the search to this objectClass. Empty defaults to
|
||||||
|
// "posixAccount" (SSSD's ldap_user_object_class default).
|
||||||
|
UserObjectClass string
|
||||||
|
// BindDN / BindPassword optionally authenticate the *search* (service
|
||||||
|
// account). Empty BindDN performs an anonymous search, matching a directory
|
||||||
|
// configured without ldap_default_bind_dn.
|
||||||
|
BindDN string
|
||||||
|
BindPassword string
|
||||||
|
// StartTLS upgrades a plain ldap:// connection to TLS before any credentials
|
||||||
|
// are sent. Ignored for ldaps:// (already TLS).
|
||||||
|
StartTLS bool
|
||||||
|
// CACertFile is an optional PEM file of CA certs to trust for the TLS
|
||||||
|
// connection (for a directory using a private CA).
|
||||||
|
CACertFile string
|
||||||
|
// InsecureSkipVerify disables TLS certificate verification. Insecure; use only
|
||||||
|
// for testing against a self-signed directory.
|
||||||
|
InsecureSkipVerify bool
|
||||||
|
// Timeout bounds each connection attempt. Zero defaults to 10s.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidCredentials is returned when the directory rejects the user's bind.
|
||||||
|
var ErrInvalidCredentials = errors.New("ldap: invalid credentials")
|
||||||
|
|
||||||
|
// Authenticator validates credentials against a fixed directory configuration.
|
||||||
|
// It is safe for concurrent use: each Authenticate call opens and closes its own
|
||||||
|
// connection.
|
||||||
|
type Authenticator struct {
|
||||||
|
cfg Config
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates cfg and returns an Authenticator. It fails fast on missing
|
||||||
|
// required fields or an unreadable CA file so misconfiguration surfaces at
|
||||||
|
// startup rather than on the first login.
|
||||||
|
func New(cfg Config) (*Authenticator, error) {
|
||||||
|
if len(cfg.URIs) == 0 {
|
||||||
|
return nil, errors.New("ldap: at least one uri is required")
|
||||||
|
}
|
||||||
|
if cfg.SearchBase == "" {
|
||||||
|
return nil, errors.New("ldap: search_base is required")
|
||||||
|
}
|
||||||
|
if cfg.UserAttr == "" {
|
||||||
|
cfg.UserAttr = "uid"
|
||||||
|
}
|
||||||
|
if cfg.UserObjectClass == "" {
|
||||||
|
cfg.UserObjectClass = "posixAccount"
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
cfg.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
|
||||||
|
if cfg.CACertFile != "" {
|
||||||
|
pem, err := os.ReadFile(cfg.CACertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ldap: reading ca_cert: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile)
|
||||||
|
}
|
||||||
|
tlsConfig.RootCAs = pool
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate verifies username/password against the directory. It returns nil
|
||||||
|
// on success, ErrInvalidCredentials when the directory rejects the bind, or
|
||||||
|
// another error on connection/search failure.
|
||||||
|
func (a *Authenticator) Authenticate(username, password string) error {
|
||||||
|
// A bind with a non-empty DN but an empty password is an "unauthenticated
|
||||||
|
// bind" that many servers accept as success — which would let anyone in with a
|
||||||
|
// blank password. Reject empty passwords before we ever bind.
|
||||||
|
if password == "" {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := a.dial()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Bind for the search: service account if configured, else anonymous.
|
||||||
|
if a.cfg.BindDN != "" {
|
||||||
|
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
|
||||||
|
return fmt.Errorf("ldap: search bind failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the user's entry. The login name is escaped to prevent LDAP filter
|
||||||
|
// injection.
|
||||||
|
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
|
||||||
|
ldap.EscapeFilter(a.cfg.UserObjectClass),
|
||||||
|
a.cfg.UserAttr,
|
||||||
|
ldap.EscapeFilter(username))
|
||||||
|
req := ldap.NewSearchRequest(
|
||||||
|
a.cfg.SearchBase,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
|
2, int(a.cfg.Timeout.Seconds()), false,
|
||||||
|
filter,
|
||||||
|
[]string{"dn"}, nil,
|
||||||
|
)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ldap: search failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(res.Entries) == 0 {
|
||||||
|
return ErrInvalidCredentials // unknown user — do not distinguish from bad password
|
||||||
|
}
|
||||||
|
if len(res.Entries) > 1 {
|
||||||
|
return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries))
|
||||||
|
}
|
||||||
|
userDN := res.Entries[0].DN
|
||||||
|
|
||||||
|
// Verify the password by binding as the user. Use a fresh connection so the
|
||||||
|
// search identity is fully dropped first.
|
||||||
|
userConn, err := a.dial()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer userConn.Close()
|
||||||
|
if err := userConn.Bind(userDN, password); err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ldap: user bind failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial connects to the first reachable URI and applies StartTLS when requested.
|
||||||
|
func (a *Authenticator) dial() (*ldap.Conn, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, uri := range a.cfg.URIs {
|
||||||
|
conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig))
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conn.SetTimeout(a.cfg.Timeout)
|
||||||
|
if a.cfg.StartTLS {
|
||||||
|
if err := conn.StartTLS(a.tlsConfig); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ldapauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRequiresURIAndBase(t *testing.T) {
|
||||||
|
if _, err := New(Config{SearchBase: "dc=x"}); err == nil {
|
||||||
|
t.Fatal("want error for missing uri")
|
||||||
|
}
|
||||||
|
if _, err := New(Config{URIs: []string{"ldap://x"}}); err == nil {
|
||||||
|
t.Fatal("want error for missing search_base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAppliesSSSDDefaults(t *testing.T) {
|
||||||
|
a, err := New(Config{URIs: []string{"ldap://x"}, SearchBase: "dc=x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if a.cfg.UserAttr != "uid" {
|
||||||
|
t.Errorf("UserAttr default = %q, want uid", a.cfg.UserAttr)
|
||||||
|
}
|
||||||
|
if a.cfg.UserObjectClass != "posixAccount" {
|
||||||
|
t.Errorf("UserObjectClass default = %q, want posixAccount", a.cfg.UserObjectClass)
|
||||||
|
}
|
||||||
|
if a.cfg.Timeout <= 0 {
|
||||||
|
t.Errorf("Timeout default not applied: %v", a.cfg.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty passwords must be rejected before any bind: a non-empty DN + empty
|
||||||
|
// password is an "unauthenticated bind" many servers accept as success.
|
||||||
|
func TestAuthenticateRejectsEmptyPasswordWithoutDialing(t *testing.T) {
|
||||||
|
// An unreachable URI guarantees the test fails loudly if it ever tries to dial.
|
||||||
|
a, err := New(Config{URIs: []string{"ldap://127.0.0.1:1"}, SearchBase: "dc=x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.Authenticate("alice", ""); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("want ErrInvalidCredentials for empty password, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadCACert(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
bad := filepath.Join(dir, "ca.pem")
|
||||||
|
if err := os.WriteFile(bad, []byte("not a certificate"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: bad}); err == nil {
|
||||||
|
t.Fatal("want error for CA file with no certificates")
|
||||||
|
}
|
||||||
|
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: filepath.Join(dir, "missing.pem")}); err == nil {
|
||||||
|
t.Fatal("want error for missing CA file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build pam
|
||||||
|
|
||||||
|
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||||
|
// stack (/etc/pam.d/<service>). It is the backend for uopi's built-in HTTP Basic
|
||||||
|
// authentication: because the uopi host is typically already an SSSD/LDAP client,
|
||||||
|
// validating through PAM reuses the exact same login path as `login`/`ssh`
|
||||||
|
// (pam_sss → the site directory) without uopi needing any directory schema.
|
||||||
|
//
|
||||||
|
// This file is the real implementation, compiled only with the `pam` build tag
|
||||||
|
// (which also requires cgo + libpam). The default fully-static CGO_ENABLED=0
|
||||||
|
// build uses stub.go instead, where Authenticate reports PAM is unavailable.
|
||||||
|
package pamauth
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -lpam
|
||||||
|
#include <security/pam_appl.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// uopiPamConv answers every password-style PAM prompt with the password passed
|
||||||
|
// through appdata_ptr. Informational/error messages get a NULL response. The PAM
|
||||||
|
// library takes ownership of the returned responses and frees them.
|
||||||
|
static int uopiPamConv(int num_msg, const struct pam_message **msg,
|
||||||
|
struct pam_response **resp, void *appdata_ptr) {
|
||||||
|
if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) {
|
||||||
|
return PAM_CONV_ERR;
|
||||||
|
}
|
||||||
|
struct pam_response *r = calloc((size_t)num_msg, sizeof(struct pam_response));
|
||||||
|
if (r == NULL) {
|
||||||
|
return PAM_BUF_ERR;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < num_msg; i++) {
|
||||||
|
int style = msg[i]->msg_style;
|
||||||
|
if (style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON) {
|
||||||
|
r[i].resp = strdup((const char *)appdata_ptr);
|
||||||
|
if (r[i].resp == NULL) {
|
||||||
|
for (int j = 0; j < i; j++) {
|
||||||
|
free(r[j].resp);
|
||||||
|
}
|
||||||
|
free(r);
|
||||||
|
return PAM_BUF_ERR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r[i].resp_retcode = 0;
|
||||||
|
}
|
||||||
|
*resp = r;
|
||||||
|
return PAM_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// uopiPamAuth runs authentication + account management for service/user using
|
||||||
|
// pass. Returns PAM_SUCCESS or the failing PAM error code.
|
||||||
|
static int uopiPamAuth(const char *service, const char *user, char *pass) {
|
||||||
|
struct pam_conv conv;
|
||||||
|
conv.conv = uopiPamConv;
|
||||||
|
conv.appdata_ptr = (void *)pass;
|
||||||
|
|
||||||
|
pam_handle_t *pamh = NULL;
|
||||||
|
int ret = pam_start(service, user, &conv, &pamh);
|
||||||
|
if (ret != PAM_SUCCESS) {
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
ret = pam_authenticate(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||||
|
if (ret == PAM_SUCCESS) {
|
||||||
|
ret = pam_acct_mgmt(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||||
|
}
|
||||||
|
pam_end(pamh, ret);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// uopiStrerror maps a PAM error code to a human-readable string. Linux-PAM
|
||||||
|
// ignores the handle, so NULL is fine after pam_end.
|
||||||
|
static const char *uopiStrerror(int code) {
|
||||||
|
return pam_strerror(NULL, code);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Available reports whether this build includes PAM support. True here.
|
||||||
|
const Available = true
|
||||||
|
|
||||||
|
// ErrUnavailable is returned by Authenticate in builds without PAM support. It
|
||||||
|
// is declared in both build variants so callers can compare against it.
|
||||||
|
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in")
|
||||||
|
|
||||||
|
// Authenticate verifies username/password against the named PAM service
|
||||||
|
// (/etc/pam.d/<service>). It returns nil on success or an error describing the
|
||||||
|
// PAM failure. It is safe for concurrent use.
|
||||||
|
func Authenticate(service, username, password string) error {
|
||||||
|
cService := C.CString(service)
|
||||||
|
cUser := C.CString(username)
|
||||||
|
cPass := C.CString(password)
|
||||||
|
defer C.free(unsafe.Pointer(cService))
|
||||||
|
defer C.free(unsafe.Pointer(cUser))
|
||||||
|
defer C.free(unsafe.Pointer(cPass))
|
||||||
|
|
||||||
|
if ret := C.uopiPamAuth(cService, cUser, cPass); ret != C.PAM_SUCCESS {
|
||||||
|
return fmt.Errorf("pam: %s", C.GoString(C.uopiStrerror(ret)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build !pam
|
||||||
|
|
||||||
|
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||||
|
// stack. This is the stub compiled into the default fully-static
|
||||||
|
// (CGO_ENABLED=0) build, which has no PAM/libpam linkage: Authenticate always
|
||||||
|
// reports PAM is unavailable. Build with `make backend-pam` (CGO_ENABLED=1
|
||||||
|
// -tags pam) to get the real implementation in pam.go.
|
||||||
|
package pamauth
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Available reports whether this build includes PAM support. False here.
|
||||||
|
const Available = false
|
||||||
|
|
||||||
|
// ErrUnavailable is returned by Authenticate because this build lacks PAM.
|
||||||
|
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in (rebuild with: make backend-pam)")
|
||||||
|
|
||||||
|
// Authenticate always fails in the non-PAM build.
|
||||||
|
func Authenticate(service, username, password string) error {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// credCache memoises successful Basic-auth validations for a short TTL. Browsers
|
||||||
|
// resend the Authorization header on every request, so without this each one
|
||||||
|
// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk).
|
||||||
|
// Only positive results are cached, keyed by a salted SHA-256 of user+password
|
||||||
|
// so the cache never holds a recoverable secret. A fresh random salt per process
|
||||||
|
// keeps keys from being precomputable.
|
||||||
|
type credCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ttl time.Duration
|
||||||
|
salt []byte
|
||||||
|
entries map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCredCache(ttl time.Duration) *credCache {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(salt)
|
||||||
|
return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *credCache) key(user, pass string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write(c.salt)
|
||||||
|
h.Write([]byte(user))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
h.Write([]byte(pass))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid reports whether user/pass was validated within the TTL.
|
||||||
|
func (c *credCache) valid(user, pass string) bool {
|
||||||
|
if c.ttl <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
k := c.key(user, pass)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
exp, ok := c.entries[k]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
delete(c.entries, k)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// store records a successful validation, opportunistically pruning expired keys.
|
||||||
|
func (c *credCache) store(user, pass string) {
|
||||||
|
if c.ttl <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k := c.key(user, pass)
|
||||||
|
now := time.Now()
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.entries[k] = now.Add(c.ttl)
|
||||||
|
if len(c.entries) > 1024 {
|
||||||
|
for kk, exp := range c.entries {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(c.entries, kk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM,
|
||||||
|
// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the
|
||||||
|
// username into userHeader for the existing access pipeline (accessMiddleware /
|
||||||
|
// wsHandler), and any inbound value of userHeader is always discarded first so a
|
||||||
|
// client cannot spoof an identity.
|
||||||
|
//
|
||||||
|
// challenge controls the no/invalid-credentials case:
|
||||||
|
// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the
|
||||||
|
// browser prompts for credentials.
|
||||||
|
// - challenge=false (WebSocket upgrade): fall through unauthenticated; the
|
||||||
|
// session resolves to default_user. Browsers cannot show a login dialog for a
|
||||||
|
// WebSocket, but once they have cached credentials from the page's API calls
|
||||||
|
// they resend them on the upgrade, so the validated path is still taken.
|
||||||
|
func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler {
|
||||||
|
if realm == "" {
|
||||||
|
realm = "uopi"
|
||||||
|
}
|
||||||
|
challengeValue := `Basic realm="` + realm + `", charset="UTF-8"`
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Never trust a client-supplied identity header; only a validated login sets it.
|
||||||
|
r.Header.Del(userHeader)
|
||||||
|
|
||||||
|
if user, pass, ok := r.BasicAuth(); ok && user != "" {
|
||||||
|
if cache.valid(user, pass) {
|
||||||
|
r.Header.Set(userHeader, user)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := authFn(user, pass); err == nil {
|
||||||
|
cache.store(user, pass)
|
||||||
|
r.Header.Set(userHeader, user)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Warn("basic auth failed", "user", user, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No or invalid credentials.
|
||||||
|
if challenge {
|
||||||
|
w.Header().Set("WWW-Authenticate", challengeValue)
|
||||||
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testUserHeader = "X-Uopi-User"
|
||||||
|
|
||||||
|
func quietLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// echoUser is a terminal handler that reports the resolved identity header so a
|
||||||
|
// test can assert what basicAuth stamped.
|
||||||
|
func echoUser() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, r.Header.Get(testUserHeader))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||||
|
if user != "" || pass != "" {
|
||||||
|
r.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
if setHeader != "" {
|
||||||
|
r.Header.Set(testUserHeader, setHeader) // a spoof attempt
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// authFn that accepts only alice/secret.
|
||||||
|
func aliceAuth(calls *int32) func(string, string) error {
|
||||||
|
return func(user, pass string) error {
|
||||||
|
atomic.AddInt32(calls, 1)
|
||||||
|
if user == "alice" && pass == "secret" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pamErr{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pamErr struct{}
|
||||||
|
|
||||||
|
func (pamErr) Error() string { return "bad credentials" }
|
||||||
|
|
||||||
|
func TestBasicAuthChallengesWithoutCredentials(t *testing.T) {
|
||||||
|
cache := newCredCache(time.Minute)
|
||||||
|
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
|
||||||
|
t.Fatalf("missing WWW-Authenticate challenge header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthValidCredentialsStampUser(t *testing.T) {
|
||||||
|
cache := newCredCache(time.Minute)
|
||||||
|
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
// Client also tries to spoof the identity header; it must be ignored.
|
||||||
|
h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root"))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "alice" {
|
||||||
|
t.Fatalf("want resolved user alice, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthInvalidCredentialsRejected(t *testing.T) {
|
||||||
|
cache := newCredCache(time.Minute)
|
||||||
|
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, basicReq(t, "alice", "wrong", ""))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthBestEffortFallsThrough(t *testing.T) {
|
||||||
|
cache := newCredCache(time.Minute)
|
||||||
|
// challenge=false (WebSocket path): no creds → pass through unauthenticated.
|
||||||
|
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 pass-through, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "" {
|
||||||
|
t.Fatalf("want empty identity, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthCachesSuccess(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
cache := newCredCache(time.Minute)
|
||||||
|
h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, basicReq(t, "alice", "secret", ""))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("req %d: want 200, got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Fatalf("want authFn called once (cached), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdlog "log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jcmturner/goidentity/v6"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/service"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// internalUserHeader is the request header the built-in authentication
|
||||||
|
// middlewares (Kerberos, Basic) use to hand the validated username to the
|
||||||
|
// downstream access pipeline when no external TrustedUserHeader is configured. It
|
||||||
|
// is always stripped from inbound requests before validation, so a client cannot
|
||||||
|
// spoof it.
|
||||||
|
const internalUserHeader = "X-Uopi-User"
|
||||||
|
|
||||||
|
// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a
|
||||||
|
// successful handshake the authenticated principal's short username (realm
|
||||||
|
// stripped) is written into userHeader, so the existing access pipeline
|
||||||
|
// (accessMiddleware / wsHandler, which both read userHeader) resolves identity
|
||||||
|
// uniformly whether it originated from a trusted proxy header or a Kerberos
|
||||||
|
// ticket.
|
||||||
|
//
|
||||||
|
// challenge controls behaviour when a request carries no valid Negotiate
|
||||||
|
// credentials:
|
||||||
|
// - challenge=true (REST/page requests): delegate to gokrb5, which replies
|
||||||
|
// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO.
|
||||||
|
// - challenge=false (WebSocket upgrades): fall through unauthenticated.
|
||||||
|
// Browsers cannot attach an Authorization header when opening a WebSocket;
|
||||||
|
// they only send Negotiate proactively to trusted URIs. When the header is
|
||||||
|
// present we still validate it, otherwise the session resolves to
|
||||||
|
// default_user exactly as before.
|
||||||
|
//
|
||||||
|
// Any inbound value of userHeader is always discarded before validation: with
|
||||||
|
// native Kerberos there is no trusted proxy stripping client-supplied headers, so
|
||||||
|
// only the SPNEGO-validated identity may set it.
|
||||||
|
func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler {
|
||||||
|
opts := []func(*service.Settings){
|
||||||
|
service.Logger(stdlog.New(slogWriter{log: log}, "", 0)),
|
||||||
|
}
|
||||||
|
if spn != "" {
|
||||||
|
opts = append(opts, service.KeytabPrincipal(spn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// inner runs only after a successful SPNEGO handshake: it copies the resolved
|
||||||
|
// identity into userHeader and continues down the chain.
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := goidentity.FromHTTPRequestContext(r)
|
||||||
|
if id == nil {
|
||||||
|
http.Error(w, "kerberos: missing identity", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := id.UserName()
|
||||||
|
if i := strings.IndexByte(user, '@'); i >= 0 {
|
||||||
|
user = user[:i]
|
||||||
|
}
|
||||||
|
r.Header.Set(userHeader, user)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...)
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Never trust a client-supplied identity header under native Kerberos.
|
||||||
|
r.Header.Del(userHeader)
|
||||||
|
|
||||||
|
negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey)
|
||||||
|
if !negotiate && !challenge {
|
||||||
|
// Best-effort path (WebSocket without proactive credentials): continue
|
||||||
|
// unauthenticated; downstream resolves the session to default_user.
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Credentials are present (validate them) or a challenge is required.
|
||||||
|
validate.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO
|
||||||
|
// validation diagnostics surface without polluting normal output.
|
||||||
|
type slogWriter struct{ log *slog.Logger }
|
||||||
|
|
||||||
|
func (s slogWriter) Write(p []byte) (int, error) {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n"))
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
+105
-10
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
"github.com/uopi/uopi/internal/audit"
|
"github.com/uopi/uopi/internal/audit"
|
||||||
@@ -23,18 +25,35 @@ import (
|
|||||||
const apiPrefix = "/api/v1"
|
const apiPrefix = "/api/v1"
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
log *slog.Logger
|
tlsCert string
|
||||||
|
tlsKey string
|
||||||
|
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
// 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.
|
// 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, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
//
|
||||||
|
// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated
|
||||||
|
// by that function (typically PAM): REST requests are challenged (401 Basic) and
|
||||||
|
// WebSocket upgrades validate proactively-resent credentials best-effort. It is
|
||||||
|
// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both
|
||||||
|
// set the server serves HTTPS via ListenAndServeTLS.
|
||||||
|
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server {
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
rec = audit.Nop()
|
rec = audit.Nop()
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// When built-in authentication (Kerberos or Basic) is enabled but no external
|
||||||
|
// proxy header is configured, use an internal header to carry the validated
|
||||||
|
// username downstream.
|
||||||
|
userHeader := trustedUserHeader
|
||||||
|
if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" {
|
||||||
|
userHeader = internalUserHeader
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
|
var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}
|
||||||
|
|
||||||
// Prometheus-format metrics
|
// Prometheus-format metrics
|
||||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||||
@@ -50,11 +69,37 @@ 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
|
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||||
// access-control middleware (identity resolution + global level enforcement).
|
// access-control middleware (identity resolution + global level enforcement).
|
||||||
apiMux := http.NewServeMux()
|
apiMux := http.NewServeMux()
|
||||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
|
||||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux)
|
||||||
|
|
||||||
|
// Native SPNEGO/Kerberos authentication (optional). REST/page requests are
|
||||||
|
// challenged (401 Negotiate); WebSocket upgrades validate proactively-sent
|
||||||
|
// credentials best-effort. Both stash the validated username in userHeader.
|
||||||
|
var frontendHandler http.Handler = http.FileServerFS(webFS)
|
||||||
|
|
||||||
|
if krbKeytab != nil {
|
||||||
|
apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler)
|
||||||
|
wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH)
|
||||||
|
} else if basicAuthFn != nil {
|
||||||
|
// Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM).
|
||||||
|
// A shared positive-result cache spares a validation round-trip on every
|
||||||
|
// browser request. REST is challenged; WebSocket upgrades are best-effort.
|
||||||
|
cache := newCredCache(5 * time.Minute)
|
||||||
|
apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler)
|
||||||
|
wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH)
|
||||||
|
// Challenge the top-level page load too. Browsers only show the native
|
||||||
|
// Basic login dialog in response to a 401 on a navigation, not on the
|
||||||
|
// SPA's background fetch('/api/v1/me'); without this the user is never
|
||||||
|
// prompted and silently resolves to default_user. Once credentials are
|
||||||
|
// entered the browser caches them for the origin and resends them on
|
||||||
|
// every asset, API call and the WebSocket upgrade.
|
||||||
|
frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler)
|
||||||
|
}
|
||||||
|
mux.Handle("/ws", wsHandlerH)
|
||||||
|
mux.Handle(apiPrefix+"/", apiHandler)
|
||||||
|
|
||||||
// Embedded frontend — must be last (catch-all)
|
// Embedded frontend — must be last (catch-all)
|
||||||
mux.Handle("/", http.FileServerFS(webFS))
|
mux.Handle("/", frontendHandler)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
httpServer: &http.Server{
|
httpServer: &http.Server{
|
||||||
@@ -64,7 +109,10 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
},
|
},
|
||||||
log: log,
|
tlsCert: tlsCert,
|
||||||
|
tlsKey: tlsKey,
|
||||||
|
tlsRedirect: tlsRedirect,
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle
|
|||||||
|
|
||||||
// Start listens and serves until ctx is cancelled.
|
// Start listens and serves until ctx is cancelled.
|
||||||
func (s *Server) Start(ctx context.Context) error {
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
tls := s.tlsCert != "" && s.tlsKey != ""
|
||||||
|
s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls)
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
var err error
|
||||||
|
if tls {
|
||||||
|
err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey)
|
||||||
|
} else {
|
||||||
|
err = s.httpServer.ListenAndServe()
|
||||||
|
}
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS
|
||||||
|
// service instead of letting them hit the TLS port with a cleartext request.
|
||||||
|
var redirectSrv *http.Server
|
||||||
|
if tls && s.tlsRedirect != "" {
|
||||||
|
redirectSrv = &http.Server{
|
||||||
|
Addr: s.tlsRedirect,
|
||||||
|
Handler: httpsRedirectHandler(s.httpServer.Addr),
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
s.log.Info("http→https redirect listening", "addr", s.tlsRedirect)
|
||||||
|
go func() {
|
||||||
|
if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
return err
|
return err
|
||||||
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
s.log.Info("shutting down")
|
s.log.Info("shutting down")
|
||||||
|
if redirectSrv != nil {
|
||||||
|
_ = redirectSrv.Shutdown(shutCtx)
|
||||||
|
}
|
||||||
return s.httpServer.Shutdown(shutCtx)
|
return s.httpServer.Shutdown(shutCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service.
|
||||||
|
// It preserves the requested hostname and path, swapping in the TLS listener's
|
||||||
|
// port (omitted when 443).
|
||||||
|
func httpsRedirectHandler(tlsAddr string) http.Handler {
|
||||||
|
_, tlsPort, _ := net.SplitHostPort(tlsAddr)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.Host
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
target := "https://" + host
|
||||||
|
if tlsPort != "" && tlsPort != "443" {
|
||||||
|
target += ":" + tlsPort
|
||||||
|
}
|
||||||
|
target += r.URL.RequestURI()
|
||||||
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
storage_dir = "./interfaces"
|
storage_dir = "./interfaces"
|
||||||
|
|
||||||
|
# ── UI presentation defaults (sent to the browser) ──────────────────────────
|
||||||
|
# default_zoom is the base UI scale used when a browser has no saved zoom of its
|
||||||
|
# own. Handy for HiDPI screens (e.g. 4K) whose OS scaling is left at 100%, where
|
||||||
|
# the browser reports devicePixelRatio=1 and the UI would otherwise be tiny. The
|
||||||
|
# in-app A+/A− control still overrides it per browser. 1.0 = no scaling.
|
||||||
|
[ui]
|
||||||
|
default_zoom = 1.0 # e.g. 1.5 or 1.75 on dense displays
|
||||||
|
|
||||||
# ── Identity & access control ───────────────────────────────────────────────
|
# ── Identity & access control ───────────────────────────────────────────────
|
||||||
# The end-user identity is read from a header set by a trusted authenticating
|
# The end-user identity is read from a header set by a trusted authenticating
|
||||||
# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
|
# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
|
||||||
@@ -11,6 +19,74 @@ trusted_user_header = "" # e.g. "X-Forwarded-User"
|
|||||||
# Identity used when the trusted header is absent/empty. Empty = anonymous.
|
# Identity used when the trusted header is absent/empty. Empty = anonymous.
|
||||||
default_user = ""
|
default_user = ""
|
||||||
|
|
||||||
|
# ── Native Kerberos / SPNEGO authentication ─────────────────────────────────
|
||||||
|
# Alternative to a reverse proxy: uopi authenticates browsers directly via
|
||||||
|
# SPNEGO ("Negotiate"). Recommended when proxy-header auth lets some browsers
|
||||||
|
# (notably Firefox) silently fall through to default_user. When enabled, uopi
|
||||||
|
# challenges API requests with 401 WWW-Authenticate: Negotiate and resolves the
|
||||||
|
# user from the validated Kerberos ticket (short principal name, realm stripped);
|
||||||
|
# any inbound trusted_user_header value is ignored to prevent spoofing.
|
||||||
|
# Browsers must be told to perform SPNEGO for this origin
|
||||||
|
# (Firefox: network.negotiate-auth.trusted-uris).
|
||||||
|
[server.kerberos]
|
||||||
|
enabled = false
|
||||||
|
# Path to the service keytab holding the HTTP service principal's key
|
||||||
|
# (e.g. HTTP/host.example.com@REALM). Required when enabled.
|
||||||
|
keytab = "" # e.g. "/etc/uopi/http.keytab"
|
||||||
|
# Optionally pin which keytab principal to accept tickets for. Empty = default.
|
||||||
|
service_principal = "" # e.g. "HTTP/host.example.com"
|
||||||
|
|
||||||
|
# ── Built-in HTTP Basic authentication (PAM) ────────────────────────────────
|
||||||
|
# Another standalone alternative (no reverse proxy, no Kerberos): uopi challenges
|
||||||
|
# with 401 WWW-Authenticate: Basic and validates the username/password through the
|
||||||
|
# host PAM stack (/etc/pam.d/<pam_service>). On an SSSD/LDAP-joined host this
|
||||||
|
# reuses each user's normal login password with no directory config in uopi. The
|
||||||
|
# validated username feeds the same access pipeline. Mutually exclusive with
|
||||||
|
# Kerberos. REQUIRES a PAM-enabled build: `make backend-pam` (cgo + libpam); the
|
||||||
|
# default static binary cannot validate and refuses to start with this enabled.
|
||||||
|
# Basic credentials travel on every request — enable [server.tls] below unless on
|
||||||
|
# a fully isolated network.
|
||||||
|
[server.basic_auth]
|
||||||
|
enabled = false
|
||||||
|
# PAM service name under /etc/pam.d/. Empty defaults to "uopi".
|
||||||
|
pam_service = "" # e.g. "uopi" or "login"
|
||||||
|
|
||||||
|
# ── Built-in HTTP Basic authentication (LDAP) ───────────────────────────────
|
||||||
|
# Pure-Go alternative to basic_auth/PAM: validates the Basic credentials directly
|
||||||
|
# against an LDAP directory using "search then bind" — the same flow an SSSD/LDAP
|
||||||
|
# client uses. Needs NO cgo, so it works in the default fully-static binary
|
||||||
|
# (`make release`), unlike PAM. Mutually exclusive with kerberos and basic_auth.
|
||||||
|
# Defaults mirror SSSD: user_attr → "uid", user_object_class → "posixAccount",
|
||||||
|
# empty bind_dn → anonymous search. Enable TLS below (or use ldaps://) so
|
||||||
|
# passwords are not sent in clear text.
|
||||||
|
[server.ldap]
|
||||||
|
enabled = false
|
||||||
|
# Directory endpoint(s), tried in order (SSSD ldap_uri).
|
||||||
|
uri = ["ldaps://ldap.example.com"]
|
||||||
|
# Subtree user entries live under (SSSD ldap_search_base).
|
||||||
|
search_base = "dc=example,dc=com"
|
||||||
|
# Attribute matched against the login name. Empty defaults to "uid".
|
||||||
|
user_attr = "" # e.g. "uid" (RFC2307) or "sAMAccountName" (AD)
|
||||||
|
# Object class to restrict the search. Empty defaults to "posixAccount".
|
||||||
|
user_object_class = "" # e.g. "posixAccount" or "user" (AD)
|
||||||
|
# Optional service account for the search. Empty bind_dn = anonymous search.
|
||||||
|
bind_dn = ""
|
||||||
|
bind_password = ""
|
||||||
|
# Upgrade a plain ldap:// connection to TLS before binding (ignored for ldaps://).
|
||||||
|
start_tls = false
|
||||||
|
# Optional PEM CA bundle to trust a private directory CA.
|
||||||
|
ca_cert = "" # e.g. "/etc/uopi/ldap-ca.pem"
|
||||||
|
# Disable TLS certificate verification (testing only — insecure).
|
||||||
|
insecure_skip_verify = false
|
||||||
|
|
||||||
|
# ── Built-in TLS / HTTPS ────────────────────────────────────────────────────
|
||||||
|
# Terminate HTTPS directly without a reverse proxy. Strongly recommended whenever
|
||||||
|
# basic_auth is enabled. Cert and key are PEM paths; both required when enabled.
|
||||||
|
[server.tls]
|
||||||
|
enabled = false
|
||||||
|
cert = "" # e.g. "/etc/uopi/tls/cert.pem"
|
||||||
|
key = "" # e.g. "/etc/uopi/tls/key.pem"
|
||||||
|
|
||||||
# Role-based access through group memberships. Each [[groups]] block lists
|
# Role-based access through group memberships. Each [[groups]] block lists
|
||||||
# members by role; a user's effective global capability is the highest role
|
# members by role; a user's effective global capability is the highest role
|
||||||
# across all their memberships, along this ladder:
|
# across all their memberships, along this ladder:
|
||||||
|
|||||||
+8
-5
@@ -5,7 +5,7 @@ import ViewMode from './ViewMode';
|
|||||||
import EditMode from './EditMode';
|
import EditMode from './EditMode';
|
||||||
import FullscreenMode from './FullscreenMode';
|
import FullscreenMode from './FullscreenMode';
|
||||||
import ControlDialogs from './ControlDialogs';
|
import ControlDialogs from './ControlDialogs';
|
||||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
import { applyZoom, getStoredZoom, setServerDefaultZoom, watchDpr } from './ZoomControl';
|
||||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||||
import type { Interface, Me } from './lib/types';
|
import type { Interface, Me } from './lib/types';
|
||||||
|
|
||||||
@@ -29,11 +29,14 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dpr = window.devicePixelRatio ?? 1;
|
applyZoom(getStoredZoom(), false);
|
||||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
const unwatchDpr = watchDpr();
|
||||||
applyZoom(getStoredZoom());
|
|
||||||
if (!fsParam) wsClient.connect('/ws');
|
if (!fsParam) wsClient.connect('/ws');
|
||||||
fetchMe().then(setMe);
|
fetchMe().then(me => {
|
||||||
|
setMe(me);
|
||||||
|
setServerDefaultZoom(me.defaultZoom);
|
||||||
|
});
|
||||||
|
return unwatchDpr;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (fsParam) {
|
if (fsParam) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
|||||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||||
import CueEditor from './CueEditor';
|
import CueEditor from './CueEditor';
|
||||||
|
import { useAuth } from './lib/auth';
|
||||||
|
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
|
||||||
|
|
||||||
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ interface ConfigSet {
|
|||||||
version: number;
|
version: number;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
scope?: string;
|
||||||
|
groups?: string[];
|
||||||
parameters: Parameter[];
|
parameters: Parameter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +47,12 @@ interface ConfigInstance {
|
|||||||
version: number;
|
version: number;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
scope?: string;
|
||||||
|
groups?: string[];
|
||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
|
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; owner?: string; scope?: string; groups?: string[]; }
|
||||||
|
|
||||||
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
|
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
|
||||||
// logic bound to a set, run when instances of that set are saved.
|
// logic bound to a set, run when instances of that set are saved.
|
||||||
@@ -292,7 +298,10 @@ function useUndoKeys(undo: () => void, redo: () => void, canUndo: boolean, canRe
|
|||||||
// ── Sets manager ──────────────────────────────────────────────────────────────
|
// ── Sets manager ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||||
|
const me = useAuth();
|
||||||
const [sets, setSets] = useState<Meta[]>([]);
|
const [sets, setSets] = useState<Meta[]>([]);
|
||||||
|
const [bucket, setBucket] = useState<Bucket>('mine');
|
||||||
|
const [scopeGroup, setScopeGroup] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const hist = useUndo<ConfigSet>();
|
const hist = useUndo<ConfigSet>();
|
||||||
const working = hist.present;
|
const working = hist.present;
|
||||||
@@ -445,6 +454,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
|||||||
} catch (err) { setError(msg(err)); }
|
} catch (err) { setError(msg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleSets = filterByScope(sets, me, bucket, scopeGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="cl-body">
|
<div class="cl-body">
|
||||||
<div class="cl-list">
|
<div class="cl-list">
|
||||||
@@ -457,8 +468,10 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
|||||||
<input ref={fileRef} type="file" accept="application/json,.json" style={{ display: 'none' }}
|
<input ref={fileRef} type="file" accept="application/json,.json" style={{ display: 'none' }}
|
||||||
onChange={(e) => { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
|
onChange={(e) => { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
|
||||||
</div>
|
</div>
|
||||||
|
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
|
||||||
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
|
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
|
||||||
{sets.map(s => (
|
{sets.length > 0 && visibleSets.length === 0 && <div class="hint cl-list-empty">No config sets in this view.</div>}
|
||||||
|
{visibleSets.map(s => (
|
||||||
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
||||||
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
||||||
<span class="cfg-badge" title="Current version">v{s.version}</span>
|
<span class="cfg-badge" title="Current version">v{s.version}</span>
|
||||||
@@ -484,6 +497,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
|||||||
</div>
|
</div>
|
||||||
<textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
|
<textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
|
||||||
onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
|
onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
|
||||||
|
<ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
|
||||||
|
onChange={(scope, groups) => patch({ scope, groups })} />
|
||||||
|
|
||||||
<div class="cfg-section-head">
|
<div class="cfg-section-head">
|
||||||
<span>Parameters</span>
|
<span>Parameters</span>
|
||||||
@@ -802,9 +817,12 @@ function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, o
|
|||||||
// ── Instances manager ─────────────────────────────────────────────────────────
|
// ── Instances manager ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||||
|
const me = useAuth();
|
||||||
const [instances, setInstances] = useState<Meta[]>([]);
|
const [instances, setInstances] = useState<Meta[]>([]);
|
||||||
const [sets, setSets] = useState<Meta[]>([]);
|
const [sets, setSets] = useState<Meta[]>([]);
|
||||||
const [setFilter, setSetFilter] = useState<string>('');
|
const [setFilter, setSetFilter] = useState<string>('');
|
||||||
|
const [bucket, setBucket] = useState<Bucket>('mine');
|
||||||
|
const [scopeGroup, setScopeGroup] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const hist = useUndo<ConfigInstance>();
|
const hist = useUndo<ConfigInstance>();
|
||||||
const working = hist.present;
|
const working = hist.present;
|
||||||
@@ -984,7 +1002,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
||||||
const visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter);
|
const scopedInstances = filterByScope(instances, me, bucket, scopeGroup);
|
||||||
|
const visibleInstances = scopedInstances.filter(s => !setFilter || s.setId === setFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="cl-body">
|
<div class="cl-body">
|
||||||
@@ -994,6 +1013,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
|||||||
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
|
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
|
||||||
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
|
||||||
{sets.length > 1 && (
|
{sets.length > 1 && (
|
||||||
<div class="cl-list-filter">
|
<div class="cl-list-filter">
|
||||||
<select class="prop-select" value={setFilter} title="Filter instances by config set"
|
<select class="prop-select" value={setFilter} title="Filter instances by config set"
|
||||||
@@ -1032,6 +1052,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
|||||||
<button class="panel-btn" disabled={busy} title="Compare stored values against the current live signal values" onClick={liveDiff}>Diff vs current</button>
|
<button class="panel-btn" disabled={busy} title="Compare stored values against the current live signal values" onClick={liveDiff}>Diff vs current</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
|
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
|
||||||
|
<ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
|
||||||
|
onChange={(scope, groups) => { hist.commit({ ...working, scope, groups }); setDirty(true); }} />
|
||||||
|
|
||||||
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
|
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
|
||||||
{boundSet && (
|
{boundSet && (
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup,
|
|||||||
import { useFlowZoom, type Box } from './lib/flowZoom';
|
import { useFlowZoom, type Box } from './lib/flowZoom';
|
||||||
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
||||||
import { wsClient } from './lib/ws';
|
import { wsClient } from './lib/ws';
|
||||||
|
import { useAuth } from './lib/auth';
|
||||||
|
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
|
||||||
|
|
||||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||||
|
|
||||||
@@ -46,6 +48,9 @@ interface CLGraph {
|
|||||||
nodes: CLNode[];
|
nodes: CLNode[];
|
||||||
wires: CLWire[];
|
wires: CLWire[];
|
||||||
groups?: NodeGroup[];
|
groups?: NodeGroup[];
|
||||||
|
owner?: string;
|
||||||
|
scope?: string;
|
||||||
|
scopeGroups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
@@ -169,7 +174,10 @@ function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
|||||||
interface Props { onClose: () => void; }
|
interface Props { onClose: () => void; }
|
||||||
|
|
||||||
export default function ControlLogicEditor({ onClose }: Props) {
|
export default function ControlLogicEditor({ onClose }: Props) {
|
||||||
|
const me = useAuth();
|
||||||
const [graphs, setGraphs] = useState<CLGraph[]>([]);
|
const [graphs, setGraphs] = useState<CLGraph[]>([]);
|
||||||
|
const [bucket, setBucket] = useState<Bucket>('mine');
|
||||||
|
const [scopeGroup, setScopeGroup] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [graph, setGraph] = useState<CLGraph | null>(null);
|
const [graph, setGraph] = useState<CLGraph | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
@@ -308,6 +316,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleGraphs = filterByScope(graphs, me, bucket, scopeGroup, g => g.scopeGroups ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
||||||
<div class="cl-modal cl-modal-full">
|
<div class="cl-modal cl-modal-full">
|
||||||
@@ -331,8 +341,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
<span>Graphs</span>
|
<span>Graphs</span>
|
||||||
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
|
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
|
||||||
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
|
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
|
||||||
{graphs.map(g => (
|
{graphs.length > 0 && visibleGraphs.length === 0 && <div class="hint cl-list-empty">No graphs in this view.</div>}
|
||||||
|
{visibleGraphs.map(g => (
|
||||||
<div key={g.id}
|
<div key={g.id}
|
||||||
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
|
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
|
||||||
onClick={() => selectGraph(g)}>
|
onClick={() => selectGraph(g)}>
|
||||||
@@ -362,6 +374,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
|
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
|
<ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
|
||||||
|
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
|
||||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cl-editor-main">
|
<div class="cl-editor-main">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
|||||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
import { useAuth, canWrite } from './lib/auth';
|
import { useAuth, canWrite } from './lib/auth';
|
||||||
import type { Interface, InterfaceListItem, Folder } from './lib/types';
|
import type { Interface, InterfaceListItem, Folder } from './lib/types';
|
||||||
|
import { ScopeFilter, bucketOf, type Bucket } from './lib/scope';
|
||||||
import ShareDialog from './ShareDialog';
|
import ShareDialog from './ShareDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,6 +51,11 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
const writable = canWrite(me.level);
|
const writable = canWrite(me.level);
|
||||||
|
// Mine/Group/Global visibility filter. Default to Global so the primary
|
||||||
|
// navigation list isn't empty for users who own no panels; legacy/public
|
||||||
|
// panels live there.
|
||||||
|
const [bucket, setBucket] = useState<Bucket>('global');
|
||||||
|
const [scopeGroup, setScopeGroup] = useState('');
|
||||||
|
|
||||||
// Panels in a folder ('' = root), sorted by their stored order then name.
|
// Panels in a folder ('' = root), sorted by their stored order then name.
|
||||||
function panelsIn(folder: string): InterfaceListItem[] {
|
function panelsIn(folder: string): InterfaceListItem[] {
|
||||||
@@ -58,6 +64,26 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether a panel falls in the active visibility bucket. In group mode a
|
||||||
|
// chosen group narrows to panels shared with that specific group.
|
||||||
|
function inScope(item: InterfaceListItem): boolean {
|
||||||
|
if (bucketOf(item, me) !== bucket) return false;
|
||||||
|
if (bucket === 'group' && scopeGroup) return (item.groups ?? []).includes(scopeGroup);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panels in a folder that pass the active visibility filter.
|
||||||
|
function visiblePanelsIn(folder: string): InterfaceListItem[] {
|
||||||
|
return panelsIn(folder).filter(inScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder is shown when it (recursively) contains any in-scope panel, so
|
||||||
|
// empty branches collapse out under the active filter.
|
||||||
|
function folderHasVisible(id: string): boolean {
|
||||||
|
if (visiblePanelsIn(id).length > 0) return true;
|
||||||
|
return folders.some(f => f.parent === id && folderHasVisible(f.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Persist a new ordering for a folder's panels (also moves panels between folders).
|
// Persist a new ordering for a folder's panels (also moves panels between folders).
|
||||||
async function reorder(folder: string, ids: string[]) {
|
async function reorder(folder: string, ids: string[]) {
|
||||||
const res = await fetch('/api/v1/interfaces/reorder', {
|
const res = await fetch('/api/v1/interfaces/reorder', {
|
||||||
@@ -116,6 +142,8 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
folder: item.folder ? String(item.folder) : '',
|
folder: item.folder ? String(item.folder) : '',
|
||||||
order: Number(item.order || 0),
|
order: Number(item.order || 0),
|
||||||
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
||||||
|
scope: item.scope ? String(item.scope) : '',
|
||||||
|
groups: Array.isArray(item.groups) ? item.groups.map(String) : [],
|
||||||
})).filter((item: InterfaceListItem) => item.id);
|
})).filter((item: InterfaceListItem) => item.id);
|
||||||
setInterfaces(normalized);
|
setInterfaces(normalized);
|
||||||
setFolders(Array.isArray(folderData) ? folderData : []);
|
setFolders(Array.isArray(folderData) ? folderData : []);
|
||||||
@@ -222,9 +250,11 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render a folder and everything beneath it (subfolders first, then panels).
|
// Render a folder and everything beneath it (subfolders first, then panels).
|
||||||
|
// Folders with no in-scope descendant panels are hidden under the filter.
|
||||||
function renderFolder(folder: Folder) {
|
function renderFolder(folder: Folder) {
|
||||||
|
if (!folderHasVisible(folder.id)) return null;
|
||||||
const children = folders.filter(f => f.parent === folder.id);
|
const children = folders.filter(f => f.parent === folder.id);
|
||||||
const panels = panelsIn(folder.id);
|
const panels = visiblePanelsIn(folder.id);
|
||||||
const open = expanded[folder.id] !== false; // default expanded
|
const open = expanded[folder.id] !== false; // default expanded
|
||||||
const canDrop = folder.perm === 'write';
|
const canDrop = folder.perm === 'write';
|
||||||
return (
|
return (
|
||||||
@@ -256,7 +286,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootFolders = folders.filter(f => !f.parent);
|
const rootFolders = folders.filter(f => !f.parent);
|
||||||
const rootPanels = panelsIn('');
|
const rootPanels = visiblePanelsIn('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||||
@@ -289,6 +319,9 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ScopeFilter me={me} value={bucket} onChange={setBucket}
|
||||||
|
group={scopeGroup} onGroupChange={setScopeGroup} />
|
||||||
|
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p class="hint">Loading…</p>
|
<p class="hint">Loading…</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { VersionTree, DiffViewer } from './VersionHistory';
|
|||||||
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
|
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
|
||||||
import { useFlowZoom, type Box } from './lib/flowZoom';
|
import { useFlowZoom, type Box } from './lib/flowZoom';
|
||||||
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
||||||
|
import { useAuth } from './lib/auth';
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
interface SignalInfo { name: string; type?: string; }
|
interface SignalInfo { name: string; type?: string; }
|
||||||
@@ -337,8 +338,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const [verReload, setVerReload] = useState(0);
|
const [verReload, setVerReload] = useState(0);
|
||||||
|
|
||||||
// Identity fields — editable only when creating a new signal.
|
// Identity fields — editable only when creating a new signal.
|
||||||
|
const me = useAuth();
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
|
const [visibility, setVisibility] = useState<'panel' | 'user' | 'group' | 'global'>(panelId ? 'panel' : 'user');
|
||||||
|
const [scopeGroups, setScopeGroups] = useState<string[]>([]);
|
||||||
const [unit, setUnit] = useState('');
|
const [unit, setUnit] = useState('');
|
||||||
const [desc, setDesc] = useState('');
|
const [desc, setDesc] = useState('');
|
||||||
const [dispLow, setDispLow] = useState('0');
|
const [dispLow, setDispLow] = useState('0');
|
||||||
@@ -878,6 +881,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
if (create) {
|
if (create) {
|
||||||
body.visibility = visibility;
|
body.visibility = visibility;
|
||||||
if (visibility === 'panel' && panelId) body.panel = panelId;
|
if (visibility === 'panel' && panelId) body.panel = panelId;
|
||||||
|
if (visibility === 'group') body.groups = scopeGroups;
|
||||||
}
|
}
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
|
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
|
||||||
@@ -1146,9 +1150,24 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||||
{panelId && <option value="panel">This panel only</option>}
|
{panelId && <option value="panel">This panel only</option>}
|
||||||
<option value="user">My signals</option>
|
<option value="user">My signals</option>
|
||||||
|
<option value="group" disabled={me.groups.length === 0}>A group</option>
|
||||||
<option value="global">Global (all users)</option>
|
<option value="global">Global (all users)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{visibility === 'group' && (
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Groups</label>
|
||||||
|
<div class="scope-picker-groups">
|
||||||
|
{me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
|
||||||
|
{me.groups.map(g => (
|
||||||
|
<label key={g} class="scope-picker-chk">
|
||||||
|
<input type="checkbox" checked={scopeGroups.includes(g)}
|
||||||
|
onChange={() => setScopeGroups(prev => prev.includes(g) ? prev.filter(x => x !== g) : [...prev, g])} />{g}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : (
|
) : (
|
||||||
<p class="hint">Editing <b>{name}</b>.</p>
|
<p class="hint">Editing <b>{name}</b>.</p>
|
||||||
|
|||||||
+76
-5
@@ -1,24 +1,95 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
const LS_KEY = 'uopi:ui-zoom';
|
const LS_KEY = 'uopi:ui-zoom';
|
||||||
|
const ZOOM_EVENT = 'uopi:zoomchange';
|
||||||
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
|
const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5];
|
||||||
|
|
||||||
|
// serverDefaultZoom is the base scale configured server-side ([ui] default_zoom),
|
||||||
|
// pushed in once /me resolves. It is the fallback when this browser has no saved
|
||||||
|
// per-machine override, so a fresh client on a HiDPI screen starts enlarged
|
||||||
|
// without per-browser setup while a user's local A+/A− choice still wins.
|
||||||
|
let serverDefaultZoom = 1;
|
||||||
|
|
||||||
|
// hasStoredZoom reports whether this browser has an explicit saved zoom override.
|
||||||
|
export function hasStoredZoom(): boolean {
|
||||||
|
return localStorage.getItem(LS_KEY) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setServerDefaultZoom records the server default and, unless the user has a saved
|
||||||
|
// override, applies it immediately (without persisting, so a later server change
|
||||||
|
// still propagates).
|
||||||
|
export function setServerDefaultZoom(z: number): void {
|
||||||
|
serverDefaultZoom = isFinite(z) && z > 0 ? z : 1;
|
||||||
|
if (!hasStoredZoom()) {
|
||||||
|
applyZoom(serverDefaultZoom, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getStoredZoom(): number {
|
export function getStoredZoom(): number {
|
||||||
const v = parseFloat(localStorage.getItem(LS_KEY) ?? '1');
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
const v = raw === null ? serverDefaultZoom : parseFloat(raw);
|
||||||
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
|
return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyZoom(z: number): number {
|
// dprFactor returns the device-pixel-ratio scaling applied on top of the manual
|
||||||
|
// zoom so the UI is proportionally larger on high-DPI displays by default.
|
||||||
|
// Firefox only honours CSS rem scaling (it does not auto-enlarge the root px the
|
||||||
|
// way Chrome does on some setups), so the ratio must be folded into the root
|
||||||
|
// font-size explicitly rather than left to the browser.
|
||||||
|
export function dprFactor(): number {
|
||||||
|
const dpr = window.devicePixelRatio;
|
||||||
|
return isFinite(dpr) && dpr > 0 ? dpr : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyZoom sets the root font-size to scale the whole UI. When persist is true
|
||||||
|
// (a deliberate user action) the choice is saved as this browser's override;
|
||||||
|
// pass false to apply the server default transiently. A ZOOM_EVENT is dispatched
|
||||||
|
// so the ZoomControl's displayed percentage stays in sync regardless of source.
|
||||||
|
export function applyZoom(z: number, persist = true): number {
|
||||||
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
|
const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z));
|
||||||
localStorage.setItem(LS_KEY, String(clamped));
|
if (persist) {
|
||||||
document.documentElement.style.fontSize = `${Math.round(16 * clamped)}px`;
|
localStorage.setItem(LS_KEY, String(clamped));
|
||||||
|
}
|
||||||
|
const dpr = dprFactor();
|
||||||
|
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||||
|
document.documentElement.style.fontSize = `${Math.round(16 * clamped * dpr)}px`;
|
||||||
|
window.dispatchEvent(new CustomEvent(ZOOM_EVENT, { detail: clamped }));
|
||||||
return clamped;
|
return clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// watchDpr re-applies the current zoom whenever the device-pixel-ratio changes
|
||||||
|
// (e.g. the window moves to a monitor with different scaling). matchMedia fires
|
||||||
|
// once per resolution change; the listener is re-armed each time. Returns an
|
||||||
|
// unsubscribe function.
|
||||||
|
export function watchDpr(): () => void {
|
||||||
|
let mql: MediaQueryList | null = null;
|
||||||
|
const onChange = () => {
|
||||||
|
// Re-apply at the new DPR; only persist if this browser actually has an
|
||||||
|
// override (otherwise a monitor change would freeze the server default).
|
||||||
|
applyZoom(getStoredZoom(), hasStoredZoom());
|
||||||
|
arm();
|
||||||
|
};
|
||||||
|
const arm = () => {
|
||||||
|
mql?.removeEventListener('change', onChange);
|
||||||
|
mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
};
|
||||||
|
arm();
|
||||||
|
return () => mql?.removeEventListener('change', onChange);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ZoomControl() {
|
export default function ZoomControl() {
|
||||||
const [zoom, setZoom] = useState(getStoredZoom);
|
const [zoom, setZoom] = useState(getStoredZoom);
|
||||||
|
|
||||||
|
// Keep the displayed percentage in sync when zoom is applied from elsewhere
|
||||||
|
// (e.g. the server default arriving via /me, or a DPR change).
|
||||||
|
useEffect(() => {
|
||||||
|
const onZoom = (e: Event) => setZoom((e as CustomEvent<number>).detail);
|
||||||
|
window.addEventListener(ZOOM_EVENT, onZoom);
|
||||||
|
return () => window.removeEventListener(ZOOM_EVENT, onZoom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function step(dir: number) {
|
function step(dir: number) {
|
||||||
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
|
const idx = STEPS.findIndex(s => s >= zoom - 0.01);
|
||||||
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
|
const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir));
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
|||||||
|
|
||||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||||
// trusted LAN user with full access, matching the backend default.
|
// trusted LAN user with full access, matching the backend default.
|
||||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true };
|
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true, defaultZoom: 1 };
|
||||||
|
|
||||||
// AuthContext carries the resolved identity + global access level for the
|
// AuthContext carries the resolved identity + global access level for the
|
||||||
// current user throughout the app.
|
// current user throughout the app.
|
||||||
@@ -39,6 +39,7 @@ export async function fetchMe(): Promise<Me> {
|
|||||||
canEditLogic: data.canEditLogic !== false,
|
canEditLogic: data.canEditLogic !== false,
|
||||||
canViewAudit: data.canViewAudit !== false,
|
canViewAudit: data.canViewAudit !== false,
|
||||||
canAdmin: data.canAdmin !== false,
|
canAdmin: data.canAdmin !== false,
|
||||||
|
defaultZoom: typeof data.defaultZoom === 'number' && data.defaultZoom > 0 ? data.defaultZoom : 1,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ME;
|
return DEFAULT_ME;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { Me } from './types';
|
||||||
|
|
||||||
|
// Visibility scope tokens shared by every user-owned, filterable object
|
||||||
|
// (config sets/instances, synthetic signals, control-logic graphs, panels).
|
||||||
|
// They mirror internal/access/scope.go. An empty/unknown token is treated as
|
||||||
|
// global so legacy objects without a scope stay visible to everyone.
|
||||||
|
export type Scope = 'private' | 'group' | 'global';
|
||||||
|
|
||||||
|
// Bucket is the per-tree selector position: which slice of the visible objects
|
||||||
|
// the user is currently looking at.
|
||||||
|
export type Bucket = 'mine' | 'group' | 'global';
|
||||||
|
|
||||||
|
// Scoped is the minimal shape the scope helpers read off any list item. The
|
||||||
|
// scope-groups list is not part of this shape because subsystems disagree on
|
||||||
|
// its field name (config/synthetic use `groups`, control-logic uses
|
||||||
|
// `scopeGroups`); filterByScope takes an explicit accessor for it instead.
|
||||||
|
export interface Scoped {
|
||||||
|
owner?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeScope coerces a raw scope string into a known token; empty/unknown
|
||||||
|
// becomes 'global' to match the backend's legacy-safe default.
|
||||||
|
export function normalizeScope(s?: string): Scope {
|
||||||
|
switch ((s ?? '').trim().toLowerCase()) {
|
||||||
|
case 'private': return 'private';
|
||||||
|
case 'group': return 'group';
|
||||||
|
default: return 'global';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketOf assigns an item to exactly one selector bucket from the caller's
|
||||||
|
// perspective: items they own go to 'mine'; otherwise group-scoped items go to
|
||||||
|
// 'group' and everything else (global/empty/unknown) to 'global'.
|
||||||
|
export function bucketOf(item: Scoped, me: Me): Bucket {
|
||||||
|
if (item.owner && item.owner === me.user) return 'mine';
|
||||||
|
if (normalizeScope(item.scope) === 'group') return 'group';
|
||||||
|
return 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterByScope keeps the items belonging to the active bucket. In group mode a
|
||||||
|
// non-empty group narrows to items shared with that specific group; groupsOf
|
||||||
|
// reads each item's scope-groups (defaulting to its `groups` field).
|
||||||
|
export function filterByScope<T extends Scoped>(
|
||||||
|
items: T[], me: Me, bucket: Bucket, group: string,
|
||||||
|
groupsOf: (t: T) => string[] = (t) => (t as any).groups ?? [],
|
||||||
|
): T[] {
|
||||||
|
return items.filter(it => {
|
||||||
|
if (bucketOf(it, me) !== bucket) return false;
|
||||||
|
if (bucket === 'group' && group) return groupsOf(it).includes(group);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeFilter is the segmented [Mine | Group ▾ | Global] selector shown above a
|
||||||
|
// filterable tree. The Group segment carries a combo to pick which group when
|
||||||
|
// the user belongs to more than one; it is disabled for users with no groups.
|
||||||
|
export function ScopeFilter({ me, value, onChange, group, onGroupChange }: {
|
||||||
|
me: Me;
|
||||||
|
value: Bucket;
|
||||||
|
onChange: (b: Bucket) => void;
|
||||||
|
group: string;
|
||||||
|
onGroupChange: (g: string) => void;
|
||||||
|
}) {
|
||||||
|
const hasGroups = me.groups.length > 0;
|
||||||
|
return (
|
||||||
|
<div class="scope-filter">
|
||||||
|
<div class="scope-segs">
|
||||||
|
<button class={`scope-seg${value === 'mine' ? ' scope-seg-active' : ''}`}
|
||||||
|
onClick={() => onChange('mine')}>Mine</button>
|
||||||
|
<button class={`scope-seg${value === 'group' ? ' scope-seg-active' : ''}`}
|
||||||
|
disabled={!hasGroups} title={hasGroups ? 'Shared with a group' : 'You are not in any group'}
|
||||||
|
onClick={() => onChange('group')}>Group</button>
|
||||||
|
<button class={`scope-seg${value === 'global' ? ' scope-seg-active' : ''}`}
|
||||||
|
onClick={() => onChange('global')}>Global</button>
|
||||||
|
</div>
|
||||||
|
{value === 'group' && hasGroups && (
|
||||||
|
<select class="scope-group-select" value={group}
|
||||||
|
onChange={(e) => onGroupChange((e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="">All my groups</option>
|
||||||
|
{me.groups.map(g => <option key={g} value={g}>{g}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopePicker is the visibility editor used in create/save flows: a scope select
|
||||||
|
// plus, for group scope, checkboxes over the user's groups. It calls onChange
|
||||||
|
// with both the scope token and the chosen groups so callers persist them
|
||||||
|
// together.
|
||||||
|
export function ScopePicker({ me, scope, groups, onChange, disabled }: {
|
||||||
|
me: Me;
|
||||||
|
scope: Scope;
|
||||||
|
groups: string[];
|
||||||
|
onChange: (scope: Scope, groups: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const toggle = (g: string) => {
|
||||||
|
const next = groups.includes(g) ? groups.filter(x => x !== g) : [...groups, g];
|
||||||
|
onChange('group', next);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div class="scope-picker">
|
||||||
|
<label class="scope-picker-label">Visibility</label>
|
||||||
|
<select class="prop-input scope-picker-select" value={scope} disabled={disabled}
|
||||||
|
onChange={(e) => onChange((e.target as HTMLSelectElement).value as Scope, groups)}>
|
||||||
|
<option value="private">Private (only me)</option>
|
||||||
|
<option value="group" disabled={me.groups.length === 0}>Group</option>
|
||||||
|
<option value="global">Global (everyone)</option>
|
||||||
|
</select>
|
||||||
|
{scope === 'group' && (
|
||||||
|
<div class="scope-picker-groups">
|
||||||
|
{me.groups.length === 0 && <span class="hint">You are not in any group.</span>}
|
||||||
|
{me.groups.map(g => (
|
||||||
|
<label key={g} class="scope-picker-chk">
|
||||||
|
<input type="checkbox" checked={groups.includes(g)} disabled={disabled}
|
||||||
|
onChange={() => toggle(g)} />{g}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -228,6 +228,9 @@ export interface Me {
|
|||||||
// server statistics). False only when an admins allowlist is configured and
|
// server statistics). False only when an admins allowlist is configured and
|
||||||
// excludes them.
|
// excludes them.
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
// Base UI scale multiplier configured server-side ([ui] default_zoom), applied
|
||||||
|
// when this browser has no saved zoom override. 1 means no scaling.
|
||||||
|
defaultZoom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective permission a user holds on a single panel or folder.
|
// Effective permission a user holds on a single panel or folder.
|
||||||
@@ -276,6 +279,10 @@ export interface InterfaceListItem {
|
|||||||
folder?: string;
|
folder?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
perm: Perm;
|
perm: Perm;
|
||||||
|
// Derived visibility bucket (private|group|global) + the groups a group-scoped
|
||||||
|
// panel is shared with, used by the Mine/Group/Global selector filter.
|
||||||
|
scope?: string;
|
||||||
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Synthetic signals ────────────────────────────────────────────────────────
|
// ── Synthetic signals ────────────────────────────────────────────────────────
|
||||||
@@ -327,7 +334,8 @@ export interface SignalDef {
|
|||||||
};
|
};
|
||||||
// Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered
|
// Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered
|
||||||
// server-side; owner is set from the trusted identity on create.
|
// server-side; owner is set from the trusted identity on create.
|
||||||
visibility?: 'panel' | 'user' | 'global';
|
visibility?: 'panel' | 'user' | 'group' | 'global';
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
groups?: string[]; // groups for 'group' visibility
|
||||||
panel?: string; // bound interface id when visibility === 'panel'
|
panel?: string; // bound interface id when visibility === 'panel'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4817,6 +4817,62 @@ kbd {
|
|||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Scope filter (Mine / Group / Global selector) ── */
|
||||||
|
.scope-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
.scope-segs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-seg {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scope-seg:hover:not(:disabled) { color: #e2e8f0; background: #232a3a; }
|
||||||
|
.scope-seg:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.scope-seg-active {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #3d4a63;
|
||||||
|
}
|
||||||
|
.scope-group-select {
|
||||||
|
background: #1a2030;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #3d4a63;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scope picker (visibility editor in create/save flows) ── */
|
||||||
|
.scope-picker { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.scope-picker-label { font-size: 0.78rem; color: #94a3b8; }
|
||||||
|
.scope-picker-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-picker-chk {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
.cfg-editor {
|
.cfg-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
Starting iocInit
|
Starting iocInit
|
||||||
iocRun: All initialization complete
|
iocRun: All initialization complete
|
||||||
|
CAS: CA beacon send to 172.21.127.255:5065 ERROR: Network is unreachable
|
||||||
|
CAS: CA beacon send to 172.21.127.255:5065 ok
|
||||||
|
|||||||
Reference in New Issue
Block a user