diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml
new file mode 100644
index 0000000..cfe785d
--- /dev/null
+++ b/.gitea/workflows/registry.yml
@@ -0,0 +1,51 @@
+name: release-tag
+on:
+ push:
+ branches:
+ - 'main'
+jobs:
+ release-image:
+ runs-on: ubuntu-latest
+ env:
+ DOCKER_ORG: sendnrw
+ DOCKER_LATEST: latest
+ RUNNER_TOOL_CACHE: /toolcache
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ - name: Set up Docker BuildX
+ uses: docker/setup-buildx-action@v2
+ with: # replace it with your local IP
+ config-inline: |
+ [registry."git.send.nrw"]
+ http = true
+ insecure = true
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ registry: git.send.nrw # replace it with your local IP
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Get Meta
+ id: meta
+ run: |
+ echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
+ echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
+
+ - name: Build and push
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: |
+ linux/amd64
+ push: true
+ tags: | # replace it with your local IP and tags
+ git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
+ git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7097ee0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/bin/
+*.exe
+*.log
+state.json
+master.json
+configs/agent.json
+deploy/master.json
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f6e6bec
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM golang:1.26-bookworm AS build
+WORKDIR /src
+COPY go.mod ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/sessionguard-master ./cmd/master
+
+FROM alpine:3.24
+RUN apk add --no-cache ca-certificates && addgroup -S sessionguard && adduser -S -G sessionguard sessionguard && mkdir -p /var/lib/sessionguard /etc/sessionguard && chown -R sessionguard:sessionguard /var/lib/sessionguard /etc/sessionguard
+COPY --from=build /out/sessionguard-master /usr/local/bin/sessionguard-master
+USER sessionguard
+VOLUME ["/var/lib/sessionguard"]
+EXPOSE 8080
+ENTRYPOINT ["/usr/local/bin/sessionguard-master"]
+CMD ["-config", "/etc/sessionguard/master.json"]
diff --git a/README.md b/README.md
index 10adf8b..217aeb6 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,119 @@
-# sessiongurad
+# SessionGuard
+SessionGuard is a Go-based management layer for Windows Remote Desktop Session Hosts. It is intended for environments that use Guacamole or another access gateway and want a small subset of the operational features commonly provided by Citrix management/profile components.
+
+## Implemented MVP
+
+- Windows service agent
+- RDS/WTS session inventory
+- delayed profile deletion after a real session disappears
+- safety exclusions, allowed profile roots, retries, and dry-run mode
+- per-user template enforcement for files, directories, `.url` links and `.lnk` shortcuts
+- server basics: hostname, Windows version/build, uptime, RAM
+- local agent dashboard
+- Linux/Docker master dashboard for all agents
+- outbound agent-to-master heartbeats
+- bootstrap enrollment followed by per-agent bearer credentials
+- per-agent policies and "apply to all agents"
+- Pocket ID / generic OIDC authentication for master and local agent UI
+- last-known policy continues to work if the master is unavailable
+
+## Important safety note
+
+Profile deletion is destructive. Start with `dry_run: true`, verify exclusions and `allowed_profile_roots`, test on a non-production RDS host, and only then disable dry-run. SessionGuard calls the Windows user-profile deletion API; it does not recursively delete arbitrary profile paths itself.
+
+## Build
+
+Requirements: Go 1.23+ and Internet access for the Go modules on the first build.
+
+```powershell
+.\scripts\build.ps1
+```
+
+Or:
+
+```bash
+GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o sessionguard-agent.exe ./cmd/agent
+go build -o sessionguard-master ./cmd/master
+```
+
+## Master deployment
+
+1. Copy `configs/master.example.json` to `deploy/master.json` and edit it.
+2. In Pocket ID create an OIDC client whose callback URL is `https://sessionguard.example.org/oidc/callback`.
+3. Restrict the Pocket ID client to the intended admin group and configure the same group in `admin_groups`.
+4. Put a TLS reverse proxy in front of the master.
+5. Start with `docker compose -f deploy/docker-compose.yml up -d --build`.
+
+The container binds the example host port only to `127.0.0.1`; publish it through your reverse proxy rather than exposing plain HTTP.
+
+## Agent deployment
+
+1. Copy `configs/agent.example.json` to `configs/agent.json` and edit the master URL, enrollment token and OIDC settings.
+2. Create a Pocket ID OIDC client for the agent's management URL, for example `https://ts01-mgmt.example.org/oidc/callback`.
+3. Build the Windows agent.
+4. Run `scripts/install-agent.ps1` from an elevated PowerShell prompt.
+5. Keep `dry_run: true` until profile cleanup has been observed successfully.
+
+The service is installed as LocalSystem by default. If a template `source` points to a UNC share, grant read access to the server computer account (`DOMAIN\SERVER$`) or change the service identity to an appropriate gMSA/service account. Do not put share passwords in the SessionGuard JSON configuration.
+
+## Example templates
+
+```json
+[
+ {
+ "id": "support",
+ "kind": "url",
+ "target": "Desktop\\Support.url",
+ "url": "https://support.example.org",
+ "overwrite": true
+ },
+ {
+ "id": "erp",
+ "kind": "shortcut",
+ "target": "Desktop\\ERP.lnk",
+ "overwrite": true,
+ "shortcut": {
+ "target": "C:\\Program Files\\ERP\\erp.exe",
+ "arguments": "--terminal"
+ }
+ },
+ {
+ "id": "defaults",
+ "kind": "file",
+ "target": "AppData\\Roaming\\Example\\defaults.json",
+ "source": "\\\\fileserver\\templates\\defaults.json",
+ "overwrite": true
+ }
+]
+```
+
+All `target` values are relative to the user's profile. Attempts to escape the profile root are rejected.
+
+## Local management during a master outage
+
+The Windows agent continues cleanup and template work using its persisted policy. Its local web UI remains available independently of the master, provided Pocket ID is reachable. A locally saved emergency policy remains in effect until the master reconnects; if the master already has a different desired policy for that agent, the master policy is then reapplied.
+
+## Pocket ID notes
+
+SessionGuard requests the `openid`, `profile`, `email` and `groups` scopes. Use `admin_groups` as a second authorization check in addition to Pocket ID's client-side allowed-group restriction. The master and agent should be served over HTTPS and `secure_cookie` should remain enabled.
+
+## What is intentionally not implemented yet
+
+Full Citrix-style profile roaming/restoration is not part of v0.1. Copying an entire profile, especially `NTUSER.DAT` and registry-backed settings, after Windows has loaded that profile is unsafe. See `docs/ARCHITECTURE.md` for the recommended extension path.
+
+## Repository layout
+
+```text
+cmd/master Linux/Docker master binary
+cmd/agent Windows service binary
+internal/agent agent lifecycle, cleanup, heartbeat, local UI
+internal/master enrollment, dashboard, policy distribution
+internal/windowsx WTS, profile and Windows server APIs
+internal/templates template comparison/application
+internal/auth Pocket ID / OIDC login
+configs example JSON configurations
+deploy Docker Compose example
+scripts build/install helpers
+docs architecture notes
+```
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
new file mode 100644
index 0000000..84a7601
--- /dev/null
+++ b/cmd/agent/main.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+)
+
+func main() {
+ cfg := flag.String("config", defaultConfigPath(), "agent config path")
+ action := flag.String("service", "", "service action: install|uninstall|start|stop|run")
+ flag.Parse()
+ if err := platformMain(*action, *cfg); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/agent/platform_other.go b/cmd/agent/platform_other.go
new file mode 100644
index 0000000..4f8f7bf
--- /dev/null
+++ b/cmd/agent/platform_other.go
@@ -0,0 +1,31 @@
+//go:build !windows
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "os/signal"
+ "syscall"
+
+ "github.com/example/sessionguard/internal/agent"
+ "github.com/example/sessionguard/internal/config"
+)
+
+func defaultConfigPath() string { return "./agent.json" }
+func platformMain(action, path string) error {
+ if action != "" && action != "run" {
+ return fmt.Errorf("Windows service actions are only available on Windows")
+ }
+ cfg, err := config.LoadAgent(path)
+ if err != nil {
+ return err
+ }
+ app, err := agent.New(cfg)
+ if err != nil {
+ return err
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ return app.Run(ctx)
+}
diff --git a/cmd/agent/platform_windows.go b/cmd/agent/platform_windows.go
new file mode 100644
index 0000000..7e28cb6
--- /dev/null
+++ b/cmd/agent/platform_windows.go
@@ -0,0 +1,172 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "github.com/example/sessionguard/internal/agent"
+ "github.com/example/sessionguard/internal/config"
+ "golang.org/x/sys/windows/svc"
+ "golang.org/x/sys/windows/svc/mgr"
+)
+
+const serviceName = "SessionGuardAgent"
+
+func defaultConfigPath() string { return `C:\ProgramData\SessionGuard\agent.json` }
+
+func platformMain(action, path string) error {
+ switch action {
+ case "install":
+ return installService(path)
+ case "uninstall":
+ return uninstallService()
+ case "start":
+ return startService()
+ case "stop":
+ return stopService()
+ case "run":
+ return runAsService(path)
+ case "":
+ isSvc, err := svc.IsWindowsService()
+ if err == nil && isSvc {
+ return runAsService(path)
+ }
+ return runConsole(path)
+ default:
+ return fmt.Errorf("unknown service action %q", action)
+ }
+}
+
+func setupLog(dataDir string) {
+ _ = os.MkdirAll(dataDir, 0o700)
+ f, err := os.OpenFile(filepath.Join(dataDir, "agent.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err == nil {
+ log.SetOutput(f)
+ }
+ log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
+}
+func loadApp(path string) (*agent.App, error) {
+ cfg, err := config.LoadAgent(path)
+ if err != nil {
+ return nil, err
+ }
+ setupLog(cfg.DataDir)
+ return agent.New(cfg)
+}
+func runConsole(path string) error {
+ app, err := loadApp(path)
+ if err != nil {
+ return err
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ return app.Run(ctx)
+}
+
+type serviceHandler struct{ path string }
+
+func (h *serviceHandler) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
+ const accepts = svc.AcceptStop | svc.AcceptShutdown
+ status <- svc.Status{State: svc.StartPending}
+ app, err := loadApp(h.path)
+ if err != nil {
+ return false, 1
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- app.Run(ctx) }()
+ status <- svc.Status{State: svc.Running, Accepts: accepts}
+ for {
+ select {
+ case c := <-requests:
+ switch c.Cmd {
+ case svc.Interrogate:
+ status <- c.CurrentStatus
+ case svc.Stop, svc.Shutdown:
+ status <- svc.Status{State: svc.StopPending}
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ }
+ return false, 0
+ }
+ case err := <-done:
+ if err != nil {
+ log.Printf("service stopped: %v", err)
+ return false, 1
+ }
+ return false, 0
+ }
+ }
+}
+func runAsService(path string) error { return svc.Run(serviceName, &serviceHandler{path: path}) }
+func installService(path string) error {
+ m, err := mgr.Connect()
+ if err != nil {
+ return err
+ }
+ defer m.Disconnect()
+ if s, err := m.OpenService(serviceName); err == nil {
+ s.Close()
+ return fmt.Errorf("service already exists")
+ }
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ s, err := m.CreateService(serviceName, exe, mgr.Config{DisplayName: "SessionGuard Agent", Description: "SessionGuard Terminal Server Agent", StartType: mgr.StartAutomatic}, "-config", path, "-service", "run")
+ if err != nil {
+ return err
+ }
+ defer s.Close()
+ return nil
+}
+func uninstallService() error {
+ m, err := mgr.Connect()
+ if err != nil {
+ return err
+ }
+ defer m.Disconnect()
+ s, err := m.OpenService(serviceName)
+ if err != nil {
+ return err
+ }
+ defer s.Close()
+ return s.Delete()
+}
+func startService() error {
+ m, err := mgr.Connect()
+ if err != nil {
+ return err
+ }
+ defer m.Disconnect()
+ s, err := m.OpenService(serviceName)
+ if err != nil {
+ return err
+ }
+ defer s.Close()
+ return s.Start()
+}
+func stopService() error {
+ m, err := mgr.Connect()
+ if err != nil {
+ return err
+ }
+ defer m.Disconnect()
+ s, err := m.OpenService(serviceName)
+ if err != nil {
+ return err
+ }
+ defer s.Close()
+ _, err = s.Control(svc.Stop)
+ return err
+}
diff --git a/cmd/master/main.go b/cmd/master/main.go
new file mode 100644
index 0000000..8793b33
--- /dev/null
+++ b/cmd/master/main.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "os/signal"
+ "syscall"
+
+ "github.com/example/sessionguard/internal/config"
+ "github.com/example/sessionguard/internal/master"
+)
+
+func main() {
+ cfgPath := flag.String("config", "/etc/sessionguard/master.json", "master config path")
+ flag.Parse()
+ cfg, err := config.LoadMaster(*cfgPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ app, err := master.New(ctx, cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := app.Run(ctx); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/configs/agent.example.json b/configs/agent.example.json
new file mode 100644
index 0000000..d3b0133
--- /dev/null
+++ b/configs/agent.example.json
@@ -0,0 +1,61 @@
+{
+ "listen": ":9091",
+ "public_url": "https://ts01-mgmt.example.org",
+ "data_dir": "C:\\ProgramData\\SessionGuard",
+ "master_url": "https://sessionguard.example.org",
+ "enrollment_token": "CHANGE-THIS-TO-A-LONG-RANDOM-SECRET",
+ "heartbeat_seconds": 10,
+ "oidc": {
+ "issuer": "https://id.example.org",
+ "client_id": "POCKET-ID-AGENT-CLIENT-ID",
+ "client_secret": "POCKET-ID-AGENT-CLIENT-SECRET",
+ "redirect_url": "https://ts01-mgmt.example.org/oidc/callback",
+ "admin_groups": ["sessionguard-admins"],
+ "secure_cookie": true
+ },
+ "policy": {
+ "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": [
+ {
+ "id": "support-url",
+ "kind": "url",
+ "target": "Desktop\\Support.url",
+ "url": "https://support.example.org",
+ "overwrite": true
+ },
+ {
+ "id": "company-shortcut",
+ "kind": "shortcut",
+ "target": "Desktop\\Fachanwendung.lnk",
+ "overwrite": true,
+ "shortcut": {
+ "target": "C:\\Program Files\\Example\\app.exe",
+ "arguments": "",
+ "description": "Fachanwendung"
+ }
+ },
+ {
+ "id": "settings-file",
+ "kind": "file",
+ "target": "AppData\\Roaming\\Example\\defaults.json",
+ "source": "\\\\fileserver\\sessionguard-templates\\defaults.json",
+ "overwrite": true
+ },
+ {
+ "id": "work-folder",
+ "kind": "directory",
+ "target": "Desktop\\Arbeit",
+ "overwrite": false
+ }
+ ]
+ }
+}
diff --git a/configs/master.example.json b/configs/master.example.json
new file mode 100644
index 0000000..a38c4a1
--- /dev/null
+++ b/configs/master.example.json
@@ -0,0 +1,15 @@
+{
+ "listen": ":8080",
+ "public_url": "https://sessionguard.example.org",
+ "data_file": "/var/lib/sessionguard/master.json",
+ "enrollment_token": "CHANGE-THIS-TO-A-LONG-RANDOM-SECRET",
+ "offline_after_seconds": 30,
+ "oidc": {
+ "issuer": "https://id.example.org",
+ "client_id": "POCKET-ID-CLIENT-ID",
+ "client_secret": "POCKET-ID-CLIENT-SECRET",
+ "redirect_url": "https://sessionguard.example.org/oidc/callback",
+ "admin_groups": ["sessionguard-admins"],
+ "secure_cookie": true
+ }
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..04363dd
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ sessionguard-master:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:8080:8080"
+ volumes:
+ - ./master.json:/etc/sessionguard/master.json:ro
+ - sessionguard-data:/var/lib/sessionguard
+
+volumes:
+ sessionguard-data:
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..d5bfd95
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,51 @@
+# SessionGuard architecture
+
+## Components
+
+- **Agent (Windows service):** watches RDS/WTS sessions, applies user-profile templates, schedules profile cleanup, exposes a local management UI, and sends outbound heartbeats to the master.
+- **Master (Linux/Docker):** receives enrollments and heartbeats, stores the latest server snapshots, provides a consolidated dashboard, and distributes per-agent or bulk policies.
+- **Pocket ID:** authenticates administrators through OIDC. The master and each independently usable agent UI have their own callback URL.
+
+## Connection model
+
+Agents initiate HTTPS calls to the master. There is no requirement for the master to open an inbound management connection to a terminal server. Enrollment uses a bootstrap secret once; the master then returns an agent-specific bearer token and stores only its SHA-256 hash.
+
+## Profile cleanup state machine
+
+1. The agent polls WTS sessions.
+2. A session that was present in the previous persisted snapshot and disappears is treated as logged off.
+3. If no other session with the same SID exists, the profile is scheduled for cleanup after `grace_seconds`.
+4. If the SID appears again before the deadline, cleanup is cancelled.
+5. Immediately before deletion, the allowed-root rule and active-session rule are checked again.
+6. Deletion uses the Windows `DeleteProfileW` API. Failures are retried.
+
+The agent persists the previous session set and pending cleanup jobs so a service restart does not normally lose a logout transition.
+
+## Template engine
+
+Targets are always relative to the resolved user profile path. Supported types:
+
+- `directory`: ensure a directory exists.
+- `file`: write inline content/base64 content or copy a source file (including a UNC path).
+- `url`: create an Internet Shortcut (`.url`).
+- `shortcut`: create or update a Windows Shell Link (`.lnk`) and compare its key properties before changing it.
+
+Templates are evaluated on a newly observed user session and again when a policy revision changes.
+
+## Policy precedence
+
+- The agent starts with its local configured/persisted policy.
+- A master policy for an agent becomes authoritative once received.
+- If the master is unavailable, the last policy remains active and can be edited locally.
+- When the master reconnects and still has a different desired policy, the master's policy wins.
+
+## Deliberate non-goal in v0.1: full roaming-profile replacement
+
+A complete restore of a Windows user profile from a share is not implemented. Restoring `NTUSER.DAT`, registry state, and profile files after the Windows profile has already been loaded is race-prone and can corrupt state. Citrix Profile Management operates much deeper in the logon/logoff lifecycle than a normal post-logon service loop.
+
+A future profile provider should therefore either:
+
+1. synchronize only explicitly selected user-data directories, or
+2. integrate with a supported pre-profile-load mechanism / profile-container technology.
+
+The current design keeps this concern separate from cleanup and template enforcement rather than pretending that copying a profile directory after logon is equivalent.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..43bd8b0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,14 @@
+module github.com/example/sessionguard
+
+go 1.23.0
+
+require (
+ github.com/coreos/go-oidc/v3 v3.14.1
+ golang.org/x/oauth2 v0.30.0
+ golang.org/x/sys v0.33.0
+)
+
+require (
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..61c996f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,20 @@
+github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
+github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
new file mode 100644
index 0000000..0594544
--- /dev/null
+++ b/internal/agent/agent.go
@@ -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)
+}
diff --git a/internal/agent/client.go b/internal/agent/client.go
new file mode 100644
index 0000000..9afaa28
--- /dev/null
+++ b/internal/agent/client.go
@@ -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[:])
+}
diff --git a/internal/agent/state.go b/internal/agent/state.go
new file mode 100644
index 0000000..05ee8be
--- /dev/null
+++ b/internal/agent/state.go
@@ -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") }
diff --git a/internal/agent/ui.go b/internal/agent/ui.go
new file mode 100644
index 0000000..96fc1ee
--- /dev/null
+++ b/internal/agent/ui.go
@@ -0,0 +1,10 @@
+package agent
+
+const agentHTML = `
SessionGuard AgentSessionGuard Agent
Lokaler Terminalserver
`
+
+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?'Offline':'Verbunden';$('sessions').innerHTML='| ID | Benutzer | Status | Client |
'+ss.map(x=>'| '+x.id+' | '+esc((x.domain?x.domain+'\\':'')+x.user)+' | '+esc(x.state)+' | '+esc(x.client_name||'–')+' |
').join('')+'
'}catch(e){$('master').innerHTML=''+esc(e.message)+''}}
+async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML=''}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);`
diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go
new file mode 100644
index 0000000..90271a1
--- /dev/null
+++ b/internal/auth/oidc.go
@@ -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)))
+ })
+}
diff --git a/internal/auth/routes.go b/internal/auth/routes.go
new file mode 100644
index 0000000..4cbb779
--- /dev/null
+++ b/internal/auth/routes.go
@@ -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)
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..2d91b50
--- /dev/null
+++ b/internal/config/config.go
@@ -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
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..ec28444
--- /dev/null
+++ b/internal/config/config_test.go
@@ -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")
+ }
+}
diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go
new file mode 100644
index 0000000..5b170a6
--- /dev/null
+++ b/internal/httpx/httpx.go
@@ -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)
+}
diff --git a/internal/master/master.go b/internal/master/master.go
new file mode 100644
index 0000000..0840a9d
--- /dev/null
+++ b/internal/master/master.go
@@ -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)
+ })
+}
diff --git a/internal/master/store.go b/internal/master/store.go
new file mode 100644
index 0000000..a740948
--- /dev/null
+++ b/internal/master/store.go
@@ -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
+}
diff --git a/internal/master/ui.go b/internal/master/ui.go
new file mode 100644
index 0000000..4bd073e
--- /dev/null
+++ b/internal/master/ui.go
@@ -0,0 +1,26 @@
+package master
+
+const masterHTML = `
+
+SessionGuard Master
+SG
SessionGuardMaster Console
+
+
Server auswählen
Links einen Agent auswählen.
`
+
+const masterJS = `
+let selected=null, current=null;
+const $=id=>document.getElementById(id);
+function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
+function bytes(n){if(!n)return '–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i1?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='| Status | Server | Sitzungen | Build |
'+a.map(x=>'| '+(x.online?'Online':'Offline')+' | '+esc(x.name)+' '+esc(x.snapshot.server.os||'')+' | '+x.active_sessions+' aktiv / '+x.total_sessions+' | '+esc(x.snapshot.agent_version||'–')+' |
').join('')+'
';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='Uptime
'+age(s.uptime_seconds)+'
RAM frei
'+bytes(s.memory_available)+' / '+bytes(s.memory_total)+'
Policy
'+esc(a.snapshot.policy_revision||'–')+'
| ID | Benutzer | Status | Client |
'+sessions.map(x=>'| '+x.id+' | '+esc((x.domain?x.domain+'\\':'')+x.user)+' | '+esc(x.state)+' | '+esc(x.client_name||'–')+' |
').join('')+'
'+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 ''}
+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);`
diff --git a/internal/model/types.go b/internal/model/types.go
new file mode 100644
index 0000000..c397835
--- /dev/null
+++ b/internal/model/types.go
@@ -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"`
+}
diff --git a/internal/templates/apply.go b/internal/templates/apply.go
new file mode 100644
index 0000000..d7675cd
--- /dev/null
+++ b/internal/templates/apply.go
@@ -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
+}
diff --git a/internal/templates/apply_test.go b/internal/templates/apply_test.go
new file mode 100644
index 0000000..43db380
--- /dev/null
+++ b/internal/templates/apply_test.go
@@ -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")
+ }
+}
diff --git a/internal/windowsx/windows_stub.go b/internal/windowsx/windows_stub.go
new file mode 100644
index 0000000..2d978fd
--- /dev/null
+++ b/internal/windowsx/windows_stub.go
@@ -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 }
diff --git a/internal/windowsx/windows_windows.go b/internal/windowsx/windows_windows.go
new file mode 100644
index 0000000..40b8d32
--- /dev/null
+++ b/internal/windowsx/windows_windows.go
@@ -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
+}
diff --git a/scripts/build.ps1 b/scripts/build.ps1
new file mode 100644
index 0000000..6766131
--- /dev/null
+++ b/scripts/build.ps1
@@ -0,0 +1,10 @@
+$ErrorActionPreference = 'Stop'
+New-Item -ItemType Directory -Force -Path .\bin | Out-Null
+$env:CGO_ENABLED='0'
+$env:GOOS='windows'
+$env:GOARCH='amd64'
+go build -trimpath -o .\bin\sessionguard-agent.exe .\cmd\agent
+$env:GOOS='linux'
+$env:GOARCH='amd64'
+go build -trimpath -o .\bin\sessionguard-master-linux-amd64 .\cmd\master
+Write-Host 'Builds written to .\bin'
diff --git a/scripts/install-agent.ps1 b/scripts/install-agent.ps1
new file mode 100644
index 0000000..6eb9afd
--- /dev/null
+++ b/scripts/install-agent.ps1
@@ -0,0 +1,14 @@
+param(
+ [string]$Binary = ".\bin\sessionguard-agent.exe",
+ [string]$Config = ".\configs\agent.json"
+)
+$ErrorActionPreference = 'Stop'
+$dest = 'C:\Program Files\SessionGuard'
+$data = 'C:\ProgramData\SessionGuard'
+New-Item -ItemType Directory -Force -Path $dest,$data | Out-Null
+Copy-Item $Binary "$dest\sessionguard-agent.exe" -Force
+Copy-Item $Config "$data\agent.json" -Force
+icacls "$data\agent.json" /inheritance:r /grant:r 'SYSTEM:(R)' 'Administrators:(F)' | Out-Null
+& "$dest\sessionguard-agent.exe" -config "$data\agent.json" -service install
+& "$dest\sessionguard-agent.exe" -service start
+Write-Host 'SessionGuard Agent installed and started.'