Added ldap and pam authentication

This commit is contained in:
Martino Ferrari
2026-06-23 17:59:40 +02:00
parent ac24011487
commit 11120bedca
36 changed files with 1935 additions and 66 deletions
+55
View File
@@ -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))
}
+47
View File
@@ -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
View File
@@ -44,12 +44,13 @@ type Handler struct {
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL 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
}
// 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.
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 {
rec = audit.Nop()
}
@@ -65,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
uiDefaultZoom: uiDefaultZoom,
log: log,
}
}
@@ -192,6 +194,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
if groups == nil {
groups = []string{}
}
defaultZoom := h.uiDefaultZoom
if defaultZoom <= 0 {
defaultZoom = 1.0
}
jsonOK(w, map[string]any{
"user": user,
"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),
"canViewAudit": h.policy.CanViewAudit(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 {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
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))
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
// caller while editing the given panel. An empty Visibility is treated as
// "global" so legacy definitions remain visible everywhere.
func synVisible(d synthetic.SignalDef, user, panel string) bool {
// caller (a member of userGroups) while editing the given panel. An empty
// Visibility is treated as "global" so legacy definitions remain visible
// everywhere.
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
switch d.Visibility {
case "user":
return user != "" && d.Owner == user
case "group":
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
case "panel":
return panel != "" && d.Panel == panel
default: // "global" or legacy empty
@@ -431,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
var out []signalInfo
for _, ds := range sources {
var metas []datasource.Metadata
if ds.Name() == "synthetic" && h.synthetic != nil {
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
return synVisible(d, user, panel, userGroups)
})
} else {
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.
type interfaceListItem struct {
storage.InterfaceMeta
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
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) {
@@ -613,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
continue // hide panels the caller cannot see
}
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.Folder = acl.Folder
item.Order = acl.Order
}
item.Scope, item.Groups = panelScope(acl)
out = append(out, item)
}
h.log.Info("list interfaces", "count", len(out))
@@ -1548,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
// ── 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 {
jsonOK(w, []any{})
return
}
graphs := h.ctrlLogic.List()
if graphs == nil {
graphs = []controllogic.Graph{}
user := caller(r)
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)
}
@@ -1588,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = genID("cl")
g.Owner = caller(r) // stamp ownership from the trusted identity
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
@@ -1608,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
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)
return
}
@@ -1618,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = id
g.Owner = prev.Owner // owner is immutable across revisions
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
+2 -2
View File
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
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)
return srv, func() {
@@ -485,7 +485,7 @@ func adminSetup(t *testing.T) (*httptest.Server, func()) {
}
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.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+23 -4
View File
@@ -37,7 +37,7 @@ func configStatus(err error) int {
// ── 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) {
return
}
@@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, sets)
jsonOK(w, h.filterConfigMetas(r, sets))
}
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())
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"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
@@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
// ── 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) {
return
}
@@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
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) {
@@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
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"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
+166
View File
@@ -13,10 +13,22 @@ type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
UI UIConfig `toml:"ui"`
// Groups are named sets of users, referenced by panel sharing rules.
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
// 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
@@ -65,6 +77,116 @@ type ServerConfig struct {
// {storage_dir}/access.json, that file — not this config — is the source of
// truth for access.
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 {
@@ -146,6 +268,45 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_DEFAULT_USER"); 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 != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
}
@@ -170,6 +331,11 @@ func applyEnv(cfg *Config) {
if v := env("EPICS_PVA_ADDR_LIST"); 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 {
+4
View File
@@ -61,6 +61,8 @@ type ConfigSet struct {
Version int `json:"version"`
Tag string `json:"tag,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"`
}
@@ -75,6 +77,8 @@ type ConfigInstance struct {
Version int `json:"version"`
Tag string `json:"tag,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"`
}
+12 -4
View File
@@ -49,6 +49,11 @@ type Meta struct {
// rules without the flag. Lets the rule list show enabled/disabled status
// without a GET per rule.
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.
@@ -87,9 +92,12 @@ type header struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
Owner string `json:"owner"`
Scope string `json:"scope"`
Groups []string `json:"groups"`
}
func validateID(id string) error {
@@ -154,7 +162,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
if err != nil {
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
}
+3
View File
@@ -66,6 +66,9 @@ type Graph struct {
Enabled bool `json:"enabled"`
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")
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"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
+5 -3
View File
@@ -15,12 +15,14 @@ type SignalDef struct {
// Visibility controls who sees this signal in the signal tree:
// "global" — listed in every panel's edit mode
// "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
// An empty value is treated as "global" for backward compatibility with
// definitions created before this field existed.
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
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
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
+195
View File
@@ -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)
}
+60
View File
@@ -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")
}
}
+106
View 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
}
+21
View File
@@ -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
}
+125
View File
@@ -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
})
}
+129
View File
@@ -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)
}
}
+92
View File
@@ -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
View File
@@ -4,10 +4,12 @@ import (
"context"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
@@ -23,18 +25,35 @@ import (
const apiPrefix = "/api/v1"
type Server struct {
httpServer *http.Server
log *slog.Logger
httpServer *http.Server
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.
// 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 {
rec = audit.Nop()
}
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
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, 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
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
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
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)
mux.Handle("/", http.FileServerFS(webFS))
mux.Handle("/", frontendHandler)
return &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,
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.
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)
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
}
}()
// 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 {
case err := <-errCh:
return err
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.log.Info("shutting down")
if redirectSrv != nil {
_ = redirectSrv.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)
})
}