This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/bin/
|
||||
*.exe
|
||||
*.log
|
||||
state.json
|
||||
master.json
|
||||
configs/agent.json
|
||||
deploy/master.json
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
119
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
|
||||
```
|
||||
|
||||
17
cmd/agent/main.go
Normal file
17
cmd/agent/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
cmd/agent/platform_other.go
Normal file
31
cmd/agent/platform_other.go
Normal 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)
|
||||
}
|
||||
172
cmd/agent/platform_windows.go
Normal file
172
cmd/agent/platform_windows.go
Normal 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
30
cmd/master/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
61
configs/agent.example.json
Normal file
61
configs/agent.example.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
15
configs/master.example.json
Normal file
15
configs/master.example.json
Normal 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
14
deploy/docker-compose.yml
Normal 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
51
docs/ARCHITECTURE.md
Normal 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
14
go.mod
Normal 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
20
go.sum
Normal 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
397
internal/agent/agent.go
Normal 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
88
internal/agent/client.go
Normal 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
57
internal/agent/state.go
Normal 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
10
internal/agent/ui.go
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
207
internal/auth/oidc.go
Normal 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
15
internal/auth/routes.go
Normal 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
120
internal/config/config.go
Normal 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
|
||||
}
|
||||
24
internal/config/config_test.go
Normal file
24
internal/config/config_test.go
Normal 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
33
internal/httpx/httpx.go
Normal 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
271
internal/master/master.go
Normal 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
57
internal/master/store.go
Normal 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
26
internal/master/ui.go
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
119
internal/model/types.go
Normal 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
167
internal/templates/apply.go
Normal 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
|
||||
}
|
||||
33
internal/templates/apply_test.go
Normal file
33
internal/templates/apply_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
16
internal/windowsx/windows_stub.go
Normal file
16
internal/windowsx/windows_stub.go
Normal 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 }
|
||||
191
internal/windowsx/windows_windows.go
Normal file
191
internal/windowsx/windows_windows.go
Normal 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
10
scripts/build.ps1
Normal 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
14
scripts/install-agent.ps1
Normal 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.'
|
||||
Reference in New Issue
Block a user