Added ldap and pam authentication
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
"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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user