init
All checks were successful
release-tag / release-image (push) Successful in 1m37s

This commit is contained in:
2026-08-05 10:46:09 +02:00
parent 99fa56505a
commit d191b871de
44 changed files with 5155 additions and 1 deletions

View File

@@ -0,0 +1,51 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: sendnrw
DOCKER_LATEST: latest
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."git.send.nrw"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: git.send.nrw # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

4
CHECKSUMS-SHA256.txt Normal file
View File

@@ -0,0 +1,4 @@
779282028f0a75f3906c2c7ac0cb4d914141fc00434abda1f8860db9a1544b10 gpo-agent-windows-amd64.exe
4be4abc096141fa575fab85ec2b5e84b0b5ecf0209bfeaa816bbc943a078a184 gpo-server
614af8ded1c092425fe39d6310518c11e6324d4eaba227ad923cbbc3df8421e0 gpoctl
0865f3bc109bffc535378abdb899dc006ff4b8585538653d6ae9c6e06af413bc gpoctl-windows-amd64.exe

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
ARG GO_VERSION=1.26
ARG ALPINE_VERSION=3.24
FROM golang:${GO_VERSION}-alpine AS build
WORKDIR /src
# Separate dependency layer for reproducible and cache-friendly builds.
COPY go.mod ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/gpo-server \
./cmd/server
FROM alpine:${ALPINE_VERSION} AS runtime
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S -g 10001 gpo \
&& adduser -S -D -H -u 10001 -G gpo gpo \
&& install -d -o gpo -g gpo -m 0750 /data /data/.tmp /app
COPY --from=build --chown=gpo:gpo /out/gpo-server /app/gpo-server
ENV GPO_SERVER_LISTEN=:8443 \
GPO_SERVER_DATA=/data \
TMPDIR=/data/.tmp
USER 10001:10001
WORKDIR /app
EXPOSE 8443
VOLUME ["/data"]
# Supports both direct TLS and plain HTTP behind a TLS reverse proxy.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD if [ -n "${GPO_SERVER_TLS_CERT:-}" ]; then \
wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8443/healthz; \
else \
wget -q -O /dev/null http://127.0.0.1:8443/healthz; \
fi
ENTRYPOINT ["/app/gpo-server"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
Makefile Normal file
View File

@@ -0,0 +1,17 @@
VERSION ?= dev
LDFLAGS = -s -w -X main.version=$(VERSION)
.PHONY: test build clean
test:
go test ./...
build:
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/gpo-server ./cmd/server
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/gpoctl ./cmd/gpoctl
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/gpo-agent-windows-amd64.exe ./cmd/agent
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/gpoctl-windows-amd64.exe ./cmd/gpoctl
clean:
rm -rf bin

402
README.md
View File

@@ -1,2 +1,402 @@
# lgpo-server
# GPO Distributor
Ein kleines, dateibasiertes System zur zentralen Verteilung von Microsoft-GPO-Sicherungen an Windows-Server, die nicht Mitglied derselben Active-Directory-Domäne sind.
Das Projekt besteht aus:
- `gpo-server`: Go-Backend mit eingebetteter WebUI für Richtlinien, Versionen, Profile, Artefakte und Client-Status.
- `gpo-agent`: Windows-Agent, der ein Profil abruft, Artefakte prüft, lokale Richtlinien sichert und die GPO-Sicherungen mit `LGPO.exe` anwendet.
- `gpoctl`: Admin-CLI zum Hochladen von Sicherungen und Verwalten von Profilen.
- WebUI unter `/ui/` für die vollständige tägliche Administration im Browser.
- PowerShell-Skripten für automatisierten AD-Export/Upload und die Agent-Installation als geplante Aufgabe.
## Architektur
```text
+----------------------+ +----------------------+
| AD/GPMC / Backup-GPO | | Browser / WebUI |
+----------+-----------+ +----------+-----------+
| ZIP + Admin-Token | HTTPS
+-------------------+ +----------------+
v v
+-----+---+--------+
| gpo-server |
| Policies |
| Versionen |
| Profile |
| Clientstatus |
+--------+---------+
|
Manifest/ZIP | Statusbericht
|
v
+--------+---------+
| Windows Standalone|
| gpo-agent (SYSTEM)|
| LGPO.exe /g |
| gpupdate /force |
+-------------------+
```
Ein **Policy-Objekt** enthält mehrere unveränderliche Versionen. Ein **Profil** ist eine geordnete Liste von Policy-Objekten. Konflikte werden durch die Reihenfolge aufgelöst: Die zuletzt angewendete Richtlinie gewinnt.
## Wichtige fachliche Grenzen
Dieses System ersetzt nicht die komplette Active-Directory-Gruppenrichtlinienverarbeitung. Auf einem nicht domänengebundenen System gibt es insbesondere keine OU-Verknüpfungen, Vererbung, Security Filtering, Loopback Processing oder WMI-Filterung. Das Profil und seine Reihenfolge ersetzen lediglich die Auswahl und Reihenfolge der anzuwendenden lokalen Richtlinien.
`LGPO.exe` eignet sich offiziell für lokale Richtlinien und nicht domänengebundene Systeme. Es unterstützt unter anderem `Registry.pol`, Sicherheitsvorlagen und erweiterte Überwachungsrichtlinien. Nicht jede Gruppenrichtlinienerweiterung und nicht jedes Group Policy Preference-Element wird dadurch automatisch funktionsgleich umgesetzt.
Weitere Grenzen:
- Skriptdateien, MSI-Pakete, Zertifikate, Dateien oder andere externe Abhängigkeiten müssen separat auf dem Zielsystem vorhanden sein.
- Domänenkonten, Domänen-SIDs und Netzwerkpfade aus der Quell-GPO können auf Standalone-Systemen ungültig sein.
- Das Anwenden ist standardmäßig **merge-orientiert**. Wird eine Einstellung aus einer neuen GPO-Version entfernt, ist sie im Backup nur noch abwesend und kann lokal bestehen bleiben. Für eine saubere Deaktivierung sollte die Richtlinie die Einstellung ausdrücklich zurücksetzen oder ein separates Deconfiguration-Policy-Objekt verwendet werden.
- Eine Member-Server-Baseline kann auf Standalone-Systemen lokale Remoteanmeldung blockieren. Vor Produktionseinführung immer mit Snapshot und Out-of-Band-/Konsolenzugriff testen.
## Versionierung
Beim Upload berechnet der Server zwei Hashes:
1. `artifact_sha256`: SHA-256 über die exakte ZIP-Datei.
2. `semantic_sha256`: kanonischer Hash über die tatsächlichen Dateien unter `DomainSysvol/GPO`, sortiert nach Windows-unabhängig normalisiertem Pfad.
Dadurch führen geänderte Sicherungszeitpunkte, Backup-IDs, Kommentare oder Reportdateien nicht automatisch zu einer neuen Version. Ist der semantische Hash bereits vorhanden, antwortet der Server mit der existierenden Version und `"created": false`. Mit `gpoctl upload -force` kann für Sonderfälle trotzdem eine neue Version angelegt werden, etwa wenn nur importrelevante Backup-Metadaten geändert wurden.
## Sicherheitsmodell
- HTTPS ist für Clients zwingend; unverschlüsseltes HTTP ist nur mit einer expliziten Testoption möglich.
- Admin- und Client-Zugriffe verwenden getrennte Bearer-Tokens.
- Die WebUI tauscht das Admin-Token gegen eine acht Stunden gültige, mit dem Admin-Token signierte `HttpOnly`-Session aus; das Admin-Token wird nicht im Browser gespeichert.
- Schreibende WebUI-Aufrufe sind zusätzlich mit einem zufälligen CSRF-Token geschützt.
- Die Oberfläche setzt eine restriktive Content Security Policy, `SameSite=Strict`, `X-Frame-Options: DENY` und weitere Browser-Sicherheitsheader.
- Das Manifest wird zusätzlich mit HMAC-SHA-256 signiert.
- Der Agent prüft Größe und SHA-256 jedes ZIP-Artefakts vor dem Entpacken.
- ZIP-Pfade, Symlinks, Dateianzahl und entpackte Gesamtgröße werden begrenzt.
- Vor jeder Änderung erstellt der Agent mit `LGPO.exe /b` eine lokale Rollback-Sicherung.
- Bei einem Importfehler versucht der Agent automatisch, die vorherige lokale Richtlinie wiederherzustellen.
- Agent-Konfiguration, Tokens, Cache und Rollback-Dateien sollten nur für `SYSTEM` und lokale Administratoren lesbar sein. Das Installationsskript setzt entsprechende ACLs.
Tokens und Signaturschlüssel sollten lang und zufällig sein, zum Beispiel jeweils mindestens 32 zufällige Bytes.
## Voraussetzungen
### Backend
- Go 1.23 oder ein Container-Host.
- TLS-Zertifikat, entweder direkt im Server oder an einem Reverse Proxy.
### Windows-Client
- Windows Server 2016 oder neuer wird als Ziel angenommen.
- Lokale Administratorrechte beziehungsweise Ausführung als `SYSTEM`.
- `LGPO.exe` aus dem Microsoft Security Compliance Toolkit. Die Binärdatei wird aus Lizenz- und Aktualitätsgründen nicht in diesem Repository mitgeliefert.
Offizielle Microsoft-Quellen:
- Security Compliance Toolkit und LGPO: https://learn.microsoft.com/windows/security/operating-system-security/device-management/windows-security-configuration-framework/security-compliance-toolkit-10
- Download Center: https://www.microsoft.com/download/details.aspx?id=55319
- `Backup-GPO`: https://learn.microsoft.com/powershell/module/grouppolicy/backup-gpo
## Build
Linux/macOS:
```bash
make test
make build VERSION=0.2.1
```
Windows PowerShell:
```powershell
.\scripts\Build.ps1 -Version 0.2.1
```
Erzeugte Dateien:
```text
bin/gpo-server
bin/gpoctl
bin/gpo-agent-windows-amd64.exe
bin/gpoctl-windows-amd64.exe
```
## Backend starten
### Direkt
```bash
export GPO_SERVER_ADMIN_TOKEN="<zufälliger-admin-token>"
export GPO_SERVER_CLIENT_TOKEN="<zufälliger-client-token>"
export GPO_SERVER_SIGNING_KEY="<zufälliger-signaturschlüssel>"
export GPO_SERVER_TLS_CERT="/etc/gpo-distributor/server.crt"
export GPO_SERVER_TLS_KEY="/etc/gpo-distributor/server.key"
./bin/gpo-server -listen :8443 -data /var/lib/gpo-distributor
```
### Docker Compose
Im Projektwurzelverzeichnis liegen ein Multi-Stage-`Dockerfile`, `compose.yml`,
`.dockerignore` und eine `.env.example`. Das Image baut den Server aus dem
Quellcode, läuft als nicht privilegierter Benutzer und speichert ausschließlich
`/data` in einem benannten Docker-Volume. Temporäre Uploads landen unter
`/data/.tmp`, damit große GPO-Pakete nicht den Container-Arbeitsspeicher als
`tmpfs` belegen.
```bash
cp .env.example .env
chmod 600 .env
# In .env drei voneinander unabhängige, zufällige Geheimnisse eintragen.
# Beispielsweise jeweils separat erzeugen:
openssl rand -base64 48
# Bei direktem TLS Zertifikat und Schlüssel ablegen:
mkdir -p tls
# cp /pfad/server.crt tls/server.crt
# cp /pfad/server.key tls/server.key
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs -f gpo-server
```
Bei einem bereits vorhandenen Volume aus einer älteren Container-Version müssen
dessen Besitzrechte gegebenenfalls einmalig auf UID/GID `10001` angepasst werden:
```bash
docker run --rm -u 0 \
-v gpo-distributor-data:/data \
alpine:3.21 chown -R 10001:10001 /data
```
Die WebUI ist anschließend standardmäßig unter
`https://<server>:8443/ui/` erreichbar. Der Healthcheck verwendet
`/healthz` und erkennt automatisch, ob internes HTTP oder HTTPS konfiguriert
ist.
Für TLS an einem Reverse Proxy werden in `.env` beide TLS-Pfade leer gesetzt:
```dotenv
GPO_SERVER_TLS_CERT=
GPO_SERVER_TLS_KEY=
```
In diesem Fall sollte `GPO_SERVER_BIND_ADDRESS` auf `127.0.0.1` gesetzt oder
der Port ausschließlich in einem internen Docker-Netz veröffentlicht werden.
Geheimnisse werden nicht in `compose.yml` hinterlegt; Compose bricht den Start
ab, wenn Admin-Token, Client-Token oder Signaturschlüssel fehlen.
Nützliche Betriebsbefehle:
```bash
# Nur das Image neu bauen und den Server ersetzen
docker compose up -d --build --no-deps gpo-server
# Datenvolume sichern
docker run --rm \
-v gpo-distributor-data:/data:ro \
-v "$PWD:/backup" \
alpine:3.21 tar -czf /backup/gpo-data-backup.tgz -C /data .
# Server stoppen; das persistente Volume bleibt erhalten
docker compose down
```
## WebUI verwenden
Nach dem Start des Backends ist die Verwaltung unter folgender Adresse erreichbar:
```text
https://gpo.example.org:8443/ui/
```
Zur Anmeldung wird derselbe Admin-Token verwendet wie bei `gpoctl`. Der Browser erhält danach nur eine `HttpOnly`-Session; der Token wird weder in `localStorage` noch in `sessionStorage` abgelegt.
Die Oberfläche bietet:
- Dashboard mit Anzahl der Richtlinien, Versionen, Profile und Clients sowie Client-Gesundheit.
- Upload neuer Policy-Objekte und Versionen einschließlich Notiz und optionalem `force`.
- Anzeige von semantischem Hash, Artefaktgröße, Dateizahlen und Versionshistorie.
- Abruf und kontrolliertes Löschen einzelner ZIP-Artefakte oder kompletter Policy-Objekte.
- Erstellen, Bearbeiten, Sortieren und Löschen geordneter Profile mit `latest` oder fest angehefteten Versionen.
- Suche und Filterung der letzten Clientmeldungen sowie Entfernen veralteter Statusdatensätze.
Das Löschen einer Policy ist gesperrt, solange sie von einem Profil referenziert wird. Eine fest angeheftete Version kann ebenfalls erst gelöscht werden, nachdem das betreffende Profil angepasst wurde. Die letzte Version eines Policy-Objekts wird nicht einzeln gelöscht; dafür wird das gesamte Policy-Objekt entfernt.
Bei TLS-Terminierung an einem Reverse Proxy muss dieser `X-Forwarded-Proto: https` setzen, damit das Session-Cookie als `Secure` markiert wird. Der Backend-Port sollte dann nur vom Reverse Proxy erreichbar sein. Ohne TLS darf die WebUI ausschließlich in einem isolierten Testnetz verwendet werden.
## GPO-Sicherung hochladen
### Vorhandene GPMC-Sicherung
Der ZIP-Inhalt sollte direkt so beginnen:
```text
manifest.xml
{BACKUP-GUID}/
backup.xml
bkupInfo.xml
gpreport.xml
DomainSysvol/GPO/...
```
Beim Komprimieren nicht unnötig mehrere äußere Verzeichnisse hinzufügen. Der Agent erkennt einen üblichen zusätzlichen Wrapper-Ordner trotzdem automatisch.
```powershell
.\gpoctl-windows-amd64.exe upload `
-server https://gpo.example.org:8443 `
-token $env:GPO_ADMIN_TOKEN `
-policy windows-firewall `
-file C:\GPO\windows-firewall.zip `
-note "Change CHG-2026-0042"
```
Optionaler Sonderfall: trotz identischem semantischem Hash eine Version erzwingen:
```powershell
.\gpoctl-windows-amd64.exe upload `
-server https://gpo.example.org:8443 `
-token $env:GPO_ADMIN_TOKEN `
-policy windows-firewall `
-file C:\GPO\windows-firewall.zip `
-force
```
### Automatischer Export aus AD und Upload
```powershell
.\scripts\Export-And-Publish.ps1 `
-GpoName 'Server - Windows Firewall' `
-PolicyName 'windows-firewall' `
-ServerUrl 'https://gpo.example.org:8443' `
-AdminToken $env:GPO_ADMIN_TOKEN `
-GpoCtl '.\bin\gpoctl-windows-amd64.exe' `
-Note 'Change CHG-2026-0042'
```
Das Skript verwendet `Backup-GPO`, erstellt ein ZIP mit korrektem Sicherungswurzelverzeichnis und lädt es hoch. Bei unverändertem Richtlinieninhalt wird keine neue Version erzeugt.
## Profil erstellen
Das folgende Profil verwendet jeweils die aktuelle Version. Die Reihenfolge ist relevant:
```powershell
.\gpoctl-windows-amd64.exe profile-set `
-server https://gpo.example.org:8443 `
-token $env:GPO_ADMIN_TOKEN `
-name standalone-servers `
-policy base-hardening@latest `
-policy audit-policy@latest `
-policy windows-firewall@latest
```
Eine Version kann auch fest angeheftet werden:
```text
-policy base-hardening@v20260805-071500-a1b2c3d4e5
```
`latest` wird bei jedem Manifestabruf auf die neueste vorhandene Version aufgelöst. Sobald eine neue Version hochgeladen wurde, ändert sich automatisch die Profil-Generation und die Clients erkennen das Update.
## Agent installieren
1. `LGPO.zip` aus dem offiziellen Microsoft Security Compliance Toolkit herunterladen und `LGPO.exe` extrahieren.
2. Agent-Binärdatei und `LGPO.exe` auf den Zielserver kopieren.
3. Als Administrator ausführen:
```powershell
.\scripts\Install-Agent.ps1 `
-AgentExe '.\bin\gpo-agent-windows-amd64.exe' `
-LGPOExe '.\LGPO.exe' `
-ServerUrl 'https://gpo.example.org:8443' `
-Profile 'standalone-servers' `
-ClientToken '<client-token>' `
-SigningKey '<signaturschlüssel>' `
-IntervalMinutes 15
```
Das Skript:
- installiert Agent und `LGPO.exe` unter `%ProgramFiles%\GPO-Distributor`,
- legt die geschützte Konfiguration unter `%ProgramData%\GPO-Distributor\agent.json` ab,
- registriert eine geplante Aufgabe als `SYSTEM`,
- startet einen ersten Synchronisationslauf.
Manueller Test:
```powershell
& 'C:\Program Files\GPO-Distributor\gpo-agent.exe' `
-config 'C:\ProgramData\GPO-Distributor\agent.json' `
-once
```
## Update-Ablauf
1. Eine GPO wird in AD geändert.
2. Die Sicherung wird erneut hochgeladen.
3. Der Server erkennt anhand des semantischen Hashes, ob sich wirksame Richtliniendateien geändert haben.
4. Ein Profil mit `@latest` erhält automatisch eine neue Generation.
5. Der Agent ruft das signierte Manifest ab.
6. Nur noch nicht gecachte ZIP-Dateien werden heruntergeladen.
7. Der Agent sichert die aktuelle lokale Richtlinie.
8. Alle Profil-Policies werden in definierter Reihenfolge mit `LGPO.exe /g` importiert.
9. `gpupdate /force` wird ausgeführt.
10. Der Agent speichert seinen Zustand und meldet Erfolg oder Fehler an das Backend.
## Admin-Abfragen
```bash
gpoctl policies -server https://gpo.example.org:8443 -token "$GPO_ADMIN_TOKEN"
gpoctl profiles -server https://gpo.example.org:8443 -token "$GPO_ADMIN_TOKEN"
gpoctl clients -server https://gpo.example.org:8443 -token "$GPO_ADMIN_TOKEN"
```
## API-Übersicht
| Methode | Pfad | Rolle | Zweck |
|---|---|---|---|
| `POST` | `/api/v1/admin/policies/{name}/versions` | Admin | GPO-ZIP hochladen |
| `DELETE` | `/api/v1/admin/policies/{name}` | Admin | Policy mit allen Versionen löschen |
| `DELETE` | `/api/v1/admin/policies/{name}/versions/{version}` | Admin | Einzelne, nicht referenzierte Version löschen |
| `GET` | `/api/v1/admin/policies/{policy}/versions/{version}/artifact` | Admin | ZIP-Artefakt administrativ abrufen |
| `GET` | `/api/v1/admin/policies` | Admin | Policies und Versionen auflisten |
| `PUT` | `/api/v1/admin/profiles/{name}` | Admin | Profil setzen |
| `DELETE` | `/api/v1/admin/profiles/{name}` | Admin | Profil löschen |
| `GET` | `/api/v1/admin/profiles` | Admin | Profile auflisten |
| `GET` | `/api/v1/admin/clients` | Admin | Letzten Clientstatus auflisten |
| `DELETE` | `/api/v1/admin/clients/{id}` | Admin | Gespeicherten Clientstatus entfernen |
| `POST` | `/ui/api/session` | WebUI | Admin-Session erstellen |
| `GET` | `/ui/api/session` | WebUI | Session und CSRF-Token lesen |
| `DELETE` | `/ui/api/session` | WebUI | Session beenden |
| `GET` | `/api/v1/profiles/{name}/manifest` | Client | Signiertes, aufgelöstes Manifest |
| `GET` | `/api/v1/artifacts/{policy}/{version}` | Client | Unveränderliches ZIP herunterladen |
| `POST` | `/api/v1/client/report` | Client | Anwendungsstatus melden |
| `GET` | `/healthz` | öffentlich | Health Check |
## Datenhaltung und Backup
Der Server verwendet absichtlich keine externe Datenbank:
```text
data/
catalog.json
artifacts/
policy-name/
version.zip
```
Für ein Server-Backup müssen `catalog.json` und `artifacts/` konsistent zusammen gesichert werden. Das Verzeichnis kann auf einem verschlüsselten Volume liegen. Schreibzugriff darf ausschließlich der Serverprozess besitzen.
## Betriebsempfehlungen
- Richtlinien zuerst auf einem repräsentativen Snapshot-Klon testen.
- Für Standalone-Server immer eine lokale oder Out-of-Band-Anmeldemöglichkeit vorhalten.
- Client-Token regelmäßig rotieren. Bei Rotation Agent-Konfiguration kontrolliert aktualisieren.
- Signaturschlüssel getrennt vom Client-Token behandeln.
- Backend-Zugriff zusätzlich per Netzwerk-ACL auf erwartete Quellnetze begrenzen.
- Ein eigenes Profil pro Serverrolle verwenden, zum Beispiel `standalone-web`, `standalone-sql` und `standalone-management`.
- Änderungen über Change-ID im Upload-`note` dokumentieren.

77
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,77 @@
# Release 0.2.1
Container deployment update.
Included:
- Root-level multi-stage `Dockerfile` for reproducible source builds.
- Root-level `compose.yml` with persistent named volume, healthcheck and log rotation.
- Non-root runtime user, read-only root filesystem, dropped Linux capabilities and `no-new-privileges`.
- Direct TLS and reverse-proxy HTTP modes using the same image.
- `.env.example`, `.dockerignore` and TLS directory guidance.
- Compatibility deployment files under `deploy/`.
Validated in the build environment:
- Compose YAML parsing and environment interpolation.
- Dockerfile structure and build-context completeness.
- Native Go build and complete Go test suite.
A Docker daemon was not available in the build environment, so an actual image build and container startup could not be executed here.
# Release 0.2.0
Web administration release.
Included:
- Embedded, dependency-free German WebUI served directly by `gpo-server` under `/ui/`.
- Dashboard for policy, version, profile and client health metrics.
- Browser upload workflow with semantic duplicate detection, notes and forced versions.
- Full policy/version browsing, administrative artifact retrieval and guarded deletion.
- Ordered profile editor with `latest` or pinned versions and drag-equivalent move controls.
- Searchable and filterable client status view.
- Stateless eight-hour `HttpOnly` admin sessions signed with the admin secret.
- CSRF protection for all cookie-authenticated mutations.
- Restrictive Content Security Policy and additional browser security headers.
- New API deletion endpoints for policies, versions, profiles and stored client reports.
- Conflict protection for policy/profile references and pinned versions.
Validated in the build environment:
- `go test -race ./...`
- `go vet ./...`
- JavaScript syntax validation with Node.js.
- Linux amd64 and Windows amd64 builds.
- HTTP end-to-end flow covering embedded assets, login cookie, CSRF enforcement, Bearer-token compatibility, policy upload, profile creation and client reports.
Browser screenshot automation could not be executed because the managed Chromium installation blocks navigation to local test servers with `ERR_BLOCKED_BY_ADMINISTRATOR`. The UI assets and browser-facing API were still exercised through unit and HTTP integration tests.
# Release 0.1.0
Initial MVP release.
Included:
- File-backed Go server with admin/client authentication.
- Multiple policy objects and immutable versions.
- Semantic change detection for GPO payload files.
- Ordered profiles with `latest` or pinned versions.
- HMAC-signed manifests and SHA-256 artifact verification.
- Windows agent with safe extraction, local LGPO backup, ordered apply, rollback attempt and status reporting.
- Admin CLI and PowerShell deployment scripts.
- Docker, systemd and example configurations.
Validated in the build environment:
- `go test -race ./...`
- `go vet ./...`
- Linux amd64 builds.
- Windows amd64 cross-builds.
- End-to-end server/API test covering upload, duplicate detection, forced versions, `latest` profile resolution, HMAC manifest signature validation and ETag/304 handling.
Not validated in this Linux build environment:
- Execution of `LGPO.exe` on a real Windows Server.
- Scheduled Task registration on each supported Windows Server version.
- Functional equivalence of every possible Group Policy client-side extension.

BIN
bin/gpo-agent-windows-amd64.exe Executable file

Binary file not shown.

BIN
bin/gpo-server Executable file

Binary file not shown.

BIN
bin/gpoctl Executable file

Binary file not shown.

BIN
bin/gpoctl-windows-amd64.exe Executable file

Binary file not shown.

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

@@ -0,0 +1,50 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"gpo-distributor/internal/agent"
)
var version = "dev"
func main() {
defaultConfig := filepath.Join(os.Getenv("ProgramData"), "GPO-Distributor", "agent.json")
if runtime.GOOS != "windows" && os.Getenv("ProgramData") == "" {
defaultConfig = "/etc/gpo-distributor/agent.json"
}
configFile := flag.String("config", defaultConfig, "agent configuration JSON")
once := flag.Bool("once", false, "synchronize once and exit")
showVersion := flag.Bool("version", false, "print version")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
if runtime.GOOS != "windows" {
log.Fatal("gpo-agent can apply policies only on Windows")
}
logger := log.New(os.Stdout, "gpo-agent ", log.LstdFlags|log.LUTC)
cfg, err := agent.LoadConfig(*configFile)
if err != nil {
logger.Fatal(err)
}
runner, err := agent.New(cfg, version, logger)
if err != nil {
logger.Fatal(err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := runner.Run(ctx, *once); err != nil && err != context.Canceled {
logger.Fatal(err)
}
}

225
cmd/gpoctl/main.go Normal file
View File

@@ -0,0 +1,225 @@
package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gpo-distributor/internal/model"
)
var version = "dev"
type clientConfig struct {
server string
token string
insecure bool
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "upload":
err = upload(os.Args[2:])
case "profile-set":
err = profileSet(os.Args[2:])
case "policies":
err = getList(os.Args[2:], "/api/v1/admin/policies")
case "profiles":
err = getList(os.Args[2:], "/api/v1/admin/profiles")
case "clients":
err = getList(os.Args[2:], "/api/v1/admin/clients")
case "version":
fmt.Println(version)
return
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func common(fs *flag.FlagSet) (*string, *string, *bool) {
server := fs.String("server", os.Getenv("GPO_SERVER_URL"), "server base URL")
token := fs.String("token", os.Getenv("GPO_ADMIN_TOKEN"), "admin bearer token")
insecure := fs.Bool("insecure-skip-verify", false, "skip TLS certificate validation (test only)")
return server, token, insecure
}
func upload(args []string) error {
fs := flag.NewFlagSet("upload", flag.ContinueOnError)
server, token, insecure := common(fs)
policy := fs.String("policy", "", "policy name")
file := fs.String("file", "", "GPO backup ZIP")
note := fs.String("note", "", "version note")
force := fs.Bool("force", false, "create a new version even when the semantic hash already exists")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := validateClient(*server, *token, *insecure)
if err != nil {
return err
}
if *policy == "" || *file == "" {
return errors.New("-policy and -file are required")
}
f, err := os.Open(*file)
if err != nil {
return err
}
defer f.Close()
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
go func() {
defer pw.Close()
part, err := mw.CreateFormFile("bundle", filepath.Base(*file))
if err == nil {
_, err = io.Copy(part, f)
}
if err == nil && *note != "" {
err = mw.WriteField("note", *note)
}
if err == nil && *force {
err = mw.WriteField("force", "true")
}
closeErr := mw.Close()
if err != nil {
_ = pw.CloseWithError(err)
} else if closeErr != nil {
_ = pw.CloseWithError(closeErr)
}
}()
endpoint := strings.TrimRight(cfg.server, "/") + "/api/v1/admin/policies/" + url.PathEscape(*policy) + "/versions"
req, err := http.NewRequest(http.MethodPost, endpoint, pr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.token)
req.Header.Set("Content-Type", mw.FormDataContentType())
return doAndPrint(cfg, req)
}
func profileSet(args []string) error {
fs := flag.NewFlagSet("profile-set", flag.ContinueOnError)
server, token, insecure := common(fs)
name := fs.String("name", "", "profile name")
var policies multiFlag
fs.Var(&policies, "policy", "ordered policy reference name@latest or name@version; repeatable")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := validateClient(*server, *token, *insecure)
if err != nil {
return err
}
if *name == "" || len(policies) == 0 {
return errors.New("-name and at least one -policy are required")
}
refs := make([]model.ProfilePolicy, 0, len(policies))
for _, value := range policies {
policy, ver, ok := strings.Cut(value, "@")
if !ok || policy == "" || ver == "" {
return fmt.Errorf("invalid policy reference %q; expected name@latest or name@version", value)
}
refs = append(refs, model.ProfilePolicy{Policy: policy, Version: ver})
}
body, _ := json.Marshal(map[string]any{"policies": refs})
endpoint := strings.TrimRight(cfg.server, "/") + "/api/v1/admin/profiles/" + url.PathEscape(*name)
req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.token)
req.Header.Set("Content-Type", "application/json")
return doAndPrint(cfg, req)
}
func getList(args []string, path string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
server, token, insecure := common(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := validateClient(*server, *token, *insecure)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(cfg.server, "/")+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.token)
return doAndPrint(cfg, req)
}
func validateClient(server, token string, insecure bool) (clientConfig, error) {
if server == "" || token == "" {
return clientConfig{}, errors.New("server and token are required")
}
u, err := url.Parse(server)
if err != nil || u.Scheme == "" || u.Host == "" {
return clientConfig{}, errors.New("server must be an absolute URL")
}
if u.Scheme != "https" && !insecure {
return clientConfig{}, errors.New("server must use https (or -insecure-skip-verify for tests)")
}
return clientConfig{server: server, token: token, insecure: insecure}, nil
}
func doAndPrint(cfg clientConfig, req *http.Request) error {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.insecure} // #nosec G402: explicit test option.
client := &http.Client{Transport: tr, Timeout: 15 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var pretty bytes.Buffer
if json.Indent(&pretty, body, "", " ") == nil {
fmt.Println(pretty.String())
} else {
fmt.Println(string(body))
}
return nil
}
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
func usage() {
fmt.Fprintln(os.Stderr, `Usage:
gpoctl upload -server https://host:8443 -token TOKEN -policy NAME -file BACKUP.zip [-note TEXT] [-force]
gpoctl profile-set -server URL -token TOKEN -name PROFILE -policy NAME@latest [-policy NAME@VERSION]
gpoctl policies|profiles|clients -server URL -token TOKEN
gpoctl version`)
}

88
cmd/server/main.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gpo-distributor/internal/httpapi"
"gpo-distributor/internal/store"
)
var version = "dev"
func main() {
listen := flag.String("listen", env("GPO_SERVER_LISTEN", ":8443"), "listen address")
dataDir := flag.String("data", env("GPO_SERVER_DATA", "./data"), "data directory")
adminToken := flag.String("admin-token", os.Getenv("GPO_SERVER_ADMIN_TOKEN"), "admin bearer token")
clientToken := flag.String("client-token", os.Getenv("GPO_SERVER_CLIENT_TOKEN"), "client bearer token")
signingKey := flag.String("signing-key", os.Getenv("GPO_SERVER_SIGNING_KEY"), "manifest HMAC signing key")
tlsCert := flag.String("tls-cert", os.Getenv("GPO_SERVER_TLS_CERT"), "TLS certificate path")
tlsKey := flag.String("tls-key", os.Getenv("GPO_SERVER_TLS_KEY"), "TLS private key path")
maxUpload := flag.Int64("max-upload", 512<<20, "maximum upload size in bytes")
showVersion := flag.Bool("version", false, "print version")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
logger := log.New(os.Stdout, "gpo-server ", log.LstdFlags|log.LUTC)
st, err := store.Open(*dataDir)
if err != nil {
logger.Fatal(err)
}
api, err := httpapi.New(st, httpapi.Config{
AdminToken: *adminToken, ClientToken: *clientToken, SigningKey: *signingKey,
ServerVersion: version, MaxUpload: *maxUpload, Logger: logger,
})
if err != nil {
logger.Fatal(err)
}
srv := &http.Server{
Addr: *listen, Handler: api.Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Minute,
WriteTimeout: 15 * time.Minute, IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 1 << 20,
}
go func() {
logger.Printf("version=%s listen=%s data=%s", version, *listen, *dataDir)
var err error
if *tlsCert != "" || *tlsKey != "" {
if *tlsCert == "" || *tlsKey == "" {
logger.Fatal("both -tls-cert and -tls-key are required")
}
err = srv.ListenAndServeTLS(*tlsCert, *tlsKey)
} else {
logger.Printf("WARNING: TLS disabled; use only behind a TLS reverse proxy or in a test network")
err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
logger.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Printf("shutdown: %v", err)
}
}
func env(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}

65
compose.yml Normal file
View File

@@ -0,0 +1,65 @@
name: gpo-distributor
services:
gpo-server:
image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.1}"
build:
context: .
dockerfile: Dockerfile
args:
VERSION: "${GPO_SERVER_VERSION:-0.2.1}"
restart: unless-stopped
init: true
stop_grace_period: 25s
environment:
GPO_SERVER_LISTEN: ":8443"
GPO_SERVER_DATA: "/data"
GPO_SERVER_ADMIN_TOKEN: "${GPO_SERVER_ADMIN_TOKEN:?Set GPO_SERVER_ADMIN_TOKEN in .env}"
GPO_SERVER_CLIENT_TOKEN: "${GPO_SERVER_CLIENT_TOKEN:?Set GPO_SERVER_CLIENT_TOKEN in .env}"
GPO_SERVER_SIGNING_KEY: "${GPO_SERVER_SIGNING_KEY:?Set GPO_SERVER_SIGNING_KEY in .env}"
GPO_SERVER_TLS_CERT: "${GPO_SERVER_TLS_CERT:-}"
GPO_SERVER_TLS_KEY: "${GPO_SERVER_TLS_KEY:-}"
command:
- -max-upload
- "${GPO_SERVER_MAX_UPLOAD:-536870912}"
ports:
- "${GPO_SERVER_BIND_ADDRESS:-0.0.0.0}:${GPO_SERVER_PORT:-8443}:8443"
volumes:
- gpo-data:/data
# Keep this mount even when TLS is terminated by a reverse proxy.
# The directory may remain empty when both TLS variables are blank.
- ./tls:/tls:ro
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD-SHELL
- >-
if [ -n "$${GPO_SERVER_TLS_CERT:-}" ]; then
wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8443/healthz;
else
wget -q -O /dev/null http://127.0.0.1:8443/healthz;
fi
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
gpo-data:
name: "${GPO_SERVER_DATA_VOLUME:-gpo-distributor-data}"

21
data/catalog.json Normal file
View File

@@ -0,0 +1,21 @@
{
"policies": {
"Applocker": {
"name": "Applocker",
"versions": [
{
"version": "v20260805-083222-b964f1aaf5",
"created_at": "2026-08-05T08:32:22.433020553Z",
"artifact_path": "artifacts/Applocker/v20260805-083222-b964f1aaf5.zip",
"artifact_sha256": "ca33096a03517dc9cc41f7137344c0fff893467b803d50bb0aae2de9029c43c3",
"semantic_sha256": "b964f1aaf5fd37dd7af001504c27884013402591bce8f35811aa474fcc05e1fb",
"size": 10132,
"file_count": 5,
"policy_file_count": 2
}
]
}
},
"profiles": {},
"clients": {}
}

39
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Compatibility copy. The canonical Dockerfile is located in the repository root.
# Build with the repository root as context:
# docker build -f deploy/Dockerfile .
# syntax=docker/dockerfile:1
ARG GO_VERSION=1.23
ARG ALPINE_VERSION=3.21
FROM golang:${GO_VERSION}-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/gpo-server \
./cmd/server
FROM alpine:${ALPINE_VERSION} AS runtime
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S -g 10001 gpo \
&& adduser -S -D -H -u 10001 -G gpo gpo \
&& install -d -o gpo -g gpo -m 0750 /data /data/.tmp /app
COPY --from=build --chown=gpo:gpo /out/gpo-server /app/gpo-server
ENV GPO_SERVER_LISTEN=:8443 GPO_SERVER_DATA=/data TMPDIR=/data/.tmp
USER 10001:10001
WORKDIR /app
EXPOSE 8443
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD if [ -n "${GPO_SERVER_TLS_CERT:-}" ]; then \
wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8443/healthz; \
else \
wget -q -O /dev/null http://127.0.0.1:8443/healthz; \
fi
ENTRYPOINT ["/app/gpo-server"]

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

@@ -0,0 +1,54 @@
# Compatibility compose file. Prefer running ../compose.yml from the repository root.
name: gpo-distributor
services:
gpo-server:
image: "${GPO_SERVER_IMAGE:-gpo-distributor-server:0.2.1}"
build:
context: ..
dockerfile: Dockerfile
args:
VERSION: "${GPO_SERVER_VERSION:-0.2.1}"
restart: unless-stopped
init: true
stop_grace_period: 25s
environment:
GPO_SERVER_LISTEN: ":8443"
GPO_SERVER_DATA: "/data"
GPO_SERVER_ADMIN_TOKEN: "${GPO_SERVER_ADMIN_TOKEN:?Set GPO_SERVER_ADMIN_TOKEN in .env}"
GPO_SERVER_CLIENT_TOKEN: "${GPO_SERVER_CLIENT_TOKEN:?Set GPO_SERVER_CLIENT_TOKEN in .env}"
GPO_SERVER_SIGNING_KEY: "${GPO_SERVER_SIGNING_KEY:?Set GPO_SERVER_SIGNING_KEY in .env}"
GPO_SERVER_TLS_CERT: "${GPO_SERVER_TLS_CERT:-}"
GPO_SERVER_TLS_KEY: "${GPO_SERVER_TLS_KEY:-}"
command: ["-max-upload", "${GPO_SERVER_MAX_UPLOAD:-536870912}"]
ports:
- "${GPO_SERVER_BIND_ADDRESS:-0.0.0.0}:${GPO_SERVER_PORT:-8443}:8443"
volumes:
- gpo-data:/data
- ../tls:/tls:ro
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD-SHELL
- >-
if [ -n "$${GPO_SERVER_TLS_CERT:-}" ]; then
wget -q --no-check-certificate -O /dev/null https://127.0.0.1:8443/healthz;
else
wget -q -O /dev/null http://127.0.0.1:8443/healthz;
fi
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
gpo-data:
name: "${GPO_SERVER_DATA_VOLUME:-gpo-distributor-data}"

21
deploy/gpo-server.service Normal file
View File

@@ -0,0 +1,21 @@
[Unit]
Description=GPO Distributor Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=gpo-distributor
Group=gpo-distributor
EnvironmentFile=/etc/gpo-distributor/server.env
ExecStart=/usr/local/bin/gpo-server -listen :8443 -data /var/lib/gpo-distributor
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/gpo-distributor
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
GPO_SERVER_ADMIN_TOKEN=CHANGE-ME-LONG-RANDOM-ADMIN-TOKEN
GPO_SERVER_CLIENT_TOKEN=CHANGE-ME-LONG-RANDOM-CLIENT-TOKEN
GPO_SERVER_SIGNING_KEY=CHANGE-ME-LONG-RANDOM-SIGNING-KEY
GPO_SERVER_TLS_CERT=/etc/gpo-distributor/tls/server.crt
GPO_SERVER_TLS_KEY=/etc/gpo-distributor/tls/server.key

View File

@@ -0,0 +1,11 @@
{
"server_url": "https://gpo-distributor.example.org:8443",
"profile": "standalone-servers",
"client_token": "CHANGE-ME",
"signing_key": "CHANGE-ME",
"lgpo_path": "C:\\Program Files\\GPO-Distributor\\LGPO.exe",
"state_dir": "C:\\ProgramData\\GPO-Distributor",
"poll_interval": "15m",
"request_timeout": "15m",
"insecure_skip_verify": false
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gpo-distributor
go 1.26

456
internal/agent/agent.go Normal file
View File

@@ -0,0 +1,456 @@
package agent
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"gpo-distributor/internal/bundle"
"gpo-distributor/internal/model"
)
type Config struct {
ServerURL string `json:"server_url"`
Profile string `json:"profile"`
ClientToken string `json:"client_token"`
SigningKey string `json:"signing_key"`
LGPOPath string `json:"lgpo_path"`
StateDir string `json:"state_dir"`
PollInterval string `json:"poll_interval"`
ClientID string `json:"client_id,omitempty"`
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
RequestTimeout string `json:"request_timeout,omitempty"`
}
type State struct {
Generation string `json:"generation"`
AppliedAt time.Time `json:"applied_at"`
Policies []model.ResolvedPolicy `json:"policies"`
}
type Runner struct {
cfg Config
client *http.Client
logger *log.Logger
version string
interval time.Duration
clientID string
hostname string
stateFile string
}
func LoadConfig(filename string) (Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return Config{}, err
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
var cfg Config
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
return Config{}, err
}
if err := validateConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func New(cfg Config, version string, logger *log.Logger) (*Runner, error) {
if err := validateConfig(cfg); err != nil {
return nil, err
}
interval, err := time.ParseDuration(cfg.PollInterval)
if err != nil {
return nil, fmt.Errorf("poll_interval: %w", err)
}
if interval < time.Minute {
return nil, errors.New("poll_interval must be at least 1m")
}
timeout := 10 * time.Minute
if cfg.RequestTimeout != "" {
timeout, err = time.ParseDuration(cfg.RequestTimeout)
if err != nil {
return nil, fmt.Errorf("request_timeout: %w", err)
}
}
if logger == nil {
logger = log.Default()
}
host, _ := os.Hostname()
clientID := cfg.ClientID
if clientID == "" {
clientID = host
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.InsecureSkipVerify} // #nosec G402: explicit opt-in for test environments.
return &Runner{
cfg: cfg, version: version, logger: logger, interval: interval,
client: &http.Client{Timeout: timeout, Transport: transport},
clientID: clientID, hostname: host,
stateFile: filepath.Join(cfg.StateDir, "state.json"),
}, nil
}
func validateConfig(cfg Config) error {
if cfg.ServerURL == "" || cfg.Profile == "" || cfg.ClientToken == "" || cfg.SigningKey == "" || cfg.LGPOPath == "" || cfg.StateDir == "" {
return errors.New("server_url, profile, client_token, signing_key, lgpo_path and state_dir are required")
}
u, err := url.Parse(cfg.ServerURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return errors.New("server_url must be an absolute URL")
}
if u.Scheme != "https" && !cfg.InsecureSkipVerify {
return errors.New("server_url must use https (or set insecure_skip_verify only for tests)")
}
if cfg.PollInterval == "" {
return errors.New("poll_interval is required")
}
return nil
}
func (r *Runner) Run(ctx context.Context, once bool) error {
if err := os.MkdirAll(r.cfg.StateDir, 0o700); err != nil {
return err
}
if once {
return r.Sync(ctx)
}
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
if err := r.Sync(ctx); err != nil {
r.logger.Printf("sync failed: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func (r *Runner) Sync(ctx context.Context) (retErr error) {
state, err := r.loadState()
if err != nil {
return err
}
manifest, changed, err := r.fetchManifest(ctx, state.Generation)
if err != nil {
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: state.Generation, Success: false, Message: err.Error(), AgentVersion: r.version, OperatingSystem: runtime.GOOS})
return err
}
if !changed {
r.logger.Printf("profile=%s generation=%s status=up-to-date", r.cfg.Profile, state.Generation)
return nil
}
unlock, err := acquireLock(filepath.Join(r.cfg.StateDir, "agent.lock"), 2*time.Hour)
if err != nil {
return err
}
defer unlock()
staging, err := os.MkdirTemp(r.cfg.StateDir, "staging-")
if err != nil {
return err
}
defer os.RemoveAll(staging)
policyDirs := make([]string, 0, len(manifest.Policies))
for i, p := range manifest.Policies {
zipPath, err := r.obtainArtifact(ctx, p)
if err != nil {
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("download %s: %w", p.Name, err))
}
dir := filepath.Join(staging, fmt.Sprintf("%03d-%s", i, p.Name))
if err := bundle.ExtractZip(zipPath, dir, bundle.ExtractLimits{}); err != nil {
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("extract %s: %w", p.Name, err))
}
importRoot, err := bundle.FindImportRoot(dir)
if err != nil {
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("locate GPO root for %s: %w", p.Name, err))
}
policyDirs = append(policyDirs, importRoot)
}
rollbackRoot := filepath.Join(r.cfg.StateDir, "rollback", time.Now().UTC().Format("20060102-150405"))
if err := os.MkdirAll(rollbackRoot, 0o700); err != nil {
return r.failAndReport(ctx, manifest.Generation, err)
}
if err := backupLocalPolicy(ctx, r.cfg.LGPOPath, rollbackRoot); err != nil {
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("pre-apply backup failed: %w", err))
}
if err := applyPolicyDirectories(ctx, r.cfg.LGPOPath, policyDirs); err != nil {
rollbackErr := restoreLocalPolicy(ctx, r.cfg.LGPOPath, rollbackRoot)
if rollbackErr != nil {
err = fmt.Errorf("apply failed: %v; rollback also failed: %w", err, rollbackErr)
} else {
err = fmt.Errorf("apply failed and rollback succeeded: %w", err)
}
return r.failAndReport(ctx, manifest.Generation, err)
}
newState := State{Generation: manifest.Generation, AppliedAt: time.Now().UTC(), Policies: manifest.Policies}
if err := r.saveState(newState); err != nil {
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("save state: %w", err))
}
r.pruneRollbacks(filepath.Join(r.cfg.StateDir, "rollback"), 5)
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: manifest.Generation, Success: true, Message: "policy profile applied", AgentVersion: r.version, AppliedAt: newState.AppliedAt, OperatingSystem: runtime.GOOS})
r.logger.Printf("profile=%s generation=%s policies=%d status=applied", r.cfg.Profile, manifest.Generation, len(manifest.Policies))
return nil
}
func (r *Runner) fetchManifest(ctx context.Context, current string) (model.Manifest, bool, error) {
base, _ := url.Parse(strings.TrimRight(r.cfg.ServerURL, "/") + "/")
rel, _ := url.Parse("api/v1/profiles/" + url.PathEscape(r.cfg.Profile) + "/manifest")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.ResolveReference(rel).String(), nil)
if err != nil {
return model.Manifest{}, false, err
}
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
if current != "" {
req.Header.Set("If-None-Match", `"`+current+`"`)
}
resp, err := r.client.Do(req)
if err != nil {
return model.Manifest{}, false, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
return model.Manifest{}, false, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return model.Manifest{}, false, err
}
if resp.StatusCode != http.StatusOK {
return model.Manifest{}, false, fmt.Errorf("manifest HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if err := verifySignature(body, resp.Header.Get("X-GPO-Signature"), r.cfg.SigningKey); err != nil {
return model.Manifest{}, false, err
}
var manifest model.Manifest
if err := json.Unmarshal(body, &manifest); err != nil {
return model.Manifest{}, false, err
}
if manifest.Profile != r.cfg.Profile || manifest.Generation == "" {
return model.Manifest{}, false, errors.New("invalid manifest identity")
}
if manifest.Generation == current {
return manifest, false, nil
}
return manifest, true, nil
}
func verifySignature(body []byte, header, key string) error {
const prefix = "hmac-sha256="
if !strings.HasPrefix(header, prefix) {
return errors.New("manifest signature missing")
}
got, err := hex.DecodeString(strings.TrimPrefix(header, prefix))
if err != nil {
return errors.New("invalid manifest signature encoding")
}
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write(body)
if !hmac.Equal(got, mac.Sum(nil)) {
return errors.New("manifest signature verification failed")
}
return nil
}
func (r *Runner) obtainArtifact(ctx context.Context, p model.ResolvedPolicy) (string, error) {
if len(p.SHA256) != 64 || p.Size <= 0 {
return "", errors.New("invalid artifact metadata")
}
cacheDir := filepath.Join(r.cfg.StateDir, "cache")
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
return "", err
}
final := filepath.Join(cacheDir, p.SHA256+".zip")
if ok, _ := verifyFile(final, p.SHA256, p.Size); ok {
return final, nil
}
base, _ := url.Parse(strings.TrimRight(r.cfg.ServerURL, "/") + "/")
rel, err := url.Parse(strings.TrimLeft(p.DownloadURL, "/"))
if err != nil {
return "", err
}
u := base.ResolveReference(rel)
if u.Host != base.Host || u.Scheme != base.Scheme {
return "", errors.New("artifact URL points to a different origin")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
tmp, err := os.CreateTemp(cacheDir, ".download-*.zip")
if err != nil {
return "", err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
h := sha256.New()
n, copyErr := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, p.Size+1))
closeErr := tmp.Close()
if copyErr != nil {
return "", copyErr
}
if closeErr != nil {
return "", closeErr
}
if n != p.Size {
return "", fmt.Errorf("artifact size mismatch: expected %d, got %d", p.Size, n)
}
if got := hex.EncodeToString(h.Sum(nil)); got != p.SHA256 {
return "", fmt.Errorf("artifact hash mismatch: expected %s, got %s", p.SHA256, got)
}
if err := os.Rename(tmpName, final); err != nil {
return "", err
}
return final, nil
}
func verifyFile(filename, expectedHash string, expectedSize int64) (bool, error) {
f, err := os.Open(filename)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return false, err
}
if st.Size() != expectedSize {
return false, nil
}
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false, err
}
return hex.EncodeToString(h.Sum(nil)) == expectedHash, nil
}
func (r *Runner) loadState() (State, error) {
b, err := os.ReadFile(r.stateFile)
if errors.Is(err, os.ErrNotExist) {
return State{}, nil
}
if err != nil {
return State{}, err
}
var st State
if err := json.Unmarshal(b, &st); err != nil {
return State{}, err
}
return st, nil
}
func (r *Runner) saveState(st State) error {
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := r.stateFile + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, r.stateFile)
}
func acquireLock(filename string, staleAfter time.Duration) (func(), error) {
f, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
_ = f.Close()
return func() { _ = os.Remove(filename) }, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, err
}
st, statErr := os.Stat(filename)
if statErr == nil && time.Since(st.ModTime()) > staleAfter {
if removeErr := os.Remove(filename); removeErr == nil {
return acquireLock(filename, staleAfter)
}
}
return nil, errors.New("another agent instance is running")
}
func (r *Runner) failAndReport(ctx context.Context, generation string, err error) error {
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: generation, Success: false, Message: err.Error(), AgentVersion: r.version, OperatingSystem: runtime.GOOS})
return err
}
func (r *Runner) report(ctx context.Context, report model.ClientReport) {
body, err := json.Marshal(report)
if err != nil {
return
}
base := strings.TrimRight(r.cfg.ServerURL, "/") + "/api/v1/client/report"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, strings.NewReader(string(body)))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
r.logger.Printf("report failed: %v", err)
return
}
_ = resp.Body.Close()
}
func (r *Runner) pruneRollbacks(root string, keep int) {
entries, err := os.ReadDir(root)
if err != nil {
return
}
var dirs []os.DirEntry
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e)
}
}
if len(dirs) <= keep {
return
}
// Names are UTC timestamps, so lexical order is chronological.
for i := 0; i < len(dirs)-keep; i++ {
_ = os.RemoveAll(filepath.Join(root, dirs[i].Name()))
}
}

View File

@@ -0,0 +1,31 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigAcceptsUTF8BOM(t *testing.T) {
filename := filepath.Join(t.TempDir(), "agent.json")
data := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{
"server_url":"http://127.0.0.1:8080",
"profile":"servers",
"client_token":"token",
"signing_key":"key",
"lgpo_path":"C:\\LGPO.exe",
"state_dir":"C:\\ProgramData\\GPO-Distributor",
"poll_interval":"15m",
"insecure_skip_verify":true
}`)...)
if err := os.WriteFile(filename, data, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(filename)
if err != nil {
t.Fatal(err)
}
if cfg.Profile != "servers" {
t.Fatalf("unexpected profile: %s", cfg.Profile)
}
}

View File

@@ -0,0 +1,14 @@
//go:build !windows
package agent
import (
"context"
"errors"
)
var errWindowsOnly = errors.New("policy application is supported only on Windows")
func backupLocalPolicy(context.Context, string, string) error { return errWindowsOnly }
func applyPolicyDirectories(context.Context, string, []string) error { return errWindowsOnly }
func restoreLocalPolicy(context.Context, string, string) error { return errWindowsOnly }

View File

@@ -0,0 +1,46 @@
//go:build windows
package agent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func backupLocalPolicy(ctx context.Context, lgpoPath, destination string) error {
if _, err := os.Stat(lgpoPath); err != nil {
return fmt.Errorf("LGPO.exe not found: %w", err)
}
return runCommand(ctx, lgpoPath, "/q", "/b", destination, "/n", "GPO Distributor pre-apply rollback")
}
func applyPolicyDirectories(ctx context.Context, lgpoPath string, directories []string) error {
for _, dir := range directories {
if err := runCommand(ctx, lgpoPath, "/q", "/g", dir); err != nil {
return err
}
}
return runCommand(ctx, filepath.Join(os.Getenv("SystemRoot"), "System32", "gpupdate.exe"), "/force", "/wait:600")
}
func restoreLocalPolicy(ctx context.Context, lgpoPath, rollbackRoot string) error {
if err := runCommand(ctx, lgpoPath, "/q", "/g", rollbackRoot); err != nil {
return err
}
return runCommand(ctx, filepath.Join(os.Getenv("SystemRoot"), "System32", "gpupdate.exe"), "/force", "/wait:600")
}
func runCommand(ctx context.Context, program string, args ...string) error {
cmd := exec.CommandContext(ctx, program, args...)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s failed: %w: %s", program, strings.Join(args, " "), err, strings.TrimSpace(out.String()))
}
return nil
}

354
internal/bundle/bundle.go Normal file
View File

@@ -0,0 +1,354 @@
package bundle
import (
"archive/zip"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
const (
DefaultMaxFiles = 20000
DefaultMaxExpanded = int64(2 << 30) // 2 GiB
DefaultMaxSingleFile = int64(512 << 20)
)
type Inspection struct {
ArtifactHash string
SemanticHash string
Size int64
FileCount int
PolicyFiles int
}
type fileDigest struct {
name string
size uint64
hash [sha256.Size]byte
}
func InspectZip(filename string) (Inspection, error) {
f, err := os.Open(filename)
if err != nil {
return Inspection{}, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return Inspection{}, err
}
artifact := sha256.New()
if _, err := io.Copy(artifact, f); err != nil {
return Inspection{}, err
}
zr, err := zip.OpenReader(filename)
if err != nil {
return Inspection{}, fmt.Errorf("invalid ZIP: %w", err)
}
defer zr.Close()
if len(zr.File) == 0 {
return Inspection{}, errors.New("ZIP is empty")
}
if len(zr.File) > DefaultMaxFiles {
return Inspection{}, fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
}
var total uint64
var payload []fileDigest
backupXML := 0
fileCount := 0
for _, zf := range zr.File {
name, err := cleanArchivePath(zf.Name)
if err != nil {
return Inspection{}, err
}
if zf.FileInfo().IsDir() {
continue
}
if !zf.Mode().IsRegular() {
return Inspection{}, fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
}
if zf.UncompressedSize64 > uint64(DefaultMaxSingleFile) {
return Inspection{}, fmt.Errorf("ZIP entry too large: %q", zf.Name)
}
total += zf.UncompressedSize64
if total > uint64(DefaultMaxExpanded) {
return Inspection{}, errors.New("expanded ZIP exceeds safety limit")
}
fileCount++
lower := strings.ToLower(name)
if path.Base(lower) == "backup.xml" {
backupXML++
}
if !isPolicyPayload(lower) {
continue
}
rc, err := zf.Open()
if err != nil {
return Inspection{}, err
}
h := sha256.New()
_, copyErr := io.Copy(h, io.LimitReader(rc, DefaultMaxSingleFile+1))
closeErr := rc.Close()
if copyErr != nil {
return Inspection{}, copyErr
}
if closeErr != nil {
return Inspection{}, closeErr
}
var sum [sha256.Size]byte
copy(sum[:], h.Sum(nil))
payload = append(payload, fileDigest{name: lower, size: zf.UncompressedSize64, hash: sum})
}
if backupXML == 0 {
return Inspection{}, errors.New("no backup.xml found; expected a Microsoft GPO backup")
}
if len(payload) == 0 {
return Inspection{}, errors.New("no policy payload under DomainSysvol/GPO found")
}
sort.Slice(payload, func(i, j int) bool { return payload[i].name < payload[j].name })
semantic := sha256.New()
for _, item := range payload {
writeField(semantic, []byte(item.name))
var size [8]byte
binary.BigEndian.PutUint64(size[:], item.size)
writeField(semantic, size[:])
writeField(semantic, item.hash[:])
}
return Inspection{
ArtifactHash: hex.EncodeToString(artifact.Sum(nil)),
SemanticHash: hex.EncodeToString(semantic.Sum(nil)),
Size: st.Size(),
FileCount: fileCount,
PolicyFiles: len(payload),
}, nil
}
func writeField(h hash.Hash, b []byte) {
var n [8]byte
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
_, _ = h.Write(n[:])
_, _ = h.Write(b)
}
func isPolicyPayload(lower string) bool {
return strings.Contains("/"+lower, "/domainsysvol/gpo/")
}
func cleanArchivePath(name string) (string, error) {
name = strings.ReplaceAll(name, "\\", "/")
if strings.ContainsRune(name, '\x00') {
return "", errors.New("ZIP path contains NUL")
}
clean := path.Clean(name)
if clean == "." || clean == "" {
return "", nil
}
if strings.HasPrefix(clean, "/") || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, ":") {
return "", fmt.Errorf("unsafe ZIP path: %q", name)
}
return clean, nil
}
type ExtractLimits struct {
MaxFiles int
MaxExpanded int64
MaxSingleFile int64
}
func (l ExtractLimits) withDefaults() ExtractLimits {
if l.MaxFiles <= 0 {
l.MaxFiles = DefaultMaxFiles
}
if l.MaxExpanded <= 0 {
l.MaxExpanded = DefaultMaxExpanded
}
if l.MaxSingleFile <= 0 {
l.MaxSingleFile = DefaultMaxSingleFile
}
return l
}
func ExtractZip(filename, destination string, limits ExtractLimits) error {
limits = limits.withDefaults()
zr, err := zip.OpenReader(filename)
if err != nil {
return err
}
defer zr.Close()
if len(zr.File) > limits.MaxFiles {
return fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
}
if err := os.MkdirAll(destination, 0o700); err != nil {
return err
}
root, err := filepath.Abs(destination)
if err != nil {
return err
}
var total int64
for _, zf := range zr.File {
name, err := cleanArchivePath(zf.Name)
if err != nil {
return err
}
if name == "" {
continue
}
if zf.UncompressedSize64 > uint64(limits.MaxSingleFile) {
return fmt.Errorf("ZIP entry too large: %q", zf.Name)
}
total += int64(zf.UncompressedSize64)
if total > limits.MaxExpanded {
return errors.New("expanded ZIP exceeds safety limit")
}
if !zf.FileInfo().IsDir() && !zf.Mode().IsRegular() {
return fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
}
target := filepath.Join(root, filepath.FromSlash(name))
absTarget, err := filepath.Abs(target)
if err != nil {
return err
}
if absTarget != root && !strings.HasPrefix(absTarget, root+string(os.PathSeparator)) {
return fmt.Errorf("ZIP path escapes destination: %q", zf.Name)
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(absTarget, 0o700); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(absTarget), 0o700); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
out, err := os.OpenFile(absTarget, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
rc.Close()
return err
}
_, copyErr := io.Copy(out, io.LimitReader(rc, limits.MaxSingleFile+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return copyErr
}
if closeOutErr != nil {
return closeOutErr
}
if closeInErr != nil {
return closeInErr
}
}
return nil
}
// FindImportRoot locates the directory that should be passed to LGPO.exe /g.
// It accepts both a normal GPMC/Backup-GPO archive root and archives wrapped
// in one additional directory by common ZIP tools.
func FindImportRoot(extractedRoot string) (string, error) {
root, err := filepath.Abs(extractedRoot)
if err != nil {
return "", err
}
type candidate struct {
path string
depth int
}
var candidates []candidate
err = filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, current)
if err != nil {
return err
}
depth := 0
if rel != "." {
depth = len(strings.Split(filepath.ToSlash(rel), "/"))
}
if entry.IsDir() && depth > 4 {
return filepath.SkipDir
}
if entry.IsDir() {
return nil
}
if strings.EqualFold(entry.Name(), "manifest.xml") {
candidates = append(candidates, candidate{path: filepath.Dir(current), depth: depth})
return nil
}
if strings.EqualFold(entry.Name(), "backup.xml") {
backupDir := filepath.Dir(current)
candidates = append(candidates, candidate{path: backupDir, depth: depth})
if backupDir != root {
parent := filepath.Dir(backupDir)
if parent == root || strings.HasPrefix(parent, root+string(os.PathSeparator)) {
candidates = append(candidates, candidate{path: parent, depth: depth - 1})
}
}
}
return nil
})
if err != nil {
return "", err
}
if len(candidates) == 0 {
return "", errors.New("no GPO backup root found after extraction")
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].depth == candidates[j].depth {
return candidates[i].path < candidates[j].path
}
return candidates[i].depth < candidates[j].depth
})
for _, c := range candidates {
if hasBackupAtOrBelow(c.path) {
return c.path, nil
}
}
return "", errors.New("no usable GPO backup root found")
}
func hasBackupAtOrBelow(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, "backup.xml")); err == nil {
return true
}
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, entry.Name(), "backup.xml")); err == nil {
return true
}
}
return false
}

View File

@@ -0,0 +1,106 @@
package bundle
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func createZip(t *testing.T, files map[string]string) string {
t.Helper()
name := filepath.Join(t.TempDir(), "bundle.zip")
f, err := os.Create(name)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
for path, content := range files {
w, err := zw.Create(path)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
return name
}
func TestSemanticHashIgnoresBackupMetadata(t *testing.T) {
base := map[string]string{
"manifest.xml": "time=one",
"{A}/backup.xml": "version=1",
"{A}/bkupInfo.xml": "time=one",
"{A}/DomainSysvol/GPO/Machine/registry.pol": "policy",
}
a := createZip(t, base)
base["manifest.xml"] = "time=two"
base["{A}/bkupInfo.xml"] = "time=two"
base["{A}/backup.xml"] = "version=2"
b := createZip(t, base)
ia, err := InspectZip(a)
if err != nil {
t.Fatal(err)
}
ib, err := InspectZip(b)
if err != nil {
t.Fatal(err)
}
if ia.SemanticHash != ib.SemanticHash {
t.Fatalf("semantic hashes differ: %s != %s", ia.SemanticHash, ib.SemanticHash)
}
if ia.ArtifactHash == ib.ArtifactHash {
t.Fatal("artifact hashes should differ")
}
}
func TestSemanticHashChangesWithPolicy(t *testing.T) {
a := createZip(t, map[string]string{
"{A}/backup.xml": "x",
"{A}/DomainSysvol/GPO/Machine/registry.pol": "one",
})
b := createZip(t, map[string]string{
"{A}/backup.xml": "x",
"{A}/DomainSysvol/GPO/Machine/registry.pol": "two",
})
ia, _ := InspectZip(a)
ib, _ := InspectZip(b)
if ia.SemanticHash == ib.SemanticHash {
t.Fatal("semantic hashes should differ")
}
}
func TestExtractRejectsTraversal(t *testing.T) {
z := createZip(t, map[string]string{"../evil": "x"})
if err := ExtractZip(z, t.TempDir(), ExtractLimits{}); err == nil {
t.Fatal("expected traversal error")
}
}
func TestFindImportRootWrappedArchive(t *testing.T) {
root := t.TempDir()
backupRoot := filepath.Join(root, "wrapper", "backup")
if err := os.MkdirAll(filepath.Join(backupRoot, "{A}"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backupRoot, "manifest.xml"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backupRoot, "{A}", "backup.xml"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
got, err := FindImportRoot(root)
if err != nil {
t.Fatal(err)
}
if got != backupRoot {
t.Fatalf("got %s, want %s", got, backupRoot)
}
}

399
internal/httpapi/server.go Normal file
View File

@@ -0,0 +1,399 @@
package httpapi
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"gpo-distributor/internal/bundle"
"gpo-distributor/internal/model"
"gpo-distributor/internal/store"
)
type Config struct {
AdminToken string
ClientToken string
SigningKey string
ServerVersion string
MaxUpload int64
Logger *log.Logger
}
type Server struct {
store *store.Store
cfg Config
mux *http.ServeMux
}
func New(s *store.Store, cfg Config) (*Server, error) {
if cfg.AdminToken == "" || cfg.ClientToken == "" || cfg.SigningKey == "" {
return nil, errors.New("admin token, client token and signing key are required")
}
if cfg.MaxUpload <= 0 {
cfg.MaxUpload = 512 << 20
}
if cfg.Logger == nil {
cfg.Logger = log.Default()
}
api := &Server{store: s, cfg: cfg, mux: http.NewServeMux()}
api.routes()
return api, nil
}
func (s *Server) Handler() http.Handler {
return s.logRequests(s.securityHeaders(s.mux))
}
func (s *Server) routes() {
s.registerWebUI()
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": s.cfg.ServerVersion})
})
s.mux.Handle("POST /api/v1/admin/policies/{name}/versions", s.requireAdmin(http.HandlerFunc(s.uploadPolicy)))
s.mux.Handle("GET /api/v1/admin/policies", s.requireAdmin(http.HandlerFunc(s.listPolicies)))
s.mux.Handle("GET /api/v1/admin/policies/{name}", s.requireAdmin(http.HandlerFunc(s.getPolicy)))
s.mux.Handle("DELETE /api/v1/admin/policies/{name}", s.requireAdmin(http.HandlerFunc(s.deletePolicy)))
s.mux.Handle("DELETE /api/v1/admin/policies/{name}/versions/{version}", s.requireAdmin(http.HandlerFunc(s.deletePolicyVersion)))
s.mux.Handle("GET /api/v1/admin/policies/{policy}/versions/{version}/artifact", s.requireAdmin(http.HandlerFunc(s.getArtifact)))
s.mux.Handle("PUT /api/v1/admin/profiles/{name}", s.requireAdmin(http.HandlerFunc(s.setProfile)))
s.mux.Handle("DELETE /api/v1/admin/profiles/{name}", s.requireAdmin(http.HandlerFunc(s.deleteProfile)))
s.mux.Handle("GET /api/v1/admin/profiles", s.requireAdmin(http.HandlerFunc(s.listProfiles)))
s.mux.Handle("GET /api/v1/admin/clients", s.requireAdmin(http.HandlerFunc(s.listClients)))
s.mux.Handle("DELETE /api/v1/admin/clients/{id}", s.requireAdmin(http.HandlerFunc(s.deleteClient)))
s.mux.Handle("GET /api/v1/profiles/{name}/manifest", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.getManifest)))
s.mux.Handle("GET /api/v1/artifacts/{policy}/{version}", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.getArtifact)))
s.mux.Handle("POST /api/v1/client/report", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.clientReport)))
}
func (s *Server) uploadPolicy(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := store.ValidateName(name); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload)
mr, err := r.MultipartReader()
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("multipart/form-data required: %w", err))
return
}
tmp, err := os.CreateTemp("", "gpo-upload-*.zip")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
defer tmp.Close()
note := ""
force := false
found := false
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
switch part.FormName() {
case "bundle":
if found {
part.Close()
writeError(w, http.StatusBadRequest, errors.New("only one bundle is allowed"))
return
}
found = true
if _, err := io.Copy(tmp, io.LimitReader(part, s.cfg.MaxUpload+1)); err != nil {
part.Close()
writeError(w, http.StatusBadRequest, err)
return
}
case "note":
b, err := io.ReadAll(io.LimitReader(part, 4097))
if err != nil {
part.Close()
writeError(w, http.StatusBadRequest, err)
return
}
if len(b) > 4096 {
part.Close()
writeError(w, http.StatusBadRequest, errors.New("note too long"))
return
}
note = string(b)
case "force":
b, err := io.ReadAll(io.LimitReader(part, 16))
if err != nil {
part.Close()
writeError(w, http.StatusBadRequest, err)
return
}
force = strings.EqualFold(strings.TrimSpace(string(b)), "true")
}
part.Close()
}
if !found {
writeError(w, http.StatusBadRequest, errors.New("multipart field 'bundle' is required"))
return
}
if err := tmp.Close(); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
inspection, err := bundle.InspectZip(tmpName)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
version, created, err := s.store.ImportPolicy(name, note, tmpName, inspection, time.Now(), force)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
status := http.StatusCreated
if !created {
status = http.StatusOK
}
writeJSON(w, status, map[string]any{"created": created, "version": version})
}
func (s *Server) listPolicies(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.store.ListPolicies())
}
func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) {
p, err := s.store.GetPolicy(r.PathValue("name"))
if err != nil {
handleStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := store.ValidateName(name); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := s.store.DeletePolicy(name); err != nil {
handleStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) deletePolicyVersion(w http.ResponseWriter, r *http.Request) {
name, version := r.PathValue("name"), r.PathValue("version")
if err := store.ValidateName(name); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := store.ValidateName(version); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := s.store.DeletePolicyVersion(name, version); err != nil {
handleStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) setProfile(w http.ResponseWriter, r *http.Request) {
var body struct {
Policies []model.ProfilePolicy `json:"policies"`
}
if err := decodeJSON(w, r, &body, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
p, err := s.store.SetProfile(r.PathValue("name"), body.Policies, time.Now())
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
} else {
writeError(w, http.StatusBadRequest, err)
}
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) listProfiles(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.store.ListProfiles())
}
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := store.ValidateName(name); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := s.store.DeleteProfile(name); err != nil {
handleStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) listClients(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.store.ListClientReports())
}
func (s *Server) deleteClient(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteClientReport(r.PathValue("id")); err != nil {
handleStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getManifest(w http.ResponseWriter, r *http.Request) {
manifest, err := s.store.ResolveManifest(r.PathValue("name"))
if err != nil {
handleStoreError(w, err)
return
}
etag := `"` + manifest.Generation + `"`
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
body, err := json.Marshal(manifest)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
mac := hmac.New(sha256.New, []byte(s.cfg.SigningKey))
_, _ = mac.Write(body)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("X-GPO-Signature", "hmac-sha256="+hex.EncodeToString(mac.Sum(nil)))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
policy, version := r.PathValue("policy"), r.PathValue("version")
if err := store.ValidateName(policy); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := store.ValidateName(version); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
meta, filename, err := s.store.Artifact(policy, version)
if err != nil {
handleStoreError(w, err)
return
}
f, err := os.Open(filename)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filepath.Base(filename)))
w.Header().Set("ETag", `"`+meta.ArtifactHash+`"`)
w.Header().Set("X-Content-SHA256", meta.ArtifactHash)
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(filename), st.ModTime(), f)
}
func (s *Server) clientReport(w http.ResponseWriter, r *http.Request) {
var report model.ClientReport
if err := decodeJSON(w, r, &report, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
report.ReportedAt = time.Now().UTC()
if err := s.store.PutClientReport(report); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) requireToken(expected string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, errors.New("missing bearer token"))
return
}
provided := strings.TrimPrefix(auth, "Bearer ")
if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
writeError(w, http.StatusForbidden, errors.New("invalid bearer token"))
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
s.cfg.Logger.Printf("method=%s path=%s remote=%s duration=%s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start).Round(time.Millisecond))
})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) error {
r.Body = http.MaxBytesReader(w, r.Body, limit)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one JSON value")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func handleStoreError(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return
}
if errors.Is(err, store.ErrConflict) {
writeError(w, http.StatusConflict, err)
return
}
writeError(w, http.StatusInternalServerError, err)
}

865
internal/httpapi/ui/app.js Normal file
View File

@@ -0,0 +1,865 @@
"use strict";
const state = {
csrf: "",
version: "dev",
policies: [],
profiles: [],
clients: [],
route: "dashboard",
policyFilter: "",
clientFilter: "",
clientStatus: "all",
clientProfile: "all",
loading: false,
};
const refs = {
loginView: document.querySelector("#login-view"),
appView: document.querySelector("#app-view"),
loginForm: document.querySelector("#login-form"),
loginError: document.querySelector("#login-error"),
tokenInput: document.querySelector("#admin-token"),
toggleToken: document.querySelector("#toggle-token"),
logout: document.querySelector("#logout-button"),
pageContent: document.querySelector("#page-content"),
pageTitle: document.querySelector("#page-title"),
pageEyebrow: document.querySelector("#page-eyebrow"),
primaryAction: document.querySelector("#primary-action"),
refresh: document.querySelector("#refresh-button"),
lastRefresh: document.querySelector("#last-refresh"),
serverVersion: document.querySelector("#server-version"),
navPolicyCount: document.querySelector("#nav-policy-count"),
navProfileCount: document.querySelector("#nav-profile-count"),
navClientCount: document.querySelector("#nav-client-count"),
modalBackdrop: document.querySelector("#modal-backdrop"),
modal: document.querySelector("#modal"),
modalTitle: document.querySelector("#modal-title"),
modalEyebrow: document.querySelector("#modal-eyebrow"),
modalContent: document.querySelector("#modal-content"),
modalClose: document.querySelector("#modal-close"),
confirmBackdrop: document.querySelector("#confirm-backdrop"),
confirmTitle: document.querySelector("#confirm-title"),
confirmMessage: document.querySelector("#confirm-message"),
confirmSubmit: document.querySelector("#confirm-submit"),
confirmCancel: document.querySelector("#confirm-cancel"),
toastRegion: document.querySelector("#toast-region"),
};
const routeMeta = {
dashboard: { title: "Übersicht", eyebrow: "Verwaltung", action: "" },
policies: { title: "Richtlinien", eyebrow: "Policy Repository", action: "Richtlinie hochladen" },
profiles: { title: "Profile", eyebrow: "Zuweisungen", action: "Profil erstellen" },
clients: { title: "Clients", eyebrow: "Agent-Status", action: "" },
};
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function encoded(value) {
return encodeURIComponent(String(value));
}
function decoded(value) {
return decodeURIComponent(String(value));
}
function formatDate(value, withSeconds = false) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: withSeconds ? "medium" : "short",
}).format(date);
}
function relativeDate(value) {
if (!value) return "nie";
const date = new Date(value);
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
const abs = Math.abs(seconds);
const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" });
if (abs < 60) return formatter.format(seconds, "second");
if (abs < 3600) return formatter.format(Math.round(seconds / 60), "minute");
if (abs < 86400) return formatter.format(Math.round(seconds / 3600), "hour");
return formatter.format(Math.round(seconds / 86400), "day");
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = value;
let unit = -1;
do {
size /= 1024;
unit += 1;
} while (size >= 1024 && unit < units.length - 1);
return `${size.toLocaleString("de-DE", { maximumFractionDigits: size >= 10 ? 1 : 2 })} ${units[unit]}`;
}
function shortHash(value, length = 12) {
if (!value) return "—";
return `${String(value).slice(0, length)}`;
}
function isStale(client) {
if (!client.reported_at) return true;
return Date.now() - new Date(client.reported_at).getTime() > 24 * 60 * 60 * 1000;
}
async function request(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const headers = new Headers(options.headers || {});
if (!["GET", "HEAD", "OPTIONS"].includes(method) && state.csrf) {
headers.set("X-CSRF-Token", state.csrf);
}
if (options.json !== undefined) {
headers.set("Content-Type", "application/json");
options.body = JSON.stringify(options.json);
delete options.json;
}
const response = await fetch(path, {
credentials: "same-origin",
...options,
method,
headers,
});
if (response.status === 401) {
showLogin();
throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden.");
}
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
message = body.error || message;
} catch (_) {
// Keep the HTTP status as fallback.
}
throw new Error(message);
}
if (response.status === 204) return null;
const type = response.headers.get("Content-Type") || "";
return type.includes("application/json") ? response.json() : response;
}
async function bootstrap() {
bindStaticEvents();
try {
const session = await request("/ui/api/session");
state.csrf = session.csrf_token;
state.version = session.version || "dev";
showApp();
await loadAll();
} catch (_) {
showLogin();
}
}
function bindStaticEvents() {
refs.loginForm.addEventListener("submit", login);
refs.toggleToken.addEventListener("click", () => {
const visible = refs.tokenInput.type === "text";
refs.tokenInput.type = visible ? "password" : "text";
refs.toggleToken.textContent = visible ? "Anzeigen" : "Verbergen";
});
refs.logout.addEventListener("click", logout);
refs.refresh.addEventListener("click", () => loadAll(true));
refs.primaryAction.addEventListener("click", () => {
if (state.route === "policies") openUploadDialog();
if (state.route === "profiles") openProfileEditor(null);
});
document.querySelectorAll(".nav-item").forEach((button) => {
button.addEventListener("click", () => navigate(button.dataset.route));
});
refs.pageContent.addEventListener("click", handlePageClick);
refs.pageContent.addEventListener("input", handlePageInput);
refs.pageContent.addEventListener("change", handlePageInput);
refs.modalClose.addEventListener("click", closeModal);
refs.modalBackdrop.addEventListener("click", (event) => {
if (event.target === refs.modalBackdrop) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !refs.modalBackdrop.classList.contains("hidden")) closeModal();
});
window.addEventListener("hashchange", () => {
const route = location.hash.replace(/^#\/?/, "");
if (routeMeta[route]) setRoute(route, false);
});
}
async function login(event) {
event.preventDefault();
refs.loginError.textContent = "";
const submit = refs.loginForm.querySelector("button[type='submit']");
submit.disabled = true;
submit.textContent = "Anmeldung läuft …";
try {
const session = await request("/ui/api/session", {
method: "POST",
json: { token: refs.tokenInput.value },
});
state.csrf = session.csrf_token;
state.version = session.version || "dev";
refs.tokenInput.value = "";
showApp();
await loadAll();
} catch (error) {
refs.loginError.textContent = error.message === "invalid credentials" ? "Das Admin-Token ist ungültig." : error.message;
} finally {
submit.disabled = false;
submit.textContent = "Anmelden";
}
}
async function logout() {
try {
await request("/ui/api/session", { method: "DELETE" });
} catch (_) {
// A local logout is still useful if the server session is already invalid.
}
state.csrf = "";
state.policies = [];
state.profiles = [];
state.clients = [];
showLogin();
}
function showLogin() {
refs.appView.classList.add("hidden");
refs.loginView.classList.remove("hidden");
refs.loginError.textContent = "";
setTimeout(() => refs.tokenInput.focus(), 0);
}
function showApp() {
refs.loginView.classList.add("hidden");
refs.appView.classList.remove("hidden");
refs.serverVersion.textContent = `Backend ${state.version}`;
const hashRoute = location.hash.replace(/^#\/?/, "");
setRoute(routeMeta[hashRoute] ? hashRoute : "dashboard", false);
}
async function loadAll(notify = false) {
if (state.loading) return;
state.loading = true;
refs.refresh.disabled = true;
refs.refresh.textContent = "Lädt …";
if (!state.policies.length && !state.profiles.length && !state.clients.length) {
refs.pageContent.innerHTML = loadingMarkup("Verwaltungsdaten werden geladen …");
}
try {
const [policies, profiles, clients] = await Promise.all([
request("/api/v1/admin/policies"),
request("/api/v1/admin/profiles"),
request("/api/v1/admin/clients"),
]);
state.policies = policies || [];
state.profiles = profiles || [];
state.clients = clients || [];
updateCounts();
renderRoute();
refs.lastRefresh.textContent = `Stand ${new Intl.DateTimeFormat("de-DE", { timeStyle: "short" }).format(new Date())}`;
if (notify) toast("success", "Aktualisiert", "Die Verwaltungsdaten sind auf dem neuesten Stand.");
} catch (error) {
refs.pageContent.innerHTML = errorState(error.message);
if (notify) toast("error", "Aktualisierung fehlgeschlagen", error.message);
} finally {
state.loading = false;
refs.refresh.disabled = false;
refs.refresh.textContent = "Aktualisieren";
}
}
function updateCounts() {
refs.navPolicyCount.textContent = state.policies.length;
refs.navProfileCount.textContent = state.profiles.length;
refs.navClientCount.textContent = state.clients.length;
}
function navigate(route) {
if (!routeMeta[route]) return;
location.hash = route;
setRoute(route, false);
}
function setRoute(route, updateHash = true) {
state.route = route;
if (updateHash) location.hash = route;
document.querySelectorAll(".nav-item").forEach((button) => {
button.classList.toggle("active", button.dataset.route === route);
});
const meta = routeMeta[route];
refs.pageTitle.textContent = meta.title;
refs.pageEyebrow.textContent = meta.eyebrow;
refs.primaryAction.textContent = meta.action;
refs.primaryAction.classList.toggle("hidden", !meta.action);
renderRoute();
refs.pageContent.focus({ preventScroll: true });
}
function renderRoute() {
if (refs.appView.classList.contains("hidden")) return;
if (state.route === "dashboard") renderDashboard();
if (state.route === "policies") renderPolicies();
if (state.route === "profiles") renderProfiles();
if (state.route === "clients") renderClients();
}
function renderDashboard() {
const versions = state.policies.reduce((sum, policy) => sum + policy.versions.length, 0);
const healthy = state.clients.filter((client) => client.success && !isStale(client)).length;
const failed = state.clients.filter((client) => !client.success && !isStale(client)).length;
const stale = state.clients.filter(isStale).length;
const healthPercent = state.clients.length ? Math.round((healthy / state.clients.length) * 100) : 0;
const recentVersions = state.policies
.flatMap((policy) => policy.versions.map((version) => ({ ...version, policy: policy.name })))
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 7);
const recentClients = [...state.clients]
.sort((a, b) => new Date(b.reported_at) - new Date(a.reported_at))
.slice(0, 6);
refs.pageContent.innerHTML = `
<section class="stats-grid" aria-label="Kennzahlen">
${statCard("Richtlinienobjekte", state.policies.length, `${versions} unveränderliche Versionen`)}
${statCard("Profile", state.profiles.length, "Geordnete Richtliniensätze")}
${statCard("Gemeldete Clients", state.clients.length, `${healthy} aktuell erfolgreich`)}
${statCard("Handlungsbedarf", failed + stale, `${failed} Fehler · ${stale} länger als 24 h still`)}
</section>
<section class="dashboard-grid">
<div class="panel">
<header class="panel-header"><h2>Letzte Richtlinienversionen</h2><button class="button ghost small" data-action="go-policies">Alle anzeigen</button></header>
<div class="panel-body table-wrap">
${recentVersions.length ? `
<table class="data-table">
<thead><tr><th>Richtlinie</th><th>Version</th><th>Zeitpunkt</th><th>Größe</th></tr></thead>
<tbody>${recentVersions.map((version) => `
<tr>
<td><strong>${escapeHTML(version.policy)}</strong></td>
<td class="mono">${escapeHTML(version.version)}</td>
<td title="${escapeHTML(formatDate(version.created_at, true))}">${escapeHTML(relativeDate(version.created_at))}</td>
<td>${formatBytes(version.size)}</td>
</tr>`).join("")}</tbody>
</table>` : emptyInline("Noch keine Richtlinienversion vorhanden.")}
</div>
</div>
<div class="panel">
<header class="panel-header"><h2>Client-Gesundheit</h2><button class="button ghost small" data-action="go-clients">Details</button></header>
<div class="health-ring">
<div class="ring-chart">
<svg class="ring-svg" viewBox="0 0 42 42" aria-hidden="true">
<circle class="ring-track" cx="21" cy="21" r="15.9155"></circle>
<circle class="ring-progress" cx="21" cy="21" r="15.9155" pathLength="100" stroke-dasharray="${healthPercent} 100"></circle>
</svg>
<div class="ring-center"><span class="ring-value">${healthPercent}%</span><span class="ring-label">aktuell erfolgreich</span></div>
</div>
<div class="health-legend">
<div class="legend-row"><span><span class="badge success">Erfolgreich</span></span><strong>${healthy}</strong></div>
<div class="legend-row"><span><span class="badge error">Fehler</span></span><strong>${failed}</strong></div>
<div class="legend-row"><span><span class="badge warning">Veraltet</span></span><strong>${stale}</strong></div>
</div>
</div>
</div>
</section>
<section class="panel panel-spaced">
<header class="panel-header"><h2>Zuletzt gemeldete Clients</h2></header>
<div class="panel-body table-wrap">
${recentClients.length ? clientTable(recentClients, false) : emptyInline("Noch kein Agent hat einen Status gemeldet.")}
</div>
</section>`;
}
function statCard(label, value, detail) {
return `<article class="stat-card"><span class="stat-label">${escapeHTML(label)}</span><strong class="stat-value">${escapeHTML(value)}</strong><span class="stat-detail">${escapeHTML(detail)}</span></article>`;
}
function renderPolicies() {
refs.pageContent.innerHTML = `
<div class="toolbar">
<div class="search-wrap"><input id="policy-search" type="search" placeholder="Richtlinien durchsuchen" value="${escapeHTML(state.policyFilter)}" aria-label="Richtlinien durchsuchen"></div>
<div class="toolbar-group"><span class="muted small-text">${state.policies.length} Objekte</span></div>
</div>
<div id="policy-list">${policyCards()}</div>`;
}
function policyCards() {
const query = state.policyFilter.trim().toLowerCase();
const policies = state.policies.filter((policy) => policy.name.toLowerCase().includes(query));
if (!policies.length) {
return state.policies.length
? emptyState("⌕", "Keine Richtlinie gefunden", "Passe den Suchbegriff an.", "")
: emptyState("▤", "Noch keine Richtlinie", "Lade die erste Microsoft-GPO-Sicherung als ZIP hoch.", `<button class="button primary" data-action="upload-policy">Richtlinie hochladen</button>`);
}
return `<div class="policy-list">${policies.map((policy) => {
const versions = [...policy.versions].reverse();
const latest = versions[0];
return `<article class="policy-card">
<div class="policy-summary">
<div>
<div class="policy-title-row"><h2>${escapeHTML(policy.name)}</h2><span class="badge blue">${policy.versions.length} Version${policy.versions.length === 1 ? "" : "en"}</span></div>
<div class="policy-meta">
<span>Aktuell: <span class="mono">${escapeHTML(latest?.version || "—")}</span></span>
<span>Geändert: ${escapeHTML(relativeDate(latest?.created_at))}</span>
<span>${formatBytes(latest?.size || 0)}</span>
</div>
</div>
<div class="policy-actions">
<button class="button secondary small" data-action="upload-to-policy" data-policy="${encoded(policy.name)}">Neue Version</button>
<button class="button danger-soft small" data-action="delete-policy" data-policy="${encoded(policy.name)}">Löschen</button>
</div>
</div>
<div class="version-list">
${versions.map((version, index) => `
<div class="version-row">
<div>
<div class="version-id mono">${escapeHTML(version.version)} ${index === 0 ? '<span class="badge success">latest</span>' : ""}</div>
<div class="small-text muted" title="${escapeHTML(formatDate(version.created_at, true))}">${escapeHTML(formatDate(version.created_at))}</div>
</div>
<div class="version-note">${version.note ? escapeHTML(version.note) : '<span class="muted">Keine Notiz</span>'}</div>
<div>
<div class="hash-line"><span>Semantik</span><span class="mono truncate" title="${escapeHTML(version.semantic_sha256)}">${escapeHTML(shortHash(version.semantic_sha256, 18))}</span><button class="icon-button" data-action="copy" data-copy="${escapeHTML(version.semantic_sha256)}">Kopieren</button></div>
<div class="hash-line"><span>${version.policy_file_count} Policy-Dateien · ${version.file_count} gesamt · ${formatBytes(version.size)}</span></div>
</div>
<div class="row-buttons">
<button class="button secondary small" data-action="download-version" data-policy="${encoded(policy.name)}" data-version="${encoded(version.version)}">ZIP</button>
<button class="button danger-soft small" data-action="delete-version" data-policy="${encoded(policy.name)}" data-version="${encoded(version.version)}" ${policy.versions.length === 1 ? "disabled title=\"Letzte Version: Richtlinie vollständig löschen\"" : ""}>Löschen</button>
</div>
</div>`).join("")}
</div>
</article>`;
}).join("")}</div>`;
}
function renderProfiles() {
if (!state.profiles.length) {
refs.pageContent.innerHTML = emptyState("◫", "Noch kein Profil", "Ein Profil definiert die Reihenfolge und Versionen, die ein Client anwenden soll.", `<button class="button primary" data-action="create-profile" ${state.policies.length ? "" : "disabled"}>Profil erstellen</button>`);
return;
}
refs.pageContent.innerHTML = `
<div class="section-heading"><div><h2>Richtlinienzuweisungen</h2><p>Die Reihenfolge bestimmt, in welcher Reihenfolge LGPO die Sicherungen importiert.</p></div></div>
<div class="profile-grid">${state.profiles.map((profile) => `
<article class="profile-card">
<div class="profile-card-header">
<div><h2>${escapeHTML(profile.name)}</h2><div class="small-text muted">Aktualisiert ${escapeHTML(relativeDate(profile.updated_at))}</div></div>
<span class="badge neutral">${profile.policies.length} Richtlinien</span>
</div>
<div class="policy-stack">${profile.policies.map((ref, index) => `
<div class="policy-stack-item"><span class="order-number">${index + 1}</span><strong>${escapeHTML(ref.policy)}</strong><span class="badge ${ref.version === "latest" ? "blue" : "neutral"}">${escapeHTML(ref.version)}</span></div>`).join("")}</div>
<footer class="profile-card-footer">
<span class="small-text muted">${escapeHTML(formatDate(profile.updated_at))}</span>
<div class="toolbar-group">
<button class="button secondary small" data-action="edit-profile" data-profile="${encoded(profile.name)}">Bearbeiten</button>
<button class="button danger-soft small" data-action="delete-profile" data-profile="${encoded(profile.name)}">Löschen</button>
</div>
</footer>
</article>`).join("")}</div>`;
}
function renderClients() {
const profiles = [...new Set(state.clients.map((client) => client.profile).filter(Boolean))].sort();
const visible = filteredClients();
refs.pageContent.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<div class="search-wrap"><input id="client-search" type="search" placeholder="Client, Hostname oder Meldung" value="${escapeHTML(state.clientFilter)}" aria-label="Clients durchsuchen"></div>
<select id="client-status" class="filter-select" aria-label="Status filtern">
<option value="all" ${state.clientStatus === "all" ? "selected" : ""}>Alle Status</option>
<option value="success" ${state.clientStatus === "success" ? "selected" : ""}>Erfolgreich</option>
<option value="error" ${state.clientStatus === "error" ? "selected" : ""}>Fehler</option>
<option value="stale" ${state.clientStatus === "stale" ? "selected" : ""}>Veraltet</option>
</select>
<select id="client-profile" class="filter-select" aria-label="Profil filtern">
<option value="all">Alle Profile</option>
${profiles.map((profile) => `<option value="${escapeHTML(profile)}" ${state.clientProfile === profile ? "selected" : ""}>${escapeHTML(profile)}</option>`).join("")}
</select>
</div>
<span class="muted small-text">${visible.length} von ${state.clients.length}</span>
</div>
<section class="panel">
<div id="client-table" class="panel-body table-wrap">${visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter.")}</div>
</section>`;
}
function filteredClients() {
const query = state.clientFilter.trim().toLowerCase();
return state.clients.filter((client) => {
const haystack = [client.client_id, client.hostname, client.profile, client.message, client.operating_system].join(" ").toLowerCase();
if (query && !haystack.includes(query)) return false;
if (state.clientProfile !== "all" && client.profile !== state.clientProfile) return false;
if (state.clientStatus === "stale" && !isStale(client)) return false;
if (state.clientStatus === "success" && (!client.success || isStale(client))) return false;
if (state.clientStatus === "error" && (client.success || isStale(client))) return false;
return true;
});
}
function clientTable(clients, includeActions) {
return `<table class="data-table">
<thead><tr><th>Client</th><th>Status</th><th>Profil</th><th>Generation</th><th>Letzte Meldung</th><th>System</th>${includeActions ? "<th></th>" : ""}</tr></thead>
<tbody>${clients.map((client) => {
const stale = isStale(client);
const status = stale ? '<span class="badge warning">Veraltet</span>' : client.success ? '<span class="badge success">Erfolgreich</span>' : '<span class="badge error">Fehler</span>';
return `<tr class="${stale ? "client-stale" : ""}">
<td><div class="client-name">${escapeHTML(client.hostname || client.client_id)}</div><div class="client-id mono">${escapeHTML(client.client_id)}</div>${client.message ? `<div class="client-message small-text muted" title="${escapeHTML(client.message)}">${escapeHTML(client.message)}</div>` : ""}</td>
<td>${status}</td>
<td><span class="badge neutral">${escapeHTML(client.profile || "—")}</span></td>
<td class="mono" title="${escapeHTML(client.generation || "")}">${escapeHTML(shortHash(client.generation, 10))}</td>
<td title="${escapeHTML(formatDate(client.reported_at, true))}">${escapeHTML(relativeDate(client.reported_at))}</td>
<td><div>${escapeHTML(client.operating_system || "—")}</div><div class="small-text muted">Agent ${escapeHTML(client.agent_version || "—")}</div></td>
${includeActions ? `<td class="actions"><button class="button danger-soft small" data-action="delete-client" data-client="${encoded(client.client_id)}">Entfernen</button></td>` : ""}
</tr>`;
}).join("")}</tbody>
</table>`;
}
function handlePageInput(event) {
if (event.target.id === "policy-search") {
state.policyFilter = event.target.value;
const list = document.querySelector("#policy-list");
if (list) list.innerHTML = policyCards();
}
if (event.target.id === "client-search") state.clientFilter = event.target.value;
if (event.target.id === "client-status") state.clientStatus = event.target.value;
if (event.target.id === "client-profile") state.clientProfile = event.target.value;
if (["client-search", "client-status", "client-profile"].includes(event.target.id)) {
const table = document.querySelector("#client-table");
const visible = filteredClients();
if (table) table.innerHTML = visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter.");
}
}
async function handlePageClick(event) {
const button = event.target.closest("[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "retry") await loadAll(true);
if (action === "go-policies") navigate("policies");
if (action === "go-clients") navigate("clients");
if (action === "upload-policy" || action === "create-profile") {
action === "upload-policy" ? openUploadDialog() : openProfileEditor(null);
}
if (action === "upload-to-policy") openUploadDialog(decoded(button.dataset.policy));
if (action === "edit-profile") openProfileEditor(state.profiles.find((profile) => profile.name === decoded(button.dataset.profile)));
if (action === "delete-policy") await deletePolicy(decoded(button.dataset.policy));
if (action === "delete-version") await deleteVersion(decoded(button.dataset.policy), decoded(button.dataset.version));
if (action === "download-version") await downloadVersion(decoded(button.dataset.policy), decoded(button.dataset.version), button);
if (action === "delete-profile") await deleteProfile(decoded(button.dataset.profile));
if (action === "delete-client") await deleteClient(decoded(button.dataset.client));
if (action === "copy") await copyText(button.dataset.copy || "");
}
function openUploadDialog(fixedPolicy = "") {
openModal("Policy Repository", fixedPolicy ? "Neue Richtlinienversion" : "Richtlinie hochladen", `
<form id="upload-form">
<div class="form-grid">
<div class="field-group">
<label for="upload-policy-name">Richtlinienname</label>
<input id="upload-policy-name" name="policy" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" maxlength="64" value="${escapeHTML(fixedPolicy)}" ${fixedPolicy ? "disabled" : ""} required>
<p class="field-help">Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.</p>
</div>
<div class="field-group">
<label for="upload-file">Microsoft-GPO-Sicherung</label>
<input id="upload-file" name="bundle" type="file" accept=".zip,application/zip" required>
<p class="field-help">ZIP mit backup.xml und DomainSysvol/GPO.</p>
</div>
<div class="field-group full">
<label for="upload-note">Änderungsnotiz</label>
<textarea id="upload-note" name="note" maxlength="4096" placeholder="Zum Beispiel Change-ID und kurze Beschreibung"></textarea>
</div>
<div class="checkbox-row full">
<input id="upload-force" name="force" type="checkbox">
<label for="upload-force">Neue Version auch bei identischem semantischem Hash erzwingen</label>
</div>
</div>
<p id="upload-error" class="form-error" role="alert"></p>
<div id="upload-progress" class="upload-progress hidden"><div class="upload-progress-bar"></div></div>
<div class="dialog-actions"><button class="button secondary" type="button" data-modal-close>Abbrechen</button><button class="button primary" type="submit">Prüfen und hochladen</button></div>
</form>`);
refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal);
refs.modalContent.querySelector("#upload-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const error = form.querySelector("#upload-error");
const submit = form.querySelector("button[type='submit']");
const progress = form.querySelector("#upload-progress");
error.textContent = "";
submit.disabled = true;
submit.textContent = "Upload läuft …";
progress.classList.remove("hidden");
const policy = fixedPolicy || form.elements.policy.value.trim();
const data = new FormData();
data.append("bundle", form.elements.bundle.files[0]);
data.append("note", form.elements.note.value);
data.append("force", form.elements.force.checked ? "true" : "false");
try {
const result = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions`, { method: "POST", body: data });
closeModal();
await loadAll();
navigate("policies");
toast("success", result.created ? "Version angelegt" : "Keine Änderung erkannt", result.created ? `${policy} · ${result.version.version}` : `Der semantische Inhalt entspricht bereits ${result.version.version}.`);
} catch (uploadError) {
error.textContent = uploadError.message;
} finally {
submit.disabled = false;
submit.textContent = "Prüfen und hochladen";
progress.classList.add("hidden");
}
});
}
function openProfileEditor(profile) {
if (!state.policies.length) {
toast("error", "Keine Richtlinien vorhanden", "Lade zuerst mindestens eine Richtlinie hoch.");
return;
}
const editing = Boolean(profile);
let rows = profile ? profile.policies.map((item) => ({ ...item })) : [{ policy: state.policies[0].name, version: "latest" }];
openModal("Zuweisungen", editing ? "Profil bearbeiten" : "Profil erstellen", `
<form id="profile-form">
<div class="field-group">
<label for="profile-name">Profilname</label>
<input id="profile-name" name="name" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" maxlength="64" value="${escapeHTML(profile?.name || "")}" ${editing ? "disabled" : ""} required>
</div>
<div class="section-heading compact"><div><h2>Reihenfolge</h2><p>Später importierte Richtlinien können frühere Einstellungen überschreiben.</p></div><button id="add-profile-row" class="button secondary small" type="button">Richtlinie hinzufügen</button></div>
<div id="profile-rows" class="profile-editor-rows"></div>
<p id="profile-error" class="form-error" role="alert"></p>
<div class="dialog-actions"><button class="button secondary" type="button" data-modal-close>Abbrechen</button><button class="button primary" type="submit">Profil speichern</button></div>
</form>`, true);
const rowsElement = refs.modalContent.querySelector("#profile-rows");
const renderRows = () => {
rowsElement.innerHTML = rows.map((row, index) => {
const policy = state.policies.find((item) => item.name === row.policy) || state.policies[0];
if (!policy) return "";
const versions = [...policy.versions].reverse();
const validVersion = row.version === "latest" || versions.some((version) => version.version === row.version) ? row.version : "latest";
row.policy = policy.name;
row.version = validVersion;
return `<div class="profile-editor-row" data-row="${index}">
<span class="drag-number">${index + 1}</span>
<select data-field="policy" aria-label="Richtlinie ${index + 1}">${state.policies.map((item) => `<option value="${escapeHTML(item.name)}" ${item.name === row.policy ? "selected" : ""}>${escapeHTML(item.name)}</option>`).join("")}</select>
<select data-field="version" aria-label="Version ${index + 1}"><option value="latest" ${row.version === "latest" ? "selected" : ""}>latest immer aktuell</option>${versions.map((version) => `<option value="${escapeHTML(version.version)}" ${version.version === row.version ? "selected" : ""}>${escapeHTML(version.version)}</option>`).join("")}</select>
<div class="row-buttons">
<button class="icon-button" type="button" data-row-action="up" title="Nach oben" ${index === 0 ? "disabled" : ""}>↑</button>
<button class="icon-button" type="button" data-row-action="down" title="Nach unten" ${index === rows.length - 1 ? "disabled" : ""}>↓</button>
<button class="icon-button" type="button" data-row-action="remove" title="Entfernen" ${rows.length === 1 ? "disabled" : ""}>×</button>
</div>
</div>`;
}).join("");
};
renderRows();
refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal);
refs.modalContent.querySelector("#add-profile-row").addEventListener("click", () => {
const used = new Set(rows.map((row) => row.policy));
const next = state.policies.find((policy) => !used.has(policy.name));
if (!next) {
toast("error", "Keine weitere Richtlinie", "Jede Richtlinie darf pro Profil nur einmal vorkommen.");
return;
}
rows.push({ policy: next.name, version: "latest" });
renderRows();
});
rowsElement.addEventListener("change", (event) => {
const rowElement = event.target.closest("[data-row]");
if (!rowElement) return;
const index = Number(rowElement.dataset.row);
if (event.target.dataset.field === "policy") {
rows[index] = { policy: event.target.value, version: "latest" };
renderRows();
}
if (event.target.dataset.field === "version") rows[index].version = event.target.value;
});
rowsElement.addEventListener("click", (event) => {
const button = event.target.closest("[data-row-action]");
if (!button) return;
const index = Number(button.closest("[data-row]").dataset.row);
if (button.dataset.rowAction === "up" && index > 0) [rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
if (button.dataset.rowAction === "down" && index < rows.length - 1) [rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
if (button.dataset.rowAction === "remove" && rows.length > 1) rows.splice(index, 1);
renderRows();
});
refs.modalContent.querySelector("#profile-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const error = form.querySelector("#profile-error");
const submit = form.querySelector("button[type='submit']");
const name = profile?.name || form.elements.name.value.trim();
const unique = new Set(rows.map((row) => row.policy));
if (unique.size !== rows.length) {
error.textContent = "Eine Richtlinie darf im Profil nur einmal vorkommen.";
return;
}
error.textContent = "";
submit.disabled = true;
submit.textContent = "Speichert …";
try {
await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "PUT", json: { policies: rows } });
closeModal();
await loadAll();
navigate("profiles");
toast("success", editing ? "Profil aktualisiert" : "Profil erstellt", `${name} enthält ${rows.length} Richtlinien.`);
} catch (saveError) {
error.textContent = saveError.message;
} finally {
submit.disabled = false;
submit.textContent = "Profil speichern";
}
});
}
async function deletePolicy(name) {
const ok = await confirmAction("Richtlinie löschen", `Alle Versionen und ZIP-Artefakte von „${name}“ werden dauerhaft gelöscht. Profile, die diese Richtlinie verwenden, müssen vorher angepasst werden.`, "Richtlinie löschen");
if (!ok) return;
try {
await request(`/api/v1/admin/policies/${encodeURIComponent(name)}`, { method: "DELETE" });
await loadAll();
toast("success", "Richtlinie gelöscht", name);
} catch (error) {
toast("error", "Löschen nicht möglich", error.message);
}
}
async function deleteVersion(policy, version) {
const ok = await confirmAction("Version löschen", `Die unveränderliche Version „${version}“ von „${policy}“ wird einschließlich ZIP-Artefakt gelöscht. Fest angeheftete Profilversionen müssen vorher geändert werden.`, "Version löschen");
if (!ok) return;
try {
await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}`, { method: "DELETE" });
await loadAll();
toast("success", "Version gelöscht", version);
} catch (error) {
toast("error", "Löschen nicht möglich", error.message);
}
}
async function deleteProfile(name) {
const ok = await confirmAction("Profil löschen", `Das Profil „${name}“ wird dauerhaft entfernt. Bereits konfigurierte Agents erhalten danach für dieses Profil HTTP 404.`, "Profil löschen");
if (!ok) return;
try {
await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
await loadAll();
toast("success", "Profil gelöscht", name);
} catch (error) {
toast("error", "Löschen nicht möglich", error.message);
}
}
async function deleteClient(clientID) {
const ok = await confirmAction("Client-Eintrag entfernen", `Der zuletzt gespeicherte Status von „${clientID}“ wird entfernt. Der Agent erscheint bei seiner nächsten Meldung automatisch wieder.`, "Eintrag entfernen");
if (!ok) return;
try {
await request(`/api/v1/admin/clients/${encodeURIComponent(clientID)}`, { method: "DELETE" });
await loadAll();
toast("success", "Client-Eintrag entfernt", clientID);
} catch (error) {
toast("error", "Entfernen nicht möglich", error.message);
}
}
async function downloadVersion(policy, version, button) {
const old = button.textContent;
button.disabled = true;
button.textContent = "Lädt …";
try {
const response = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}/artifact`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${policy}-${version}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (error) {
toast("error", "ZIP konnte nicht geladen werden", error.message);
} finally {
button.disabled = false;
button.textContent = old;
}
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast("success", "Kopiert", "Der Hash wurde in die Zwischenablage kopiert.");
} catch (_) {
toast("error", "Kopieren fehlgeschlagen", "Die Zwischenablage ist für diese Seite nicht verfügbar.");
}
}
function openModal(eyebrow, title, content, wide = false) {
refs.modalEyebrow.textContent = eyebrow;
refs.modalTitle.textContent = title;
refs.modalContent.innerHTML = content;
refs.modal.classList.toggle("wide-modal", wide);
refs.modalBackdrop.classList.remove("hidden");
document.body.style.overflow = "hidden";
setTimeout(() => refs.modalContent.querySelector("input, select, textarea, button")?.focus(), 0);
}
function closeModal() {
refs.modalBackdrop.classList.add("hidden");
refs.modal.classList.remove("wide-modal");
refs.modalContent.innerHTML = "";
document.body.style.overflow = "";
}
function confirmAction(title, message, submitLabel) {
refs.confirmTitle.textContent = title;
refs.confirmMessage.textContent = message;
refs.confirmSubmit.textContent = submitLabel;
refs.confirmBackdrop.classList.remove("hidden");
return new Promise((resolve) => {
const finish = (value) => {
refs.confirmBackdrop.classList.add("hidden");
refs.confirmSubmit.removeEventListener("click", accept);
refs.confirmCancel.removeEventListener("click", cancel);
refs.confirmBackdrop.removeEventListener("click", backdrop);
resolve(value);
};
const accept = () => finish(true);
const cancel = () => finish(false);
const backdrop = (event) => { if (event.target === refs.confirmBackdrop) finish(false); };
refs.confirmSubmit.addEventListener("click", accept);
refs.confirmCancel.addEventListener("click", cancel);
refs.confirmBackdrop.addEventListener("click", backdrop);
refs.confirmCancel.focus();
});
}
function toast(type, title, message) {
const node = document.createElement("div");
node.className = `toast ${type}`;
node.innerHTML = `<span class="toast-bar"></span><div><strong>${escapeHTML(title)}</strong><p>${escapeHTML(message)}</p></div><button type="button" aria-label="Meldung schließen">×</button>`;
node.querySelector("button").addEventListener("click", () => node.remove());
refs.toastRegion.appendChild(node);
setTimeout(() => node.remove(), 6000);
}
function loadingMarkup(message) {
return `<div class="loading-state"><div><div class="spinner"></div>${escapeHTML(message)}</div></div>`;
}
function errorState(message) {
return emptyState("!", "Daten konnten nicht geladen werden", message, '<button class="button primary" data-action="retry">Erneut versuchen</button>');
}
function emptyState(icon, title, message, action) {
return `<div class="empty-state"><div class="empty-state-icon">${escapeHTML(icon)}</div><h2>${escapeHTML(title)}</h2><p>${escapeHTML(message)}</p>${action}</div>`;
}
function emptyInline(message) {
return `<div class="empty-state empty-inline"><p>${escapeHTML(message)}</p></div>`;
}
bootstrap();

View File

@@ -0,0 +1,103 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>GPO Distributor</title>
<link rel="stylesheet" href="/ui/styles.css">
</head>
<body>
<div id="login-view" class="login-view">
<main class="login-card" aria-labelledby="login-title">
<div class="brand-mark" aria-hidden="true">GP</div>
<p class="eyebrow">Zentrale Richtlinienverwaltung</p>
<h1 id="login-title">GPO Distributor</h1>
<p class="muted">Melde dich mit dem Admin-Token des Backends an.</p>
<form id="login-form" class="stack">
<label for="admin-token">Admin-Token</label>
<div class="password-field">
<input id="admin-token" name="token" type="password" autocomplete="current-password" required autofocus>
<button id="toggle-token" class="icon-button" type="button" aria-label="Token anzeigen">Anzeigen</button>
</div>
<p id="login-error" class="form-error" role="alert"></p>
<button class="button primary wide" type="submit">Anmelden</button>
</form>
<p class="login-hint">Die Browser-Session ist acht Stunden gültig. Das Token wird nicht im Browser gespeichert.</p>
</main>
</div>
<div id="app-view" class="app-shell hidden">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark small" aria-hidden="true">GP</div>
<div>
<strong>GPO Distributor</strong>
<span id="server-version">Backend</span>
</div>
</div>
<nav class="nav-list" aria-label="Hauptnavigation">
<button class="nav-item active" data-route="dashboard">
<span class="nav-icon"></span><span>Übersicht</span>
</button>
<button class="nav-item" data-route="policies">
<span class="nav-icon"></span><span>Richtlinien</span><span id="nav-policy-count" class="nav-count">0</span>
</button>
<button class="nav-item" data-route="profiles">
<span class="nav-icon"></span><span>Profile</span><span id="nav-profile-count" class="nav-count">0</span>
</button>
<button class="nav-item" data-route="clients">
<span class="nav-icon"></span><span>Clients</span><span id="nav-client-count" class="nav-count">0</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="connection-state"><span class="status-dot"></span> Verbunden</div>
<button id="logout-button" class="button ghost dark wide" type="button">Abmelden</button>
</div>
</aside>
<div class="main-column">
<header class="topbar">
<div>
<p id="page-eyebrow" class="eyebrow">Verwaltung</p>
<h1 id="page-title">Übersicht</h1>
</div>
<div class="topbar-actions">
<span id="last-refresh" class="refresh-time"></span>
<button id="refresh-button" class="button secondary" type="button">Aktualisieren</button>
<button id="primary-action" class="button primary hidden" type="button"></button>
</div>
</header>
<main id="page-content" class="page-content" tabindex="-1"></main>
</div>
</div>
<div id="modal-backdrop" class="modal-backdrop hidden" role="presentation">
<section id="modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header class="modal-header">
<div>
<p id="modal-eyebrow" class="eyebrow"></p>
<h2 id="modal-title"></h2>
</div>
<button id="modal-close" class="icon-button close-button" type="button" aria-label="Dialog schließen">×</button>
</header>
<div id="modal-content" class="modal-content"></div>
</section>
</div>
<div id="confirm-backdrop" class="modal-backdrop hidden" role="presentation">
<section class="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
<div class="danger-icon" aria-hidden="true">!</div>
<h2 id="confirm-title">Aktion bestätigen</h2>
<p id="confirm-message"></p>
<div class="dialog-actions">
<button id="confirm-cancel" class="button secondary" type="button">Abbrechen</button>
<button id="confirm-submit" class="button danger" type="button">Löschen</button>
</div>
</section>
</div>
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
<script src="/ui/app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,367 @@
:root {
--navy-950: #0b1428;
--navy-900: #111d35;
--navy-800: #1a2947;
--blue-700: #1e5bb8;
--blue-600: #2871d5;
--blue-100: #eaf2ff;
--slate-950: #172033;
--slate-700: #44516a;
--slate-600: #647088;
--slate-500: #7b879d;
--slate-300: #cbd3df;
--slate-200: #dde3ec;
--slate-100: #edf1f6;
--slate-50: #f6f8fb;
--white: #ffffff;
--green-700: #14724a;
--green-100: #e1f5eb;
--amber-700: #976315;
--amber-100: #fff2d8;
--red-700: #b4232f;
--red-100: #fde9eb;
--shadow-sm: 0 1px 2px rgba(12, 25, 48, .06), 0 1px 4px rgba(12, 25, 48, .04);
--shadow-lg: 0 22px 55px rgba(11, 20, 40, .24);
--radius: 14px;
--radius-sm: 9px;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--slate-950);
background: var(--slate-50);
line-height: 1.45;
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
.muted { color: var(--slate-600); }
.small-text { font-size: .85rem; }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eyebrow {
margin: 0 0 3px;
color: var(--blue-700);
font-size: .72rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
.login-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: 30px;
background:
radial-gradient(circle at 12% 14%, rgba(40, 113, 213, .34), transparent 28%),
radial-gradient(circle at 82% 72%, rgba(46, 196, 143, .14), transparent 25%),
linear-gradient(145deg, var(--navy-950), #15284a 64%, #17365b);
}
.login-card {
width: min(430px, 100%);
padding: 38px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 22px;
background: rgba(255,255,255,.97);
box-shadow: var(--shadow-lg);
}
.login-card h1 { margin: 6px 0 9px; font-size: 2rem; letter-spacing: -.04em; }
.login-card .brand-mark { margin-bottom: 24px; }
.login-hint { margin: 22px 0 0; color: var(--slate-500); font-size: .8rem; }
.brand-mark {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 14px;
color: var(--white);
background: linear-gradient(145deg, var(--blue-600), #17478f);
box-shadow: 0 8px 18px rgba(30,91,184,.28);
font-size: .9rem;
font-weight: 900;
letter-spacing: .04em;
}
.brand-mark.small { width: 38px; height: 38px; border-radius: 10px; box-shadow: none; font-size: .72rem; }
.stack { display: grid; gap: 10px; margin-top: 25px; }
label { color: var(--slate-700); font-size: .86rem; font-weight: 700; }
input, select, textarea {
width: 100%;
border: 1px solid var(--slate-300);
border-radius: var(--radius-sm);
padding: 10px 12px;
color: var(--slate-950);
background: var(--white);
outline: none;
transition: border-color .15s, box-shadow .15s;
}
textarea { resize: vertical; min-height: 92px; }
input:focus, select:focus, textarea:focus {
border-color: var(--blue-600);
box-shadow: 0 0 0 3px rgba(40,113,213,.14);
}
input:disabled, select:disabled { background: var(--slate-100); color: var(--slate-600); cursor: not-allowed; }
.password-field { position: relative; }
.password-field input { padding-right: 88px; }
.password-field .icon-button { position: absolute; top: 50%; right: 7px; transform: translateY(-50%); }
.form-error { min-height: 1.25em; margin: 0; color: var(--red-700); font-size: .84rem; }
.field-help { margin: 5px 0 0; color: var(--slate-500); font-size: .78rem; }
.field-group { display: grid; gap: 6px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-grid .full { grid-column: 1 / -1; }
.checkbox-row { display: flex; align-items: flex-start; gap: 9px; }
.checkbox-row input { width: 17px; height: 17px; margin-top: 2px; }
.checkbox-row label { font-weight: 600; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 250px minmax(0, 1fr); }
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 23px 18px 18px;
color: var(--white);
background: linear-gradient(180deg, var(--navy-950), var(--navy-900));
}
.sidebar-brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; border-bottom: 1px solid rgba(255,255,255,.08); }
.sidebar-brand strong { display: block; font-size: .94rem; }
.sidebar-brand span { display: block; margin-top: 2px; color: #94a7c6; font-size: .72rem; }
.nav-list { display: grid; gap: 6px; margin-top: 22px; }
.nav-item {
display: grid;
grid-template-columns: 25px 1fr auto;
align-items: center;
gap: 7px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 9px;
color: #b6c5dc;
background: transparent;
text-align: left;
font-weight: 650;
transition: background .15s, color .15s;
}
.nav-item:hover { color: var(--white); background: rgba(255,255,255,.065); }
.nav-item.active { color: var(--white); background: rgba(40,113,213,.30); }
.nav-icon { width: 22px; text-align: center; font-size: 1.15rem; }
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; color: #cbd8ea; background: rgba(255,255,255,.08); font-size: .7rem; text-align: center; }
.sidebar-footer { margin-top: auto; display: grid; gap: 12px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,.08); }
.connection-state { color: #a9bad2; font-size: .78rem; }
.status-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #42d39c; box-shadow: 0 0 0 3px rgba(66,211,156,.13); }
.main-column { min-width: 0; }
.topbar {
min-height: 102px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 20px 34px;
border-bottom: 1px solid var(--slate-200);
background: rgba(255,255,255,.92);
backdrop-filter: blur(12px);
}
.topbar h1 { margin: 0; font-size: 1.7rem; letter-spacing: -.035em; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.refresh-time { color: var(--slate-500); font-size: .76rem; }
.page-content { padding: 30px 34px 50px; outline: none; }
.button {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 7px;
min-height: 38px;
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 8px;
font-size: .84rem;
font-weight: 750;
transition: transform .12s, background .12s, border-color .12s, opacity .12s;
}
.button:hover { transform: translateY(-1px); }
.button:disabled { cursor: not-allowed; opacity: .6; transform: none; }
.button.primary { color: var(--white); background: var(--blue-600); box-shadow: 0 3px 8px rgba(40,113,213,.18); }
.button.primary:hover { background: var(--blue-700); }
.button.secondary { color: var(--slate-700); border-color: var(--slate-300); background: var(--white); }
.button.secondary:hover { border-color: var(--slate-500); }
.button.ghost { color: var(--slate-600); background: transparent; }
.button.ghost.dark { color: #cad5e5; border-color: rgba(255,255,255,.13); }
.button.danger { color: var(--white); background: var(--red-700); }
.button.danger-soft { color: var(--red-700); border-color: transparent; background: var(--red-100); }
.button.small { min-height: 31px; padding: 5px 10px; font-size: .76rem; }
.button.wide { width: 100%; }
.icon-button { padding: 6px 9px; border: 0; border-radius: 7px; color: var(--slate-600); background: transparent; font-size: .77rem; font-weight: 750; }
.icon-button:hover { color: var(--slate-950); background: var(--slate-100); }
.close-button { font-size: 1.6rem; line-height: 1; }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stat-card, .panel, .policy-card, .profile-card {
border: 1px solid var(--slate-200);
border-radius: var(--radius);
background: var(--white);
box-shadow: var(--shadow-sm);
}
.stat-card { position: relative; overflow: hidden; padding: 21px; }
.stat-card::after { content: ""; position: absolute; right: -15px; bottom: -22px; width: 76px; height: 76px; border-radius: 50%; background: var(--blue-100); }
.stat-label { color: var(--slate-600); font-size: .78rem; font-weight: 700; }
.stat-value { display: block; margin: 8px 0 4px; font-size: 2rem; font-weight: 800; letter-spacing: -.05em; }
.stat-detail { color: var(--slate-500); font-size: .75rem; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(300px, .8fr); gap: 20px; margin-top: 20px; }
.panel { min-width: 0; }
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-bottom: 1px solid var(--slate-200); }
.panel-header h2, .section-heading h2 { margin: 0; font-size: 1rem; }
.panel-body { padding: 4px 20px 14px; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 17px; }
.section-heading p { margin: 3px 0 0; color: var(--slate-600); font-size: .84rem; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { padding: 12px 9px; color: var(--slate-500); border-bottom: 1px solid var(--slate-200); font-size: .7rem; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
.data-table td { padding: 13px 9px; border-bottom: 1px solid var(--slate-100); font-size: .82rem; vertical-align: middle; }
.data-table tr:last-child td { border-bottom: 0; }
.data-table .actions { text-align: right; white-space: nowrap; }
.table-wrap { overflow-x: auto; }
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 999px; font-size: .7rem; font-weight: 750; white-space: nowrap; }
.badge.success { color: var(--green-700); background: var(--green-100); }
.badge.warning { color: var(--amber-700); background: var(--amber-100); }
.badge.error { color: var(--red-700); background: var(--red-100); }
.badge.neutral { color: var(--slate-700); background: var(--slate-100); }
.badge.blue { color: var(--blue-700); background: var(--blue-100); }
.health-ring { display: grid; place-items: center; padding: 28px 20px; }
.ring-chart { position: relative; width: 150px; height: 150px; }
.ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-track, .ring-progress { fill: none; stroke-width: 4.2; }
.ring-track { stroke: var(--slate-100); }
.ring-progress { stroke: var(--green-700); stroke-linecap: round; }
.ring-center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
.ring-value { display: block; font-size: 1.75rem; font-weight: 850; line-height: 1; }
.ring-label { margin-top: 5px; color: var(--slate-500); font-size: .72rem; }
.health-legend { width: 100%; display: grid; gap: 9px; margin-top: 22px; }
.legend-row { display: flex; justify-content: space-between; color: var(--slate-600); font-size: .8rem; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 17px; }
.toolbar-group { display: flex; align-items: center; gap: 9px; }
.search-input { max-width: 320px; padding-left: 35px; background-image: linear-gradient(transparent, transparent); }
.search-wrap { position: relative; min-width: 260px; }
.search-wrap::before { content: "⌕"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%); z-index: 1; color: var(--slate-500); }
.search-wrap input { padding-left: 35px; }
.filter-select { width: auto; min-width: 150px; }
.policy-list, .profile-grid { display: grid; gap: 14px; }
.profile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.policy-card { overflow: hidden; }
.policy-summary { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 20px; padding: 18px 20px; }
.policy-title-row { display: flex; align-items: center; gap: 10px; }
.policy-title-row h2 { margin: 0; font-size: 1rem; }
.policy-meta { display: flex; flex-wrap: wrap; gap: 15px; margin-top: 8px; color: var(--slate-500); font-size: .76rem; }
.policy-actions { display: flex; gap: 8px; }
.version-list { border-top: 1px solid var(--slate-200); background: #fbfcfe; }
.version-row { display: grid; grid-template-columns: minmax(190px, 1.1fr) minmax(160px, .8fr) minmax(180px, 1.4fr) auto; align-items: center; gap: 15px; padding: 14px 20px; border-bottom: 1px solid var(--slate-200); }
.version-row:last-child { border-bottom: 0; }
.version-id { font-size: .78rem; font-weight: 750; }
.version-note { color: var(--slate-600); font-size: .78rem; }
.hash-line { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--slate-500); font-size: .72rem; }
.profile-card { padding: 19px; }
.profile-card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 13px; }
.profile-card h2 { margin: 0; font-size: 1rem; }
.profile-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--slate-100); }
.policy-stack { display: grid; gap: 7px; margin-top: 15px; }
.policy-stack-item { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: center; padding: 8px 10px; border-radius: 8px; background: var(--slate-50); font-size: .78rem; }
.order-number { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: var(--blue-700); background: var(--blue-100); font-size: .67rem; font-weight: 800; }
.empty-state { display: grid; place-items: center; min-height: 290px; padding: 35px; border: 1px dashed var(--slate-300); border-radius: var(--radius); background: var(--white); text-align: center; }
.empty-state-icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 14px; border-radius: 14px; color: var(--blue-700); background: var(--blue-100); font-size: 1.5rem; }
.empty-state h2 { margin: 0; font-size: 1.05rem; }
.empty-state p { max-width: 430px; margin: 7px 0 18px; color: var(--slate-600); font-size: .84rem; }
.loading-state { display: grid; place-items: center; min-height: 350px; color: var(--slate-500); }
.spinner { width: 30px; height: 30px; margin-bottom: 12px; border: 3px solid var(--slate-200); border-top-color: var(--blue-600); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: rgba(7, 15, 30, .58); backdrop-filter: blur(3px); }
.modal { width: min(720px, 100%); max-height: calc(100vh - 48px); overflow: auto; border-radius: 17px; background: var(--white); box-shadow: var(--shadow-lg); }
.modal.wide-modal { width: min(880px, 100%); }
.modal-header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; align-items: flex-start; padding: 20px 22px; border-bottom: 1px solid var(--slate-200); background: rgba(255,255,255,.96); backdrop-filter: blur(8px); }
.modal-header h2 { margin: 0; font-size: 1.25rem; }
.modal-content { padding: 22px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
.confirm-dialog { width: min(430px, 100%); padding: 28px; border-radius: 16px; background: var(--white); box-shadow: var(--shadow-lg); text-align: center; }
.confirm-dialog h2 { margin: 12px 0 7px; }
.confirm-dialog p { margin: 0; color: var(--slate-600); }
.confirm-dialog .dialog-actions { justify-content: center; }
.danger-icon { display: grid; place-items: center; width: 46px; height: 46px; margin: 0 auto; border-radius: 50%; color: var(--red-700); background: var(--red-100); font-size: 1.25rem; font-weight: 900; }
.profile-editor-rows { display: grid; gap: 10px; margin-top: 16px; }
.profile-editor-row { display: grid; grid-template-columns: 34px minmax(170px, 1.1fr) minmax(180px, 1.25fr) auto; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--slate-200); border-radius: 10px; background: var(--slate-50); }
.drag-number { color: var(--slate-500); font-size: .75rem; text-align: center; }
.row-buttons { display: flex; gap: 4px; }
.row-buttons .icon-button { background: var(--white); border: 1px solid var(--slate-200); }
.upload-progress { height: 7px; margin-top: 14px; overflow: hidden; border-radius: 999px; background: var(--slate-100); }
.upload-progress-bar { width: 35%; height: 100%; border-radius: inherit; background: var(--blue-600); animation: progress 1.1s ease-in-out infinite alternate; }
@keyframes progress { from { transform: translateX(-80%); } to { transform: translateX(280%); } }
.toast-region { position: fixed; right: 22px; bottom: 22px; z-index: 90; display: grid; gap: 9px; width: min(380px, calc(100vw - 44px)); }
.toast { display: grid; grid-template-columns: 10px 1fr auto; align-items: start; gap: 10px; padding: 13px 14px; border: 1px solid var(--slate-200); border-radius: 11px; background: var(--white); box-shadow: 0 12px 28px rgba(11,20,40,.18); animation: toast-in .18s ease-out; }
.toast-bar { width: 4px; min-height: 34px; border-radius: 99px; background: var(--blue-600); }
.toast.success .toast-bar { background: var(--green-700); }
.toast.error .toast-bar { background: var(--red-700); }
.toast strong { display: block; font-size: .82rem; }
.toast p { margin: 3px 0 0; color: var(--slate-600); font-size: .76rem; }
.toast button { border: 0; color: var(--slate-500); background: transparent; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
.client-message { max-width: 300px; }
.client-name { font-weight: 750; }
.client-id { margin-top: 2px; color: var(--slate-500); font-size: .7rem; }
.client-stale { opacity: .72; }
@media (max-width: 1120px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-grid { grid-template-columns: 1fr; }
.profile-grid { grid-template-columns: 1fr; }
}
@media (max-width: 800px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; padding: 14px; }
.sidebar-brand { padding-bottom: 13px; }
.nav-list { grid-template-columns: repeat(4, 1fr); margin-top: 13px; }
.nav-item { display: flex; justify-content: center; padding: 9px; }
.nav-item .nav-icon, .nav-count { display: none; }
.sidebar-footer { display: none; }
.topbar { min-height: 88px; padding: 16px 20px; }
.refresh-time { display: none; }
.page-content { padding: 22px 20px 38px; }
.version-row { grid-template-columns: 1fr; gap: 8px; }
.profile-editor-row { grid-template-columns: 28px 1fr; }
.profile-editor-row select { grid-column: 2; }
.row-buttons { grid-column: 2; }
}
@media (max-width: 560px) {
.login-card { padding: 27px 23px; }
.nav-item { font-size: .72rem; }
.stats-grid { grid-template-columns: 1fr; }
.topbar { align-items: flex-start; }
.topbar-actions { flex-wrap: wrap; justify-content: flex-end; }
.topbar .secondary { display: none; }
.page-content { padding-left: 14px; padding-right: 14px; }
.toolbar { align-items: stretch; flex-direction: column; }
.toolbar-group { flex-wrap: wrap; }
.search-wrap { min-width: 100%; }
.filter-select { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.policy-summary { grid-template-columns: 1fr; }
.policy-actions { justify-content: flex-start; }
.modal-backdrop { padding: 10px; }
.modal { max-height: calc(100vh - 20px); }
}
.panel-spaced { margin-top: 20px; }
.section-heading.compact { margin-top: 22px; margin-bottom: 0; }
.empty-state.empty-inline { min-height: 180px; border: 0; }

286
internal/httpapi/webui.go Normal file
View File

@@ -0,0 +1,286 @@
package httpapi
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"path"
"strings"
"time"
)
const (
adminSessionCookie = "gpo_admin_session"
adminSessionTTL = 8 * time.Hour
)
//go:embed ui/*
var embeddedUI embed.FS
type adminSession struct {
ExpiresAt int64 `json:"exp"`
CSRF string `json:"csrf"`
Nonce string `json:"nonce"`
}
func (s *Server) registerWebUI() {
s.mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect)
})
s.mux.HandleFunc("POST /ui/api/session", s.createWebSession)
s.mux.HandleFunc("GET /ui/api/session", s.getWebSession)
s.mux.HandleFunc("DELETE /ui/api/session", s.deleteWebSession)
s.mux.HandleFunc("GET /ui", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect)
})
s.mux.HandleFunc("GET /ui/{$}", s.serveUIIndex)
s.mux.HandleFunc("GET /ui/{asset...}", s.serveUIAsset)
}
func (s *Server) serveUIIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ui/" {
http.NotFound(w, r)
return
}
s.serveEmbeddedFile(w, r, "ui/index.html")
}
func (s *Server) serveUIAsset(w http.ResponseWriter, r *http.Request) {
asset := path.Clean(r.PathValue("asset"))
if asset == "." || strings.HasPrefix(asset, "../") || strings.Contains(asset, "\\") {
http.NotFound(w, r)
return
}
if asset != "app.js" && asset != "styles.css" {
http.NotFound(w, r)
return
}
s.serveEmbeddedFile(w, r, "ui/"+asset)
}
func (s *Server) serveEmbeddedFile(w http.ResponseWriter, r *http.Request, name string) {
data, err := fs.ReadFile(embeddedUI, name)
if err != nil {
http.NotFound(w, r)
return
}
contentType := mime.TypeByExtension(path.Ext(name))
if contentType == "" {
contentType = http.DetectContentType(data)
}
w.Header().Set("Content-Type", contentType)
if strings.HasSuffix(name, ".html") {
w.Header().Set("Cache-Control", "no-store")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (s *Server) createWebSession(w http.ResponseWriter, r *http.Request) {
var request struct {
Token string `json:"token"`
}
if err := decodeJSON(w, r, &request, 64<<10); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if !constantTimeEqual(request.Token, s.cfg.AdminToken) {
writeError(w, http.StatusUnauthorized, errors.New("invalid credentials"))
return
}
csrf, err := randomToken(32)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
nonce, err := randomToken(16)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
session := adminSession{ExpiresAt: time.Now().Add(adminSessionTTL).Unix(), CSRF: csrf, Nonce: nonce}
value, err := s.signAdminSession(session)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: value,
Path: "/",
MaxAge: int(adminSessionTTL.Seconds()),
Expires: time.Unix(session.ExpiresAt, 0),
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{
"csrf_token": csrf,
"expires_at": time.Unix(session.ExpiresAt, 0).UTC(),
"version": s.cfg.ServerVersion,
})
}
func (s *Server) getWebSession(w http.ResponseWriter, r *http.Request) {
session, err := s.readAdminSession(r)
if err != nil {
writeError(w, http.StatusUnauthorized, errors.New("not authenticated"))
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{
"csrf_token": session.CSRF,
"expires_at": time.Unix(session.ExpiresAt, 0).UTC(),
"version": s.cfg.ServerVersion,
})
}
func (s *Server) deleteWebSession(w http.ResponseWriter, r *http.Request) {
if session, err := s.readAdminSession(r); err == nil {
provided := r.Header.Get("X-CSRF-Token")
if !constantTimeEqual(provided, session.CSRF) {
writeError(w, http.StatusForbidden, errors.New("invalid CSRF token"))
return
}
}
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(1, 0),
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
w.Header().Set("Cache-Control", "no-store")
if auth != "" {
if !strings.HasPrefix(auth, "Bearer ") || !constantTimeEqual(strings.TrimPrefix(auth, "Bearer "), s.cfg.AdminToken) {
writeError(w, http.StatusForbidden, errors.New("invalid bearer token"))
return
}
next.ServeHTTP(w, r)
return
}
session, err := s.readAdminSession(r)
if err != nil {
writeError(w, http.StatusUnauthorized, errors.New("not authenticated"))
return
}
if methodNeedsCSRF(r.Method) && !constantTimeEqual(r.Header.Get("X-CSRF-Token"), session.CSRF) {
writeError(w, http.StatusForbidden, errors.New("invalid CSRF token"))
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) signAdminSession(session adminSession) (string, error) {
payload, err := json.Marshal(session)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken))
_, _ = mac.Write([]byte("gpo-web-session-v1\x00"))
_, _ = mac.Write([]byte(encoded))
return encoded + "." + hex.EncodeToString(mac.Sum(nil)), nil
}
func (s *Server) readAdminSession(r *http.Request) (adminSession, error) {
cookie, err := r.Cookie(adminSessionCookie)
if err != nil {
return adminSession{}, err
}
encoded, signature, ok := strings.Cut(cookie.Value, ".")
if !ok || encoded == "" || signature == "" {
return adminSession{}, errors.New("malformed session")
}
sig, err := hex.DecodeString(signature)
if err != nil {
return adminSession{}, errors.New("malformed session signature")
}
mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken))
_, _ = mac.Write([]byte("gpo-web-session-v1\x00"))
_, _ = mac.Write([]byte(encoded))
if !hmac.Equal(sig, mac.Sum(nil)) {
return adminSession{}, errors.New("invalid session signature")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return adminSession{}, errors.New("malformed session payload")
}
var session adminSession
if err := json.Unmarshal(payload, &session); err != nil {
return adminSession{}, errors.New("malformed session payload")
}
if session.ExpiresAt <= time.Now().Unix() || session.CSRF == "" || session.Nonce == "" {
return adminSession{}, errors.New("expired session")
}
return session, nil
}
func randomToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate random token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func constantTimeEqual(provided, expected string) bool {
if len(provided) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func methodNeedsCSRF(method string) bool {
return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
}
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
}
func (s *Server) 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("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if strings.HasPrefix(r.URL.Path, "/ui") || r.URL.Path == "/" {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,116 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
"gpo-distributor/internal/store"
)
func TestWebSessionAndCSRF(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
api, err := New(st, Config{
AdminToken: "admin-secret-with-sufficient-entropy",
ClientToken: "client-secret-with-sufficient-entropy",
SigningKey: "manifest-secret-with-sufficient-entropy",
ServerVersion: "test",
Logger: log.New(io.Discard, "", 0),
})
if err != nil {
t.Fatal(err)
}
handler := api.Handler()
loginBody := bytes.NewBufferString(`{"token":"admin-secret-with-sufficient-entropy"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/ui/api/session", loginBody)
loginReq.Header.Set("Content-Type", "application/json")
loginRec := httptest.NewRecorder()
handler.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", loginRec.Code, loginRec.Body.String())
}
cookies := loginRec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != adminSessionCookie {
t.Fatalf("expected admin session cookie, got %#v", cookies)
}
var loginResponse struct {
CSRF string `json:"csrf_token"`
}
if err := json.Unmarshal(loginRec.Body.Bytes(), &loginResponse); err != nil {
t.Fatal(err)
}
if loginResponse.CSRF == "" {
t.Fatal("missing CSRF token")
}
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/policies", nil)
listReq.AddCookie(cookies[0])
listRec := httptest.NewRecorder()
handler.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/missing", nil)
deleteReq.AddCookie(cookies[0])
deleteRec := httptest.NewRecorder()
handler.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusForbidden {
t.Fatalf("delete without CSRF status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
}
deleteReq = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/missing", nil)
deleteReq.AddCookie(cookies[0])
deleteReq.Header.Set("X-CSRF-Token", loginResponse.CSRF)
deleteRec = httptest.NewRecorder()
handler.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusNotFound {
t.Fatalf("delete with CSRF status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
}
}
func TestEmbeddedUIAndBearerCompatibility(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
api, err := New(st, Config{
AdminToken: "admin-token",
ClientToken: "client-token",
SigningKey: "signing-key",
Logger: log.New(io.Discard, "", 0),
})
if err != nil {
t.Fatal(err)
}
handler := api.Handler()
uiReq := httptest.NewRequest(http.MethodGet, "/ui/", nil)
uiRec := httptest.NewRecorder()
handler.ServeHTTP(uiRec, uiReq)
if uiRec.Code != http.StatusOK {
t.Fatalf("ui status=%d", uiRec.Code)
}
if got := uiRec.Header().Get("Content-Security-Policy"); got == "" {
t.Fatal("missing CSP header")
}
if !bytes.Contains(uiRec.Body.Bytes(), []byte("GPO Distributor")) {
t.Fatal("embedded UI body is unexpected")
}
apiReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/profiles", nil)
apiReq.Header.Set("Authorization", "Bearer admin-token")
apiRec := httptest.NewRecorder()
handler.ServeHTTP(apiRec, apiReq)
if apiRec.Code != http.StatusOK {
t.Fatalf("bearer status=%d body=%s", apiRec.Code, apiRec.Body.String())
}
}

66
internal/model/model.go Normal file
View File

@@ -0,0 +1,66 @@
package model
import "time"
type PolicyVersion struct {
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
Note string `json:"note,omitempty"`
ArtifactPath string `json:"artifact_path"`
ArtifactHash string `json:"artifact_sha256"`
SemanticHash string `json:"semantic_sha256"`
Size int64 `json:"size"`
FileCount int `json:"file_count"`
PolicyFiles int `json:"policy_file_count"`
}
type Policy struct {
Name string `json:"name"`
Versions []PolicyVersion `json:"versions"`
}
type ProfilePolicy struct {
Policy string `json:"policy"`
Version string `json:"version"` // "latest" or an exact version
}
type Profile struct {
Name string `json:"name"`
Policies []ProfilePolicy `json:"policies"`
UpdatedAt time.Time `json:"updated_at"`
}
type ResolvedPolicy struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
SemanticHash string `json:"semantic_sha256"`
Size int64 `json:"size"`
DownloadURL string `json:"download_url"`
}
type Manifest struct {
Profile string `json:"profile"`
ProfileUpdated time.Time `json:"profile_updated_at"`
Generation string `json:"generation"`
Policies []ResolvedPolicy `json:"policies"`
}
type ClientReport struct {
ClientID string `json:"client_id"`
Hostname string `json:"hostname,omitempty"`
Profile string `json:"profile"`
Generation string `json:"generation,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
ReportedAt time.Time `json:"reported_at"`
AppliedAt time.Time `json:"applied_at,omitempty"`
OperatingSystem string `json:"operating_system,omitempty"`
}
type Catalog struct {
Policies map[string]*Policy `json:"policies"`
Profiles map[string]*Profile `json:"profiles"`
Clients map[string]ClientReport `json:"clients"`
}

506
internal/store/store.go Normal file
View File

@@ -0,0 +1,506 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"gpo-distributor/internal/bundle"
"gpo-distributor/internal/model"
)
var validName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
type Store struct {
mu sync.RWMutex
root string
catalog model.Catalog
}
func Open(root string) (*Store, error) {
if root == "" {
return nil, errors.New("data directory is required")
}
if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0o700); err != nil {
return nil, err
}
s := &Store{root: root}
s.catalog = model.Catalog{
Policies: map[string]*model.Policy{},
Profiles: map[string]*model.Profile{},
Clients: map[string]model.ClientReport{},
}
filename := filepath.Join(root, "catalog.json")
data, err := os.ReadFile(filename)
if errors.Is(err, os.ErrNotExist) {
if err := s.saveLocked(); err != nil {
return nil, err
}
return s, nil
}
if err != nil {
return nil, err
}
if len(data) > 0 {
if err := json.Unmarshal(data, &s.catalog); err != nil {
return nil, fmt.Errorf("read catalog: %w", err)
}
}
if s.catalog.Policies == nil {
s.catalog.Policies = map[string]*model.Policy{}
}
if s.catalog.Profiles == nil {
s.catalog.Profiles = map[string]*model.Profile{}
}
if s.catalog.Clients == nil {
s.catalog.Clients = map[string]model.ClientReport{}
}
return s, nil
}
func ValidateName(name string) error {
if !validName.MatchString(name) {
return fmt.Errorf("name must match %s", validName.String())
}
return nil
}
func (s *Store) ImportPolicy(name, note, sourceZip string, inspection bundle.Inspection, now time.Time, force bool) (model.PolicyVersion, bool, error) {
if err := ValidateName(name); err != nil {
return model.PolicyVersion{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
policy := s.catalog.Policies[name]
if policy == nil {
policy = &model.Policy{Name: name}
s.catalog.Policies[name] = policy
}
if !force {
for _, v := range policy.Versions {
if v.SemanticHash == inspection.SemanticHash {
return v, false, nil
}
}
}
versionID := fmt.Sprintf("v%s-%s", now.UTC().Format("20060102-150405"), inspection.SemanticHash[:10])
for _, v := range policy.Versions {
if v.Version == versionID {
versionID = fmt.Sprintf("%s-%d", versionID, now.UnixNano()%100000)
break
}
}
rel := filepath.Join("artifacts", name, versionID+".zip")
dst := filepath.Join(s.root, rel)
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return model.PolicyVersion{}, false, err
}
if err := copyFileAtomic(sourceZip, dst); err != nil {
return model.PolicyVersion{}, false, err
}
v := model.PolicyVersion{
Version: versionID,
CreatedAt: now.UTC(),
Note: strings.TrimSpace(note),
ArtifactPath: filepath.ToSlash(rel),
ArtifactHash: inspection.ArtifactHash,
SemanticHash: inspection.SemanticHash,
Size: inspection.Size,
FileCount: inspection.FileCount,
PolicyFiles: inspection.PolicyFiles,
}
policy.Versions = append(policy.Versions, v)
if err := s.saveLocked(); err != nil {
_ = os.Remove(dst)
policy.Versions = policy.Versions[:len(policy.Versions)-1]
return model.PolicyVersion{}, false, err
}
return v, true, nil
}
func (s *Store) ListPolicies() []model.Policy {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.Policy, 0, len(s.catalog.Policies))
for _, p := range s.catalog.Policies {
cp := model.Policy{Name: p.Name, Versions: append([]model.PolicyVersion(nil), p.Versions...)}
out = append(out, cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (s *Store) GetPolicy(name string) (model.Policy, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p := s.catalog.Policies[name]
if p == nil {
return model.Policy{}, ErrNotFound
}
return model.Policy{Name: p.Name, Versions: append([]model.PolicyVersion(nil), p.Versions...)}, nil
}
func (s *Store) SetProfile(name string, refs []model.ProfilePolicy, now time.Time) (model.Profile, error) {
if err := ValidateName(name); err != nil {
return model.Profile{}, err
}
if len(refs) == 0 {
return model.Profile{}, errors.New("profile requires at least one policy")
}
s.mu.Lock()
defer s.mu.Unlock()
seen := map[string]bool{}
for _, ref := range refs {
if err := ValidateName(ref.Policy); err != nil {
return model.Profile{}, fmt.Errorf("policy %q: %w", ref.Policy, err)
}
if seen[ref.Policy] {
return model.Profile{}, fmt.Errorf("policy %q occurs more than once", ref.Policy)
}
seen[ref.Policy] = true
p := s.catalog.Policies[ref.Policy]
if p == nil {
return model.Profile{}, fmt.Errorf("policy %q: %w", ref.Policy, ErrNotFound)
}
if ref.Version == "" {
ref.Version = "latest"
}
if ref.Version != "latest" && !hasVersion(p, ref.Version) {
return model.Profile{}, fmt.Errorf("policy %q version %q: %w", ref.Policy, ref.Version, ErrNotFound)
}
}
cp := append([]model.ProfilePolicy(nil), refs...)
for i := range cp {
if cp[i].Version == "" {
cp[i].Version = "latest"
}
}
profile := &model.Profile{Name: name, Policies: cp, UpdatedAt: now.UTC()}
s.catalog.Profiles[name] = profile
if err := s.saveLocked(); err != nil {
return model.Profile{}, err
}
return *profile, nil
}
func (s *Store) ListProfiles() []model.Profile {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.Profile, 0, len(s.catalog.Profiles))
for _, p := range s.catalog.Profiles {
out = append(out, model.Profile{Name: p.Name, Policies: append([]model.ProfilePolicy(nil), p.Policies...), UpdatedAt: p.UpdatedAt})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (s *Store) ResolveManifest(profileName string) (model.Manifest, error) {
s.mu.RLock()
defer s.mu.RUnlock()
profile := s.catalog.Profiles[profileName]
if profile == nil {
return model.Manifest{}, ErrNotFound
}
manifest := model.Manifest{Profile: profile.Name, ProfileUpdated: profile.UpdatedAt}
for _, ref := range profile.Policies {
policy := s.catalog.Policies[ref.Policy]
if policy == nil || len(policy.Versions) == 0 {
return model.Manifest{}, fmt.Errorf("policy %q has no versions", ref.Policy)
}
var selected model.PolicyVersion
if ref.Version == "latest" || ref.Version == "" {
selected = policy.Versions[len(policy.Versions)-1]
} else {
found := false
for _, v := range policy.Versions {
if v.Version == ref.Version {
selected = v
found = true
break
}
}
if !found {
return model.Manifest{}, fmt.Errorf("policy %q version %q missing", ref.Policy, ref.Version)
}
}
manifest.Policies = append(manifest.Policies, model.ResolvedPolicy{
Name: ref.Policy, Version: selected.Version, SHA256: selected.ArtifactHash,
SemanticHash: selected.SemanticHash, Size: selected.Size,
DownloadURL: fmt.Sprintf("/api/v1/artifacts/%s/%s", ref.Policy, selected.Version),
})
}
gen, err := generationHash(manifest)
if err != nil {
return model.Manifest{}, err
}
manifest.Generation = gen
return manifest, nil
}
func generationHash(m model.Manifest) (string, error) {
m.Generation = ""
b, err := json.Marshal(m)
if err != nil {
return "", err
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:]), nil
}
func (s *Store) Artifact(name, version string) (model.PolicyVersion, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p := s.catalog.Policies[name]
if p == nil {
return model.PolicyVersion{}, "", ErrNotFound
}
for _, v := range p.Versions {
if v.Version == version {
return v, filepath.Join(s.root, filepath.FromSlash(v.ArtifactPath)), nil
}
}
return model.PolicyVersion{}, "", ErrNotFound
}
func (s *Store) PutClientReport(report model.ClientReport) error {
if report.ClientID == "" {
return errors.New("client_id is required")
}
if len(report.ClientID) > 128 {
return errors.New("client_id is too long")
}
s.mu.Lock()
defer s.mu.Unlock()
s.catalog.Clients[report.ClientID] = report
return s.saveLocked()
}
func (s *Store) ListClientReports() []model.ClientReport {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.ClientReport, 0, len(s.catalog.Clients))
for _, r := range s.catalog.Clients {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].ReportedAt.After(out[j].ReportedAt) })
return out
}
func (s *Store) DeletePolicy(name string) error {
if err := ValidateName(name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
policy := s.catalog.Policies[name]
if policy == nil {
return ErrNotFound
}
for _, profile := range s.catalog.Profiles {
for _, ref := range profile.Policies {
if ref.Policy == name {
return fmt.Errorf("policy %q is used by profile %q: %w", name, profile.Name, ErrConflict)
}
}
}
artifactDir := filepath.Join(s.root, "artifacts", name)
tomb := artifactDir + fmt.Sprintf(".delete-%d", time.Now().UnixNano())
renamed := false
if _, err := os.Stat(artifactDir); err == nil {
if err := os.Rename(artifactDir, tomb); err != nil {
return err
}
renamed = true
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
delete(s.catalog.Policies, name)
if err := s.saveLocked(); err != nil {
s.catalog.Policies[name] = policy
if renamed {
_ = os.Rename(tomb, artifactDir)
}
return err
}
if renamed {
_ = os.RemoveAll(tomb)
}
return nil
}
func (s *Store) DeletePolicyVersion(name, version string) error {
if err := ValidateName(name); err != nil {
return err
}
if err := ValidateName(version); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
policy := s.catalog.Policies[name]
if policy == nil {
return ErrNotFound
}
if len(policy.Versions) <= 1 {
return fmt.Errorf("cannot delete the last version of policy %q; delete the policy instead: %w", name, ErrConflict)
}
index := -1
var selected model.PolicyVersion
for i, candidate := range policy.Versions {
if candidate.Version == version {
index = i
selected = candidate
break
}
}
if index < 0 {
return ErrNotFound
}
for _, profile := range s.catalog.Profiles {
for _, ref := range profile.Policies {
if ref.Policy == name && ref.Version == version {
return fmt.Errorf("policy %q version %q is pinned by profile %q: %w", name, version, profile.Name, ErrConflict)
}
}
}
artifactPath := filepath.Join(s.root, filepath.FromSlash(selected.ArtifactPath))
tomb := artifactPath + fmt.Sprintf(".delete-%d", time.Now().UnixNano())
renamed := false
if _, err := os.Stat(artifactPath); err == nil {
if err := os.Rename(artifactPath, tomb); err != nil {
return err
}
renamed = true
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
original := append([]model.PolicyVersion(nil), policy.Versions...)
policy.Versions = append(policy.Versions[:index], policy.Versions[index+1:]...)
if err := s.saveLocked(); err != nil {
policy.Versions = original
if renamed {
_ = os.Rename(tomb, artifactPath)
}
return err
}
if renamed {
_ = os.Remove(tomb)
}
return nil
}
func (s *Store) DeleteProfile(name string) error {
if err := ValidateName(name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
profile := s.catalog.Profiles[name]
if profile == nil {
return ErrNotFound
}
delete(s.catalog.Profiles, name)
if err := s.saveLocked(); err != nil {
s.catalog.Profiles[name] = profile
return err
}
return nil
}
func (s *Store) DeleteClientReport(clientID string) error {
if strings.TrimSpace(clientID) == "" || len(clientID) > 128 {
return errors.New("invalid client id")
}
s.mu.Lock()
defer s.mu.Unlock()
report, ok := s.catalog.Clients[clientID]
if !ok {
return ErrNotFound
}
delete(s.catalog.Clients, clientID)
if err := s.saveLocked(); err != nil {
s.catalog.Clients[clientID] = report
return err
}
return nil
}
func hasVersion(p *model.Policy, version string) bool {
for _, v := range p.Versions {
if v.Version == version {
return true
}
}
return false
}
func (s *Store) saveLocked() error {
b, err := json.MarshalIndent(s.catalog, "", " ")
if err != nil {
return err
}
filename := filepath.Join(s.root, "catalog.json")
tmp := filename + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, filename)
}
func copyFileAtomic(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
syncErr := out.Sync()
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if syncErr != nil {
_ = os.Remove(tmp)
return syncErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

View File

@@ -0,0 +1,100 @@
package store
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"gpo-distributor/internal/bundle"
"gpo-distributor/internal/model"
)
func TestResolveLatest(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
zipPath := filepath.Join(t.TempDir(), "x.zip")
if err := os.WriteFile(zipPath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i, hash := range []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} {
_, _, err := s.ImportPolicy("baseline", "", zipPath, bundle.Inspection{ArtifactHash: hash, SemanticHash: hash, Size: 1, FileCount: 1, PolicyFiles: 1}, base.Add(time.Duration(i)*time.Second), false)
if err != nil {
t.Fatal(err)
}
}
if _, err := s.SetProfile("servers", []model.ProfilePolicy{{Policy: "baseline", Version: "latest"}}, base); err != nil {
t.Fatal(err)
}
m, err := s.ResolveManifest("servers")
if err != nil {
t.Fatal(err)
}
if got := m.Policies[0].SemanticHash; got[0] != 'b' {
t.Fatalf("expected latest, got %s", got)
}
}
func TestDeletePolicyVersionAndReferences(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
zipPath := filepath.Join(t.TempDir(), "bundle.zip")
if err := os.WriteFile(zipPath, []byte("bundle"), 0o600); err != nil {
t.Fatal(err)
}
base := time.Date(2026, 8, 5, 8, 0, 0, 0, time.UTC)
var versions []model.PolicyVersion
for i, hash := range []string{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
} {
version, _, err := s.ImportPolicy("baseline", "", zipPath, bundle.Inspection{
ArtifactHash: hash,
SemanticHash: hash,
Size: 6,
FileCount: 1,
PolicyFiles: 1,
}, base.Add(time.Duration(i)*time.Second), false)
if err != nil {
t.Fatal(err)
}
versions = append(versions, version)
}
if _, err := s.SetProfile("servers", []model.ProfilePolicy{{Policy: "baseline", Version: versions[0].Version}}, base); err != nil {
t.Fatal(err)
}
if err := s.DeletePolicyVersion("baseline", versions[0].Version); !errors.Is(err, ErrConflict) {
t.Fatalf("expected pinned version conflict, got %v", err)
}
if err := s.DeletePolicy("baseline"); !errors.Is(err, ErrConflict) {
t.Fatalf("expected referenced policy conflict, got %v", err)
}
if err := s.DeleteProfile("servers"); err != nil {
t.Fatal(err)
}
_, artifact, err := s.Artifact("baseline", versions[0].Version)
if err != nil {
t.Fatal(err)
}
if err := s.DeletePolicyVersion("baseline", versions[0].Version); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(artifact); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("artifact still exists or unexpected stat error: %v", err)
}
if err := s.DeletePolicyVersion("baseline", versions[1].Version); !errors.Is(err, ErrConflict) {
t.Fatalf("expected last-version conflict, got %v", err)
}
if err := s.DeletePolicy("baseline"); err != nil {
t.Fatal(err)
}
if _, err := s.GetPolicy("baseline"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected deleted policy to be missing, got %v", err)
}
}

6
run.sh Executable file
View File

@@ -0,0 +1,6 @@
export GPO_SERVER_ADMIN_TOKEN="admin"
export GPO_SERVER_CLIENT_TOKEN="admin"
export GPO_SERVER_SIGNING_KEY="admin"
#export GPO_SERVER_TLS_CERT="/etc/gpo-distributor/server.crt"
#export GPO_SERVER_TLS_KEY="/etc/gpo-distributor/server.key"
go run ./cmd/server

6
runa.sh Executable file
View File

@@ -0,0 +1,6 @@
export GPO_SERVER_ADMIN_TOKEN="admin"
export GPO_SERVER_CLIENT_TOKEN="admin"
export GPO_SERVER_SIGNING_KEY="admin"
#export GPO_SERVER_TLS_CERT="/etc/gpo-distributor/server.crt"
#export GPO_SERVER_TLS_KEY="/etc/gpo-distributor/server.key"
go run ./cmd/gpoctl

12
scripts/Build.ps1 Normal file
View File

@@ -0,0 +1,12 @@
[CmdletBinding()]
param([string] $Version = 'dev', [string] $Output = '.\bin')
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $Output | Out-Null
$ldflags = "-s -w -X main.version=$Version"
$env:CGO_ENABLED = '0'
$env:GOOS = 'windows'; $env:GOARCH = 'amd64'
go build -trimpath -ldflags $ldflags -o (Join-Path $Output 'gpo-agent-windows-amd64.exe') .\cmd\agent
go build -trimpath -ldflags $ldflags -o (Join-Path $Output 'gpoctl-windows-amd64.exe') .\cmd\gpoctl
$env:GOOS = 'linux'; $env:GOARCH = 'amd64'
go build -trimpath -ldflags $ldflags -o (Join-Path $Output 'gpo-server-linux-amd64') .\cmd\server
Remove-Item Env:GOOS, Env:GOARCH, Env:CGO_ENABLED

View File

@@ -0,0 +1,48 @@
#requires -Modules GroupPolicy
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $GpoName,
[Parameter(Mandatory)] [string] $PolicyName,
[Parameter(Mandatory)] [string] $ServerUrl,
[Parameter(Mandatory)] [string] $AdminToken,
[Parameter(Mandatory)] [string] $GpoCtl,
[string] $Note = '',
[string] $Domain,
[string] $DomainController,
[switch] $InsecureSkipVerify,
[switch] $ForceVersion
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $GpoCtl -PathType Leaf)) { throw "gpoctl not found: $GpoCtl" }
$work = Join-Path ([IO.Path]::GetTempPath()) ("gpo-publish-" + [guid]::NewGuid().ToString('N'))
$backup = Join-Path $work 'backup'
$zip = Join-Path $work 'gpo-backup.zip'
New-Item -ItemType Directory -Force -Path $backup | Out-Null
try {
$params = @{ Name = $GpoName; Path = $backup }
if ($Note) { $params.Comment = $Note }
if ($Domain) { $params.Domain = $Domain }
if ($DomainController) { $params.Server = $DomainController }
Backup-GPO @params | Out-Null
# CreateFromDirectory includes hidden files such as bkupInfo.xml. The ZIP root
# contains manifest.xml plus the GUID-named GPO backup directory expected by LGPO.exe.
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::CreateFromDirectory(
$backup,
$zip,
[IO.Compression.CompressionLevel]::Optimal,
$false
)
$args = @('upload', '-server', $ServerUrl, '-token', $AdminToken, '-policy', $PolicyName, '-file', $zip)
if ($Note) { $args += @('-note', $Note) }
if ($InsecureSkipVerify) { $args += '-insecure-skip-verify' }
if ($ForceVersion) { $args += '-force' }
& $GpoCtl @args
if ($LASTEXITCODE -ne 0) { throw "gpoctl exited with code $LASTEXITCODE" }
}
finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}

52
scripts/Install-Agent.ps1 Normal file
View File

@@ -0,0 +1,52 @@
#requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $AgentExe,
[Parameter(Mandatory)] [string] $LGPOExe,
[Parameter(Mandatory)] [string] $ServerUrl,
[Parameter(Mandatory)] [string] $Profile,
[Parameter(Mandatory)] [string] $ClientToken,
[Parameter(Mandatory)] [string] $SigningKey,
[ValidateRange(1, 1440)] [int] $IntervalMinutes = 15,
[string] $InstallDir = "$env:ProgramFiles\GPO-Distributor",
[string] $StateDir = "$env:ProgramData\GPO-Distributor",
[switch] $InsecureSkipVerify
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $AgentExe -PathType Leaf)) { throw "Agent executable not found: $AgentExe" }
if (-not (Test-Path -LiteralPath $LGPOExe -PathType Leaf)) { throw "LGPO.exe not found: $LGPOExe" }
New-Item -ItemType Directory -Force -Path $InstallDir, $StateDir | Out-Null
$agentTarget = Join-Path $InstallDir 'gpo-agent.exe'
$lgpoTarget = Join-Path $InstallDir 'LGPO.exe'
Copy-Item -LiteralPath $AgentExe -Destination $agentTarget -Force
Copy-Item -LiteralPath $LGPOExe -Destination $lgpoTarget -Force
$config = [ordered]@{
server_url = $ServerUrl.TrimEnd('/')
profile = $Profile
client_token = $ClientToken
signing_key = $SigningKey
lgpo_path = $lgpoTarget
state_dir = $StateDir
poll_interval = "${IntervalMinutes}m"
request_timeout = '15m'
insecure_skip_verify = [bool]$InsecureSkipVerify
}
$configPath = Join-Path $StateDir 'agent.json'
$config | ConvertTo-Json | Set-Content -LiteralPath $configPath -Encoding UTF8
# Restrict secrets and cached policy bundles to SYSTEM and local Administrators.
& icacls.exe $InstallDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
& icacls.exe $StateDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
$action = New-ScheduledTaskAction -Execute $agentTarget -Argument "-config `"$configPath`" -once"
$startupTrigger = New-ScheduledTaskTrigger -AtStartup
$periodicTrigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(2)) -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 30)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName 'GPO Distributor Agent' -Action $action -Trigger @($startupTrigger, $periodicTrigger) -Settings $settings -Principal $principal -Force | Out-Null
Start-ScheduledTask -TaskName 'GPO Distributor Agent'
Write-Host "Installed. Configuration: $configPath"

13
tls/README.md Normal file
View File

@@ -0,0 +1,13 @@
# TLS-Dateien
Für direktes TLS im Container werden hier folgende Dateien erwartet:
- `server.crt`: Serverzertifikat inklusive erforderlicher Zwischenzertifikate
- `server.key`: unverschlüsselter privater Schlüssel, nur für den Container lesbar
Die Pfade werden in `.env` als `/tls/server.crt` und `/tls/server.key` gesetzt.
Wird TLS bereits von einem Reverse Proxy terminiert, bleiben `GPO_SERVER_TLS_CERT` und
`GPO_SERVER_TLS_KEY` in `.env` leer. Der Container lauscht dann intern per HTTP auf Port
8443. Der Port sollte in diesem Fall nur an Loopback oder ein internes Docker-Netz
gebunden werden.