Init
Some checks failed
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
internal/agent/agent.go Normal file
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
internal/agent/client.go Normal file
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
internal/agent/state.go Normal file
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
internal/agent/ui.go Normal file
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);`

207
internal/auth/oidc.go Normal file
View File

@@ -0,0 +1,207 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/example/sessionguard/internal/model"
"golang.org/x/oauth2"
)
type User struct {
Sub string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Exp int64 `json:"exp"`
}
type pending struct {
Nonce string
Exp time.Time
}
type Manager struct {
cfg model.OIDCConfig
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
key []byte
mu sync.Mutex
pending map[string]pending
}
func New(ctx context.Context, cfg model.OIDCConfig) (*Manager, error) {
if cfg.Issuer == "" || cfg.ClientID == "" || cfg.RedirectURL == "" {
return nil, errors.New("OIDC is not configured")
}
p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
if err != nil {
return nil, err
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
return &Manager{
cfg: cfg,
provider: p,
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: oauth2.Config{ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}},
key: key,
pending: map[string]pending{},
}, nil
}
func randomURLSafe(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (m *Manager) Login(w http.ResponseWriter, r *http.Request) {
state, nonce := randomURLSafe(24), randomURLSafe(24)
m.mu.Lock()
m.pending[state] = pending{Nonce: nonce, Exp: time.Now().Add(5 * time.Minute)}
m.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "sg_oidc_state", Value: state, Path: "/oidc/callback", 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 *Manager) Callback(w http.ResponseWriter, r *http.Request) error {
if e := r.URL.Query().Get("error"); e != "" {
return fmt.Errorf("oidc error: %s", e)
}
state := r.URL.Query().Get("state")
cookie, err := r.Cookie("sg_oidc_state")
if err != nil || cookie.Value != state {
return errors.New("OIDC state is not bound to this browser")
}
http.SetCookie(w, &http.Cookie{Name: "sg_oidc_state", Value: "", Path: "/oidc/callback", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode, MaxAge: -1})
m.mu.Lock()
p, ok := m.pending[state]
delete(m.pending, state)
m.mu.Unlock()
if !ok || time.Now().After(p.Exp) {
return errors.New("invalid or expired OIDC state")
}
tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
return err
}
raw, ok := tok.Extra("id_token").(string)
if !ok {
return errors.New("missing id_token")
}
idToken, err := m.verifier.Verify(r.Context(), raw)
if err != nil {
return err
}
if idToken.Nonce != p.Nonce {
return errors.New("invalid OIDC nonce")
}
var claims struct {
Sub, Email, Name string
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return err
}
u := User{Sub: claims.Sub, Email: claims.Email, Name: claims.Name, Groups: claims.Groups, Exp: time.Now().Add(8 * time.Hour).Unix()}
if !m.allowed(u) {
return errors.New("user is not in an allowed admin group")
}
value, err := m.sign(u)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{Name: "sg_session", Value: value, Path: "/", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode, MaxAge: 8 * 3600})
return nil
}
func (m *Manager) allowed(u User) bool {
if len(m.cfg.AdminGroups) == 0 {
return true
}
set := map[string]struct{}{}
for _, g := range u.Groups {
set[strings.ToLower(g)] = struct{}{}
}
for _, g := range m.cfg.AdminGroups {
if _, ok := set[strings.ToLower(g)]; ok {
return true
}
}
return false
}
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "sg_session", Value: "", Path: "/", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode, MaxAge: -1})
http.Redirect(w, r, "/", http.StatusFound)
}
func (m *Manager) sign(u User) (string, error) {
b, err := json.Marshal(u)
if err != nil {
return "", err
}
p := base64.RawURLEncoding.EncodeToString(b)
mac := hmac.New(sha256.New, m.key)
mac.Write([]byte(p))
return p + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (m *Manager) parse(v string) (User, bool) {
var u User
parts := strings.Split(v, ".")
if len(parts) != 2 {
return u, false
}
mac := hmac.New(sha256.New, m.key)
mac.Write([]byte(parts[0]))
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(sig, mac.Sum(nil)) {
return u, false
}
b, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || json.Unmarshal(b, &u) != nil || time.Now().Unix() >= u.Exp {
return User{}, false
}
if !m.allowed(u) {
return User{}, false
}
return u, true
}
type ctxKey int
const userKey ctxKey = 1
func UserFrom(r *http.Request) (User, bool) { u, ok := r.Context().Value(userKey).(User); return u, ok }
func (m *Manager) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("sg_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
u, ok := m.parse(c.Value)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
})
}

15
internal/auth/routes.go Normal file
View File

@@ -0,0 +1,15 @@
package auth
import "net/http"
func (m *Manager) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /login", m.Login)
mux.HandleFunc("GET /oidc/callback", func(w http.ResponseWriter, r *http.Request) {
if err := m.Callback(w, r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/", http.StatusFound)
})
mux.HandleFunc("POST /logout", m.Logout)
}

120
internal/config/config.go Normal file
View File

@@ -0,0 +1,120 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/example/sessionguard/internal/model"
)
type Master struct {
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataFile string `json:"data_file"`
EnrollmentToken string `json:"enrollment_token"`
OIDC model.OIDCConfig `json:"oidc"`
OfflineAfterSeconds int `json:"offline_after_seconds"`
}
type Agent struct {
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataDir string `json:"data_dir"`
MasterURL string `json:"master_url"`
EnrollmentToken string `json:"enrollment_token"`
HeartbeatSeconds int `json:"heartbeat_seconds"`
OIDC model.OIDCConfig `json:"oidc"`
Policy model.Policy `json:"policy"`
}
func LoadMaster(path string) (Master, error) {
var c Master
if err := read(path, &c); err != nil {
return c, err
}
if c.Listen == "" {
c.Listen = ":8080"
}
if c.DataFile == "" {
c.DataFile = "./data/master.json"
}
if c.OfflineAfterSeconds <= 0 {
c.OfflineAfterSeconds = 30
}
return c, validateOIDC(c.OIDC)
}
func LoadAgent(path string) (Agent, error) {
var c Agent
if err := read(path, &c); err != nil {
return c, err
}
if c.Listen == "" {
c.Listen = ":9091"
}
if c.DataDir == "" {
c.DataDir = `C:\ProgramData\SessionGuard`
}
if c.HeartbeatSeconds <= 0 {
c.HeartbeatSeconds = 10
}
if c.Policy.Cleanup.GraceSeconds <= 0 {
c.Policy.Cleanup.GraceSeconds = 600
}
if c.Policy.Cleanup.PollSeconds <= 0 {
c.Policy.Cleanup.PollSeconds = 10
}
if c.Policy.Cleanup.RetrySeconds <= 0 {
c.Policy.Cleanup.RetrySeconds = 60
}
if len(c.Policy.Cleanup.AllowedProfileRoots) == 0 {
c.Policy.Cleanup.AllowedProfileRoots = []string{`C:\Users`}
}
if c.Policy.Cleanup.ExcludeUsers == nil {
c.Policy.Cleanup.ExcludeUsers = []string{"Administrator", "DefaultAccount", "WDAGUtilityAccount"}
}
if c.Policy.Cleanup.ExcludeSIDs == nil {
c.Policy.Cleanup.ExcludeSIDs = []string{"S-1-5-18", "S-1-5-19", "S-1-5-20"}
}
if c.OIDC.Issuer != "" {
if err := validateOIDC(c.OIDC); err != nil {
return c, err
}
}
return c, nil
}
func read(path string, out any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(b, out); err != nil {
return err
}
return nil
}
func SaveJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func validateOIDC(c model.OIDCConfig) error {
if c.Issuer == "" || c.ClientID == "" || c.RedirectURL == "" {
return errors.New("oidc issuer, client_id and redirect_url are required")
}
return nil
}

View File

@@ -0,0 +1,24 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestAgentDefaults(t *testing.T) {
p := filepath.Join(t.TempDir(), "agent.json")
if err := os.WriteFile(p, []byte(`{"listen":"127.0.0.1:9091","policy":{"cleanup":{"enabled":true}}}`), 0600); err != nil {
t.Fatal(err)
}
c, err := LoadAgent(p)
if err != nil {
t.Fatal(err)
}
if c.Policy.Cleanup.GraceSeconds != 600 {
t.Fatalf("grace=%d", c.Policy.Cleanup.GraceSeconds)
}
if len(c.Policy.Cleanup.AllowedProfileRoots) == 0 {
t.Fatal("missing allowed profile root")
}
}

33
internal/httpx/httpx.go Normal file
View File

@@ -0,0 +1,33 @@
package httpx
import (
"encoding/json"
"io"
"net/http"
"strings"
)
func JSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func Error(w http.ResponseWriter, status int, msg string) {
JSON(w, status, map[string]any{"error": msg})
}
func DecodeJSON(r *http.Request, dst any, max int64) error {
dec := json.NewDecoder(io.LimitReader(r.Body, max))
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func SameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
host := r.Host
return strings.HasSuffix(origin, "://"+host)
}

271
internal/master/master.go Normal file
View File

@@ -0,0 +1,271 @@
package master
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"net/http"
"sort"
"strings"
"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"
)
const Version = "0.1.0"
type App struct {
cfg config.Master
store *store
auth *auth.Manager
}
func New(ctx context.Context, cfg config.Master) (*App, error) {
s, err := newStore(cfg.DataFile)
if err != nil {
return nil, err
}
a, err := auth.New(ctx, cfg.OIDC)
if err != nil {
return nil, fmt.Errorf("OIDC: %w", err)
}
return &App{cfg: cfg, store: s, auth: a}, nil
}
func (a *App) Run(ctx context.Context) error {
mux := http.NewServeMux()
a.auth.Register(mux)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version})
})
mux.HandleFunc("POST /api/v1/agents/enroll", a.enroll)
mux.HandleFunc("POST /api/v1/agents/heartbeat", a.heartbeat)
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, masterJS)
})
mux.Handle("GET /", a.auth.Require(http.HandlerFunc(a.masterPage)))
mux.Handle("GET /api/v1/dashboard", a.auth.Require(http.HandlerFunc(a.dashboard)))
mux.Handle("GET /api/v1/agents/{id}", a.auth.Require(http.HandlerFunc(a.agentDetail)))
mux.Handle("PUT /api/v1/agents/{id}/policy", a.auth.Require(http.HandlerFunc(a.policy)))
mux.Handle("PUT /api/v1/policy/all", a.auth.Require(http.HandlerFunc(a.policyAll)))
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("master listening on %s", a.cfg.Listen)
err := server.ListenAndServe()
if err == http.ErrServerClosed {
return nil
}
return err
}
func (a *App) enroll(w http.ResponseWriter, r *http.Request) {
var req model.EnrollRequest
if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
httpx.Error(w, 400, err.Error())
return
}
if !constantEqual(req.EnrollmentToken, a.cfg.EnrollmentToken) || req.MachineID == "" {
httpx.Error(w, 401, "invalid enrollment")
return
}
now := time.Now().UTC()
token := randomToken(32)
id := randomToken(16)
a.store.mu.Lock()
defer a.store.mu.Unlock()
for oldID, rec := range a.store.data.Agents {
if rec.MachineID == req.MachineID {
id = oldID
break
}
}
rec := a.store.data.Agents[id]
rec.ID = id
rec.Name = req.Name
rec.MachineID = req.MachineID
rec.TokenHash = hashToken(token)
if rec.EnrolledAt.IsZero() {
rec.EnrolledAt = now
}
a.store.data.Agents[id] = rec
if err := a.store.saveLocked(); err != nil {
httpx.Error(w, 500, err.Error())
return
}
httpx.JSON(w, 200, model.EnrollResponse{AgentID: id, Token: token})
}
func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Agent-ID")
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if id == "" || token == "" {
httpx.Error(w, 401, "missing agent credentials")
return
}
var snap model.AgentSnapshot
if err := httpx.DecodeJSON(r, &snap, 2<<20); err != nil {
httpx.Error(w, 400, err.Error())
return
}
a.store.mu.Lock()
defer a.store.mu.Unlock()
rec, ok := a.store.data.Agents[id]
if !ok || !constantEqual(hashToken(token), rec.TokenHash) {
httpx.Error(w, 401, "invalid agent credentials")
return
}
if snap.ProtocolVersion != model.ProtocolVersion {
httpx.Error(w, 409, "protocol version mismatch")
return
}
now := time.Now().UTC()
snap.AgentID = id
rec.LastSeen = now
rec.Snapshot = snap
if snap.Server.Hostname != "" {
rec.Name = snap.Server.Hostname
}
a.store.data.Agents[id] = rec
if err := a.store.saveLocked(); err != nil {
httpx.Error(w, 500, err.Error())
return
}
var desired *model.Policy
if rec.DesiredPolicy != nil && rec.DesiredPolicy.Revision != snap.PolicyRevision {
p := *rec.DesiredPolicy
desired = &p
}
httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: desired, ServerTime: now})
}
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
recs := a.store.all()
sort.Slice(recs, func(i, j int) bool { return strings.ToLower(recs[i].Name) < strings.ToLower(recs[j].Name) })
now := time.Now().UTC()
type row struct {
model.AgentRecord
Online bool `json:"online"`
Active int `json:"active_sessions"`
Total int `json:"total_sessions"`
}
out := make([]row, 0, len(recs))
for _, rec := range recs {
active := 0
for _, s := range rec.Snapshot.Sessions {
if s.State == "Active" {
active++
}
}
out = append(out, row{AgentRecord: rec, Online: now.Sub(rec.LastSeen) < time.Duration(a.cfg.OfflineAfterSeconds)*time.Second, Active: active, Total: len(rec.Snapshot.Sessions)})
}
httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now})
}
func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) {
rec, ok := a.store.get(r.PathValue("id"))
if !ok {
httpx.Error(w, 404, "agent not found")
return
}
httpx.JSON(w, 200, rec)
}
func (a *App) policy(w http.ResponseWriter, r *http.Request) {
if !httpx.SameOrigin(r) {
httpx.Error(w, 403, "cross-origin request rejected")
return
}
id := r.PathValue("id")
var p model.Policy
if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
httpx.Error(w, 400, err.Error())
return
}
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 {
httpx.Error(w, 400, "invalid cleanup timing")
return
}
p.Revision = randomToken(12)
p.UpdatedAt = time.Now().UTC()
a.store.mu.Lock()
defer a.store.mu.Unlock()
rec, ok := a.store.data.Agents[id]
if !ok {
httpx.Error(w, 404, "agent not found")
return
}
rec.DesiredPolicy = &p
a.store.data.Agents[id] = rec
if err := a.store.saveLocked(); err != nil {
httpx.Error(w, 500, err.Error())
return
}
httpx.JSON(w, 200, p)
}
func (a *App) policyAll(w http.ResponseWriter, r *http.Request) {
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
}
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 {
httpx.Error(w, 400, "invalid cleanup timing")
return
}
p.Revision = randomToken(12)
p.UpdatedAt = time.Now().UTC()
a.store.mu.Lock()
defer a.store.mu.Unlock()
for id, rec := range a.store.data.Agents {
cp := p
rec.DesiredPolicy = &cp
a.store.data.Agents[id] = rec
}
if err := a.store.saveLocked(); err != nil {
httpx.Error(w, 500, err.Error())
return
}
httpx.JSON(w, 200, map[string]any{"updated_agents": len(a.store.data.Agents), "policy": p})
}
func (a *App) masterPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, masterHTML)
}
func randomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func hashToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func constantEqual(x, y string) bool {
if len(x) != len(y) {
return false
}
return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1
}
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)
})
}

57
internal/master/store.go Normal file
View File

@@ -0,0 +1,57 @@
package master
import (
"encoding/json"
"errors"
"os"
"sync"
"github.com/example/sessionguard/internal/config"
"github.com/example/sessionguard/internal/model"
)
type data struct {
Agents map[string]model.AgentRecord `json:"agents"`
}
type store struct {
path string
mu sync.RWMutex
data data
}
func newStore(path string) (*store, error) {
s := &store{path: path, data: data{Agents: map[string]model.AgentRecord{}}}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
return nil, err
}
if s.data.Agents == nil {
s.data.Agents = map[string]model.AgentRecord{}
}
return s, nil
}
func (s *store) saveLocked() error { return config.SaveJSON(s.path, s.data) }
func (s *store) all() []model.AgentRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.AgentRecord, 0, len(s.data.Agents))
for _, a := range s.data.Agents {
a.TokenHash = ""
out = append(out, a)
}
return out
}
func (s *store) get(id string) (model.AgentRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
a, ok := s.data.Agents[id]
a.TokenHash = ""
return a, ok
}

26
internal/master/ui.go Normal file
View File

@@ -0,0 +1,26 @@
package master
const masterHTML = `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1500px;margin:auto;padding:28px}.top{display:flex;align-items:center;justify-content:space-between;margin-bottom:22px}.brand{display:flex;gap:12px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:800}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.card,.panel{background:rgba(18,27,49,.85);border:1px solid #263654;border-radius:16px;box-shadow:0 14px 35px rgba(0,0,0,.18)}.card{padding:18px}.value{font-size:28px;font-weight:750;margin-top:7px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:16px 18px;border-bottom:1px solid #263654}.split{display:grid;grid-template-columns:1.05fr 1.45fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:12px 14px;border-bottom:1px solid #22314e;text-align:left;font-size:13px}.table th{color:#94a5c4;font-weight:600}.row{cursor:pointer}.row:hover{background:#182642}.status{display:inline-flex;gap:6px;align-items:center}.dot{width:8px;height:8px;border-radius:50%;background:#5ee08b}.off{background:#65728a}.pill{padding:3px 8px;border-radius:999px;background:#1b2a4a;color:#bfd0ee;font-size:12px}button{background:#5b8cff;color:white;border:0;border-radius:10px;padding:9px 13px;font-weight:650;cursor:pointer}button.secondary{background:#22314e}input,textarea{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px 10px}textarea{min-height:128px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.form{padding:16px;display:grid;gap:12px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.check{display:flex;align-items:center;gap:8px}.check input{width:auto}.sessions{max-height:270px;overflow:auto}.empty{padding:24px;color:#8190aa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none}@media(max-width:950px){.grid{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}.cols{grid-template-columns:1fr}} </style></head>
<body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div>
<div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktive Sitzungen</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Cleanup geplant</div><div class="value" id="mCleanup"></div></div></div>
<div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div></div><div class="toast" id="toast"></div><script src="/app.js"></script></body></html>`
const masterJS = `
let selected=null, current=null;
const $=id=>document.getElementById(id);
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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 age(sec){if(!sec)return '';let d=Math.floor(sec/86400),h=Math.floor(sec%86400/3600),m=Math.floor(sec%3600/60);return d+'d '+h+'h '+m+'m'}
function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',2500)}
async function api(url,opt){let r=await fetch(url,opt);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}
async function refresh(){try{let d=await api('/api/v1/dashboard');let a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Sitzungen</th><th>Build</th></tr></thead><tbody>'+a.map(x=>'<tr class="row" data-id="'+esc(x.id)+'"><td><span class="status"><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.server.os||'')+'</span></td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+esc(x.snapshot.agent_version||'')+'</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id));if(selected)selectAgent(selected,true)}catch(e){toast(e.message)}}
async function selectAgent(id,quiet){selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current)}catch(e){if(!quiet)toast(e.message)}}
function renderDetail(a){let s=a.snapshot.server||{},sessions=a.snapshot.sessions||[],p=a.desired_policy||defaultPolicy(a.snapshot.policy_revision);$('detailTitle').textContent=a.name||a.id;$('detail').className='';$('detail').innerHTML='<div class="grid" style="grid-template-columns:repeat(3,1fr);padding:16px"><div class="card"><div class="muted">Uptime</div><div class="value" style="font-size:20px">'+age(s.uptime_seconds)+'</div></div><div class="card"><div class="muted">RAM frei</div><div class="value" style="font-size:20px">'+bytes(s.memory_available)+' / '+bytes(s.memory_total)+'</div></div><div class="card"><div class="muted">Policy</div><div class="value" style="font-size:15px">'+esc(a.snapshot.policy_revision||'')+'</div></div></div><div class="sessions"><table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Client</th></tr></thead><tbody>'+sessions.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td><span class="pill">'+esc(x.state)+'</span></td><td>'+esc(x.client_name||'')+'</td></tr>').join('')+'</tbody></table></div>'+policyForm(p)}
function defaultPolicy(rev){return {revision:rev||'',cleanup:{enabled:true,grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,exclude_users:['Administrator','DefaultAccount','WDAGUtilityAccount'],exclude_sids:['S-1-5-18','S-1-5-19','S-1-5-20'],allowed_profile_roots:['C:\\Users']},templates:[]}}
function policyForm(p){let c=p.cleanup||{};return '<div class="form"><strong>Policy bearbeiten</strong><div class="cols"><label>Grace Period (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label for="enabled">Profil-Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label for="dry">Dry-Run (empfohlen zum Testen)</label></div><label>Ausgeschlossene Benutzer (eine Zeile je Eintrag)<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Ausgeschlossene SIDs / SID-Präfixe<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label><label>Templates (JSON Array)<textarea id="templates" style="min-height:230px">'+esc(JSON.stringify(p.templates||[],null,2))+'</textarea></label><div style="display:flex;gap:8px"><button onclick="savePolicy(false)">Für diesen Server speichern</button><button class="secondary" onclick="savePolicy(true)">Auf alle Server anwenden</button></div></div>'}
function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}
async function savePolicy(all){if(!selected)return;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(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)}catch(e){toast(e.message)}}
refresh();setInterval(refresh,5000);`

119
internal/model/types.go Normal file
View File

@@ -0,0 +1,119 @@
package model
import "time"
const ProtocolVersion = 1
type OIDCConfig struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
AdminGroups []string `json:"admin_groups,omitempty"`
SecureCookie bool `json:"secure_cookie"`
}
type CleanupPolicy struct {
Enabled bool `json:"enabled"`
GraceSeconds int `json:"grace_seconds"`
PollSeconds int `json:"poll_seconds"`
RetrySeconds int `json:"retry_seconds"`
DryRun bool `json:"dry_run"`
ExcludeUsers []string `json:"exclude_users,omitempty"`
ExcludeSIDs []string `json:"exclude_sids,omitempty"`
AllowedProfileRoots []string `json:"allowed_profile_roots,omitempty"`
}
type ShortcutSpec struct {
Target string `json:"target"`
Arguments string `json:"arguments,omitempty"`
WorkingDirectory string `json:"working_directory,omitempty"`
IconLocation string `json:"icon_location,omitempty"`
Description string `json:"description,omitempty"`
}
type TemplateItem struct {
ID string `json:"id"`
Kind string `json:"kind"` // file, directory, url, shortcut
Target string `json:"target"`
Source string `json:"source,omitempty"`
Content string `json:"content,omitempty"`
ContentBase64 string `json:"content_base64,omitempty"`
URL string `json:"url,omitempty"`
Shortcut *ShortcutSpec `json:"shortcut,omitempty"`
Overwrite bool `json:"overwrite"`
}
type Policy struct {
Revision string `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
Cleanup CleanupPolicy `json:"cleanup"`
Templates []TemplateItem `json:"templates,omitempty"`
}
type Session struct {
ID uint32 `json:"id"`
State string `json:"state"`
User string `json:"user,omitempty"`
Domain string `json:"domain,omitempty"`
SID string `json:"sid,omitempty"`
ClientName string `json:"client_name,omitempty"`
StationName string `json:"station_name,omitempty"`
}
type ServerInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
UptimeSeconds uint64 `json:"uptime_seconds"`
MemoryTotal uint64 `json:"memory_total"`
MemoryAvailable uint64 `json:"memory_available"`
}
type CleanupJob struct {
SID string `json:"sid"`
User string `json:"user"`
ProfilePath string `json:"profile_path"`
DueAt time.Time `json:"due_at"`
Attempts int `json:"attempts"`
LastError string `json:"last_error,omitempty"`
}
type AgentSnapshot struct {
ProtocolVersion int `json:"protocol_version"`
AgentID string `json:"agent_id"`
Server ServerInfo `json:"server"`
Sessions []Session `json:"sessions"`
PendingCleanup []CleanupJob `json:"pending_cleanup,omitempty"`
PolicyRevision string `json:"policy_revision"`
AgentVersion string `json:"agent_version"`
Time time.Time `json:"time"`
}
type AgentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
TokenHash string `json:"token_hash"`
EnrolledAt time.Time `json:"enrolled_at"`
LastSeen time.Time `json:"last_seen"`
Snapshot AgentSnapshot `json:"snapshot"`
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
}
type EnrollRequest struct {
EnrollmentToken string `json:"enrollment_token"`
Name string `json:"name"`
MachineID string `json:"machine_id"`
}
type EnrollResponse struct {
AgentID string `json:"agent_id"`
Token string `json:"token"`
}
type HeartbeatResponse struct {
DesiredPolicy *Policy `json:"desired_policy,omitempty"`
ServerTime time.Time `json:"server_time"`
}

167
internal/templates/apply.go Normal file
View File

@@ -0,0 +1,167 @@
package templates
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
"github.com/example/sessionguard/internal/model"
)
func Apply(profile string, item model.TemplateItem) (changed bool, err error) {
if item.ID == "" {
return false, errors.New("template item id is required")
}
target, err := safeTarget(profile, item.Target)
if err != nil {
return false, err
}
switch strings.ToLower(item.Kind) {
case "directory":
if st, err := os.Stat(target); err == nil && st.IsDir() {
return false, nil
}
return true, os.MkdirAll(target, 0o755)
case "file":
data, err := sourceData(item)
if err != nil {
return false, err
}
return ensureFile(target, data, item.Overwrite)
case "url":
if item.URL == "" {
return false, errors.New("url template requires url")
}
data := []byte("[InternetShortcut]\r\nURL=" + item.URL + "\r\n")
return ensureFile(target, data, item.Overwrite)
case "shortcut":
if item.Shortcut == nil || item.Shortcut.Target == "" {
return false, errors.New("shortcut template requires shortcut.target")
}
if runtime.GOOS != "windows" {
return false, errors.New("shortcut generation is Windows-only")
}
if _, err := os.Stat(target); err == nil && !item.Overwrite {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return false, err
}
return ensureShortcut(target, *item.Shortcut)
default:
return false, fmt.Errorf("unknown template kind %q", item.Kind)
}
}
func safeTarget(profile, rel string) (string, error) {
if rel == "" || filepath.IsAbs(rel) {
return "", errors.New("template target must be relative to the user profile")
}
root, err := filepath.Abs(profile)
if err != nil {
return "", err
}
t, err := filepath.Abs(filepath.Join(root, rel))
if err != nil {
return "", err
}
rp := strings.ToLower(filepath.Clean(root)) + string(os.PathSeparator)
tp := strings.ToLower(filepath.Clean(t))
if tp != strings.TrimSuffix(rp, string(os.PathSeparator)) && !strings.HasPrefix(tp, rp) {
return "", errors.New("template target escapes profile root")
}
return t, nil
}
func sourceData(item model.TemplateItem) ([]byte, error) {
if item.Source != "" {
return os.ReadFile(item.Source)
}
if item.ContentBase64 != "" {
return base64.StdEncoding.DecodeString(item.ContentBase64)
}
return []byte(item.Content), nil
}
func ensureFile(path string, data []byte, overwrite bool) (bool, error) {
if old, err := os.ReadFile(path); err == nil {
if hash(old) == hash(data) {
return false, nil
}
if !overwrite {
return false, nil
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return false, err
}
tmp := path + ".sessionguard.tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return false, err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return false, err
}
return true, nil
}
func hash(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
func ensureShortcut(path string, s model.ShortcutSpec) (bool, error) {
script := "$w=New-Object -ComObject WScript.Shell;" +
"$p=" + psQuote(path) + ";" +
"if(Test-Path -LiteralPath $p){$x=$w.CreateShortcut($p);" +
"if(($x.TargetPath -eq " + psQuote(s.Target) + ") -and ($x.Arguments -eq " + psQuote(s.Arguments) + ") -and ($x.WorkingDirectory -eq " + psQuote(s.WorkingDirectory) + ") -and ($x.IconLocation -eq " + psQuote(s.IconLocation) + ") -and ($x.Description -eq " + psQuote(s.Description) + ")){Write-Output 'UNCHANGED';exit 0}};" +
"$l=$w.CreateShortcut($p);" +
"$l.TargetPath=" + psQuote(s.Target) + ";" +
"$l.Arguments=" + psQuote(s.Arguments) + ";" +
"$l.WorkingDirectory=" + psQuote(s.WorkingDirectory) + ";" +
"$l.IconLocation=" + psQuote(s.IconLocation) + ";" +
"$l.Description=" + psQuote(s.Description) + ";$l.Save();Write-Output 'CHANGED'"
u16 := utf16.Encode([]rune(script))
bytes := make([]byte, len(u16)*2)
for i, v := range u16 {
bytes[i*2] = byte(v)
bytes[i*2+1] = byte(v >> 8)
}
enc := base64.StdEncoding.EncodeToString(bytes)
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc)
out, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("ensure shortcut: %w: %s", err, strings.TrimSpace(string(out)))
}
return strings.Contains(string(out), "CHANGED"), nil
}
func CopyFile(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
_, cpErr := io.Copy(out, in)
closeErr := out.Close()
if cpErr != nil {
return cpErr
}
return closeErr
}

View File

@@ -0,0 +1,33 @@
package templates
import (
"os"
"path/filepath"
"testing"
"github.com/example/sessionguard/internal/model"
)
func TestFileTemplateIsIdempotent(t *testing.T) {
root := t.TempDir()
item := model.TemplateItem{ID: "x", Kind: "file", Target: filepath.Join("Desktop", "x.txt"), Content: "hello", Overwrite: true}
changed, err := Apply(root, item)
if err != nil || !changed {
t.Fatalf("first apply: changed=%v err=%v", changed, err)
}
changed, err = Apply(root, item)
if err != nil || changed {
t.Fatalf("second apply: changed=%v err=%v", changed, err)
}
b, _ := os.ReadFile(filepath.Join(root, "Desktop", "x.txt"))
if string(b) != "hello" {
t.Fatalf("unexpected content %q", b)
}
}
func TestTargetCannotEscapeProfile(t *testing.T) {
_, err := Apply(t.TempDir(), model.TemplateItem{ID: "x", Kind: "file", Target: filepath.Join("..", "escape.txt"), Content: "x", Overwrite: true})
if err == nil {
t.Fatal("expected traversal rejection")
}
}

View File

@@ -0,0 +1,16 @@
//go:build !windows
package windowsx
import (
"errors"
"github.com/example/sessionguard/internal/model"
)
var ErrUnsupported = errors.New("Windows functionality is only available on Windows")
func Sessions() ([]model.Session, error) { return nil, ErrUnsupported }
func Server() (model.ServerInfo, error) { return model.ServerInfo{}, ErrUnsupported }
func ProfilePath(string) (string, error) { return "", ErrUnsupported }
func DeleteProfile(string) error { return ErrUnsupported }
func MachineID() (string, error) { return "nonwindows", nil }

View File

@@ -0,0 +1,191 @@
//go:build windows
package windowsx
import (
"fmt"
"os"
"strings"
"syscall"
"unsafe"
"github.com/example/sessionguard/internal/model"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
var (
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSQueryUserToken = wtsapi32.NewProc("WTSQueryUserToken")
userenv = windows.NewLazySystemDLL("userenv.dll")
procDeleteProfileW = userenv.NewProc("DeleteProfileW")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetTickCount64 = kernel32.NewProc("GetTickCount64")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procExpandEnvironmentStringsW = kernel32.NewProc("ExpandEnvironmentStringsW")
)
type wtsSessionInfo struct {
SessionID uint32
WinStationName *uint16
State uint32
}
const (
wtsUserName = 5
wtsWinStationName = 6
wtsDomainName = 7
wtsClientName = 10
)
var stateNames = map[uint32]string{
0: "Active", 1: "Connected", 2: "ConnectQuery", 3: "Shadow", 4: "Disconnected",
5: "Idle", 6: "Listen", 7: "Reset", 8: "Down", 9: "Init",
}
func Sessions() ([]model.Session, error) {
var buf uintptr
var count uint32
r1, _, e := procWTSEnumerateSessionsW.Call(0, 0, 1, uintptr(unsafe.Pointer(&buf)), uintptr(unsafe.Pointer(&count)))
if r1 == 0 {
return nil, fmt.Errorf("WTSEnumerateSessionsW: %w", e)
}
defer procWTSFreeMemory.Call(buf)
rows := unsafe.Slice((*wtsSessionInfo)(unsafe.Pointer(buf)), int(count))
out := make([]model.Session, 0, len(rows))
for _, row := range rows {
s := model.Session{ID: row.SessionID, State: stateNames[row.State]}
if s.State == "" {
s.State = fmt.Sprintf("State%d", row.State)
}
if row.WinStationName != nil {
s.StationName = windows.UTF16PtrToString(row.WinStationName)
}
s.User, _ = queryString(row.SessionID, wtsUserName)
s.Domain, _ = queryString(row.SessionID, wtsDomainName)
s.ClientName, _ = queryString(row.SessionID, wtsClientName)
if s.StationName == "" {
s.StationName, _ = queryString(row.SessionID, wtsWinStationName)
}
if s.User != "" {
var token windows.Token
r, _, _ := procWTSQueryUserToken.Call(uintptr(row.SessionID), uintptr(unsafe.Pointer(&token)))
if r != 0 {
if tu, err := token.GetTokenUser(); err == nil && tu.User.Sid != nil {
s.SID = tu.User.Sid.String()
}
_ = token.Close()
}
}
out = append(out, s)
}
return out, nil
}
func queryString(sessionID uint32, class uintptr) (string, error) {
var p uintptr
var bytes uint32
r1, _, e := procWTSQuerySessionInformationW.Call(0, uintptr(sessionID), class, uintptr(unsafe.Pointer(&p)), uintptr(unsafe.Pointer(&bytes)))
if r1 == 0 {
return "", e
}
defer procWTSFreeMemory.Call(p)
if p == 0 || bytes < 2 {
return "", nil
}
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(p))), nil
}
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func Server() (model.ServerInfo, error) {
host, _ := os.Hostname()
m := memoryStatusEx{Length: uint32(unsafe.Sizeof(memoryStatusEx{}))}
r, _, e := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
if r == 0 {
return model.ServerInfo{}, fmt.Errorf("GlobalMemoryStatusEx: %w", e)
}
ticks, _, _ := procGetTickCount64.Call()
info := model.ServerInfo{Hostname: host, OS: "Windows", UptimeSeconds: uint64(ticks) / 1000, MemoryTotal: m.TotalPhys, MemoryAvailable: m.AvailPhys}
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE); err == nil {
defer k.Close()
if v, _, err := k.GetStringValue("ProductName"); err == nil {
info.OS = v
}
if v, _, err := k.GetStringValue("DisplayVersion"); err == nil {
info.Version = v
}
if v, _, err := k.GetStringValue("CurrentBuildNumber"); err == nil {
info.Build = v
}
}
return info, nil
}
func ProfilePath(sid string) (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
if err != nil {
return "", err
}
defer k.Close()
p, _, err := k.GetStringValue("ProfileImagePath")
if err != nil {
return "", err
}
return expandEnv(p), nil
}
func expandEnv(s string) string {
in, err := windows.UTF16PtrFromString(s)
if err != nil {
return s
}
n, _, _ := procExpandEnvironmentStringsW.Call(uintptr(unsafe.Pointer(in)), 0, 0)
if n == 0 {
return s
}
buf := make([]uint16, n)
n2, _, _ := procExpandEnvironmentStringsW.Call(uintptr(unsafe.Pointer(in)), uintptr(unsafe.Pointer(&buf[0])), uintptr(n))
if n2 == 0 || n2 > n {
return s
}
return windows.UTF16ToString(buf)
}
func DeleteProfile(sid string) error {
p, err := windows.UTF16PtrFromString(sid)
if err != nil {
return err
}
r, _, e := procDeleteProfileW.Call(uintptr(unsafe.Pointer(p)), 0, 0)
if r == 0 {
if e == syscall.Errno(0) {
return fmt.Errorf("DeleteProfileW failed")
}
return fmt.Errorf("DeleteProfileW(%s): %w", sid, e)
}
return nil
}
func MachineID() (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE)
if err != nil {
return "", err
}
defer k.Close()
v, _, err := k.GetStringValue("MachineGuid")
return strings.TrimSpace(v), err
}