This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."git.send.nrw"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.send.nrw # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
4
CHECKSUMS-SHA256.txt
Normal file
4
CHECKSUMS-SHA256.txt
Normal 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
50
Dockerfile
Normal 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
21
LICENSE
Normal 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
17
Makefile
Normal 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
402
README.md
@@ -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
77
RELEASE_NOTES.md
Normal 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
BIN
bin/gpo-agent-windows-amd64.exe
Executable file
Binary file not shown.
BIN
bin/gpo-server
Executable file
BIN
bin/gpo-server
Executable file
Binary file not shown.
BIN
bin/gpoctl
Executable file
BIN
bin/gpoctl
Executable file
Binary file not shown.
BIN
bin/gpoctl-windows-amd64.exe
Executable file
BIN
bin/gpoctl-windows-amd64.exe
Executable file
Binary file not shown.
50
cmd/agent/main.go
Normal file
50
cmd/agent/main.go
Normal 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
225
cmd/gpoctl/main.go
Normal 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
88
cmd/server/main.go
Normal 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
65
compose.yml
Normal 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}"
|
||||
BIN
data/artifacts/Applocker/v20260805-083222-b964f1aaf5.zip
Normal file
BIN
data/artifacts/Applocker/v20260805-083222-b964f1aaf5.zip
Normal file
Binary file not shown.
21
data/catalog.json
Normal file
21
data/catalog.json
Normal 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
39
deploy/Dockerfile
Normal 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
54
deploy/docker-compose.yml
Normal 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
21
deploy/gpo-server.service
Normal 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
|
||||
5
deploy/server.env.example
Normal file
5
deploy/server.env.example
Normal 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
|
||||
11
examples/agent.example.json
Normal file
11
examples/agent.example.json
Normal 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
|
||||
}
|
||||
456
internal/agent/agent.go
Normal file
456
internal/agent/agent.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
31
internal/agent/agent_test.go
Normal file
31
internal/agent/agent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
14
internal/agent/platform_other.go
Normal file
14
internal/agent/platform_other.go
Normal 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 }
|
||||
46
internal/agent/platform_windows.go
Normal file
46
internal/agent/platform_windows.go
Normal 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
354
internal/bundle/bundle.go
Normal 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
|
||||
}
|
||||
106
internal/bundle/bundle_test.go
Normal file
106
internal/bundle/bundle_test.go
Normal 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
399
internal/httpapi/server.go
Normal 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
865
internal/httpapi/ui/app.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
103
internal/httpapi/ui/index.html
Normal file
103
internal/httpapi/ui/index.html
Normal 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>
|
||||
367
internal/httpapi/ui/styles.css
Normal file
367
internal/httpapi/ui/styles.css
Normal 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
286
internal/httpapi/webui.go
Normal 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)
|
||||
})
|
||||
}
|
||||
116
internal/httpapi/webui_test.go
Normal file
116
internal/httpapi/webui_test.go
Normal 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
66
internal/model/model.go
Normal 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
506
internal/store/store.go
Normal 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
|
||||
}
|
||||
100
internal/store/store_test.go
Normal file
100
internal/store/store_test.go
Normal 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
6
run.sh
Executable 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
6
runa.sh
Executable 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
12
scripts/Build.ps1
Normal 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
|
||||
48
scripts/Export-And-Publish.ps1
Normal file
48
scripts/Export-And-Publish.ps1
Normal 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
52
scripts/Install-Agent.ps1
Normal 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
13
tls/README.md
Normal 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.
|
||||
Reference in New Issue
Block a user