@@ -0,0 +1,397 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/example/sessionguard/internal/auth"
|
||||
"github.com/example/sessionguard/internal/config"
|
||||
"github.com/example/sessionguard/internal/httpx"
|
||||
"github.com/example/sessionguard/internal/model"
|
||||
tpl "github.com/example/sessionguard/internal/templates"
|
||||
"github.com/example/sessionguard/internal/windowsx"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
type App struct {
|
||||
cfg config.Agent
|
||||
store stateStore
|
||||
mu sync.RWMutex
|
||||
state State
|
||||
snapshot model.AgentSnapshot
|
||||
lastMasterOK time.Time
|
||||
masterErr string
|
||||
client *masterClient
|
||||
}
|
||||
|
||||
func New(cfg config.Agent) (*App, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Policy.Revision == "" {
|
||||
cfg.Policy.Revision = newRevision()
|
||||
cfg.Policy.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
st, err := loadState(statePath(cfg.DataDir), cfg.Policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &App{cfg: cfg, store: stateStore{path: statePath(cfg.DataDir)}, state: st, client: newMasterClient(cfg.MasterURL)}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func newRevision() string { b := make([]byte, 12); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
||||
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
go a.worker(ctx)
|
||||
return a.serveHTTP(ctx)
|
||||
}
|
||||
|
||||
func (a *App) worker(ctx context.Context) {
|
||||
poll := time.NewTicker(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second)
|
||||
defer poll.Stop()
|
||||
hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second)
|
||||
defer hb.Stop()
|
||||
a.tick(ctx)
|
||||
a.sendHeartbeat(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-poll.C:
|
||||
a.tick(ctx)
|
||||
case <-hb.C:
|
||||
a.sendHeartbeat(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) tick(ctx context.Context) {
|
||||
sessions, err := windowsx.Sessions()
|
||||
if err != nil {
|
||||
log.Printf("sessions: %v", err)
|
||||
return
|
||||
}
|
||||
server, err := windowsx.Server()
|
||||
if err != nil {
|
||||
log.Printf("server info: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
currentBySID := map[string]bool{}
|
||||
currentIDs := map[uint32]model.Session{}
|
||||
for _, s := range sessions {
|
||||
currentIDs[s.ID] = s
|
||||
if s.SID != "" {
|
||||
currentBySID[s.SID] = true
|
||||
delete(a.state.Pending, s.SID)
|
||||
}
|
||||
}
|
||||
|
||||
for id, prev := range a.state.LastSessions {
|
||||
if _, exists := currentIDs[id]; exists || prev.SID == "" || currentBySID[prev.SID] {
|
||||
continue
|
||||
}
|
||||
if _, exists := a.state.Pending[prev.SID]; exists {
|
||||
continue
|
||||
}
|
||||
if a.excluded(prev) {
|
||||
continue
|
||||
}
|
||||
path, err := windowsx.ProfilePath(prev.SID)
|
||||
if err != nil {
|
||||
log.Printf("profile path for %s/%s: %v", prev.User, prev.SID, err)
|
||||
continue
|
||||
}
|
||||
if !a.safeProfilePath(path) {
|
||||
log.Printf("refusing cleanup outside allowed roots: %s (%s)", path, prev.User)
|
||||
continue
|
||||
}
|
||||
a.state.Pending[prev.SID] = model.CleanupJob{SID: prev.SID, User: displayUser(prev), ProfilePath: path, DueAt: now.Add(time.Duration(a.state.Policy.Cleanup.GraceSeconds) * time.Second)}
|
||||
log.Printf("scheduled profile cleanup: %s in %ds", displayUser(prev), a.state.Policy.Cleanup.GraceSeconds)
|
||||
}
|
||||
|
||||
for _, s := range sessions {
|
||||
if s.SID == "" || s.User == "" {
|
||||
continue
|
||||
}
|
||||
_, was := a.state.LastSessions[s.ID]
|
||||
if !was {
|
||||
a.applyTemplatesLocked(s)
|
||||
}
|
||||
}
|
||||
|
||||
if a.state.Policy.Cleanup.Enabled {
|
||||
a.processCleanupLocked(now, currentBySID)
|
||||
}
|
||||
a.state.LastSessions = currentIDs
|
||||
a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Sessions: sessions, PendingCleanup: pendingSlice(a.state.Pending), PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now}
|
||||
if err := a.store.save(a.state); err != nil {
|
||||
log.Printf("save state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyTemplatesLocked(s model.Session) {
|
||||
path, err := windowsx.ProfilePath(s.SID)
|
||||
if err != nil {
|
||||
log.Printf("templates profile %s: %v", displayUser(s), err)
|
||||
return
|
||||
}
|
||||
for _, item := range a.state.Policy.Templates {
|
||||
changed, err := tpl.Apply(path, item)
|
||||
if err != nil {
|
||||
log.Printf("template %s for %s: %v", item.ID, displayUser(s), err)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
log.Printf("template %s applied for %s", item.ID, displayUser(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processCleanupLocked(now time.Time, active map[string]bool) {
|
||||
p := a.state.Policy.Cleanup
|
||||
for sid, job := range a.state.Pending {
|
||||
if active[sid] {
|
||||
delete(a.state.Pending, sid)
|
||||
continue
|
||||
}
|
||||
if now.Before(job.DueAt) {
|
||||
continue
|
||||
}
|
||||
if !a.safeProfilePath(job.ProfilePath) {
|
||||
job.LastError = "profile path is outside allowed roots"
|
||||
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
||||
a.state.Pending[sid] = job
|
||||
continue
|
||||
}
|
||||
if p.DryRun {
|
||||
job.LastError = "dry-run: deletion skipped"
|
||||
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
||||
a.state.Pending[sid] = job
|
||||
log.Printf("dry-run profile deletion: %s (%s)", job.User, job.ProfilePath)
|
||||
continue
|
||||
}
|
||||
if err := windowsx.DeleteProfile(sid); err != nil {
|
||||
job.Attempts++
|
||||
job.LastError = err.Error()
|
||||
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
||||
a.state.Pending[sid] = job
|
||||
log.Printf("delete profile %s: %v", job.User, err)
|
||||
continue
|
||||
}
|
||||
delete(a.state.Pending, sid)
|
||||
log.Printf("deleted profile: %s (%s)", job.User, job.ProfilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) excluded(s model.Session) bool {
|
||||
p := a.state.Policy.Cleanup
|
||||
for _, u := range p.ExcludeUsers {
|
||||
if strings.EqualFold(strings.TrimSpace(u), s.User) || strings.EqualFold(strings.TrimSpace(u), displayUser(s)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, x := range p.ExcludeSIDs {
|
||||
if strings.EqualFold(s.SID, x) || strings.HasPrefix(strings.ToUpper(s.SID), strings.ToUpper(x)+"-") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) safeProfilePath(path string) bool {
|
||||
clean, err := filepath.Abs(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, root := range a.state.Policy.Cleanup.AllowedProfileRoots {
|
||||
r, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c, rr := strings.ToLower(clean), strings.ToLower(r)
|
||||
if c == rr || strings.HasPrefix(c, rr+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func displayUser(s model.Session) string {
|
||||
if s.Domain != "" {
|
||||
return s.Domain + `\` + s.User
|
||||
}
|
||||
return s.User
|
||||
}
|
||||
func pendingSlice(m map[string]model.CleanupJob) []model.CleanupJob {
|
||||
out := make([]model.CleanupJob, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
func (a *App) policy() model.Policy { a.mu.RLock(); defer a.mu.RUnlock(); return a.state.Policy }
|
||||
|
||||
func (a *App) sendHeartbeat(ctx context.Context) {
|
||||
if a.cfg.MasterURL == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.state.AgentID == "" || a.state.AgentToken == "" {
|
||||
server, _ := windowsx.Server()
|
||||
mid, _ := windowsx.MachineID()
|
||||
resp, err := a.client.enroll(ctx, model.EnrollRequest{EnrollmentToken: a.cfg.EnrollmentToken, Name: server.Hostname, MachineID: mid})
|
||||
if err != nil {
|
||||
a.masterErr = err.Error()
|
||||
a.mu.Unlock()
|
||||
log.Printf("master enroll: %v", err)
|
||||
return
|
||||
}
|
||||
a.state.AgentID, a.state.AgentToken = resp.AgentID, resp.Token
|
||||
_ = a.store.save(a.state)
|
||||
}
|
||||
id, token, snap := a.state.AgentID, a.state.AgentToken, a.snapshot
|
||||
snap.AgentID = id
|
||||
a.mu.Unlock()
|
||||
resp, err := a.client.heartbeat(ctx, id, token, snap)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err != nil {
|
||||
a.masterErr = err.Error()
|
||||
log.Printf("master heartbeat: %v", err)
|
||||
return
|
||||
}
|
||||
a.lastMasterOK = time.Now().UTC()
|
||||
a.masterErr = ""
|
||||
if resp.DesiredPolicy != nil && resp.DesiredPolicy.Revision != "" && resp.DesiredPolicy.Revision != a.state.Policy.Revision {
|
||||
a.state.Policy = *resp.DesiredPolicy
|
||||
_ = a.store.save(a.state)
|
||||
log.Printf("applied master policy revision %s", a.state.Policy.Revision)
|
||||
for _, s := range a.state.LastSessions {
|
||||
if s.SID != "" && s.User != "" {
|
||||
a.applyTemplatesLocked(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) serveHTTP(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"ok": true}) })
|
||||
var am *auth.Manager
|
||||
if a.cfg.OIDC.Issuer != "" {
|
||||
var err error
|
||||
am, err = auth.New(ctx, a.cfg.OIDC)
|
||||
if err != nil {
|
||||
log.Printf("local OIDC unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
if am != nil {
|
||||
am.Register(mux)
|
||||
}
|
||||
secure := func(h http.Handler) http.Handler {
|
||||
if am == nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "OIDC is not configured or unavailable", http.StatusServiceUnavailable)
|
||||
})
|
||||
}
|
||||
return am.Require(h)
|
||||
}
|
||||
mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, agentJS)
|
||||
})
|
||||
mux.Handle("GET /", secure(http.HandlerFunc(a.agentPage)))
|
||||
mux.Handle("GET /api/v1/status", secure(http.HandlerFunc(a.statusAPI)))
|
||||
mux.Handle("GET /api/v1/policy", secure(http.HandlerFunc(a.policyAPI)))
|
||||
mux.Handle("PUT /api/v1/policy", secure(http.HandlerFunc(a.policyAPI)))
|
||||
server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(c)
|
||||
}()
|
||||
log.Printf("agent web listening on %s", a.cfg.Listen)
|
||||
err := server.ListenAndServe()
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) statusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
httpx.JSON(w, 200, map[string]any{"snapshot": a.snapshot, "last_master_ok": a.lastMasterOK, "master_error": a.masterErr, "master_url": a.cfg.MasterURL})
|
||||
}
|
||||
func (a *App) policyAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
httpx.JSON(w, 200, a.state.Policy)
|
||||
return
|
||||
}
|
||||
if !httpx.SameOrigin(r) {
|
||||
httpx.Error(w, 403, "cross-origin request rejected")
|
||||
return
|
||||
}
|
||||
var p model.Policy
|
||||
if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
|
||||
httpx.Error(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
p.Revision = newRevision()
|
||||
p.UpdatedAt = time.Now().UTC()
|
||||
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 {
|
||||
httpx.Error(w, 400, "invalid cleanup timing")
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.state.Policy = p
|
||||
_ = a.store.save(a.state)
|
||||
sessions := a.state.LastSessions
|
||||
a.mu.Unlock()
|
||||
for _, s := range sessions {
|
||||
if s.SID != "" && s.User != "" {
|
||||
a.mu.Lock()
|
||||
a.applyTemplatesLocked(s)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
httpx.JSON(w, 200, p)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) agentPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, agentHTML)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/example/sessionguard/internal/model"
|
||||
)
|
||||
|
||||
type masterClient struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newMasterClient(base string) *masterClient {
|
||||
return &masterClient{base: strings.TrimRight(base, "/"), http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *masterClient) enroll(ctx context.Context, req model.EnrollRequest) (model.EnrollResponse, error) {
|
||||
var out model.EnrollResponse
|
||||
if c.base == "" {
|
||||
return out, fmt.Errorf("master_url is empty")
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/v1/agents/enroll", "", req, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *masterClient) heartbeat(ctx context.Context, agentID, token string, snap model.AgentSnapshot) (model.HeartbeatResponse, error) {
|
||||
var out model.HeartbeatResponse
|
||||
reqBody, _ := json.Marshal(snap)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/api/v1/agents/heartbeat", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
return out, fmt.Errorf("master heartbeat: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||
}
|
||||
|
||||
func (c *masterClient) do(ctx context.Context, method, path, bearer string, in, out any) error {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
return fmt.Errorf("master: %s: %s", resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func tokenHash(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/example/sessionguard/internal/config"
|
||||
"github.com/example/sessionguard/internal/model"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
AgentToken string `json:"agent_token,omitempty"`
|
||||
Policy model.Policy `json:"policy"`
|
||||
LastSessions map[uint32]model.Session `json:"last_sessions,omitempty"`
|
||||
Pending map[string]model.CleanupJob `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
type stateStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func loadState(path string, initial model.Policy) (State, error) {
|
||||
s := State{Policy: initial, LastSessions: map[uint32]model.Session{}, Pending: map[string]model.CleanupJob{}}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if s.LastSessions == nil {
|
||||
s.LastSessions = map[uint32]model.Session{}
|
||||
}
|
||||
if s.Pending == nil {
|
||||
s.Pending = map[string]model.CleanupJob{}
|
||||
}
|
||||
if s.Policy.Revision == "" {
|
||||
s.Policy = initial
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (ss *stateStore) save(s State) error {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
return config.SaveJSON(ss.path, s)
|
||||
}
|
||||
|
||||
func statePath(dataDir string) string { return filepath.Join(dataDir, "state.json") }
|
||||
@@ -0,0 +1,10 @@
|
||||
package agent
|
||||
|
||||
const agentHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Agent</title><style>
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0c1120;color:#edf2fb}*{box-sizing:border-box}body{margin:0;background:#0c1120}.wrap{max-width:1200px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center}.brand{font-size:20px;font-weight:750}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.card,.panel{background:#131d32;border:1px solid #263653;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:750;margin-top:7px}.panel{margin-top:15px;overflow:hidden}.panel h2{font-size:15px;padding:14px 16px;margin:0;border-bottom:1px solid #263653}.table{width:100%;border-collapse:collapse}.table td,.table th{padding:11px 13px;border-bottom:1px solid #22314d;text-align:left;font-size:13px}.table th{color:#93a5c2}.form{padding:16px;display:grid;gap:11px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}input,textarea{width:100%;background:#0d1629;color:#edf2fb;border:1px solid #334866;border-radius:9px;padding:8px}textarea{min-height:110px;font-family:ui-monospace,Consolas,monospace;font-size:12px}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}button{background:#5d8eff;color:#fff;border:0;border-radius:9px;padding:9px 13px;font-weight:650;cursor:pointer}.secondary{background:#22314d}.bad{color:#ff9b9b}.good{color:#74e59a}@media(max-width:850px){.grid{grid-template-columns:repeat(2,1fr)}.cols{grid-template-columns:1fr}}</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active">–</div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total">–</div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="pending">–</div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:16px" id="master">–</div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
|
||||
|
||||
const agentJS = `
|
||||
const $=id=>document.getElementById(id);function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}async function api(u,o){let r=await fetch(u,o),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)}
|
||||
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{},ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('total').textContent=ss.length;$('pending').textContent=(s.pending_cleanup||[]).length;$('master').innerHTML=d.master_error?'<span class="bad">Offline</span>':'<span class="good">Verbunden</span>';$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(x.client_name||'–')+'</td></tr>').join('')+'</tbody></table>'}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
|
||||
async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML='<div class="form"><div class="cols"><label>Grace (s)<input id="grace" type="number" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'> Cleanup aktiv</div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'> Dry-Run</div><label>Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:220px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div><button onclick="savePolicy()">Lokal speichern</button></div><div class="muted">Wenn der Master für diesen Agent eine gewünschte Policy gesetzt hat, ist diese nach Wiederherstellung der Verbindung wieder maßgeblich.</div></div>'}catch(e){$('policy').textContent=e.message}}
|
||||
async function savePolicy(){try{let p={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:JSON.parse($('templates').value||'[]')};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});await loadPolicy()}catch(e){alert(e.message)}}refresh();loadPolicy();setInterval(refresh,5000);`
|
||||
Reference in New Issue
Block a user