0.5.0
release-tag / release-image (push) Successful in 2m6s
release-main / release-images (push) Successful in 3m40s

This commit is contained in:
2026-08-23 08:31:17 +02:00
parent 1b29cbb39d
commit b61b2d2ef1
24 changed files with 1212 additions and 54 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"github.com/example/sessionguard/internal/windowsx"
)
const Version = "0.4.1"
const Version = "0.5.0"
type App struct {
cfg config.Agent
+2 -2
View File
@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.4.1</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.0</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master"></div></div></div></section>
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
@@ -44,7 +44,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
<section class="page-section" id="profiles-section"><div class="section-heading"><div><div class="eyebrow">Profile</div><h2>Profil-Pipeline</h2><div class="section-copy">Backup, Restore, Retry und Cleanup-Status pro Benutzer.</div></div></div><section class="panel"><div id="profiles"></div></section></section>
<section class="page-section" id="events-section"><div class="section-heading"><div><div class="eyebrow">Telemetry</div><h2>Aktivitäts- & Audit-Log</h2><div class="section-copy">Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.</div></div></div><section class="panel"><div id="events"></div></section></section>
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div></div><section class="panel"><div id="policy"></div></section></section>
</main></div></div><script src="/app.js?v=0.4.1"></script></body></html>`
</main></div></div><script src="/app.js?v=0.5.0"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
+507
View File
@@ -0,0 +1,507 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/example/sessionguard/internal/model"
"golang.org/x/oauth2"
)
const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
type AccessSessionStore interface {
PutAuthSession(model.AuthSession) error
GetAuthSession(hash string) (model.AuthSession, bool)
DeleteAuthSession(hash string) error
RevokeAuthSessions(sid, sub string) (int, error)
CleanupAuthSessions(time.Time) error
}
type accessPending struct {
Nonce string
ReturnURL string
Exp time.Time
}
type AccessManager struct {
cfg model.AccessAuthConfig
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
logoutVer *oidc.IDTokenVerifier
oauth oauth2.Config
sessions AccessSessionStore
endSession string
mu sync.Mutex
pending map[string]accessPending
logoutSeen map[string]time.Time
}
func NewAccess(ctx context.Context, cfg model.AccessAuthConfig, sessions AccessSessionStore) (*AccessManager, error) {
if !cfg.Enabled {
return nil, nil
}
if sessions == nil {
return nil, errors.New("access auth session store is required")
}
p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
if err != nil {
return nil, err
}
var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
_ = p.Claims(&discovery)
return &AccessManager{
cfg: cfg,
provider: p,
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
// Back-channel logout tokens are not ID tokens and may omit exp. We
// still verify issuer, audience and signature, then validate the
// logout-specific claims below.
logoutVer: p.Verifier(&oidc.Config{ClientID: cfg.ClientID, SkipExpiryCheck: true}),
oauth: oauth2.Config{
ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
},
sessions: sessions,
endSession: discovery.EndSessionEndpoint,
pending: map[string]accessPending{},
logoutSeen: map[string]time.Time{},
}, nil
}
func (m *AccessManager) Register(mux *http.ServeMux) {
if m == nil {
return
}
mux.HandleFunc("/auth/verify", m.Verify)
mux.HandleFunc("GET /auth/login", m.Login)
mux.HandleFunc("GET /auth/oidc/callback", m.Callback)
mux.HandleFunc("GET /auth/logout", m.Logout)
mux.HandleFunc("POST /auth/logout", m.Logout)
mux.HandleFunc("POST /auth/backchannel-logout", m.BackchannelLogout)
mux.HandleFunc("GET /auth/status", m.Status)
}
func (m *AccessManager) Login(w http.ResponseWriter, r *http.Request) {
_ = m.sessions.CleanupAuthSessions(time.Now().UTC())
target := m.validReturnURL(r.URL.Query().Get("return"))
state, nonce := randomAccessToken(24), randomAccessToken(24)
m.mu.Lock()
m.prunePendingLocked(time.Now())
m.pending[state] = accessPending{Nonce: nonce, ReturnURL: target, Exp: time.Now().Add(5 * time.Minute)}
m.mu.Unlock()
// One state cookie per login flow avoids the common multi-tab race where a
// second login overwrites the first flow's single state cookie.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName(state), Value: state, Path: m.externalCallbackPath(),
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
http.Redirect(w, r, m.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}
func (m *AccessManager) Callback(w http.ResponseWriter, r *http.Request) {
if e := r.URL.Query().Get("error"); e != "" {
http.Error(w, "OIDC: "+e, http.StatusUnauthorized)
return
}
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state == "" {
http.Error(w, "missing OIDC state", http.StatusUnauthorized)
return
}
cookieName := stateCookieName(state)
c, err := r.Cookie(cookieName)
if err != nil || c.Value != state {
http.Error(w, "OIDC state is not bound to this browser", http.StatusUnauthorized)
return
}
clearCookie(w, cookieName, "", m.externalCallbackPath(), m.cfg.SecureCookie)
m.mu.Lock()
p, ok := m.pending[state]
delete(m.pending, state)
m.mu.Unlock()
if !ok || time.Now().After(p.Exp) {
http.Error(w, "invalid or expired OIDC state", http.StatusUnauthorized)
return
}
tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "OIDC token exchange failed", http.StatusUnauthorized)
return
}
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok || strings.TrimSpace(rawIDToken) == "" {
http.Error(w, "missing id_token", http.StatusUnauthorized)
return
}
idToken, err := m.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "invalid id_token", http.StatusUnauthorized)
return
}
if idToken.Nonce != p.Nonce {
http.Error(w, "invalid OIDC nonce", http.StatusUnauthorized)
return
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "invalid OIDC claims", http.StatusUnauthorized)
return
}
username := claimString(claims, m.cfg.UsernameClaim)
if username == "" {
// Pocket ID documents preferred_username and it is a safer Guacamole
// identity than display-name. Never invent a fallback identity.
username = claimString(claims, "preferred_username")
}
if username == "" {
http.Error(w, "OIDC token has no usable username claim", http.StatusForbidden)
return
}
groups := claimStrings(claims, "groups")
if !allowedGroups(groups, m.cfg.AllowedGroups) {
http.Error(w, "user is not in an allowed access group", http.StatusForbidden)
return
}
browserToken := randomAccessToken(32)
now := time.Now().UTC()
sess := model.AuthSession{
ID: randomAccessToken(12), TokenHash: hashAccessToken(browserToken), Subject: idToken.Subject,
SID: claimString(claims, "sid"), Username: username,
Email: claimString(claims, "email"), Name: claimString(claims, "name"),
Groups: groups, IDToken: rawIDToken, CreatedAt: now,
ExpiresAt: now.Add(time.Duration(m.cfg.SessionHours) * time.Hour),
}
if err := m.sessions.PutAuthSession(sess); err != nil {
http.Error(w, "could not create access session", http.StatusInternalServerError)
return
}
m.setSessionCookie(w, browserToken, int(time.Until(sess.ExpiresAt).Seconds()))
http.Redirect(w, r, p.ReturnURL, http.StatusFound)
}
func (m *AccessManager) Verify(w http.ResponseWriter, r *http.Request) {
sess, ok := m.sessionFromRequest(r)
if !ok {
target := m.forwardedTarget(r)
http.Redirect(w, r, m.loginURL(target), http.StatusFound)
return
}
// These are the only identity headers Traefik should copy to Guacamole.
// authResponseHeaders replaces conflicting client-provided values.
w.Header().Set("X-Guacamole-User", sess.Username)
w.Header().Set("X-SessionGuard-User", sess.Username)
if sess.Email != "" {
w.Header().Set("X-SessionGuard-Email", sess.Email)
}
if len(sess.Groups) > 0 {
w.Header().Set("X-SessionGuard-Groups", strings.Join(sess.Groups, ","))
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
}
func (m *AccessManager) Status(w http.ResponseWriter, r *http.Request) {
sess, ok := m.sessionFromRequest(r)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"authenticated":false}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": true, "username": sess.Username, "email": sess.Email,
"groups": sess.Groups, "expires_at": sess.ExpiresAt,
})
}
func (m *AccessManager) Logout(w http.ResponseWriter, r *http.Request) {
sess, _ := m.sessionFromRequest(r)
if c, err := r.Cookie(m.cfg.CookieName); err == nil {
_ = m.sessions.DeleteAuthSession(hashAccessToken(c.Value))
}
clearCookie(w, m.cfg.CookieName, m.cfg.CookieDomain, "/", m.cfg.SecureCookie)
target := strings.TrimSpace(m.cfg.LogoutRedirectURL)
if target == "" {
target = m.validReturnURL(r.URL.Query().Get("return"))
}
if m.endSession == "" {
http.Redirect(w, r, target, http.StatusFound)
return
}
u, err := url.Parse(m.endSession)
if err != nil {
http.Redirect(w, r, target, http.StatusFound)
return
}
q := u.Query()
if sess.IDToken != "" {
q.Set("id_token_hint", sess.IDToken)
} else {
q.Set("client_id", m.cfg.ClientID)
}
if target != "" {
q.Set("post_logout_redirect_uri", target)
}
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
func (m *AccessManager) BackchannelLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
raw := strings.TrimSpace(r.Form.Get("logout_token"))
if raw == "" {
http.Error(w, "missing logout_token", http.StatusBadRequest)
return
}
tok, err := m.logoutVer.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "invalid logout_token", http.StatusBadRequest)
return
}
var claims struct {
SID string `json:"sid"`
Sub string `json:"sub"`
Nonce string `json:"nonce"`
JTI string `json:"jti"`
IAT int64 `json:"iat"`
Events map[string]json.RawMessage `json:"events"`
}
if err := tok.Claims(&claims); err != nil {
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
return
}
if claims.Nonce != "" || claims.Events == nil {
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
return
}
if _, ok := claims.Events[backchannelLogoutEvent]; !ok {
http.Error(w, "missing backchannel logout event", http.StatusBadRequest)
return
}
if claims.SID == "" && claims.Sub == "" {
http.Error(w, "logout_token has neither sid nor sub", http.StatusBadRequest)
return
}
if strings.TrimSpace(claims.JTI) == "" {
http.Error(w, "logout_token has no jti", http.StatusBadRequest)
return
}
if claims.IAT == 0 || time.Since(time.Unix(claims.IAT, 0)) > 10*time.Minute || time.Until(time.Unix(claims.IAT, 0)) > 5*time.Minute {
http.Error(w, "logout_token iat outside allowed window", http.StatusBadRequest)
return
}
if !m.acceptLogoutJTI(claims.JTI, time.Now()) {
http.Error(w, "logout_token replayed", http.StatusBadRequest)
return
}
if _, err := m.sessions.RevokeAuthSessions(claims.SID, claims.Sub); err != nil {
http.Error(w, "could not revoke access session", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (m *AccessManager) sessionFromRequest(r *http.Request) (model.AuthSession, bool) {
c, err := r.Cookie(m.cfg.CookieName)
if err != nil || strings.TrimSpace(c.Value) == "" {
return model.AuthSession{}, false
}
return m.sessions.GetAuthSession(hashAccessToken(c.Value))
}
func (m *AccessManager) setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: m.cfg.CookieName, Value: token, Path: "/", Domain: m.cfg.CookieDomain,
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
MaxAge: maxAge,
})
}
func (m *AccessManager) forwardedTarget(r *http.Request) string {
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
uri := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri"))
if proto == "" {
proto = "https"
}
if uri == "" {
uri = "/"
}
if host == "" {
return m.cfg.LogoutRedirectURL
}
return m.validReturnURL(proto + "://" + host + uri)
}
func (m *AccessManager) externalCallbackPath() string {
u, err := url.Parse(m.cfg.RedirectURL)
if err != nil || strings.TrimSpace(u.Path) == "" {
return "/auth/oidc/callback"
}
return u.Path
}
func (m *AccessManager) loginURL(target string) string {
u, _ := url.Parse(m.cfg.RedirectURL)
// Preserve an external Traefik prefix such as /_sessionguard. The Master
// itself sees /auth/* after StripPrefix, while the browser must be sent to
// the externally routable prefixed URL.
base := strings.TrimSuffix(u.Path, "/oidc/callback")
if base == u.Path {
base = strings.TrimSuffix(u.Path, "/")
}
u.Path = base + "/login"
u.RawQuery = url.Values{"return": []string{target}}.Encode()
return u.String()
}
func (m *AccessManager) validReturnURL(raw string) string {
fallback := strings.TrimSpace(m.cfg.LogoutRedirectURL)
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.Hostname() == "" {
return fallback
}
host := strings.ToLower(u.Hostname())
for _, allowed := range m.cfg.AllowedHosts {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == host {
return u.String()
}
if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) {
return u.String()
}
}
// If no allow-list was provided, constrain redirects to the configured
// cookie domain or, for host-only cookies, to logout_redirect_url. This is
// still closed against arbitrary open redirects.
if len(m.cfg.AllowedHosts) == 0 {
if m.cfg.CookieDomain != "" {
d := strings.TrimPrefix(strings.ToLower(m.cfg.CookieDomain), ".")
if host == d || strings.HasSuffix(host, "."+d) {
return u.String()
}
}
if f, err := url.Parse(fallback); err == nil && strings.EqualFold(f.Hostname(), host) {
return u.String()
}
}
return fallback
}
func (m *AccessManager) acceptLogoutJTI(jti string, now time.Time) bool {
m.mu.Lock()
defer m.mu.Unlock()
for k, exp := range m.logoutSeen {
if !now.Before(exp) {
delete(m.logoutSeen, k)
}
}
if _, exists := m.logoutSeen[jti]; exists {
return false
}
m.logoutSeen[jti] = now.Add(15 * time.Minute)
return true
}
func (m *AccessManager) prunePendingLocked(now time.Time) {
for k, p := range m.pending {
if now.After(p.Exp) {
delete(m.pending, k)
}
}
}
func allowedGroups(got, allowed []string) bool {
if len(allowed) == 0 {
return true
}
set := map[string]struct{}{}
for _, g := range got {
set[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
}
for _, g := range allowed {
if _, ok := set[strings.ToLower(strings.TrimSpace(g))]; ok {
return true
}
}
return false
}
func claimString(claims map[string]any, key string) string {
v, ok := claims[key]
if !ok {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func claimStrings(claims map[string]any, key string) []string {
v, ok := claims[key]
if !ok {
return nil
}
switch x := v.(type) {
case []any:
out := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
case []string:
return append([]string(nil), x...)
case string:
if strings.TrimSpace(x) != "" {
return []string{strings.TrimSpace(x)}
}
}
return nil
}
func randomAccessToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func hashAccessToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func stateCookieName(state string) string {
if len(state) > 16 {
state = state[:16]
}
return "sg_access_state_" + state
}
func clearCookie(w http.ResponseWriter, name, domain, path string, secure bool) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Domain: domain, Path: path, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
}
+62
View File
@@ -0,0 +1,62 @@
package auth
import (
"testing"
"time"
"github.com/example/sessionguard/internal/model"
)
func TestExternalCallbackPathAndLoginURL(t *testing.T) {
m := &AccessManager{cfg: model.AccessAuthConfig{
RedirectURL: "https://ts.hilden.info/_sessionguard/auth/oidc/callback",
LogoutRedirectURL: "https://ts.hilden.info/",
AllowedHosts: []string{"ts.hilden.info"},
}}
if got := m.externalCallbackPath(); got != "/_sessionguard/auth/oidc/callback" {
t.Fatalf("callback path = %q", got)
}
want := "https://ts.hilden.info/_sessionguard/auth/login?return=https%3A%2F%2Fts.hilden.info%2F"
if got := m.loginURL("https://ts.hilden.info/"); got != want {
t.Fatalf("login URL = %q, want %q", got, want)
}
}
func TestValidReturnURL(t *testing.T) {
m := &AccessManager{cfg: model.AccessAuthConfig{
LogoutRedirectURL: "https://ts.hilden.info/",
AllowedHosts: []string{"ts.hilden.info"},
}}
if got := m.validReturnURL("https://ts.hilden.info/#/client/1"); got != "https://ts.hilden.info/#/client/1" {
t.Fatalf("allowed return URL changed to %q", got)
}
if got := m.validReturnURL("https://evil.example/"); got != "https://ts.hilden.info/" {
t.Fatalf("open redirect was accepted: %q", got)
}
if got := m.validReturnURL("javascript:alert(1)"); got != "https://ts.hilden.info/" {
t.Fatalf("non-https redirect was accepted: %q", got)
}
}
func TestLogoutJTIReplayProtection(t *testing.T) {
m := &AccessManager{logoutSeen: map[string]time.Time{}}
now := time.Now()
if !m.acceptLogoutJTI("abc", now) {
t.Fatal("first jti rejected")
}
if m.acceptLogoutJTI("abc", now.Add(time.Second)) {
t.Fatal("replayed jti accepted")
}
if !m.acceptLogoutJTI("abc", now.Add(16*time.Minute)) {
t.Fatal("expired replay marker was not pruned")
}
}
func TestAllowedGroupsCaseInsensitive(t *testing.T) {
if !allowedGroups([]string{"SessionGuard-Users"}, []string{"sessionguard-users"}) {
t.Fatal("case-insensitive group match failed")
}
if allowedGroups([]string{"other"}, []string{"sessionguard-users"}) {
t.Fatal("unexpected group match")
}
}
+60 -11
View File
@@ -3,6 +3,8 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -11,17 +13,18 @@ import (
)
type Master struct {
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file,omitempty"`
DatabaseURL string `json:"database_url,omitempty"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
RBAC model.RBACConfig `json:"rbac"`
Broker model.BrokerConfig `json:"broker"`
Alerts model.AlertConfig `json:"alerts"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
HistoryLimit int `json:"history_limit"`
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file,omitempty"`
DatabaseURL string `json:"database_url,omitempty"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
AccessAuth model.AccessAuthConfig `json:"access_auth"`
RBAC model.RBACConfig `json:"rbac"`
Broker model.BrokerConfig `json:"broker"`
Alerts model.AlertConfig `json:"alerts"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
HistoryLimit int `json:"history_limit"`
}
type Agent struct {
@@ -92,6 +95,29 @@ func LoadMaster(path string) (Master, error) {
if c.RBAC.Groups == nil {
c.RBAC.Groups = map[string][]string{}
}
if c.AccessAuth.Enabled {
if strings.TrimSpace(c.AccessAuth.Issuer) == "" {
c.AccessAuth.Issuer = c.OIDC.Issuer
}
if strings.TrimSpace(c.AccessAuth.ClientID) == "" {
c.AccessAuth.ClientID = c.OIDC.ClientID
}
if strings.TrimSpace(c.AccessAuth.ClientSecret) == "" {
c.AccessAuth.ClientSecret = c.OIDC.ClientSecret
}
if c.AccessAuth.CookieName == "" {
c.AccessAuth.CookieName = "sg_access_session"
}
if c.AccessAuth.SessionHours <= 0 {
c.AccessAuth.SessionHours = 8
}
if c.AccessAuth.UsernameClaim == "" {
c.AccessAuth.UsernameClaim = "preferred_username"
}
if err := validateAccessAuth(c.AccessAuth); err != nil {
return c, err
}
}
if err := validateOIDC(c.OIDC); err != nil {
return c, err
}
@@ -156,6 +182,7 @@ func applyMasterEnv(c *Master) {
set("SESSIONGUARD_ENROLLMENT_TOKEN", &c.EnrollmentToken)
set("SESSIONGUARD_BROKER_API_KEY", &c.Broker.APIKey)
set("SESSIONGUARD_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
set("SESSIONGUARD_ACCESS_OIDC_CLIENT_SECRET", &c.AccessAuth.ClientSecret)
set("SESSIONGUARD_ALERT_WEBHOOK_URL", &c.Alerts.WebhookURL)
}
@@ -198,6 +225,28 @@ func validateOIDC(c model.OIDCConfig) error {
return nil
}
func validateAccessAuth(c model.AccessAuthConfig) error {
if strings.TrimSpace(c.Issuer) == "" || strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" || strings.TrimSpace(c.RedirectURL) == "" || strings.TrimSpace(c.LogoutRedirectURL) == "" {
return errors.New("access_auth issuer, client_id, client_secret, redirect_url and logout_redirect_url are required when access_auth is enabled")
}
for label, raw := range map[string]string{"redirect_url": c.RedirectURL, "logout_redirect_url": c.LogoutRedirectURL} {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Hostname() == "" || u.Scheme == "" {
return fmt.Errorf("access_auth.%s must be an absolute URL", label)
}
if c.SecureCookie && !strings.EqualFold(u.Scheme, "https") {
return fmt.Errorf("access_auth.%s must use https when secure_cookie is enabled", label)
}
}
if c.SessionHours < 1 || c.SessionHours > 168 {
return errors.New("access_auth.session_hours must be between 1 and 168")
}
if strings.ContainsAny(c.CookieName, " ;,\t\r\n") {
return errors.New("access_auth.cookie_name contains invalid characters")
}
return nil
}
func NormalizePolicy(p *model.Policy) {
if p.Cleanup.GraceSeconds <= 0 {
p.Cleanup.GraceSeconds = 600
+26
View File
@@ -48,3 +48,29 @@ func TestDisconnectedTimeoutMinimum(t *testing.T) {
t.Fatal("expected disconnected timeout validation error")
}
}
func TestValidateAccessAuthRequiresHTTPSForSecureCookie(t *testing.T) {
c := model.AccessAuthConfig{
Enabled: true, Issuer: "https://id.example.org", ClientID: "client", ClientSecret: "secret",
RedirectURL: "http://guac.example.org/_sessionguard/auth/oidc/callback", LogoutRedirectURL: "https://guac.example.org/",
CookieName: "sg_access_session", SecureCookie: true, SessionHours: 8,
}
if err := validateAccessAuth(c); err == nil {
t.Fatal("expected https validation error")
}
c.RedirectURL = "https://guac.example.org/_sessionguard/auth/oidc/callback"
if err := validateAccessAuth(c); err != nil {
t.Fatal(err)
}
}
func TestValidateAccessAuthRequiresClientSecret(t *testing.T) {
c := model.AccessAuthConfig{
Issuer: "https://id.example.org", ClientID: "client",
RedirectURL: "https://guac.example.org/_sessionguard/auth/oidc/callback", LogoutRedirectURL: "https://guac.example.org/",
CookieName: "sg_access_session", SecureCookie: true, SessionHours: 8,
}
if err := validateAccessAuth(c); err == nil {
t.Fatal("expected missing client secret validation error")
}
}
+78
View File
@@ -0,0 +1,78 @@
package master
import (
"strings"
"time"
"github.com/example/sessionguard/internal/model"
)
// authSessionStore adapts the Master's existing persistent control-plane store
// to the auth package. Auth session mutations are rare (login/logout/revoke), so
// persisting the small map alongside control-plane state avoids a separate
// database dependency while verification itself stays read-only and cheap.
type authSessionStore struct{ s *store }
func (r authSessionStore) PutAuthSession(sess model.AuthSession) error {
r.s.mu.Lock()
defer r.s.mu.Unlock()
if r.s.data.AuthSessions == nil {
r.s.data.AuthSessions = map[string]model.AuthSession{}
}
r.s.data.AuthSessions[sess.TokenHash] = sess
return r.s.saveLocked()
}
func (r authSessionStore) GetAuthSession(hash string) (model.AuthSession, bool) {
r.s.mu.RLock()
defer r.s.mu.RUnlock()
sess, ok := r.s.data.AuthSessions[hash]
if !ok || (!sess.ExpiresAt.IsZero() && time.Now().UTC().After(sess.ExpiresAt)) {
return model.AuthSession{}, false
}
return sess, true
}
func (r authSessionStore) DeleteAuthSession(hash string) error {
r.s.mu.Lock()
defer r.s.mu.Unlock()
if _, ok := r.s.data.AuthSessions[hash]; !ok {
return nil
}
delete(r.s.data.AuthSessions, hash)
return r.s.saveLocked()
}
func (r authSessionStore) RevokeAuthSessions(sid, sub string) (int, error) {
sid = strings.TrimSpace(sid)
sub = strings.TrimSpace(sub)
r.s.mu.Lock()
defer r.s.mu.Unlock()
n := 0
for h, sess := range r.s.data.AuthSessions {
if (sid != "" && sess.SID == sid) || (sub != "" && sess.Subject == sub) {
delete(r.s.data.AuthSessions, h)
n++
}
}
if n == 0 {
return 0, nil
}
return n, r.s.saveLocked()
}
func (r authSessionStore) CleanupAuthSessions(now time.Time) error {
r.s.mu.Lock()
defer r.s.mu.Unlock()
changed := false
for h, sess := range r.s.data.AuthSessions {
if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) {
delete(r.s.data.AuthSessions, h)
changed = true
}
}
if !changed {
return nil
}
return r.s.saveLocked()
}
+85 -7
View File
@@ -23,13 +23,14 @@ import (
"github.com/example/sessionguard/internal/model"
)
const Version = "0.4.1"
const Version = "0.5.0"
type App struct {
cfg config.Master
store *store
auth *auth.Manager
http *http.Client
cfg config.Master
store *store
auth *auth.Manager
access *auth.AccessManager
http *http.Client
}
func New(ctx context.Context, cfg config.Master) (*App, error) {
@@ -42,12 +43,20 @@ func New(ctx context.Context, cfg config.Master) (*App, error) {
_ = s.close()
return nil, fmt.Errorf("OIDC: %w", err)
}
return &App{cfg: cfg, store: s, auth: a, http: &http.Client{Timeout: 8 * time.Second}}, nil
access, err := auth.NewAccess(ctx, cfg.AccessAuth, authSessionStore{s: s})
if err != nil {
_ = s.close()
return nil, fmt.Errorf("access auth: %w", err)
}
return &App{cfg: cfg, store: s, auth: a, access: access, http: &http.Client{Timeout: 8 * time.Second}}, nil
}
func (a *App) Run(ctx context.Context) error {
mux := http.NewServeMux()
a.auth.Register(mux)
if a.access != nil {
a.access.Register(mux)
}
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version, "store": a.store.kind()})
})
@@ -88,6 +97,8 @@ func (a *App) Run(ctx context.Context) error {
mux.Handle("DELETE /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceDelete))))
mux.Handle("GET /api/v1/alerts", a.auth.Require(http.HandlerFunc(a.alerts)))
mux.Handle("GET /api/v1/leases", a.auth.Require(http.HandlerFunc(a.leases)))
mux.Handle("GET /api/v1/access/sessions", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessions))))
mux.Handle("DELETE /api/v1/access/sessions/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessionRevoke))))
server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second}
go a.monitor(ctx)
go func() {
@@ -590,13 +601,19 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
farms := len(a.store.data.Farms)
resources := len(a.store.data.Resources)
alerts := 0
accessSessions := 0
for _, x := range a.store.data.Alerts {
if x.Active {
alerts++
}
}
for _, sess := range a.store.data.AuthSessions {
if sess.ExpiresAt.IsZero() || now.Before(sess.ExpiresAt) {
accessSessions++
}
}
a.store.mu.RUnlock()
httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "store": a.store.kind()})
httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "access_sessions": accessSessions, "store": a.store.kind()})
}
func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) {
rec, ok := a.store.get(r.PathValue("id"))
@@ -1274,6 +1291,67 @@ func (a *App) alerts(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, 200, map[string]any{"alerts": out})
}
func (a *App) accessSessions(w http.ResponseWriter, r *http.Request) {
type publicSession struct {
ID string `json:"id"`
Subject string `json:"subject"`
SID string `json:"sid,omitempty"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
now := time.Now().UTC()
a.store.mu.RLock()
out := make([]publicSession, 0, len(a.store.data.AuthSessions))
for _, sess := range a.store.data.AuthSessions {
if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) {
continue
}
out = append(out, publicSession{ID: sess.ID, Subject: sess.Subject, SID: sess.SID, Username: sess.Username, Email: sess.Email, Name: sess.Name, Groups: append([]string(nil), sess.Groups...), CreatedAt: sess.CreatedAt, ExpiresAt: sess.ExpiresAt})
}
a.store.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
httpx.JSON(w, 200, map[string]any{"sessions": out})
}
func (a *App) accessSessionRevoke(w http.ResponseWriter, r *http.Request) {
if !httpx.SameOrigin(r) {
httpx.Error(w, 403, "cross-origin request rejected")
return
}
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
httpx.Error(w, 400, "session id is required")
return
}
a.store.mu.Lock()
var username string
var hash string
for h, sess := range a.store.data.AuthSessions {
if sess.ID == id {
hash, username = h, sess.Username
break
}
}
if hash == "" {
a.store.mu.Unlock()
httpx.Error(w, 404, "access session not found")
return
}
delete(a.store.data.AuthSessions, hash)
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "access_session_revoke", Target: username, Result: "success", Details: id})
err := a.store.saveLocked()
a.store.mu.Unlock()
if err != nil {
httpx.Error(w, 500, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *App) leases(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
a.store.mu.RLock()
+5 -1
View File
@@ -24,6 +24,7 @@ type data struct {
SessionHistory []model.SessionHistoryEvent `json:"session_history,omitempty"`
PolicyHistory []model.PolicyVersion `json:"policy_history,omitempty"`
Alerts map[string]model.Alert `json:"alerts,omitempty"`
AuthSessions map[string]model.AuthSession `json:"auth_sessions,omitempty"`
GlobalPolicy *model.Policy `json:"global_policy,omitempty"`
}
@@ -47,7 +48,7 @@ func emptyData() data {
return data{
Agents: map[string]model.AgentRecord{}, Farms: map[string]model.Farm{}, Resources: map[string]model.Resource{},
Leases: map[string]model.UserLease{}, Audit: []model.AuditEntry{}, SessionHistory: []model.SessionHistoryEvent{},
PolicyHistory: []model.PolicyVersion{}, Alerts: map[string]model.Alert{},
PolicyHistory: []model.PolicyVersion{}, Alerts: map[string]model.Alert{}, AuthSessions: map[string]model.AuthSession{},
}
}
@@ -96,6 +97,9 @@ func (s *store) normalize() {
if s.data.Alerts == nil {
s.data.Alerts = map[string]model.Alert{}
}
if s.data.AuthSessions == nil {
s.data.AuthSessions = map[string]model.AuthSession{}
}
for id, a := range s.data.Agents {
if a.Tags == nil {
a.Tags = map[string]string{}
+8 -6
View File
@@ -37,21 +37,22 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell">
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.4.1</div></div></div>
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a></nav>
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.0</div></div></div>
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a><a class="nav-link" href="#access-section"><span class="nav-icon">◎</span>Access-Sessions</a></nav>
<nav class="nav-group"><div class="nav-label">Betrieb</div><a class="nav-link" href="#history-section"><span class="nav-icon">↻</span>Session-Historie</a><a class="nav-link" href="#policies-section"><span class="nav-icon">≋</span>Policy-Historie</a><a class="nav-link" href="#alerts-section"><span class="nav-icon">!</span>Alerts</a><a class="nav-link" href="#audit-section"><span class="nav-icon">✓</span>Audit-Log</a></nav>
<div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Master Console</div><div class="topbar-sub">Broker, Director, Profile & Policy Management</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button" title="Theme wechseln">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Control Plane</div><h1>Infrastruktur auf einen Blick</h1><div class="section-copy">Live-Status der RDS-Farm, Profile und Broker-Ressourcen.</div></div></div>
<div class="metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Server</span><span class="metric-icon">▣</span></div><div class="value" id="mServers"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Online</span><span class="metric-icon">●</span></div><div class="value" id="mOnline"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="mActive"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="mDisc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="mProfile"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="mCleanup"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Farms</span><span class="metric-icon">⌘</span></div><div class="value" id="mFarms"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Alerts</span><span class="metric-icon">!</span></div><div class="value" id="mAlerts"></div></div></div></section>
<div class="metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Server</span><span class="metric-icon">▣</span></div><div class="value" id="mServers"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Online</span><span class="metric-icon">●</span></div><div class="value" id="mOnline"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="mActive"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="mDisc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="mProfile"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="mCleanup"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Farms</span><span class="metric-icon">⌘</span></div><div class="value" id="mFarms"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Access</span><span class="metric-icon">◎</span></div><div class="value" id="mAccess"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Alerts</span><span class="metric-icon">!</span></div><div class="value" id="mAlerts"></div></div></div></section>
<section class="page-section" id="servers"><div class="section-heading"><div><div class="eyebrow">Director</div><h2>Terminalserver</h2><div class="section-copy">Server auswählen, Sitzungen steuern und Health-Details prüfen.</div></div></div><div class="workspace-grid"><section class="panel"><div class="panel-head"><div><h2>Serverliste</h2><div class="panel-kicker">Online, Sessions, Health und Jobs</div></div></div><div id="agents"></div></section><section class="panel"><div class="panel-head"><div><h2 id="detailTitle">Server auswählen</h2><div class="panel-kicker">Live-Details und Konfiguration</div></div></div><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></section>
<section class="page-section" id="farms-section"><div class="section-heading"><div><div class="eyebrow">Broker</div><h2>Farms & Broker</h2><div class="section-copy">Hosts gruppieren, Tags definieren und aktive Leases nachvollziehen.</div></div></div><section class="panel"><div id="farms"></div></section></section>
<section class="page-section" id="resources-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>Apps & Desktops</h2><div class="section-copy">Guacamole-Ressourcen zu Farms zuordnen und RemoteApps optional direkt durch die Windows-Agents veröffentlichen.</div></div></div><section class="panel"><div id="resources"></div></section></section>
<section class="page-section" id="access-section"><div class="section-heading"><div><div class="eyebrow">Access Plane</div><h2>Guacamole Access-Sessions</h2><div class="section-copy">Serverseitige PocketID-Sitzungen, die Traefik zur Laufzeit vor jedem Guacamole-Request prüft. Eine widerrufene oder abgelaufene Session liefert keinen Benutzer-Header mehr.</div></div></div><section class="panel"><div id="accessSessions"><div class="empty">Access-Sessions werden geladen…</div></div></section></section>
<section class="page-section" id="history-section"><div class="section-heading"><div><div class="eyebrow">Historie</div><h2>Session-Historie</h2><div class="section-copy">Logon, Reconnect, Disconnect und Profilereignisse nachvollziehen.</div></div></div><section class="panel"><div id="history" class="content"></div></section></section>
<section class="page-section" id="policies-section"><div class="section-heading"><div><div class="eyebrow">Governance</div><h2>Policy-Historie & Rollback</h2><div class="section-copy">Revisionen vergleichen und frühere Sollstände wieder aktivieren.</div></div></div><section class="panel"><div id="policyHistory" class="content"></div></section></section>
<section class="page-section" id="alerts-section"><div class="section-heading"><div><div class="eyebrow">Monitoring</div><h2>Alerts</h2><div class="section-copy">Aktive und gelöste Betriebswarnungen.</div></div></div><section class="panel"><div id="alerts" class="content"></div></section></section>
<section class="page-section" id="audit-section"><div class="section-heading"><div><div class="eyebrow">Compliance</div><h2>Audit-Log</h2><div class="section-copy">Administrative Änderungen und Aktionen revisionsfreundlich nachvollziehen.</div></div></div><section class="panel"><div id="audit" class="content"></div></section></section>
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.4.1"></script></body></html>`
</main></div></div><div class="toast" id="toast"></div><script src="/app.js?v=0.5.0"></script></body></html>`
const masterJS = `
let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));function when(v){if(!v)return'';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
@@ -67,10 +68,11 @@ function initShell(){
if('IntersectionObserver' in window){let links=[...document.querySelectorAll('.nav-link[href^="#"]')],map=new Map(links.map(a=>[a.getAttribute('href').slice(1),a]));let io=new IntersectionObserver(entries=>{let best=entries.filter(e=>e.isIntersecting).sort((a,b)=>b.intersectionRatio-a.intersectionRatio)[0];if(!best)return;let a=map.get(best.target.id);if(a){links.forEach(x=>x.classList.remove('active'));a.classList.add('active')}},{rootMargin:'-18% 0px -65% 0px',threshold:[0,.15,.4]});map.forEach((_,id)=>{let el=document.getElementById(id);if(el)io.observe(el)})}
}
function renderAgentTable(a){$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Mode</th><th>Health</th><th>Sitzungen</th><th>Jobs</th></tr></thead><tbody>'+a.map(x=>'<tr class="row '+(x.id===selected?'selected':'')+'" data-id="'+esc(x.id)+'"><td><span class="status-badge '+(x.online?'status-online':'status-offline')+'"><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</span></td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.agent_version||'')+'</span></td><td><span class="badge '+((x.maintenance_mode||'online')==='online'?'good':(x.maintenance_mode==='drain'?'warning':'bad'))+'">'+esc(x.maintenance_mode||'online')+'</span></td><td>'+esc((x.snapshot.health||{}).score??'')+'</td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+((x.snapshot.profile_jobs||[]).length)+' P / '+((x.snapshot.pending_cleanup||[]).length)+' C</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id,false,false))}
async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];agentCache=a;$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();renderResources();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}catch(e){toast(e.message)}}
async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];agentCache=a;$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAccess').textContent=d.access_sessions||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();renderResources();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');if((me.permissions||[]).includes('manage')){try{let as=await api('/api/v1/access/sessions');renderAccessSessions(as.sessions||[])}catch(e){$('accessSessions').innerHTML='<div class="empty">Access-Sessions konnten nicht geladen werden: '+esc(e.message)+'</div>'}}else $('accessSessions').innerHTML='<div class="empty">Access-Sessions erfordern Manage/Admin-Rechte.</div>';try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}catch(e){toast(e.message)}}
async function selectAgent(id,quiet,preserve){let same=selected===id;selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current,!!preserve&&same)}catch(e){if(!quiet)toast(e.message)}}
function renderPolicyHistory(){let rows=(policyHistory||[]).slice().reverse().slice(0,250);$('policyHistory').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Ziel</th><th>Revision</th><th>Akteur</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.created_at)+'</td><td>'+esc(x.target)+'</td><td><code>'+esc(x.revision)+'</code></td><td>'+esc(x.actor||'')+'</td><td><button class="secondary" data-global-action="rollback-policy" data-target="'+esc(x.target)+'" data-revision="'+esc(x.revision)+'">Rollback</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Policy-Versionen.</div>'}
function renderAudit(rows){rows=(rows||[]).slice().reverse().slice(0,250);$('audit').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ziel</th><th>Ergebnis</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+esc(when(x.time))+'</td><td>'+esc(x.actor)+'</td><td>'+esc(x.action)+'</td><td>'+esc(x.target||'')+'</td><td class="'+(x.result==='error'?'bad':x.result==='success'?'good':'')+'">'+esc(x.result)+(x.details?'<br><span class="muted">'+esc(x.details)+'</span>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Audit-Einträge.</div>'}
function renderAccessSessions(rows){let root=$('accessSessions');if(!root)return;rows=rows||[];root.innerHTML=rows.length?'<table class="table"><thead><tr><th>Benutzer</th><th>Gruppen</th><th>Erstellt</th><th>Läuft ab</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td><strong>'+esc(x.username||'')+'</strong>'+(x.name?'<br><span class="muted">'+esc(x.name)+'</span>':'')+(x.email?'<br><span class="muted">'+esc(x.email)+'</span>':'')+'</td><td>'+esc((x.groups||[]).join(', ')||'')+'</td><td>'+esc(when(x.created_at))+'</td><td>'+esc(when(x.expires_at))+'</td><td><button class="danger" data-global-action="revoke-access" data-id="'+esc(x.id)+'" data-user="'+esc(x.username||'')+'">Widerrufen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine aktiven Guacamole Access-Sessions.</div>'}
function overviewStats(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0;return'<div class="form"><div class="cols"><div><span class="muted">OS</span><br><strong>'+esc(v.os||'')+'</strong></div><div><span class="muted">Health</span><br><strong class="'+((h.score||0)>=70?'good':'bad')+'">'+esc(h.score??'')+'/100</strong></div><div><span class="muted">CPU</span><br><strong>'+Number(v.cpu_percent||0).toFixed(1)+' %</strong></div><div><span class="muted">RAM</span><br><strong>'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei</strong></div><div><span class="muted">Systemdisk</span><br><strong>'+bytes(v.disk_free)+' frei</strong></div><div><span class="muted">Heartbeat</span><br><strong>'+when(a.last_seen)+'</strong></div></div><div class="note">RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'</div></div>'}
function controlHTML(a){let tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<div class="form"><div class="two"><label>Maintenance Mode<select id="agentMode"><option value="online" '+((a.maintenance_mode||'online')==='online'?'selected':'')+'>Online</option><option value="drain" '+(a.maintenance_mode==='drain'?'selected':'')+'>Drain</option><option value="maintenance" '+(a.maintenance_mode==='maintenance'?'selected':'')+'>Maintenance</option></select></label><label>Farm IDs (eine pro Zeile)<textarea id="agentFarms">'+esc((a.farm_ids||[]).join('\n'))+'</textarea></label><label>Tags key=value<textarea id="agentTags">'+esc(tags)+'</textarea></label><div><div class="check"><input id="restartDrained" type="checkbox" '+(a.restart_when_drained?'checked':'')+'><label>Neu starten sobald leer</label></div><div class="actions"><button type="button" data-action="save-control">Serversteuerung speichern</button><button type="button" class="secondary" data-action="broadcast">Nachricht an alle</button><button type="button" class="danger" data-action="logoff-disconnected">Getrennte abmelden</button><button type="button" class="secondary" data-action="reload-control">Steuerung neu laden</button></div></div></div></div>'}
async function sessionAction(id,action){if(!selected)return;let body={action};if(action==='message'){let m=prompt('Nachricht an Sitzung '+id+':');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profilsicherung startet nach dem Sitzungsende.'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Sitzungsaktion an Agent gesendet');setTimeout(()=>selectAgent(selected,true,true),1000)}catch(e){toast(e.message)}}
@@ -99,5 +101,5 @@ async function bulkSessionAction(action,scope){if(!selected)return;let body={act
async function saveAgentControl(){if(!selected)return;let tags={};($('agentTags').value||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)tags[x.slice(0,i).trim()]=x.slice(i+1).trim()});let body={mode:$('agentMode').value,restart_when_drained:$('restartDrained').checked,tags,farm_ids:$('agentFarms').value.split('\n').map(x=>x.trim()).filter(Boolean)};try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/control',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Serversteuerung gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
async function killProcess(pid){if(!selected||!confirm('Prozess '+pid+' wirklich beenden?'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/processes/'+pid+'/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});toast('Prozess-Beenden an Agent gesendet')}catch(e){toast(e.message)}}
async function savePolicy(all){if(!selected)return;try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api(all?'/api/v1/policy/all':'/api/v1/agents/'+encodeURIComponent(selected)+'/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});toast(all?'Policy auf alle Server angewendet':'Policy gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&&current&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&&current&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('change',e=>{if(e.target&&((e.target.id==='resKind')||(e.target.id==='resManage')))updateResourceEditorState()});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){resetResourceForm();return}if(a==='edit-resource'){editResource(b.dataset.id);return}if(a==='save-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;let body={name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),manage_remote_app:$('resManage').checked,remote_app_path:$('resRemotePath').value.trim(),remote_app_icon_path:$('resRemoteIcon').value.trim(),remote_app_icon_index:+$('resIconIndex').value,remote_app_command_line_setting:+$('resCmd').value,remote_app_required_command_line:$('resRemoteRequired').value,remote_app_show_in_portal:$('resPortal').checked,enabled:true};let editing=!!resourceEditID;await api(editing?'/api/v1/resources/'+encodeURIComponent(resourceEditID):'/api/v1/resources',{method:editing?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast(editing?'Resource aktualisiert':'Resource angelegt');resetResourceForm();await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='revoke-access'){if(!confirm('Access-Session von '+(b.dataset.user||'diesem Benutzer')+' wirklich widerrufen?'))return;await api('/api/v1/access/sessions/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Access-Session widerrufen');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
initShell();refresh();setInterval(refresh,5000);`
+37
View File
@@ -13,6 +13,43 @@ type OIDCConfig struct {
SecureCookie bool `json:"secure_cookie"`
}
// AccessAuthConfig configures the SessionGuard Master as a Traefik ForwardAuth
// endpoint for browser-facing services such as Apache Guacamole. It deliberately
// has its own redirect/cookie scope because the SessionGuard admin UI and the
// protected application may live on different DNS domains. Blank issuer/client
// fields inherit their values from the primary OIDC configuration.
type AccessAuthConfig struct {
Enabled bool `json:"enabled"`
Issuer string `json:"issuer,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
RedirectURL string `json:"redirect_url"`
LogoutRedirectURL string `json:"logout_redirect_url,omitempty"`
CookieName string `json:"cookie_name,omitempty"`
CookieDomain string `json:"cookie_domain,omitempty"`
SecureCookie bool `json:"secure_cookie"`
SessionHours int `json:"session_hours,omitempty"`
UsernameClaim string `json:"username_claim,omitempty"`
AllowedGroups []string `json:"allowed_groups,omitempty"`
AllowedHosts []string `json:"allowed_hosts,omitempty"`
}
// AuthSession is an opaque, server-side browser session. SessionGuard stores
// only the SHA-256 hash of the random browser token as the map key.
type AuthSession struct {
ID string `json:"id"`
TokenHash string `json:"token_hash"`
Subject string `json:"subject"`
SID string `json:"sid,omitempty"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
IDToken string `json:"id_token,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type RBACConfig struct {
DefaultRole string `json:"default_role,omitempty"`
Groups map[string][]string `json:"groups,omitempty"`