RC-2 0.5.2
release-tag / release-image (push) Successful in 2m3s
release-main / release-images (push) Failing after 3m9s

This commit is contained in:
2026-08-24 05:46:42 +02:00
parent 934b013d18
commit 7c69432097
34 changed files with 2393 additions and 52 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"github.com/example/sessionguard/internal/windowsx"
)
const Version = "0.5.0"
const Version = "0.5.2"
type App struct {
cfg config.Agent
+3 -3
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}}
/* SessionGuard UI v0.5.1 — modal-first responsive console */
/* SessionGuard UI v0.5.2 — modal-first responsive console */
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
@@ -68,13 +68,13 @@ body.modal-open{overflow:hidden}
@media(max-width:720px){dialog.sg-modal,dialog.sg-modal.wide,dialog.sg-modal.compact{width:100vw;max-width:none;height:100dvh;max-height:100dvh;border-radius:0;border:0}.modal-shell{max-height:100dvh;height:100dvh}.modal-head{padding:13px 14px}.modal-title{font-size:16px}.modal-body.pad,.detail-pane{padding:12px}.detail-tabs{padding:8px}.form{padding:12px}.form-grid,.form-grid.three,.entity-stats{grid-template-columns:1fr}.entity-grid{grid-template-columns:1fr;padding:10px}.mobile-only{display:inline-flex}.table th,.table td{white-space:nowrap}.section-heading .section-actions{width:100%}.section-heading .section-actions button{flex:1}}
</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.5.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="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</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.2</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="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</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>
<section class="page-section" id="operations-section"><div class="section-heading"><div><div class="eyebrow">Operations</div><h2>Betriebsdaten</h2><div class="section-copy">RemoteApps, Profil-Pipeline und lokale Ereignisse kompakt in einer Arbeitsfläche.</div></div></div><section class="panel"><div class="detail-tabs" style="position:static"><button class="detail-tab active" type="button" data-agent-tab="remoteapps">RemoteApps</button><button class="detail-tab" type="button" data-agent-tab="profiles">Profil-Pipeline</button><button class="detail-tab" type="button" data-agent-tab="events">Aktivitätslog</button></div><div class="detail-pane active" data-agent-pane="remoteapps"><div id="remoteapps"></div></div><div class="detail-pane" data-agent-pane="profiles"><div id="profiles"></div></div><div class="detail-pane" data-agent-pane="events"><div id="events"></div></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 class="section-actions"><button type="button" class="primary-soft" id="openPolicyModal">⚙ Policy bearbeiten</button></div></div><section class="panel"><div class="form"><div class="note"><strong>Fallback-Verhalten:</strong> Die lokale Policy bleibt als Notfallkonfiguration verfügbar. Eine aktive Master-Soll-Policy hat im zentral verwalteten Betrieb Vorrang.</div><div id="policySummary" class="entity-grid" style="padding:0"></div></div></section></section>
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.1"></script></body></html>`
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.2"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
+306
View File
@@ -0,0 +1,306 @@
package edgeguard
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/netip"
"os"
"sort"
"strings"
"time"
)
type RateLimit struct {
RatePerSecond float64 `json:"rate_per_second"`
Burst int `json:"burst"`
}
type RateRule struct {
Name string `json:"name"`
Host string `json:"host,omitempty"`
PathPrefix string `json:"path_prefix"`
RatePerSecond float64 `json:"rate_per_second"`
Burst int `json:"burst"`
}
type AutoBanConfig struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"`
WindowSeconds int `json:"window_seconds"`
BanSeconds int `json:"ban_seconds"`
ScannerWeight int `json:"scanner_weight"`
MethodWeight int `json:"method_weight"`
RateLimitWeight int `json:"rate_limit_weight"`
InvalidURIWeight int `json:"invalid_uri_weight"`
}
type Config struct {
Listen string `json:"listen"`
AllowedHosts []string `json:"allowed_hosts"`
BlacklistFile string `json:"blacklist_file"`
RateExemptFile string `json:"rate_exempt_file"`
StateFile string `json:"state_file"`
ReloadSeconds int `json:"reload_seconds"`
MaxURILength int `json:"max_uri_length"`
MaxTrackedIPs int `json:"max_tracked_ips"`
GlobalLimit RateLimit `json:"global_limit"`
PerIPLimit RateLimit `json:"per_ip_limit"`
Rules []RateRule `json:"rules"`
BlockedMethods []string `json:"blocked_methods"`
ScannerPathPrefixes []string `json:"scanner_path_prefixes"`
AutoBan AutoBanConfig `json:"auto_ban"`
}
type RuntimeConfig struct {
Config
allowedHosts map[string]struct{}
blockedMethods map[string]struct{}
blacklist []netip.Prefix
rateExempt []netip.Prefix
fingerprint string
}
func DefaultConfig() Config {
return Config{
Listen: "127.0.0.1:9081",
ReloadSeconds: 15,
MaxURILength: 8192,
MaxTrackedIPs: 100000,
GlobalLimit: RateLimit{
RatePerSecond: 2500,
Burst: 5000,
},
PerIPLimit: RateLimit{
RatePerSecond: 200,
Burst: 500,
},
Rules: []RateRule{
{Name: "access-login", PathPrefix: "/_sessionguard/auth/login", RatePerSecond: 5, Burst: 100},
{Name: "access-callback", PathPrefix: "/_sessionguard/auth/oidc/callback", RatePerSecond: 10, Burst: 100},
{Name: "admin-oidc", PathPrefix: "/oidc/", RatePerSecond: 5, Burst: 50},
},
BlockedMethods: []string{"CONNECT", "TRACE", "TRACK"},
ScannerPathPrefixes: []string{
"/.env", "/.git", "/.svn", "/.hg", "/wp-admin", "/wp-login.php",
"/phpmyadmin", "/pma", "/cgi-bin", "/server-status", "/actuator",
"/vendor/phpunit", "/boaform", "/HNAP1", "/solr/", "/jenkins/",
},
AutoBan: AutoBanConfig{
Enabled: true,
Threshold: 10,
WindowSeconds: 120,
BanSeconds: 900,
ScannerWeight: 5,
MethodWeight: 3,
RateLimitWeight: 0,
InvalidURIWeight: 3,
},
}
}
func LoadRuntimeConfig(path string) (*RuntimeConfig, error) {
cfg := DefaultConfig()
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(b, &cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := validateConfig(&cfg); err != nil {
return nil, err
}
rc := &RuntimeConfig{Config: cfg}
rc.allowedHosts = make(map[string]struct{}, len(cfg.AllowedHosts))
for _, h := range cfg.AllowedHosts {
h = normalizeHost(h)
if h != "" {
rc.allowedHosts[h] = struct{}{}
}
}
rc.blockedMethods = make(map[string]struct{}, len(cfg.BlockedMethods))
for _, m := range cfg.BlockedMethods {
m = strings.ToUpper(strings.TrimSpace(m))
if m != "" {
rc.blockedMethods[m] = struct{}{}
}
}
rc.blacklist, err = loadPrefixFile(cfg.BlacklistFile)
if err != nil {
return nil, fmt.Errorf("load blacklist: %w", err)
}
rc.rateExempt, err = loadPrefixFile(cfg.RateExemptFile)
if err != nil {
return nil, fmt.Errorf("load rate exempt list: %w", err)
}
h := sha256.New()
h.Write(b)
for _, p := range rc.blacklist {
h.Write([]byte("blacklist:" + p.String() + "\n"))
}
for _, p := range rc.rateExempt {
h.Write([]byte("rate-exempt:" + p.String() + "\n"))
}
rc.fingerprint = hex.EncodeToString(h.Sum(nil))
return rc, nil
}
func validateConfig(cfg *Config) error {
if strings.TrimSpace(cfg.Listen) == "" {
return fmt.Errorf("listen must not be empty")
}
if cfg.ReloadSeconds <= 0 {
cfg.ReloadSeconds = 15
}
if cfg.MaxURILength <= 0 {
cfg.MaxURILength = 8192
}
if cfg.MaxTrackedIPs <= 0 {
cfg.MaxTrackedIPs = 100000
}
if err := validateRate("global_limit", cfg.GlobalLimit); err != nil {
return err
}
if err := validateRate("per_ip_limit", cfg.PerIPLimit); err != nil {
return err
}
for i := range cfg.Rules {
r := &cfg.Rules[i]
r.Host = normalizeHost(r.Host)
r.PathPrefix = strings.ToLower(strings.TrimSpace(r.PathPrefix))
if r.PathPrefix == "" {
return fmt.Errorf("rules[%d].path_prefix must not be empty", i)
}
if r.RatePerSecond <= 0 || r.Burst <= 0 {
return fmt.Errorf("rules[%d] must have positive rate_per_second and burst", i)
}
}
if cfg.AutoBan.Enabled {
if cfg.AutoBan.Threshold <= 0 || cfg.AutoBan.WindowSeconds <= 0 || cfg.AutoBan.BanSeconds <= 0 {
return fmt.Errorf("auto_ban threshold/window_seconds/ban_seconds must be positive")
}
}
return nil
}
func validateRate(name string, r RateLimit) error {
if r.RatePerSecond <= 0 || r.Burst <= 0 {
return fmt.Errorf("%s must have positive rate_per_second and burst", name)
}
return nil
}
func loadPrefixFile(path string) ([]netip.Prefix, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []netip.Prefix
s := bufio.NewScanner(f)
lineNo := 0
for s.Scan() {
lineNo++
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if i := strings.IndexByte(line, '#'); i >= 0 {
line = strings.TrimSpace(line[:i])
}
p, err := parsePrefix(line)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", path, lineNo, err)
}
out = append(out, p)
}
if err := s.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() })
return out, nil
}
func parsePrefix(s string) (netip.Prefix, error) {
if p, err := netip.ParsePrefix(s); err == nil {
return p.Masked(), nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid IP/CIDR %q", s)
}
bits := 128
if a.Is4() {
bits = 32
}
return netip.PrefixFrom(a, bits), nil
}
func normalizeHost(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
if i := strings.IndexByte(s, ':'); i > 0 && strings.Count(s, ":") == 1 {
s = s[:i]
}
return strings.TrimSuffix(s, ".")
}
func (r *RuntimeConfig) IsBlacklisted(ip netip.Addr) bool {
return containsPrefix(r.blacklist, ip)
}
func (r *RuntimeConfig) IsRateExempt(ip netip.Addr) bool {
return containsPrefix(r.rateExempt, ip)
}
func containsPrefix(prefixes []netip.Prefix, ip netip.Addr) bool {
for _, p := range prefixes {
if p.Contains(ip) {
return true
}
}
return false
}
func (r *RuntimeConfig) HostAllowed(host string) bool {
if len(r.allowedHosts) == 0 {
return true
}
_, ok := r.allowedHosts[normalizeHost(host)]
return ok
}
func (r *RuntimeConfig) MethodBlocked(method string) bool {
_, ok := r.blockedMethods[strings.ToUpper(strings.TrimSpace(method))]
return ok
}
func (r *RuntimeConfig) MatchingRule(host, path string) *RateRule {
host = normalizeHost(host)
for i := range r.Rules {
rule := &r.Rules[i]
if rule.Host != "" && rule.Host != host {
continue
}
if strings.HasPrefix(path, rule.PathPrefix) {
return rule
}
}
return nil
}
func (r *RuntimeConfig) ReloadInterval() time.Duration {
return time.Duration(r.ReloadSeconds) * time.Second
}
func (r *RuntimeConfig) Fingerprint() string { return r.fingerprint }
+440
View File
@@ -0,0 +1,440 @@
package edgeguard
import (
"encoding/json"
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
type bucket struct {
tokens float64
last time.Time
}
type offenseState struct {
points int
start time.Time
}
type clientState struct {
bucket bucket
rules map[string]*bucket
offense offenseState
lastSeen time.Time
}
type persistedState struct {
Bans map[string]time.Time `json:"bans"`
}
type Counters struct {
Requests atomic.Uint64
Allowed atomic.Uint64
DeniedBlacklist atomic.Uint64
DeniedBan atomic.Uint64
DeniedRate atomic.Uint64
DeniedGlobal atomic.Uint64
DeniedScanner atomic.Uint64
DeniedMethod atomic.Uint64
DeniedHost atomic.Uint64
DeniedInvalid atomic.Uint64
DeniedCapacity atomic.Uint64
AutoBans atomic.Uint64
}
type Decision struct {
Allowed bool
StatusCode int
Reason string
RetryAfter int
}
type Guard struct {
mu sync.Mutex
cfg atomic.Pointer[RuntimeConfig]
global bucket
clients map[netip.Addr]*clientState
bans map[netip.Addr]time.Time
stateDirty bool
counters Counters
logger *slog.Logger
}
func NewGuard(cfg *RuntimeConfig, logger *slog.Logger) *Guard {
if logger == nil {
logger = slog.Default()
}
g := &Guard{
clients: make(map[netip.Addr]*clientState),
bans: make(map[netip.Addr]time.Time),
logger: logger,
}
g.cfg.Store(cfg)
g.loadState(cfg.StateFile)
return g
}
func (g *Guard) Config() *RuntimeConfig { return g.cfg.Load() }
func (g *Guard) ReplaceConfig(cfg *RuntimeConfig) {
old := g.cfg.Swap(cfg)
if old == nil || old.Fingerprint() != cfg.Fingerprint() {
g.logger.Info("edgeguard configuration loaded", "fingerprint", cfg.Fingerprint()[:12])
}
}
func (g *Guard) Check(ip netip.Addr, host, method, rawURI string, now time.Time) Decision {
g.counters.Requests.Add(1)
cfg := g.cfg.Load()
if cfg == nil {
return Decision{StatusCode: 503, Reason: "configuration unavailable"}
}
if !ip.IsValid() {
g.counters.DeniedInvalid.Add(1)
return Decision{StatusCode: 400, Reason: "invalid client ip"}
}
if len(rawURI) == 0 || len(rawURI) > cfg.MaxURILength {
g.counters.DeniedInvalid.Add(1)
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
return Decision{StatusCode: 414, Reason: "invalid uri"}
}
path, ok := normalizeRequestPath(rawURI)
if !ok {
g.counters.DeniedInvalid.Add(1)
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
return Decision{StatusCode: 400, Reason: "invalid uri"}
}
if !cfg.HostAllowed(host) {
g.counters.DeniedHost.Add(1)
return Decision{StatusCode: 421, Reason: "host not allowed"}
}
if cfg.IsBlacklisted(ip) {
g.counters.DeniedBlacklist.Add(1)
return Decision{StatusCode: 403, Reason: "blacklisted"}
}
g.mu.Lock()
if until, ok := g.bans[ip]; ok {
if now.Before(until) {
g.mu.Unlock()
g.counters.DeniedBan.Add(1)
return Decision{StatusCode: 403, Reason: "temporarily banned", RetryAfter: max(1, int(until.Sub(now).Seconds()))}
}
delete(g.bans, ip)
g.stateDirty = true
}
g.mu.Unlock()
if cfg.MethodBlocked(method) {
g.counters.DeniedMethod.Add(1)
g.addOffense(ip, cfg.AutoBan.MethodWeight, now, cfg)
return Decision{StatusCode: 405, Reason: "method blocked"}
}
if scannerPath(path, cfg.ScannerPathPrefixes) {
g.counters.DeniedScanner.Add(1)
g.addOffense(ip, cfg.AutoBan.ScannerWeight, now, cfg)
return Decision{StatusCode: 404, Reason: "scanner path"}
}
g.mu.Lock()
defer g.mu.Unlock()
if !take(&g.global, cfg.GlobalLimit.RatePerSecond, cfg.GlobalLimit.Burst, now) {
g.counters.DeniedGlobal.Add(1)
return Decision{StatusCode: 429, Reason: "global rate limit", RetryAfter: 1}
}
cs := g.clients[ip]
if cs == nil {
if len(g.clients) >= cfg.MaxTrackedIPs {
g.counters.DeniedCapacity.Add(1)
return Decision{StatusCode: 429, Reason: "edge state capacity", RetryAfter: 1}
}
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
g.clients[ip] = cs
}
cs.lastSeen = now
if !cfg.IsRateExempt(ip) {
if !take(&cs.bucket, cfg.PerIPLimit.RatePerSecond, cfg.PerIPLimit.Burst, now) {
g.counters.DeniedRate.Add(1)
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
return Decision{StatusCode: 429, Reason: "per-ip rate limit", RetryAfter: 1}
}
if rule := cfg.MatchingRule(host, path); rule != nil {
rb := cs.rules[rule.Name]
if rb == nil {
rb = &bucket{}
cs.rules[rule.Name] = rb
}
if !take(rb, rule.RatePerSecond, rule.Burst, now) {
g.counters.DeniedRate.Add(1)
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
return Decision{StatusCode: 429, Reason: "endpoint rate limit", RetryAfter: 1}
}
}
}
g.counters.Allowed.Add(1)
return Decision{Allowed: true, StatusCode: 204, Reason: "allow"}
}
func take(b *bucket, rate float64, burst int, now time.Time) bool {
if b.last.IsZero() {
b.tokens = float64(burst)
b.last = now
}
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens += elapsed * rate
if b.tokens > float64(burst) {
b.tokens = float64(burst)
}
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func normalizeRequestPath(rawURI string) (string, bool) {
u, err := url.ParseRequestURI(rawURI)
if err != nil {
return "", false
}
p := u.EscapedPath()
if p == "" {
p = "/"
}
decoded, err := url.PathUnescape(p)
if err != nil {
return "", false
}
decoded = strings.ReplaceAll(decoded, "\\", "/")
decoded = pathpkg.Clean(decoded)
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
return strings.ToLower(decoded), true
}
func scannerPath(path string, prefixes []string) bool {
for _, prefix := range prefixes {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix != "" && strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func (g *Guard) addOffense(ip netip.Addr, weight int, now time.Time, cfg *RuntimeConfig) {
if weight <= 0 || !cfg.AutoBan.Enabled {
return
}
g.mu.Lock()
defer g.mu.Unlock()
cs := g.clients[ip]
if cs == nil {
if len(g.clients) >= cfg.MaxTrackedIPs {
return
}
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
g.clients[ip] = cs
}
g.addOffenseLocked(ip, cs, weight, now, cfg)
}
func (g *Guard) addOffenseLocked(ip netip.Addr, cs *clientState, weight int, now time.Time, cfg *RuntimeConfig) {
if weight <= 0 || !cfg.AutoBan.Enabled {
return
}
window := time.Duration(cfg.AutoBan.WindowSeconds) * time.Second
if cs.offense.start.IsZero() || now.Sub(cs.offense.start) > window {
cs.offense = offenseState{start: now}
}
cs.offense.points += weight
cs.lastSeen = now
if cs.offense.points < cfg.AutoBan.Threshold {
return
}
until := now.Add(time.Duration(cfg.AutoBan.BanSeconds) * time.Second)
g.bans[ip] = until
cs.offense = offenseState{}
g.counters.AutoBans.Add(1)
g.logger.Warn("temporary IP ban", "ip", ip.String(), "until", until.UTC().Format(time.RFC3339))
g.stateDirty = true
}
func (g *Guard) Cleanup(now time.Time) {
cfg := g.cfg.Load()
if cfg == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
for ip, until := range g.bans {
if !now.Before(until) {
delete(g.bans, ip)
g.stateDirty = true
}
}
stale := now.Add(-30 * time.Minute)
for ip, cs := range g.clients {
if cs.lastSeen.Before(stale) {
delete(g.clients, ip)
}
}
}
func (g *Guard) ActiveBans(now time.Time) int {
g.mu.Lock()
defer g.mu.Unlock()
n := 0
for _, until := range g.bans {
if now.Before(until) {
n++
}
}
return n
}
func (g *Guard) loadState(path string) {
if strings.TrimSpace(path) == "" {
return
}
b, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
g.logger.Warn("read edgeguard state", "error", err)
}
return
}
var state persistedState
if err := json.Unmarshal(b, &state); err != nil {
g.logger.Warn("decode edgeguard state", "error", err)
return
}
now := time.Now()
for raw, until := range state.Bans {
ip, err := netip.ParseAddr(raw)
if err == nil && now.Before(until) {
g.bans[ip] = until
}
}
}
func (g *Guard) FlushState() {
cfg := g.cfg.Load()
if cfg == nil || strings.TrimSpace(cfg.StateFile) == "" {
return
}
g.mu.Lock()
if !g.stateDirty {
g.mu.Unlock()
return
}
state := persistedState{Bans: make(map[string]time.Time)}
now := time.Now()
for ip, until := range g.bans {
if now.Before(until) {
state.Bans[ip.String()] = until
}
}
g.stateDirty = false
g.mu.Unlock()
if err := writeState(cfg.StateFile, state); err != nil {
g.logger.Error("persist edgeguard state", "error", err)
g.mu.Lock()
g.stateDirty = true
g.mu.Unlock()
}
}
func writeState(path string, state persistedState) error {
b, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
func (g *Guard) Metrics() string {
return fmt.Sprintf(`# HELP sessionguard_edgeguard_requests_total Requests evaluated by EdgeGuard.
# TYPE sessionguard_edgeguard_requests_total counter
sessionguard_edgeguard_requests_total %d
# HELP sessionguard_edgeguard_allowed_total Requests allowed by EdgeGuard.
# TYPE sessionguard_edgeguard_allowed_total counter
sessionguard_edgeguard_allowed_total %d
# HELP sessionguard_edgeguard_denied_blacklist_total Requests denied by static blacklist.
# TYPE sessionguard_edgeguard_denied_blacklist_total counter
sessionguard_edgeguard_denied_blacklist_total %d
# HELP sessionguard_edgeguard_denied_ban_total Requests denied by temporary auto-ban.
# TYPE sessionguard_edgeguard_denied_ban_total counter
sessionguard_edgeguard_denied_ban_total %d
# HELP sessionguard_edgeguard_denied_rate_total Requests denied by per-IP or endpoint rate limits.
# TYPE sessionguard_edgeguard_denied_rate_total counter
sessionguard_edgeguard_denied_rate_total %d
# HELP sessionguard_edgeguard_denied_global_total Requests denied by the global rate limit.
# TYPE sessionguard_edgeguard_denied_global_total counter
sessionguard_edgeguard_denied_global_total %d
# HELP sessionguard_edgeguard_denied_scanner_total Requests denied as scanner paths.
# TYPE sessionguard_edgeguard_denied_scanner_total counter
sessionguard_edgeguard_denied_scanner_total %d
# HELP sessionguard_edgeguard_denied_method_total Requests denied by HTTP method policy.
# TYPE sessionguard_edgeguard_denied_method_total counter
sessionguard_edgeguard_denied_method_total %d
# HELP sessionguard_edgeguard_denied_host_total Requests denied because the public host is not allowed.
# TYPE sessionguard_edgeguard_denied_host_total counter
sessionguard_edgeguard_denied_host_total %d
# HELP sessionguard_edgeguard_denied_invalid_total Requests denied because the client IP or URI was invalid.
# TYPE sessionguard_edgeguard_denied_invalid_total counter
sessionguard_edgeguard_denied_invalid_total %d
# HELP sessionguard_edgeguard_denied_capacity_total Requests denied because the bounded per-IP state table is full.
# TYPE sessionguard_edgeguard_denied_capacity_total counter
sessionguard_edgeguard_denied_capacity_total %d
# HELP sessionguard_edgeguard_autobans_total Temporary bans created.
# TYPE sessionguard_edgeguard_autobans_total counter
sessionguard_edgeguard_autobans_total %d
# HELP sessionguard_edgeguard_active_bans Current temporary bans.
# TYPE sessionguard_edgeguard_active_bans gauge
sessionguard_edgeguard_active_bans %d
`,
g.counters.Requests.Load(),
g.counters.Allowed.Load(),
g.counters.DeniedBlacklist.Load(),
g.counters.DeniedBan.Load(),
g.counters.DeniedRate.Load(),
g.counters.DeniedGlobal.Load(),
g.counters.DeniedScanner.Load(),
g.counters.DeniedMethod.Load(),
g.counters.DeniedHost.Load(),
g.counters.DeniedInvalid.Load(),
g.counters.DeniedCapacity.Load(),
g.counters.AutoBans.Load(),
g.ActiveBans(time.Now()),
)
}
+217
View File
@@ -0,0 +1,217 @@
package edgeguard
import (
"encoding/json"
"log/slog"
"net/netip"
"os"
"path/filepath"
"testing"
"time"
)
func testConfig(t *testing.T, mutate func(*Config)) *RuntimeConfig {
t.Helper()
dir := t.TempDir()
cfg := DefaultConfig()
cfg.Listen = "127.0.0.1:0"
cfg.BlacklistFile = filepath.Join(dir, "blacklist.txt")
cfg.RateExemptFile = filepath.Join(dir, "rate-exempt.txt")
if err := os.WriteFile(cfg.BlacklistFile, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfg.RateExemptFile, nil, 0o600); err != nil {
t.Fatal(err)
}
cfg.StateFile = filepath.Join(dir, "state.json")
cfg.AllowedHosts = []string{"ts.example.test", "sessionguard.example.test"}
if mutate != nil {
mutate(&cfg)
}
b, err := jsonMarshal(cfg)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "edgeguard.json")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
rc, err := LoadRuntimeConfig(path)
if err != nil {
t.Fatal(err)
}
return rc
}
func jsonMarshal(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}
func newTestGuard(cfg *RuntimeConfig) *Guard {
return NewGuard(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
}
func TestAllowsNormalRequest(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
d := g.Check(netip.MustParseAddr("203.0.113.10"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
if !d.Allowed || d.StatusCode != 204 {
t.Fatalf("unexpected decision: %+v", d)
}
}
func TestBlacklistCIDR(t *testing.T) {
dir := t.TempDir()
blacklist := filepath.Join(dir, "blacklist.txt")
if err := os.WriteFile(blacklist, []byte("203.0.113.0/24\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.AllowedHosts = []string{"ts.example.test"}
cfg.BlacklistFile = blacklist
cfg.RateExemptFile = ""
cfg.StateFile = filepath.Join(dir, "state.json")
b, _ := json.Marshal(cfg)
configPath := filepath.Join(dir, "edgeguard.json")
_ = os.WriteFile(configPath, b, 0o600)
rc, err := LoadRuntimeConfig(configPath)
if err != nil {
t.Fatal(err)
}
g := newTestGuard(rc)
d := g.Check(netip.MustParseAddr("203.0.113.99"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
if d.StatusCode != 403 || d.Reason != "blacklisted" {
t.Fatalf("unexpected decision: %+v", d)
}
}
func TestScannerTriggersAutoBan(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.AutoBan.Threshold = 10
c.AutoBan.ScannerWeight = 5
})
g := newTestGuard(cfg)
ip := netip.MustParseAddr("198.51.100.7")
now := time.Unix(1000, 0)
for i := 0; i < 2; i++ {
d := g.Check(ip, "ts.example.test", "GET", "/.env", now.Add(time.Duration(i)*time.Second))
if d.StatusCode != 404 {
t.Fatalf("scanner request %d: %+v", i, d)
}
}
d := g.Check(ip, "ts.example.test", "GET", "/", now.Add(3*time.Second))
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
t.Fatalf("expected temp ban, got %+v", d)
}
}
func TestPerIPRateLimit(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 2}
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.AutoBan.Enabled = false
c.Rules = nil
})
g := newTestGuard(cfg)
ip := netip.MustParseAddr("198.51.100.8")
now := time.Unix(1000, 0)
for i := 0; i < 2; i++ {
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("request %d should be allowed: %+v", i, d)
}
}
d := g.Check(ip, "ts.example.test", "GET", "/", now)
if d.StatusCode != 429 {
t.Fatalf("expected 429, got %+v", d)
}
}
func TestRateExemptCIDR(t *testing.T) {
dir := t.TempDir()
exempt := filepath.Join(dir, "exempt.txt")
if err := os.WriteFile(exempt, []byte("198.51.100.0/24\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.AllowedHosts = []string{"ts.example.test"}
cfg.BlacklistFile = ""
cfg.RateExemptFile = exempt
cfg.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 1}
cfg.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
cfg.Rules = nil
cfg.StateFile = filepath.Join(dir, "state.json")
b, _ := json.Marshal(cfg)
configPath := filepath.Join(dir, "edgeguard.json")
_ = os.WriteFile(configPath, b, 0o600)
rc, err := LoadRuntimeConfig(configPath)
if err != nil {
t.Fatal(err)
}
g := newTestGuard(rc)
ip := netip.MustParseAddr("198.51.100.42")
now := time.Unix(1000, 0)
for i := 0; i < 20; i++ {
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("request %d unexpectedly denied: %+v", i, d)
}
}
}
func TestBlockedMethodAndHost(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
ip := netip.MustParseAddr("192.0.2.10")
now := time.Unix(1000, 0)
if d := g.Check(ip, "unknown.example.test", "GET", "/", now); d.StatusCode != 421 {
t.Fatalf("expected 421, got %+v", d)
}
if d := g.Check(ip, "ts.example.test", "TRACE", "/", now); d.StatusCode != 405 {
t.Fatalf("expected 405, got %+v", d)
}
}
func TestEncodedScannerPath(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
d := g.Check(netip.MustParseAddr("192.0.2.20"), "ts.example.test", "GET", "/.%65nv", time.Unix(1000, 0))
if d.StatusCode != 404 {
t.Fatalf("expected encoded /.env to be denied, got %+v", d)
}
}
func TestTemporaryBanPersistsAcrossRestart(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.AutoBan.Threshold = 5
c.AutoBan.ScannerWeight = 5
})
ip := netip.MustParseAddr("203.0.113.77")
now := time.Now()
g1 := newTestGuard(cfg)
if d := g1.Check(ip, "ts.example.test", "GET", "/.env", now); d.StatusCode != 404 {
t.Fatalf("expected scanner deny, got %+v", d)
}
g1.FlushState()
g2 := newTestGuard(cfg)
d := g2.Check(ip, "ts.example.test", "GET", "/", now.Add(time.Second))
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
t.Fatalf("expected persisted temporary ban, got %+v", d)
}
}
func TestTrackedIPCapacityIsBounded(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.MaxTrackedIPs = 2
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.PerIPLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.Rules = nil
c.AutoBan.Enabled = false
})
g := newTestGuard(cfg)
now := time.Unix(1000, 0)
for _, raw := range []string{"192.0.2.1", "192.0.2.2"} {
if d := g.Check(netip.MustParseAddr(raw), "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("first two IPs should be tracked: %s %+v", raw, d)
}
}
d := g.Check(netip.MustParseAddr("192.0.2.3"), "ts.example.test", "GET", "/", now)
if d.StatusCode != 429 || d.Reason != "edge state capacity" {
t.Fatalf("expected bounded-state 429, got %+v", d)
}
}
+80
View File
@@ -0,0 +1,80 @@
package edgeguard
import (
"encoding/json"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
)
type HTTPServer struct {
guard *Guard
logger *slog.Logger
}
func NewHTTPServer(g *Guard, logger *slog.Logger) *HTTPServer {
if logger == nil {
logger = slog.Default()
}
return &HTTPServer{guard: g, logger: logger}
}
func (s *HTTPServer) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /check", s.check)
return mux
}
func (s *HTTPServer) healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *HTTPServer) metrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(s.guard.Metrics()))
}
func (s *HTTPServer) check(w http.ResponseWriter, r *http.Request) {
rawIP := strings.TrimSpace(r.Header.Get("X-Edge-Client-IP"))
ip, err := netip.ParseAddr(rawIP)
if err != nil {
s.writeDeny(w, http.StatusBadRequest, "invalid client ip", 0)
return
}
host := r.Header.Get("X-Edge-Original-Host")
method := r.Header.Get("X-Edge-Original-Method")
uri := r.Header.Get("X-Edge-Original-URI")
decision := s.guard.Check(ip.Unmap(), host, method, uri, time.Now())
if decision.Allowed {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// Do not log every denial here: during an attack that would turn logging
// itself into a resource-exhaustion vector. Caddy access logs are sampled
// and EdgeGuard exposes counters; only state changes such as auto-bans are
// emitted as runtime log events.
s.writeDeny(w, decision.StatusCode, decision.Reason, decision.RetryAfter)
}
func (s *HTTPServer) writeDeny(w http.ResponseWriter, code int, reason string, retryAfter int) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
if retryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
}
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": http.StatusText(code),
"reason": reason,
})
}
+1 -1
View File
@@ -23,7 +23,7 @@ import (
"github.com/example/sessionguard/internal/model"
)
const Version = "0.5.0"
const Version = "0.5.2"
type App struct {
cfg config.Master
+3 -3
View File
@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:720px){.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}}
/* SessionGuard UI v0.5.1 — modal-first responsive console */
/* SessionGuard UI v0.5.2 — modal-first responsive console */
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
@@ -69,7 +69,7 @@ body.modal-open{overflow:hidden}
</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.5.1</div></div></div>
<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.2</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>
@@ -88,7 +88,7 @@ body.modal-open{overflow:hidden}
<dialog class="sg-modal wide" id="agentModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="detailTitle">Terminalserver</div><div class="modal-sub">Live-Daten, Sitzungen und Richtlinien</div></div><button type="button" class="modal-close icon-btn" data-close-modal="agentModal" aria-label="Schließen">×</button></div><div class="modal-body"><div id="detail" class="empty">Server wird geladen…</div></div></div></dialog>
<dialog class="sg-modal compact" id="farmModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Neue Farm</div><div class="modal-sub">Broker-Ziel und optionale Tag-Selektion definieren</div></div><button type="button" class="modal-close icon-btn" data-close-modal="farmModal">×</button></div><div class="modal-body pad"><div id="farmEditor" class="form"><div class="form-grid"><label>Name<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc" placeholder="Standard Desktop-Farm"></label></div><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label><div class="actions end"><button type="button" class="secondary" data-close-modal="farmModal">Abbrechen</button><button type="button" data-global-action="create-farm">Farm anlegen</button></div></div></div></div></dialog>
<dialog class="sg-modal" id="resourceModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="resourceModalTitle">Resource anlegen</div><div class="modal-sub">Desktop oder RemoteApp für eine Farm veröffentlichen</div></div><button type="button" class="modal-close icon-btn" data-close-modal="resourceModal">×</button></div><div class="modal-body"><div id="resourceEditor" class="form"><div class="form-grid three"><label>Name<input id="resName" placeholder="Sage 100"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName" placeholder="Sage"></label><label>RemoteApp Alias<input id="resRemoteApp" placeholder="||Sage"></label><label>Guacamole Working Dir<input id="resRemoteDir"></label><label>Guacamole Argumente<input id="resRemoteArgs"></label></div><div class="note" id="remoteAppManageNote"><strong>Agent-gesteuerte RemoteApp-Veröffentlichung</strong><br>SessionGuard hält den Alias auf allen geeigneten Farm-Agents im Sollzustand; fehlerhafte Hosts werden für diese Resource nicht gebrokert.</div><div class="check"><input id="resManage" type="checkbox"><label>RemoteApp auf Farm-Agents automatisch veröffentlichen und überwachen</label></div><div class="form-grid three"><label>Executable auf dem Terminalserver<input id="resRemotePath" placeholder="C:\Program Files\Hersteller\app.exe"></label><label>Icon-Pfad (optional)<input id="resRemoteIcon" placeholder="leer = Executable"></label><label>Icon-Index<input id="resIconIndex" type="number" value="0"></label><label>Command-Line-Policy<select id="resCmd"><option value="0">Keine Argumente erlauben</option><option value="1">Argumente aus RDP/Guacamole erlauben</option><option value="2">Vorgegebene Argumente erzwingen</option></select></label><label>Vorgegebene Argumente<input id="resRemoteRequired"></label><div class="check"><input id="resPortal" type="checkbox"><label>Auch in RD Web Access anzeigen</label></div></div><div class="actions end"><button type="button" class="secondary" data-close-modal="resourceModal">Abbrechen</button><button id="resSave" type="button" data-global-action="save-resource">Resource anlegen</button></div></div></div></div></dialog>
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.1"></script></body></html>`
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.2"></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)}function openModal(id){let d=$(id);if(!d)return;document.body.classList.add('modal-open');if(!d.open)d.showModal()}function closeModal(id){let d=$(id);if(!d)return;if(d.open)d.close();if(!document.querySelector('dialog[open]'))document.body.classList.remove('modal-open')}function activateDetailTab(name){document.querySelectorAll('#detail .detail-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===name));document.querySelectorAll('#detail .detail-pane').forEach(p=>p.classList.toggle('active',p.dataset.pane===name))}