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

View File

@@ -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 }}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/bin/
*.exe
*.log
state.json
master.json
configs/agent.json
deploy/master.json

15
Dockerfile Normal file
View File

@@ -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"]

119
README.md
View File

@@ -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
```

17
cmd/agent/main.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

30
cmd/master/main.go Normal file
View File

@@ -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)
}
}

View File

@@ -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
}
]
}
}

View File

@@ -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
}
}

14
deploy/docker-compose.yml Normal file
View File

@@ -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:

51
docs/ARCHITECTURE.md Normal file
View File

@@ -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.

14
go.mod Normal file
View File

@@ -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
)

20
go.sum Normal file
View File

@@ -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=

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
}

10
scripts/build.ps1 Normal file
View File

@@ -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'

14
scripts/install-agent.ps1 Normal file
View File

@@ -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.'