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 Agent
SessionGuard Agent
Lokaler Terminalserver
Aktiv
Sitzungen
Cleanup geplant
Master

Sitzungen

Lokale Policy

` + +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=''+ss.map(x=>'').join('')+'
IDBenutzerStatusClient
'+x.id+''+esc((x.domain?x.domain+'\\':'')+x.user)+''+esc(x.state)+''+esc(x.client_name||'–')+'
'}catch(e){$('master').innerHTML=''+esc(e.message)+''}} +async function loadPolicy(){try{let p=await api('/api/v1/policy'),c=p.cleanup||{};$('policy').innerHTML='
Cleanup aktiv
Dry-Run
Wenn der Master für diesen Agent eine gewünschte Policy gesetzt hat, ist diese nach Wiederherstellung der Verbindung wieder maßgeblich.
'}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 +
SessionGuard
Master Console
+
Server
Online
Aktive Sitzungen
Cleanup geplant
+

Terminalserver

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=''+a.map(x=>'').join('')+'
StatusServerSitzungenBuild
'+(x.online?'Online':'Offline')+''+esc(x.name)+'
'+esc(x.snapshot.server.os||'')+'
'+x.active_sessions+' aktiv / '+x.total_sessions+''+esc(x.snapshot.agent_version||'–')+'
';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||'–')+'
'+sessions.map(x=>'').join('')+'
IDBenutzerStatusClient
'+x.id+''+esc((x.domain?x.domain+'\\':'')+x.user)+''+esc(x.state)+''+esc(x.client_name||'–')+'
'+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 '
Policy bearbeiten
'} +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.'