Init
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-22 08:10:11 +02:00
parent 6458e81172
commit 1d8e36c53a
33 changed files with 2471 additions and 1 deletions
+397
View File
@@ -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)
}
+88
View File
@@ -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[:])
}
+57
View File
@@ -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") }
+10
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);`