diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b2827e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +backups +controller.password +controller.json +env-controller diff --git a/.env b/.env new file mode 100644 index 0000000..0721cc6 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +ENV_CONTROLLER_PASSWORD = "admin" \ No newline at end of file diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7714446 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +controller.password +controller.json +backups/ +env-controller +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4231a87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 0.1.2 + +- Plattformgerechte automatische Suche nach `controller.json` ergänzt. +- Priorität: `-config`, `ENV_CONTROLLER_CONFIG`, aktuelles Verzeichnis, EXE-Verzeichnis, `/config/controller.json`. +- Aussagekräftige Fehlermeldung mit allen geprüften Pfaden und Hinweis auf `controller.example.json`. +- Anleitung für den direkten Windows-Start mit `password_env` ergänzt. +- Containerbetrieb mit explizitem `/config/controller.json` bleibt unverändert. + +## 0.1.1 + +- Windows-Kompilierung repariert: `syscall.Stat_t` wird nur noch in der Unix-Implementierung verwendet. +- Unter Windows wird die nicht verfügbare UID/GID-Übernahme beim atomischen Schreiben übersprungen. +- Linux-/Unix-Verhalten zur Erhaltung von Dateibesitzer und Gruppe bleibt unverändert. +- Windows-AMD64-Binary unter `dist/env-controller-windows-amd64.exe` ergänzt. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a8359c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/env-controller ./cmd/env-controller + +FROM alpine:3.24 +RUN apk add --no-cache ca-certificates docker-cli docker-cli-compose tzdata +COPY --from=build /out/env-controller /usr/local/bin/env-controller +ENV HOME=/tmp +EXPOSE 8090 +ENTRYPOINT ["/usr/local/bin/env-controller"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8cd465 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: test vet race build docker-build + +test: + go test ./... + +vet: + go vet ./... + +race: + go test -race ./... + +build: + CGO_ENABLED=0 go build -trimpath -o env-controller ./cmd/env-controller + +docker-build: + docker build -t glpi-env-controller:local . diff --git a/README.md b/README.md index 56cfea4..819f523 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,316 @@ -# glpi-ai-agent-controller +# ENV Controller +Eigenständige Webanwendung zum Vergleichen, Bearbeiten, Sichern und Wiederherstellen von `.env`-Dateien. Nach einer Änderung können ausschließlich freigegebene Docker-Container kontrolliert neu gestartet oder über Docker Compose neu erstellt werden. + +## Kernfunktionen + +- liest eine bestehende `.env` und die zugehörige `.env.example` +- zeigt fehlende und zusätzliche Schlüssel an +- importiert neue Schlüssel aus `.env.example`, ohne bestehende Werte zu überschreiben +- erhält Kommentare, Reihenfolge und zusätzliche lokale Schlüssel weitgehend unverändert +- erstellt **vor jedem Import, Speichern und Restore** automatisch ein Backup +- legt Backup-Dateien und Metadaten mit Dateimodus `0600` ab +- ermöglicht Download und Wiederherstellung früherer Stände +- lädt Secret-Werte erst beim Fokus des Feldes vom Server +- erlaubt Docker-Aktionen nur für eine feste Allowlist exakter `container_name`-Werte +- unterstützt `restart` und Compose-basiertes `recreate` +- kann abhängige Dritt-Container aus anderen Compose-Projekten mit derselben Änderung neu erstellen +- kann fehlende Schlüssel optional beim Controller-Start automatisch importieren + +## Plattformkompatibilität + +Der Controller ist für den produktiven Betrieb als Linux-Container vorgesehen. Der Quellcode lässt sich zusätzlich unter Windows entwickeln und kompilieren. Unix-Dateibesitzrechte (UID/GID) werden nur auf Unix-Systemen übernommen; unter Windows wird dieser nicht verfügbare Schritt übersprungen. + + +## Direkter Start unter Windows + +Beim direkten Start der EXE wird die Konfiguration in dieser Reihenfolge gesucht: + +1. Pfad aus `-config` +2. Pfad aus `ENV_CONTROLLER_CONFIG` +3. `controller.json` im aktuellen Arbeitsverzeichnis +4. `controller.json` neben der EXE +5. Container-Standard `/config/controller.json` + +Erstelle zunächst eine lokale Konfiguration: + +```powershell +Copy-Item .\controller.windows.example.json .\controller.json +``` + +Die mitgelieferte `controller.windows.example.json` verwendet bereits eine Passwort-Umgebungsvariable. Bei einer Übernahme aus `controller.example.json` müssen diese Felder so gesetzt werden: + +```json +{ + "password_env": "ENV_CONTROLLER_PASSWORD", + "password_file": "" +} +``` + +Setze anschließend das Passwort und starte die Anwendung: + +```powershell +$env:ENV_CONTROLLER_PASSWORD = "ein-langes-zufaelliges-passwort" +.\dist\env-controller-windows-amd64.exe -config .\controller.json +``` + +Alternativ kann der Pfad dauerhaft über die Umgebung gesetzt werden: + +```powershell +$env:ENV_CONTROLLER_CONFIG = "E:\GoProjects\glpi-ai-agent-controller\controller.json" +.\dist\env-controller-windows-amd64.exe +``` + +Die Projektpfade in `controller.json` müssen unter Windows als absolute Pfade angegeben werden, beispielsweise `E:/GoProjects/glpi-ai-agent/.env`. Der produktive Docker-Betrieb verwendet weiterhin `/config/controller.json` und das Compose-Secret. + +## Architektur + +```text +Browser + │ Basic Auth + CSRF + ▼ +ENV Controller + ├── liest/schreibt /opt/.../.env atomar + ├── vergleicht /opt/.../.env.example + ├── sichert nach /backups/ + └── Docker CLI + ├── docker restart + └── docker compose ... up -d --no-deps --force-recreate +``` + +Das Schreiben der `.env` verändert die bereits gestartete Prozessumgebung eines Containers nicht. Erst ein Compose-Recreate übernimmt die neue Konfiguration. Ein normaler Restart ist nur für Anwendungen geeignet, die ihre Konfiguration selbst aus einer gemounteten Datei neu einlesen. + +## Schnellstart + +```bash +cp controller.password.example controller.password +cp controller.example.json controller.json +mkdir -p backups +``` + +Danach anpassen: + +1. den zufälligen Inhalt von `controller.password` +2. Host-Pfade in `compose.yml` +3. Projekte und erlaubte Container in `controller.json` +4. stabile Container-Namen über ein Compose-Override festlegen + +Start: + +```bash +docker compose -f compose.yml up -d --build +``` + +Aufruf standardmäßig: + +```text +http://127.0.0.1:8090 +``` + +Benutzername ist im Beispiel `admin`; das Passwort wird als Compose-Secret aus `controller.password` eingebunden. Alternativ unterstützt die Anwendung weiterhin `password_env` in `controller.json`. + +## Stabile Container-Namen + +Der Controller arbeitet absichtlich mit exakten Namen. Im verwalteten Projekt kann dafür ein Override wie `examples/compose.controller-names.yml` verwendet werden: + +```yaml +services: + agent: + container_name: glpi-ai-agent + ollama: + container_name: glpi-ai-ollama +``` + +Das Projekt muss anschließend mit beiden Dateien erstellt werden: + +```bash +docker compose \ + -f docker-compose.yml \ + -f compose.controller-names.yml \ + up -d +``` + +Dieselben Compose-Dateien müssen beim Ziel in `controller.json` angegeben sein, damit ein Recreate die identische Definition verwendet. + +## Projektkonfiguration + +Ein Projekt verwaltet genau eine `.env` samt `.env.example` und Backup-Verzeichnis: + +```json +{ + "id": "glpi-ai-stack", + "title": "GLPI AI Stack", + "env_file": "/opt/glpi-ai-stack/.env", + "example_file": "/opt/glpi-ai-stack/.env.example", + "backup_dir": "/backups/glpi-ai-stack", + "targets": [] +} +``` + +Alle drei Pfade müssen im Container absolut sein. Die `.env` und das Backup-Verzeichnis benötigen Schreibzugriff; `.env.example` darf read-only gemountet werden, sofern sie separat eingebunden wird. + +### Automatischer Import + +```json +"auto_import_missing": true +``` + +Beim Start werden fehlende Schlüssel automatisch importiert. Auch dabei wird vorher ein Backup erstellt. Standardmäßig ist die Funktion deaktiviert, damit neue Vorlagenwerte zunächst geprüft werden können. + +## Container-Ziele + +### Compose-Recreate + +```json +{ + "container_name": "glpi-ai-agent", + "display_name": "GLPI AI Agent", + "allowed_actions": ["restart", "recreate"], + "default_action": "recreate", + "apply_by_default": true, + "project_dir": "/opt/glpi-ai-stack", + "compose_files": [ + "/opt/glpi-ai-stack/docker-compose.yml", + "/opt/glpi-ai-stack/compose.controller-names.yml" + ], + "compose_service": "agent", + "compose_project": "glpi-ai-stack", + "env_file": "/opt/glpi-ai-stack/.env" +} +``` + +Ausgeführt wird sinngemäß: + +```bash +docker compose \ + --env-file /opt/glpi-ai-stack/.env \ + --project-directory /opt/glpi-ai-stack \ + -p glpi-ai-stack \ + -f /opt/glpi-ai-stack/docker-compose.yml \ + -f /opt/glpi-ai-stack/compose.controller-names.yml \ + up -d --no-deps --force-recreate agent +``` + +`--no-deps` verhindert, dass Compose eigenständig weitere Dienste verändert. Gewünschte abhängige Dienste werden als separate, explizite Ziele eingetragen und in der Oberfläche ausgewählt. + +### Einfacher Restart + +Für einen nicht von Compose verwalteten Container oder eine Anwendung, die eine gemountete Datei bei Restart selbst liest: + +```json +{ + "container_name": "legacy-service", + "display_name": "Legacy Service", + "allowed_actions": ["restart"], + "default_action": "restart", + "apply_by_default": false +} +``` + +### Dritt-Container aus einem anderen Compose-Projekt + +Ein Ziel kann ein anderes `project_dir`, andere Compose-Dateien und einen anderen Service besitzen. Mit `env_file` kann dennoch die gerade verwaltete gemeinsame `.env` an Compose übergeben werden: + +```json +{ + "container_name": "glpi-kb-search", + "allowed_actions": ["recreate"], + "default_action": "recreate", + "apply_by_default": true, + "project_dir": "/opt/glpi-kb-stack", + "compose_files": ["/opt/glpi-kb-stack/compose.yml"], + "compose_service": "kb-search", + "compose_project": "glpi-kb-stack", + "env_file": "/opt/glpi-ai-stack/.env" +} +``` + +Damit lassen sich mehrere von gemeinsamen Einstellungen abhängige Anwendungen gezielt nacheinander aktualisieren. + +## Automatische Compose-Erkennung + +Fehlen die expliziten Felder `project_dir`, `compose_files` oder `compose_service`, versucht der Controller diese Werte aus folgenden Compose-Labels des laufenden Containers zu lesen: + +- `com.docker.compose.project.working_dir` +- `com.docker.compose.project.config_files` +- `com.docker.compose.project` +- `com.docker.compose.service` + +Explizite Angaben sind robuster. Bei automatischer Erkennung müssen die im Label genannten Host-Pfade unter exakt demselben Pfad in den Controller gemountet sein. + +## Backup-Verhalten + +Vor jeder Mutation wird der aktuell gültige Stand gesichert: + +- `edit` vor dem Speichern +- `import` vor dem Import neuer Schlüssel +- `pre_restore` vor einer Wiederherstellung +- `import` beim automatischen Startimport + +Beispiel: + +```text +20260801T211500.123456789Z_edit_f2c9148c7a11.env +20260801T211500.123456789Z_edit_f2c9148c7a11.env.json +``` + +Die JSON-Metadaten enthalten Zeitpunkt, Grund, Benutzer, Größe und SHA-256. Zusätzlich wird `audit.jsonl` fortgeschrieben. `max_backups` begrenzt die Anzahl der aufbewahrten `.env`-Revisionen je Projekt. + +Das Wiederherstellen erzeugt zuerst ein neues `pre_restore`-Backup des aktuellen Standes. Dadurch kann auch ein versehentlicher Restore unmittelbar rückgängig gemacht werden. + +## Secret-Felder + +Ein Schlüssel gilt als geheim, wenn sein Name beispielsweise eines dieser Muster enthält: + +- `PASSWORD` +- `PASSWD` +- `SECRET` +- `TOKEN` +- `API_KEY` +- `PRIVATE_KEY` +- `CREDENTIAL` + +Der Klartext wird nicht in die initiale HTML-Seite eingebettet. Beim Fokus sendet der Browser einen authentifizierten, CSRF-geschützten Request. Beim Blur wird das Feld wieder als Passwortfeld dargestellt. Der Klartext befindet sich nach dem Öffnen technisch im Browserprozess; die Weboberfläche darf deshalb ausschließlich über ein vertrauenswürdiges Netz und vorzugsweise TLS erreichbar sein. + +## Sicherheitsmodell + +Der Docker-Socket verleiht dem Controller sehr weitreichende Rechte auf dem Docker-Host. Die Anwendung reduziert die Angriffsfläche, indem sie: + +- keine frei eingegebenen Container-Namen akzeptiert +- keine frei eingegebenen Shell-Befehle ausführt +- Aktionen und Namen ausschließlich aus `controller.json` übernimmt +- nur `restart` und einen fest aufgebauten Compose-Recreate unterstützt +- Basic Auth und CSRF-Schutz verwendet +- Sicherheitsheader setzt +- Schreibvorgänge atomar ausführt +- als read-only Container mit `cap_drop: ALL` und `no-new-privileges` läuft + +Der Socket bleibt trotzdem eine hochprivilegierte Schnittstelle. Die Oberfläche sollte nur an `127.0.0.1` oder ein internes Verwaltungsnetz gebunden und über einen TLS-Reverse-Proxy mit zusätzlicher Zugriffskontrolle veröffentlicht werden. + +## Dateibesitz und Rechte + +Beim atomaren Ersetzen versucht der Controller Modus, UID und GID der ursprünglichen `.env` beizubehalten. Backups werden mit `0600` angelegt. Der Controller benötigt Schreibrechte auf: + +- das Verzeichnis der `.env` +- alle `backup_dir`-Verzeichnisse + +Für den Docker-Socket läuft das Beispiel als root im Container. Dies bedeutet nicht, dass der Socket dadurch weniger privilegiert wäre; der Zugriff auf den Daemon selbst ist bereits die entscheidende Berechtigung. + +## Verhalten bei Fehlern + +- Schlägt das Backup fehl, wird die `.env` nicht verändert. +- Schlägt das atomare Schreiben fehl, bleibt die vorherige Datei bestehen. +- Schlägt ein Recreate nach erfolgreichem Speichern fehl, bleibt die neue `.env` gespeichert und die Oberfläche zeigt den Fehler je Ziel an. Das vorherige Backup kann wiederhergestellt werden. +- Nicht allowlistete Namen oder Aktionen werden abgelehnt. +- Ein Recreate ohne vollständige Compose-Metadaten wird abgelehnt, statt einen Container improvisiert aus `docker inspect` nachzubauen. + +## Entwicklung + +```bash +make test +make vet +make race +make build +``` + +Das Projekt verwendet ausschließlich die Go-Standardbibliothek. Für Container-Aktionen wird die Docker CLI mit Compose-Plugin im Image verwendet. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..079f2be --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +## Docker socket + +`/var/run/docker.sock` is a privileged host-control interface. A compromise of this application can compromise the Docker host. Do not expose the controller directly to the internet. + +Recommended controls: + +- bind the HTTP port to loopback or a dedicated management network +- terminate TLS at a hardened reverse proxy +- add network-level access control in addition to Basic Auth +- use a long random password +- keep `controller.json` read-only +- keep the target allowlist minimal +- do not allow the controller to recreate itself +- mount only the project directories that are actually required +- review backups because they contain secrets + +## Secret handling + +Secrets are omitted from the initial HTML and fetched only after an authenticated focus action. They are still visible to the browser after retrieval. Backups contain the complete `.env` and use mode `0600`; protect the backup volume and include it in the host's secure backup policy. + +## Reporting + +Report suspected vulnerabilities privately to the project owner. Include the affected version, deployment model, reproduction steps and impact. Do not include real credentials or `.env` files in reports. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..d917d3e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.2 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..3a9d2f0 --- /dev/null +++ b/compose.yml @@ -0,0 +1,37 @@ +services: + env-controller: + build: . + container_name: glpi-env-controller + restart: unless-stopped + environment: + ENV_CONTROLLER_CONFIG: /config/controller.json + HOME: /tmp + secrets: + - env_controller_password + ports: + - "127.0.0.1:8090:8090" + volumes: + - ./controller.json:/config/controller.json:ro + - ./backups:/backups:rw + - /var/run/docker.sock:/var/run/docker.sock + # Die verwalteten Compose-Projekte müssen im Controller unter den + # Pfaden verfügbar sein, die in controller.json stehen. + - /opt/glpi-ai-stack:/opt/glpi-ai-stack:rw + - /opt/glpi-kb-stack:/opt/glpi-kb-stack:rw + read_only: true + tmpfs: + - /tmp:size=32m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +secrets: + env_controller_password: + file: ./controller.password diff --git a/controller.example.json b/controller.example.json new file mode 100644 index 0000000..d40baf9 --- /dev/null +++ b/controller.example.json @@ -0,0 +1,64 @@ +{ + "listen_addr": ":8090", + "username": "admin", + "password_env": "ENV_CONTROLLER_PASSWORD", + "password_file": "/run/secrets/env_controller_password", + "auto_import_missing": false, + "max_backups": 100, + "docker_timeout": "5m", + "projects": [ + { + "id": "glpi-ai-stack", + "title": "GLPI AI Stack", + "env_file": "/opt/glpi-ai-stack/.env", + "example_file": "/opt/glpi-ai-stack/.env.example", + "backup_dir": "/backups/glpi-ai-stack", + "targets": [ + { + "container_name": "glpi-ai-agent", + "display_name": "GLPI AI Agent", + "allowed_actions": ["restart", "recreate"], + "default_action": "recreate", + "apply_by_default": true, + "project_dir": "/opt/glpi-ai-stack", + "compose_files": [ + "/opt/glpi-ai-stack/docker-compose.yml", + "/opt/glpi-ai-stack/compose.controller-names.yml" + ], + "compose_service": "agent", + "compose_project": "glpi-ai-stack", + "env_file": "/opt/glpi-ai-stack/.env" + }, + { + "container_name": "glpi-ai-ollama", + "display_name": "Ollama", + "allowed_actions": ["restart", "recreate"], + "default_action": "restart", + "apply_by_default": false, + "project_dir": "/opt/glpi-ai-stack", + "compose_files": [ + "/opt/glpi-ai-stack/docker-compose.yml", + "/opt/glpi-ai-stack/compose.controller-names.yml" + ], + "compose_service": "ollama", + "compose_project": "glpi-ai-stack", + "env_file": "/opt/glpi-ai-stack/.env" + }, + { + "container_name": "glpi-kb-search", + "display_name": "KB Search (Dritt-Container)", + "allowed_actions": ["restart", "recreate"], + "default_action": "recreate", + "apply_by_default": true, + "project_dir": "/opt/glpi-kb-stack", + "compose_files": [ + "/opt/glpi-kb-stack/compose.yml" + ], + "compose_service": "kb-search", + "compose_project": "glpi-kb-stack", + "env_file": "/opt/glpi-ai-stack/.env" + } + ] + } + ] +} diff --git a/controller.password.example b/controller.password.example new file mode 100644 index 0000000..61339e0 --- /dev/null +++ b/controller.password.example @@ -0,0 +1 @@ +replace-with-a-long-random-password diff --git a/controller.windows.example.json b/controller.windows.example.json new file mode 100644 index 0000000..bc57777 --- /dev/null +++ b/controller.windows.example.json @@ -0,0 +1,35 @@ +{ + "listen_addr": "127.0.0.1:8090", + "username": "admin", + "password_env": "ENV_CONTROLLER_PASSWORD", + "password_file": "", + "auto_import_missing": false, + "max_backups": 100, + "docker_timeout": "5m", + "projects": [ + { + "id": "glpi-ai-stack", + "title": "GLPI AI Stack", + "env_file": "E:/GoProjects/glpi-ai-agent/.env", + "example_file": "E:/GoProjects/glpi-ai-agent/.env.example", + "backup_dir": "E:/GoProjects/glpi-ai-agent-controller/backups/glpi-ai-stack", + "targets": [ + { + "container_name": "glpi-ai-agent", + "display_name": "GLPI AI Agent", + "allowed_actions": ["restart", "recreate"], + "default_action": "recreate", + "apply_by_default": true, + "project_dir": "E:/GoProjects/glpi-ai-agent", + "compose_files": [ + "E:/GoProjects/glpi-ai-agent/docker-compose.yml", + "E:/GoProjects/glpi-ai-agent/compose.controller-names.yml" + ], + "compose_service": "agent", + "compose_project": "glpi-ai-stack", + "env_file": "E:/GoProjects/glpi-ai-agent/.env" + } + ] + } + ] +} diff --git a/dist/env-controller-linux-amd64 b/dist/env-controller-linux-amd64 new file mode 100644 index 0000000..037cba8 Binary files /dev/null and b/dist/env-controller-linux-amd64 differ diff --git a/dist/env-controller-windows-amd64.exe b/dist/env-controller-windows-amd64.exe new file mode 100644 index 0000000..42835b3 Binary files /dev/null and b/dist/env-controller-windows-amd64.exe differ diff --git a/examples/compose.controller-names.yml b/examples/compose.controller-names.yml new file mode 100644 index 0000000..de1801a --- /dev/null +++ b/examples/compose.controller-names.yml @@ -0,0 +1,8 @@ +# Dieses Override sorgt für stabile, explizit erlaubbare Container-Namen. +# Zusammen mit der eigentlichen Compose-Datei starten: +# docker compose -f docker-compose.yml -f compose.controller-names.yml up -d +services: + agent: + container_name: glpi-ai-agent + ollama: + container_name: glpi-ai-ollama diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b1cd6c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/example/glpi-env-controller + +go 1.26 diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..791a756 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,115 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/example/glpi-env-controller/internal/dockerctl" +) + +type Config struct { + ListenAddr string `json:"listen_addr"` + Username string `json:"username"` + PasswordEnv string `json:"password_env"` + PasswordFile string `json:"password_file"` + AutoImportMissing bool `json:"auto_import_missing"` + MaxBackups int `json:"max_backups"` + DockerTimeout string `json:"docker_timeout"` + Projects []ProjectConfig `json:"projects"` +} + +type ProjectConfig struct { + ID string `json:"id"` + Title string `json:"title"` + EnvFile string `json:"env_file"` + ExampleFile string `json:"example_file"` + BackupDir string `json:"backup_dir"` + Targets []dockerctl.Target `json:"targets"` +} + +func LoadConfig(path string) (Config, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, "", err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, "", fmt.Errorf("decode config: %w", err) + } + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":8090" + } + if cfg.Username == "" { + cfg.Username = "admin" + } + if cfg.PasswordEnv == "" && cfg.PasswordFile == "" { + cfg.PasswordEnv = "ENV_CONTROLLER_PASSWORD" + } + if cfg.MaxBackups <= 0 { + cfg.MaxBackups = 100 + } + if cfg.DockerTimeout == "" { + cfg.DockerTimeout = "3m" + } + if _, err := time.ParseDuration(cfg.DockerTimeout); err != nil { + return Config{}, "", fmt.Errorf("docker_timeout: %w", err) + } + password := "" + if cfg.PasswordFile != "" { + data, err := os.ReadFile(cfg.PasswordFile) + if err != nil { + return Config{}, "", fmt.Errorf("read password_file: %w", err) + } + password = strings.TrimSpace(string(data)) + } else { + password = os.Getenv(cfg.PasswordEnv) + } + if password == "" { + return Config{}, "", fmt.Errorf("controller password is empty") + } + if len(cfg.Projects) == 0 { + return Config{}, "", fmt.Errorf("at least one project is required") + } + seen := map[string]struct{}{} + for i := range cfg.Projects { + p := &cfg.Projects[i] + if p.ID == "" || !safeID(p.ID) { + return Config{}, "", fmt.Errorf("project %d has invalid id", i) + } + if _, ok := seen[p.ID]; ok { + return Config{}, "", fmt.Errorf("duplicate project id %q", p.ID) + } + seen[p.ID] = struct{}{} + if p.Title == "" { + p.Title = p.ID + } + for label, value := range map[string]string{"env_file": p.EnvFile, "example_file": p.ExampleFile, "backup_dir": p.BackupDir} { + if value == "" || !filepath.IsAbs(value) { + return Config{}, "", fmt.Errorf("project %q %s must be an absolute path", p.ID, label) + } + } + if filepath.Clean(p.EnvFile) == filepath.Clean(p.ExampleFile) { + return Config{}, "", fmt.Errorf("project %q env_file and example_file must differ", p.ID) + } + if err := dockerctl.ValidateTargets(p.Targets); err != nil { + return Config{}, "", fmt.Errorf("project %q targets: %w", p.ID, err) + } + } + return cfg, password, nil +} + +func safeID(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { + return false + } + } + return !strings.HasPrefix(value, "-") +} diff --git a/internal/app/configpath.go b/internal/app/configpath.go new file mode 100644 index 0000000..bc41403 --- /dev/null +++ b/internal/app/configpath.go @@ -0,0 +1,93 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ResolveConfigPath determines which controller configuration file to load. +// Explicit CLI and environment paths are authoritative. Without either, the +// current directory, executable directory, and container default are checked. +func ResolveConfigPath(cliPath, envPath string) (string, error) { + if path := strings.TrimSpace(cliPath); path != "" { + return requireConfigFile(path, "-config") + } + if path := strings.TrimSpace(envPath); path != "" { + return requireConfigFile(path, "ENV_CONTROLLER_CONFIG") + } + + candidates := make([]string, 0, 3) + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, "controller.json")) + } + if executable, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(executable), "controller.json")) + } + candidates = append(candidates, filepath.FromSlash("/config/controller.json")) + candidates = uniqueCleanPaths(candidates) + + for _, candidate := range candidates { + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + absolute, absErr := filepath.Abs(candidate) + if absErr == nil { + return absolute, nil + } + return candidate, nil + } + } + + exampleHints := make([]string, 0, 2) + for _, candidate := range candidates { + example := filepath.Join(filepath.Dir(candidate), "controller.example.json") + if info, err := os.Stat(example); err == nil && !info.IsDir() { + exampleHints = append(exampleHints, example) + } + } + + message := fmt.Sprintf("controller configuration not found; searched: %s", strings.Join(candidates, ", ")) + if len(exampleHints) > 0 { + message += fmt.Sprintf("; create controller.json from: %s", strings.Join(uniqueCleanPaths(exampleHints), ", ")) + } + message += "; alternatively pass -config or set ENV_CONTROLLER_CONFIG" + return "", fmt.Errorf("%s", message) +} + +func requireConfigFile(path, source string) (string, error) { + cleaned := filepath.Clean(path) + info, err := os.Stat(cleaned) + if err != nil { + return "", fmt.Errorf("configuration path from %s %q: %w", source, cleaned, err) + } + if info.IsDir() { + return "", fmt.Errorf("configuration path from %s %q is a directory", source, cleaned) + } + absolute, err := filepath.Abs(cleaned) + if err == nil { + return absolute, nil + } + return cleaned, nil +} + +func uniqueCleanPaths(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + cleaned := filepath.Clean(path) + key := cleaned + if filepath.Separator == '\\' { + key = strings.ToLower(cleaned) + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, cleaned) + } + return result +} diff --git a/internal/app/configpath_test.go b/internal/app/configpath_test.go new file mode 100644 index 0000000..b5fb60f --- /dev/null +++ b/internal/app/configpath_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveConfigPathExplicit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "custom.json") + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveConfigPath(path, "") + if err != nil { + t.Fatal(err) + } + want, _ := filepath.Abs(path) + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestResolveConfigPathEnvironmentIsAuthoritative(t *testing.T) { + _, err := ResolveConfigPath("", filepath.Join(t.TempDir(), "missing.json")) + if err == nil || !strings.Contains(err.Error(), "ENV_CONTROLLER_CONFIG") { + t.Fatalf("expected environment-path error, got %v", err) + } +} + +func TestResolveConfigPathCurrentDirectory(t *testing.T) { + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + path := filepath.Join(dir, "controller.json") + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveConfigPath("", "") + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("got %q, want %q", got, path) + } +} + +func TestResolveConfigPathMentionsExample(t *testing.T) { + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + example := filepath.Join(dir, "controller.example.json") + if err := os.WriteFile(example, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + _, err = ResolveConfigPath("", "") + if err == nil || !strings.Contains(err.Error(), example) { + t.Fatalf("expected hint for %q, got %v", example, err) + } +} diff --git a/internal/app/ownership_unix.go b/internal/app/ownership_unix.go new file mode 100644 index 0000000..81b1d73 --- /dev/null +++ b/internal/app/ownership_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package app + +import ( + "errors" + "os" + "syscall" +) + +// preserveOwnership applies the owner and group of the original file to the +// temporary replacement file. Permission failures are ignored because the +// process may be allowed to replace the file without being allowed to chown it. +func preserveOwnership(path string, info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if err := os.Chown(path, int(stat.Uid), int(stat.Gid)); err != nil && !errors.Is(err, os.ErrPermission) { + return err + } + return nil +} diff --git a/internal/app/ownership_windows.go b/internal/app/ownership_windows.go new file mode 100644 index 0000000..a50244b --- /dev/null +++ b/internal/app/ownership_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package app + +import "os" + +// Windows file metadata does not expose Unix UID/GID ownership. File mode is +// still preserved by atomicWrite; there is no ownership operation to perform. +func preserveOwnership(_ string, _ os.FileInfo) error { + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..be6ba0e --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,678 @@ +package app + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/example/glpi-env-controller/internal/backup" + "github.com/example/glpi-env-controller/internal/dockerctl" + "github.com/example/glpi-env-controller/internal/envfile" +) + +const unchangedSecret = "__ENV_CONTROLLER_UNCHANGED__" + +type Server struct { + cfg Config + password string + csrf string + tpl *template.Template + docker dockerctl.Controller + projects map[string]ProjectConfig + locks map[string]*sync.Mutex + logger *slog.Logger +} + +type pageData struct { + Title string + CSRF string + Projects []projectSummary + Project *projectView + Flash string + FlashKind string + Now time.Time +} + +type projectSummary struct { + ID, Title string + Missing, Extra, Backups int + Error string +} + +type fieldView struct { + Key, Value, Description string + Secret, Missing, Extra, Duplicate, Long, Boolean bool +} + +type projectView struct { + Config ProjectConfig + Fields []fieldView + Missing []string + Extra []string + Duplicates []string + Backups []backup.Entry + Targets []targetView + CurrentSHA256 string +} + +type targetView struct { + Target dockerctl.Target + Status dockerctl.Status +} + +func NewServer(cfg Config, password string, htmlTemplate string, logger *slog.Logger) (*Server, error) { + funcs := template.FuncMap{ + "humanBytes": humanBytes, + "duration": func(d time.Duration) string { return d.Round(time.Millisecond).String() }, + "join": strings.Join, + "hasAction": func(actions []string, action string) bool { + for _, a := range actions { + if a == action { + return true + } + } + return false + }, + } + tpl, err := template.New("page").Funcs(funcs).Parse(htmlTemplate) + if err != nil { + return nil, err + } + csrfBytes := make([]byte, 32) + if _, err := rand.Read(csrfBytes); err != nil { + return nil, err + } + if logger == nil { + logger = slog.Default() + } + timeout, _ := time.ParseDuration(cfg.DockerTimeout) + s := &Server{cfg: cfg, password: password, csrf: base64.RawURLEncoding.EncodeToString(csrfBytes), tpl: tpl, docker: dockerctl.Controller{Timeout: timeout}, projects: map[string]ProjectConfig{}, locks: map[string]*sync.Mutex{}, logger: logger} + for _, p := range cfg.Projects { + s.projects[p.ID] = p + s.locks[p.ID] = &sync.Mutex{} + } + return s, nil +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.health) + mux.HandleFunc("/", s.route) + return s.securityHeaders(s.basicAuth(mux)) +} + +func (s *Server) AutoImport(ctx context.Context) { + if !s.cfg.AutoImportMissing { + return + } + for _, project := range s.cfg.Projects { + if _, err := s.importMissing(ctx, project, "startup", true, nil); err != nil { + s.logger.Error("automatic env import failed", "project", project.ID, "error", err) + } + } +} + +func (s *Server) route(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + s.index(w, r) + return + } + parts := splitPath(r.URL.Path) + if len(parts) < 2 || parts[0] != "project" { + http.NotFound(w, r) + return + } + project, ok := s.projects[parts[1]] + if !ok { + http.NotFound(w, r) + return + } + if len(parts) == 2 { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + s.projectPage(w, r, project, "", "") + return + } + action := parts[2] + switch action { + case "save": + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + s.save(w, r, project) + case "import": + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + s.importHandler(w, r, project) + case "restore": + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + s.restore(w, r, project) + case "containers": + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + s.containers(w, r, project) + case "reveal": + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + s.reveal(w, r, project) + case "backup": + if r.Method != http.MethodGet || len(parts) != 4 { + methodNotAllowed(w) + return + } + s.downloadBackup(w, r, project, parts[3]) + default: + http.NotFound(w, r) + } +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"ok"}`) +} + +func (s *Server) index(w http.ResponseWriter, r *http.Request) { + data := pageData{Title: "ENV Controller", CSRF: s.csrf, Now: time.Now()} + for _, project := range s.cfg.Projects { + summary := projectSummary{ID: project.ID, Title: project.Title} + current, example, err := readDocs(project) + if err != nil { + summary.Error = err.Error() + } else { + missing, extra := envfile.Compare(current, example) + summary.Missing, summary.Extra = len(missing), len(extra) + } + entries, _ := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).List() + summary.Backups = len(entries) + data.Projects = append(data.Projects, summary) + } + s.render(w, data) +} + +func (s *Server) projectPage(w http.ResponseWriter, r *http.Request, project ProjectConfig, flash, kind string) { + view, err := s.loadProjectView(r.Context(), project) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.render(w, pageData{Title: project.Title, CSRF: s.csrf, Project: &view, Flash: flash, FlashKind: kind, Now: time.Now()}) +} + +func (s *Server) loadProjectView(ctx context.Context, project ProjectConfig) (projectView, error) { + current, example, err := readDocs(project) + if err != nil { + return projectView{}, err + } + curValues := current.Effective() + exValues := example.Effective() + descriptions := envfile.Descriptions(example) + occ := current.Occurrences() + missing, extra := envfile.Compare(current, example) + missingSet := setOf(missing) + extraSet := setOf(extra) + var keys []string + seen := map[string]struct{}{} + for _, line := range example.Lines { + if line.Kind == envfile.LineAssignment { + if _, ok := seen[line.Key]; !ok { + keys = append(keys, line.Key) + seen[line.Key] = struct{}{} + } + } + } + var extraSorted []string + for key := range curValues { + if _, ok := seen[key]; !ok { + extraSorted = append(extraSorted, key) + } + } + sort.Strings(extraSorted) + keys = append(keys, extraSorted...) + var fields []fieldView + for _, key := range keys { + value, exists := curValues[key] + if !exists { + value = exValues[key] + } + secret := isSecret(key) + shown := value + if secret { + shown = unchangedSecret + } + fields = append(fields, fieldView{Key: key, Value: shown, Description: descriptions[key], Secret: secret, Missing: contains(missingSet, key), Extra: contains(extraSet, key), Duplicate: occ[key] > 1, Long: isLong(key, value), Boolean: isBoolean(exValues[key])}) + } + var duplicates []string + for key, count := range occ { + if count > 1 { + duplicates = append(duplicates, fmt.Sprintf("%s (%dx)", key, count)) + } + } + sort.Strings(duplicates) + entries, err := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).List() + if err != nil { + return projectView{}, err + } + var targets []targetView + for _, target := range dockerctl.SortedTargets(project.Targets) { + targets = append(targets, targetView{Target: target, Status: s.docker.Status(ctx, target)}) + } + return projectView{Config: project, Fields: fields, Missing: missing, Extra: extra, Duplicates: duplicates, Backups: entries, Targets: targets}, nil +} + +func (s *Server) save(w http.ResponseWriter, r *http.Request, project ProjectConfig) { + if !s.validatePost(w, r) { + return + } + lock := s.locks[project.ID] + lock.Lock() + defer lock.Unlock() + currentData, err := os.ReadFile(project.EnvFile) + if err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + current, err := envfile.Parse(currentData) + if err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + values := current.Effective() + for key := range values { + formKey := "v." + key + posted, ok := r.Form[formKey] + if !ok || len(posted) == 0 { + continue + } + value := posted[0] + if isSecret(key) && value == unchangedSecret { + continue + } + if err := current.Set(key, value); err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + } + store := backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups} + entry, err := store.Create(project.EnvFile, "edit", s.cfg.Username) + if err != nil { + s.projectPage(w, r, project, "Backup fehlgeschlagen: "+err.Error(), "error") + return + } + if err := atomicWrite(project.EnvFile, current.Render()); err != nil { + s.projectPage(w, r, project, "Schreiben fehlgeschlagen: "+err.Error(), "error") + return + } + message := "Konfiguration gespeichert; Sicherung " + entry.Name + " wurde vorher erstellt." + message += s.applyFromForm(r.Context(), r, project) + s.projectPage(w, r, project, message, "success") +} + +func (s *Server) importHandler(w http.ResponseWriter, r *http.Request, project ProjectConfig) { + if !s.validatePost(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + missing, err := s.importMissing(r.Context(), project, s.cfg.Username, true, r) + if err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + if len(missing) == 0 { + s.projectPage(w, r, project, "Keine neuen Einträge in .env.example gefunden.", "info") + return + } + message := fmt.Sprintf("%d neue Einträge importiert: %s.", len(missing), strings.Join(missing, ", ")) + message += s.applyFromForm(r.Context(), r, project) + s.projectPage(w, r, project, message, "success") +} + +func (s *Server) importMissing(_ context.Context, project ProjectConfig, actor string, withBackup bool, _ *http.Request) ([]string, error) { + lock := s.locks[project.ID] + lock.Lock() + defer lock.Unlock() + current, example, err := readDocs(project) + if err != nil { + return nil, err + } + missing, _ := envfile.Compare(current, example) + if len(missing) == 0 { + return nil, nil + } + if withBackup { + if _, err := (backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups}).Create(project.EnvFile, "import", actor); err != nil { + return nil, fmt.Errorf("backup before import: %w", err) + } + } + marker := "# --- Automatisch aus .env.example importiert am " + time.Now().Format(time.RFC3339) + " ---" + added, err := current.ImportMissing(example, marker) + if err != nil { + return nil, err + } + if err := atomicWrite(project.EnvFile, current.Render()); err != nil { + return nil, err + } + return added, nil +} + +func (s *Server) restore(w http.ResponseWriter, r *http.Request, project ProjectConfig) { + if !s.validatePost(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + name := r.FormValue("backup") + if name == "" { + s.projectPage(w, r, project, "Keine Sicherung ausgewählt.", "error") + return + } + lock := s.locks[project.ID] + lock.Lock() + defer lock.Unlock() + store := backup.Store{Dir: project.BackupDir, MaxBackups: s.cfg.MaxBackups} + pre, err := store.Create(project.EnvFile, "pre_restore", s.cfg.Username) + if err != nil { + s.projectPage(w, r, project, "Sicherung vor Wiederherstellung fehlgeschlagen: "+err.Error(), "error") + return + } + data, err := store.Read(name) + if err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + if _, err := envfile.Parse(data); err != nil { + s.projectPage(w, r, project, "Ungültige Sicherung: "+err.Error(), "error") + return + } + if err := atomicWrite(project.EnvFile, data); err != nil { + s.projectPage(w, r, project, err.Error(), "error") + return + } + message := "Sicherung " + name + " wiederhergestellt. Der vorherige Stand wurde als " + pre.Name + " gesichert." + message += s.applyFromForm(r.Context(), r, project) + s.projectPage(w, r, project, message, "success") +} + +func (s *Server) containers(w http.ResponseWriter, r *http.Request, project ProjectConfig) { + if !s.validatePost(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + message := s.applyFromForm(r.Context(), r, project) + if message == "" { + message = " Keine Container ausgewählt." + } + s.projectPage(w, r, project, strings.TrimSpace(message), "info") +} + +func (s *Server) applyFromForm(ctx context.Context, r *http.Request, project ProjectConfig) string { + if r.Form == nil { + _ = r.ParseForm() + } + selected := r.Form["target"] + if len(selected) == 0 { + return "" + } + allow := map[string]dockerctl.Target{} + for _, target := range project.Targets { + allow[target.ContainerName] = target + } + var results []string + for _, name := range selected { + target, ok := allow[name] + if !ok { + results = append(results, name+": nicht freigegeben") + continue + } + action := r.FormValue("action." + name) + if action == "" { + action = target.DefaultAction + } + result := s.docker.Execute(ctx, target, action) + if result.Success { + results = append(results, fmt.Sprintf("%s: %s erfolgreich", name, action)) + } else { + results = append(results, fmt.Sprintf("%s: %s fehlgeschlagen (%s)", name, action, result.Error)) + } + } + return " Container-Aktionen: " + strings.Join(results, "; ") + "." +} + +func (s *Server) reveal(w http.ResponseWriter, r *http.Request, project ProjectConfig) { + if !s.validatePost(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + key := r.FormValue("key") + if !isSecret(key) { + http.Error(w, "not a secret field", http.StatusBadRequest) + return + } + current, _, err := readDocs(project) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + value, ok := current.Effective()[key] + if !ok { + http.Error(w, "unknown key", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]string{"value": value}) +} + +func (s *Server) downloadBackup(w http.ResponseWriter, r *http.Request, project ProjectConfig, name string) { + path, err := (backup.Store{Dir: project.BackupDir}).Path(name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, path) +} + +func (s *Server) validatePost(w http.ResponseWriter, r *http.Request) bool { + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return false + } + provided := r.FormValue("csrf") + if subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrf)) != 1 { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return false + } + return true +} + +func (s *Server) render(w http.ResponseWriter, data pageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := s.tpl.ExecuteTemplate(w, "page", data); err != nil { + s.logger.Error("render page", "error", err) + } +} + +func (s *Server) basicAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + user, pass, ok := r.BasicAuth() + if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(s.cfg.Username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(s.password)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="ENV Controller", charset="UTF-8"`) + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +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("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + next.ServeHTTP(w, r) + }) +} + +func readDocs(project ProjectConfig) (*envfile.Document, *envfile.Document, error) { + curData, err := os.ReadFile(project.EnvFile) + if err != nil { + return nil, nil, fmt.Errorf("read .env: %w", err) + } + exData, err := os.ReadFile(project.ExampleFile) + if err != nil { + return nil, nil, fmt.Errorf("read .env.example: %w", err) + } + cur, err := envfile.Parse(curData) + if err != nil { + return nil, nil, err + } + ex, err := envfile.Parse(exData) + if err != nil { + return nil, nil, err + } + return cur, ex, nil +} + +func atomicWrite(path string, data []byte) error { + info, err := os.Stat(path) + if err != nil { + return err + } + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".env-controller-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + tmp.Close() + return err + } + if err := preserveOwnership(tmpName, info); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() + } + return nil +} + +func splitPath(path string) []string { + var out []string + for _, p := range strings.Split(strings.Trim(path, "/"), "/") { + if p != "" { + out = append(out, p) + } + } + return out +} +func methodNotAllowed(w http.ResponseWriter) { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} +func setOf(values []string) map[string]struct{} { + m := map[string]struct{}{} + for _, v := range values { + m[v] = struct{}{} + } + return m +} +func contains(m map[string]struct{}, k string) bool { _, ok := m[k]; return ok } +func isSecret(key string) bool { + u := strings.ToUpper(key) + for _, part := range []string{"PASSWORD", "PASSWD", "SECRET", "TOKEN", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"} { + if strings.Contains(u, part) { + return true + } + } + return false +} +func isLong(key, value string) bool { + return strings.Contains(value, "\n") || len(value) > 100 || strings.HasSuffix(strings.ToUpper(key), "_TEXT") +} +func isBoolean(value string) bool { + return strings.EqualFold(value, "true") || strings.EqualFold(value, "false") +} +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return strconv.FormatInt(n, 10) + " B" + } + div, exp := int64(unit), 0 + for n >= div*unit && exp < 4 { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/app/server_test.go b/internal/app/server_test.go new file mode 100644 index 0000000..0ec5948 --- /dev/null +++ b/internal/app/server_test.go @@ -0,0 +1,88 @@ +package app + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/example/glpi-env-controller/internal/dockerctl" +) + +func testTemplate(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("../../cmd/env-controller/page.html") + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestSaveCreatesBackupAndKeepsUnrevealedSecret(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + examplePath := filepath.Join(dir, ".env.example") + backupDir := filepath.Join(dir, "backups") + if err := os.WriteFile(envPath, []byte("A=1\nWEB_PASSWORD=top-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(examplePath, []byte("A=0\nWEB_PASSWORD=placeholder\n"), 0o600); err != nil { + t.Fatal(err) + } + project := ProjectConfig{ID: "test", Title: "Test", EnvFile: envPath, ExampleFile: examplePath, BackupDir: backupDir} + cfg := Config{Username: "admin", MaxBackups: 10, DockerTimeout: "1s", Projects: []ProjectConfig{project}} + server, err := NewServer(cfg, "password", testTemplate(t), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + form := url.Values{"csrf": {server.csrf}, "v.A": {"2"}, "v.WEB_PASSWORD": {unchangedSecret}} + req := httptest.NewRequest(http.MethodPost, "/project/test/save", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("admin", "password") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("status %d: %s", res.Code, res.Body.String()) + } + got, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "A=2\nWEB_PASSWORD=top-secret\n" { + t.Fatalf("unexpected env: %q", got) + } + entries, err := os.ReadDir(backupDir) + if err != nil { + t.Fatal(err) + } + found := false + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".env") { + found = true + } + } + if !found { + t.Fatal("expected backup") + } +} + +func TestTargetPickerTemplateExecutes(t *testing.T) { + cfg := Config{Username: "admin", MaxBackups: 10, DockerTimeout: "1s", Projects: []ProjectConfig{{ID: "test"}}} + server, err := NewServer(cfg, "password", testTemplate(t), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + data := pageData{Title: "x", CSRF: server.csrf, Project: &projectView{Config: ProjectConfig{ID: "test"}, Targets: []targetView{{Target: dockerctl.Target{ContainerName: "agent", DisplayName: "Agent", AllowedActions: []string{"restart", "recreate"}, DefaultAction: "recreate", ApplyByDefault: true}, Status: dockerctl.Status{State: "running"}}}}} + var b strings.Builder + if err := server.tpl.ExecuteTemplate(&b, "page", data); err != nil { + t.Fatal(err) + } + if !strings.Contains(b.String(), "glpi") && !strings.Contains(b.String(), "agent") { + t.Fatal("target not rendered") + } +} diff --git a/internal/backup/store.go b/internal/backup/store.go new file mode 100644 index 0000000..2f2113e --- /dev/null +++ b/internal/backup/store.go @@ -0,0 +1,162 @@ +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type Entry struct { + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Reason string `json:"reason"` + Actor string `json:"actor"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type Store struct { + Dir string + MaxBackups int + Now func() time.Time +} + +func (s Store) Create(sourcePath, reason, actor string) (Entry, error) { + data, err := os.ReadFile(sourcePath) + if err != nil { + return Entry{}, err + } + if s.Now == nil { + s.Now = time.Now + } + now := s.Now().UTC() + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + reason = safe(reason) + name := fmt.Sprintf("%s_%s_%s.env", now.Format("20060102T150405.000000000Z"), reason, hash[:12]) + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return Entry{}, err + } + path := filepath.Join(s.Dir, name) + if err := writeExclusive(path, data, 0o600); err != nil { + return Entry{}, err + } + entry := Entry{Name: name, CreatedAt: now, Reason: reason, Actor: actor, SHA256: hash, Size: int64(len(data))} + meta, _ := json.MarshalIndent(entry, "", " ") + if err := writeExclusive(path+".json", append(meta, '\n'), 0o600); err != nil { + return Entry{}, err + } + _ = s.appendAudit(entry) + _ = s.prune() + return entry, nil +} + +func (s Store) List() ([]Entry, error) { + items, err := os.ReadDir(s.Dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Entry + for _, item := range items { + if item.IsDir() || !strings.HasSuffix(item.Name(), ".env.json") { + continue + } + data, err := os.ReadFile(filepath.Join(s.Dir, item.Name())) + if err != nil { + continue + } + var entry Entry + if json.Unmarshal(data, &entry) == nil { + out = append(out, entry) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +func (s Store) Path(name string) (string, error) { + if filepath.Base(name) != name || !strings.HasSuffix(name, ".env") { + return "", fmt.Errorf("invalid backup name") + } + path := filepath.Join(s.Dir, name) + if _, err := os.Stat(path); err != nil { + return "", err + } + return path, nil +} + +func (s Store) Read(name string) ([]byte, error) { + path, err := s.Path(name) + if err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func (s Store) appendAudit(entry Entry) error { + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return err + } + f, err := os.OpenFile(filepath.Join(s.Dir, "audit.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + data, _ := json.Marshal(entry) + _, err = f.Write(append(data, '\n')) + return err +} + +func (s Store) prune() error { + if s.MaxBackups <= 0 { + return nil + } + entries, err := s.List() + if err != nil { + return err + } + if len(entries) <= s.MaxBackups { + return nil + } + for _, entry := range entries[s.MaxBackups:] { + _ = os.Remove(filepath.Join(s.Dir, entry.Name)) + _ = os.Remove(filepath.Join(s.Dir, entry.Name+".json")) + } + return nil +} + +func safe(value string) string { + value = strings.ToLower(value) + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "change" + } + return b.String() +} + +func writeExclusive(path string, data []byte, mode fs.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(data); err != nil { + return err + } + return f.Sync() +} diff --git a/internal/backup/store_test.go b/internal/backup/store_test.go new file mode 100644 index 0000000..9e26e60 --- /dev/null +++ b/internal/backup/store_test.go @@ -0,0 +1,28 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCreateAndRead(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, ".env") + if err := os.WriteFile(source, []byte("A=1\n"), 0o600); err != nil { + t.Fatal(err) + } + store := Store{Dir: filepath.Join(dir, "backups"), MaxBackups: 5, Now: func() time.Time { return time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) }} + entry, err := store.Create(source, "save", "admin") + if err != nil { + t.Fatal(err) + } + got, err := store.Read(entry.Name) + if err != nil { + t.Fatal(err) + } + if string(got) != "A=1\n" { + t.Fatalf("unexpected data %q", got) + } +} diff --git a/internal/dockerctl/controller.go b/internal/dockerctl/controller.go new file mode 100644 index 0000000..3d2af43 --- /dev/null +++ b/internal/dockerctl/controller.go @@ -0,0 +1,239 @@ +package dockerctl + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + "time" +) + +type Runner interface { + Run(ctx context.Context, name string, args ...string) ([]byte, error) +} + +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return out, nil +} + +type Target struct { + ContainerName string `json:"container_name"` + DisplayName string `json:"display_name"` + AllowedActions []string `json:"allowed_actions"` + DefaultAction string `json:"default_action"` + ApplyByDefault bool `json:"apply_by_default"` + ProjectDir string `json:"project_dir,omitempty"` + ComposeFiles []string `json:"compose_files,omitempty"` + ComposeService string `json:"compose_service,omitempty"` + ComposeProject string `json:"compose_project,omitempty"` + EnvFile string `json:"env_file,omitempty"` +} + +type Status struct { + ContainerName string `json:"container_name"` + State string `json:"state"` + Health string `json:"health"` + Image string `json:"image"` + Error string `json:"error,omitempty"` +} + +type Result struct { + ContainerName string `json:"container_name"` + Action string `json:"action"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Duration time.Duration `json:"duration"` +} + +type Controller struct { + Runner Runner + Timeout time.Duration +} + +func (c Controller) Status(ctx context.Context, target Target) Status { + ctx, cancel := context.WithTimeout(ctx, c.timeout()) + defer cancel() + format := `{{json .}}` + out, err := c.runner().Run(ctx, "docker", "inspect", "--format", format, target.ContainerName) + if err != nil { + return Status{ContainerName: target.ContainerName, Error: err.Error()} + } + var raw struct { + Config struct { + Image string `json:"Image"` + } `json:"Config"` + State struct { + Status string `json:"Status"` + Health *struct { + Status string `json:"Status"` + } `json:"Health"` + } `json:"State"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return Status{ContainerName: target.ContainerName, Error: err.Error()} + } + health := "" + if raw.State.Health != nil { + health = raw.State.Health.Status + } + return Status{ContainerName: target.ContainerName, State: raw.State.Status, Health: health, Image: raw.Config.Image} +} + +func (c Controller) Execute(ctx context.Context, target Target, action string) Result { + start := time.Now() + result := Result{ContainerName: target.ContainerName, Action: action} + if !allowed(target.AllowedActions, action) { + result.Error = "action is not allowlisted for this container" + result.Duration = time.Since(start) + return result + } + ctx, cancel := context.WithTimeout(ctx, c.timeout()) + defer cancel() + var out []byte + var err error + switch action { + case "restart": + out, err = c.runner().Run(ctx, "docker", "restart", target.ContainerName) + case "recreate": + out, err = c.recreate(ctx, target) + default: + err = fmt.Errorf("unsupported action %q", action) + } + result.Output = strings.TrimSpace(string(out)) + if err != nil { + result.Error = err.Error() + } else { + result.Success = true + } + result.Duration = time.Since(start) + return result +} + +func (c Controller) recreate(ctx context.Context, target Target) ([]byte, error) { + meta, err := c.composeMetadata(ctx, target) + if err != nil { + return nil, err + } + args := []string{"compose"} + if meta.EnvFile != "" { + args = append(args, "--env-file", meta.EnvFile) + } + if meta.ProjectDir != "" { + args = append(args, "--project-directory", meta.ProjectDir) + } + if meta.Project != "" { + args = append(args, "-p", meta.Project) + } + for _, file := range meta.Files { + args = append(args, "-f", file) + } + args = append(args, "up", "-d", "--no-deps", "--force-recreate", meta.Service) + return c.runner().Run(ctx, "docker", args...) +} + +type composeMeta struct { + ProjectDir string + Files []string + Service, Project, EnvFile string +} + +func (c Controller) composeMetadata(ctx context.Context, target Target) (composeMeta, error) { + meta := composeMeta{ProjectDir: target.ProjectDir, Files: append([]string(nil), target.ComposeFiles...), Service: target.ComposeService, Project: target.ComposeProject, EnvFile: target.EnvFile} + if meta.ProjectDir != "" && len(meta.Files) > 0 && meta.Service != "" { + return meta, nil + } + out, err := c.runner().Run(ctx, "docker", "inspect", "--format", `{{json .Config.Labels}}`, target.ContainerName) + if err != nil { + return meta, fmt.Errorf("inspect compose metadata: %w", err) + } + labels := map[string]string{} + if err := json.Unmarshal(out, &labels); err != nil { + return meta, fmt.Errorf("decode compose labels: %w", err) + } + if meta.ProjectDir == "" { + meta.ProjectDir = labels["com.docker.compose.project.working_dir"] + } + if meta.Service == "" { + meta.Service = labels["com.docker.compose.service"] + } + if meta.Project == "" { + meta.Project = labels["com.docker.compose.project"] + } + if len(meta.Files) == 0 { + for _, f := range strings.Split(labels["com.docker.compose.project.config_files"], ",") { + if f = strings.TrimSpace(f); f != "" { + meta.Files = append(meta.Files, f) + } + } + } + if meta.ProjectDir == "" || meta.Service == "" || len(meta.Files) == 0 { + return meta, fmt.Errorf("container %q has incomplete Compose metadata; configure project_dir, compose_files and compose_service explicitly", target.ContainerName) + } + return meta, nil +} + +func (c Controller) runner() Runner { + if c.Runner != nil { + return c.Runner + } + return ExecRunner{} +} +func (c Controller) timeout() time.Duration { + if c.Timeout > 0 { + return c.Timeout + } + return 3 * time.Minute +} +func allowed(values []string, needle string) bool { + for _, v := range values { + if v == needle { + return true + } + } + return false +} + +func ValidateTargets(targets []Target) error { + seen := map[string]struct{}{} + for i := range targets { + target := &targets[i] + if target.ContainerName == "" { + return fmt.Errorf("target container_name is required") + } + if _, ok := seen[target.ContainerName]; ok { + return fmt.Errorf("duplicate target %q", target.ContainerName) + } + seen[target.ContainerName] = struct{}{} + if len(target.AllowedActions) == 0 { + return fmt.Errorf("target %q has no allowed_actions", target.ContainerName) + } + for _, action := range target.AllowedActions { + if action != "restart" && action != "recreate" { + return fmt.Errorf("target %q has invalid action %q", target.ContainerName, action) + } + } + if target.DefaultAction == "" { + target.DefaultAction = target.AllowedActions[0] + } + if !allowed(target.AllowedActions, target.DefaultAction) { + return fmt.Errorf("target %q default action is not allowed", target.ContainerName) + } + } + return nil +} + +func SortedTargets(targets []Target) []Target { + out := append([]Target(nil), targets...) + sort.SliceStable(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName }) + return out +} diff --git a/internal/dockerctl/controller_test.go b/internal/dockerctl/controller_test.go new file mode 100644 index 0000000..35194df --- /dev/null +++ b/internal/dockerctl/controller_test.go @@ -0,0 +1,52 @@ +package dockerctl + +import ( + "context" + "strings" + "testing" +) + +type fakeRunner struct { + calls [][]string + output []byte + err error +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.output, f.err +} + +func TestRecreateUsesAllowlistedComposeMetadata(t *testing.T) { + f := &fakeRunner{output: []byte("ok")} + ctl := Controller{Runner: f} + target := Target{ContainerName: "agent", AllowedActions: []string{"recreate"}, ProjectDir: "/srv/stack", ComposeFiles: []string{"/srv/stack/compose.yml"}, ComposeService: "agent", ComposeProject: "stack", EnvFile: "/srv/stack/.env"} + result := ctl.Execute(context.Background(), target, "recreate") + if !result.Success { + t.Fatalf("unexpected failure: %s", result.Error) + } + joined := strings.Join(f.calls[0], " ") + for _, want := range []string{"docker compose", "--env-file /srv/stack/.env", "--force-recreate agent"} { + if !strings.Contains(joined, want) { + t.Fatalf("call %q missing %q", joined, want) + } + } +} + +func TestRejectsNonAllowlistedAction(t *testing.T) { + ctl := Controller{Runner: &fakeRunner{}} + result := ctl.Execute(context.Background(), Target{ContainerName: "agent", AllowedActions: []string{"restart"}}, "recreate") + if result.Success || result.Error == "" { + t.Fatal("expected rejection") + } +} + +func TestValidateTargetsSetsDefaultAction(t *testing.T) { + targets := []Target{{ContainerName: "agent", AllowedActions: []string{"recreate"}}} + if err := ValidateTargets(targets); err != nil { + t.Fatal(err) + } + if targets[0].DefaultAction != "recreate" { + t.Fatalf("default action not set: %#v", targets[0]) + } +} diff --git a/internal/envfile/document.go b/internal/envfile/document.go new file mode 100644 index 0000000..7229355 --- /dev/null +++ b/internal/envfile/document.go @@ -0,0 +1,342 @@ +package envfile + +import ( + "bufio" + "errors" + "fmt" + "strconv" + "strings" + "unicode" +) + +type LineKind int + +const ( + LineOther LineKind = iota + LineAssignment +) + +type Line struct { + Raw string + Kind LineKind + Key string + Prefix string + RawValue string + Comment string + LineIndex int +} + +type Entry struct { + Key string + Value string + RawValue string + Description string + Secret bool + Missing bool + Extra bool + Duplicate bool +} + +type Document struct { + Lines []Line + Newline string + TrailingNL bool +} + +func Parse(data []byte) (*Document, error) { + text := string(data) + newline := "\n" + if strings.Contains(text, "\r\n") { + newline = "\r\n" + } + trailing := strings.HasSuffix(text, "\n") + text = strings.ReplaceAll(text, "\r\n", "\n") + parts := strings.Split(text, "\n") + if trailing && len(parts) > 0 { + parts = parts[:len(parts)-1] + } + doc := &Document{Newline: newline, TrailingNL: trailing} + for i, raw := range parts { + line := Line{Raw: raw, Kind: LineOther, LineIndex: i} + key, prefix, rawValue, ok := parseAssignment(raw) + if ok { + line.Kind = LineAssignment + line.Key = key + line.Prefix = prefix + line.RawValue = rawValue + } + doc.Lines = append(doc.Lines, line) + } + return doc, nil +} + +func parseAssignment(raw string) (key, prefix, value string, ok bool) { + trimmedLeft := strings.TrimLeftFunc(raw, unicode.IsSpace) + if trimmedLeft == "" || strings.HasPrefix(trimmedLeft, "#") { + return "", "", "", false + } + pos := 0 + if strings.HasPrefix(trimmedLeft, "export ") { + pos = len("export ") + } + rest := trimmedLeft[pos:] + eq := strings.IndexByte(rest, '=') + if eq <= 0 { + return "", "", "", false + } + candidate := strings.TrimSpace(rest[:eq]) + if !validKey(candidate) { + return "", "", "", false + } + absoluteEq := len(raw) - len(trimmedLeft) + pos + eq + return candidate, raw[:absoluteEq+1], raw[absoluteEq+1:], true +} + +func validKey(key string) bool { + if key == "" { + return false + } + for i, r := range key { + if i == 0 { + if !(r == '_' || unicode.IsLetter(r)) { + return false + } + continue + } + if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) { + return false + } + } + return true +} + +func (d *Document) Effective() map[string]string { + out := make(map[string]string) + for _, line := range d.Lines { + if line.Kind == LineAssignment { + out[line.Key] = DecodeValue(line.RawValue) + } + } + return out +} + +func (d *Document) Occurrences() map[string]int { + out := make(map[string]int) + for _, line := range d.Lines { + if line.Kind == LineAssignment { + out[line.Key]++ + } + } + return out +} + +func (d *Document) Set(key, value string) error { + if !validKey(key) { + return fmt.Errorf("invalid environment key %q", key) + } + encoded := EncodeValue(value) + last := -1 + for i := range d.Lines { + if d.Lines[i].Kind == LineAssignment && d.Lines[i].Key == key { + last = i + } + } + if last >= 0 { + line := &d.Lines[last] + line.RawValue = encoded + line.Raw = line.Prefix + encoded + return nil + } + if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" { + d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther}) + } + d.Lines = append(d.Lines, Line{Raw: key + "=" + encoded, Kind: LineAssignment, Key: key, Prefix: key + "=", RawValue: encoded}) + return nil +} + +func (d *Document) Render() []byte { + lines := make([]string, 0, len(d.Lines)) + for _, line := range d.Lines { + lines = append(lines, line.Raw) + } + text := strings.Join(lines, d.Newline) + if d.TrailingNL || len(lines) > 0 { + text += d.Newline + } + return []byte(text) +} + +func DecodeValue(raw string) string { + raw = strings.TrimSpace(raw) + if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' { + return raw[1 : len(raw)-1] + } + if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { + if v, err := strconv.Unquote(raw); err == nil { + return v + } + } + return raw +} + +func EncodeValue(value string) string { + if value == "" { + return "" + } + needsQuote := strings.ContainsAny(value, "\n\r\t#\"'") || strings.TrimSpace(value) != value + if !needsQuote { + return value + } + return strconv.Quote(value) +} + +func (d *Document) ImportMissing(example *Document, marker string) ([]string, error) { + if example == nil { + return nil, errors.New("example document is nil") + } + existing := d.Effective() + var missing []string + for _, line := range example.Lines { + if line.Kind == LineAssignment { + if _, ok := existing[line.Key]; !ok { + missing = append(missing, line.Key) + } + } + } + if len(missing) == 0 { + return nil, nil + } + if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" { + d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther}) + } + if marker != "" { + d.Lines = append(d.Lines, Line{Raw: marker, Kind: LineOther}) + } + missingSet := make(map[string]struct{}, len(missing)) + for _, key := range missing { + missingSet[key] = struct{}{} + } + pendingComments := []string{} + added := make(map[string]struct{}) + for _, line := range example.Lines { + trimmed := strings.TrimSpace(line.Raw) + if line.Kind != LineAssignment { + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + pendingComments = append(pendingComments, line.Raw) + } else { + pendingComments = nil + } + continue + } + if _, ok := missingSet[line.Key]; !ok { + pendingComments = nil + continue + } + if _, done := added[line.Key]; done { + pendingComments = nil + continue + } + for _, comment := range trimBoundaryBlanks(pendingComments) { + d.Lines = append(d.Lines, Line{Raw: comment, Kind: LineOther}) + } + copyLine := line + copyLine.LineIndex = len(d.Lines) + d.Lines = append(d.Lines, copyLine) + added[line.Key] = struct{}{} + pendingComments = nil + } + return missing, nil +} + +func trimBoundaryBlanks(lines []string) []string { + start, end := 0, len(lines) + for start < end && strings.TrimSpace(lines[start]) == "" { + start++ + } + for end > start && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + return lines[start:end] +} + +func Descriptions(example *Document) map[string]string { + out := make(map[string]string) + var comments []string + for _, line := range example.Lines { + trimmed := strings.TrimSpace(line.Raw) + if line.Kind == LineAssignment { + var cleaned []string + for _, c := range comments { + c = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c), "#")) + if c != "" && !allRune(c, '#') { + cleaned = append(cleaned, c) + } + } + if len(cleaned) > 0 { + out[line.Key] = strings.Join(cleaned, " ") + } + comments = nil + continue + } + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + comments = append(comments, line.Raw) + } else { + comments = nil + } + } + return out +} + +func allRune(s string, r rune) bool { + if s == "" { + return false + } + for _, got := range s { + if got != r { + return false + } + } + return true +} + +func Compare(current, example *Document) (missing, extra []string) { + cur := current.Effective() + ex := example.Effective() + for key := range ex { + if _, ok := cur[key]; !ok { + missing = append(missing, key) + } + } + for key := range cur { + if _, ok := ex[key]; !ok { + extra = append(extra, key) + } + } + sortStrings(missing) + sortStrings(extra) + return missing, extra +} + +func sortStrings(values []string) { + for i := 1; i < len(values); i++ { + for j := i; j > 0 && values[j] < values[j-1]; j-- { + values[j], values[j-1] = values[j-1], values[j] + } + } +} + +func ReadScanner(scanner *bufio.Scanner) (*Document, error) { + var b strings.Builder + first := true + for scanner.Scan() { + if !first { + b.WriteByte('\n') + } + first = false + b.WriteString(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return Parse([]byte(b.String())) +} diff --git a/internal/envfile/document_test.go b/internal/envfile/document_test.go new file mode 100644 index 0000000..b060d6a --- /dev/null +++ b/internal/envfile/document_test.go @@ -0,0 +1,46 @@ +package envfile + +import ( + "strings" + "testing" +) + +func TestSetPreservesDocumentAndUpdatesLastDuplicate(t *testing.T) { + doc, err := Parse([]byte("# title\nA=one\nA=two\nB=3\n")) + if err != nil { + t.Fatal(err) + } + if err := doc.Set("A", "hello world"); err != nil { + t.Fatal(err) + } + got := string(doc.Render()) + want := "# title\nA=one\nA=hello world\nB=3\n" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestImportMissingCopiesComments(t *testing.T) { + current, _ := Parse([]byte("A=1\n")) + example, _ := Parse([]byte("# Alpha\nA=1\n\n# Beta help\nB=2\n")) + missing, err := current.ImportMissing(example, "# imported") + if err != nil { + t.Fatal(err) + } + if len(missing) != 1 || missing[0] != "B" { + t.Fatalf("unexpected missing: %#v", missing) + } + got := string(current.Render()) + if !strings.Contains(got, "# Beta help\nB=2") { + t.Fatalf("comments/value not imported: %q", got) + } +} + +func TestEncodeDecode(t *testing.T) { + values := []string{"plain", "contains # hash", "line1\nline2", " leading"} + for _, value := range values { + if got := DecodeValue(EncodeValue(value)); got != value { + t.Fatalf("roundtrip %q -> %q", value, got) + } + } +} diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..1a8cd74 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,79 @@ +param( + [switch]$NoEnv +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ProjectRoot = $PSScriptRoot +Set-Location $ProjectRoot + +function Import-DotEnv { + param([Parameter(Mandatory = $true)][string]$Path) + + Get-Content -LiteralPath $Path | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + + $parts = $line.Split("=", 2) + if ($parts.Count -ne 2) { return } + + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if (-not $name) { return } + + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } +} + +if (-not $NoEnv) { + $envFile = Join-Path $ProjectRoot ".env" + if (Test-Path -LiteralPath $envFile) { + Import-DotEnv -Path $envFile + } + else { + Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet." + } +} + +# Migration helper for .env files from older ZIP versions. These values were Docker-only +# and are invalid when the agent is started natively with `go run` on Windows. +if ($env:DATA_DIR -eq "/app/data") { + $env:DATA_DIR = Join-Path $ProjectRoot "data" + Write-Warning "DATA_DIR=/app/data ist ein Docker-Pfad; verwende lokal '$env:DATA_DIR'." +} +if ($env:KNOWLEDGE_DIR -eq "/app/knowledge") { + $env:KNOWLEDGE_DIR = Join-Path $ProjectRoot "knowledge" + Write-Warning "KNOWLEDGE_DIR=/app/knowledge ist ein Docker-Pfad; verwende lokal '$env:KNOWLEDGE_DIR'." +} +if ($env:OLLAMA_URL -eq "http://ollama:11434") { + $env:OLLAMA_URL = "http://localhost:11434" + Write-Warning "OLLAMA_URL=http://ollama:11434 ist der Docker-Hostname; verwende lokal '$env:OLLAMA_URL'." +} + +if (-not $env:DATA_DIR) { + $env:DATA_DIR = Join-Path $ProjectRoot "data" +} +if (-not $env:KNOWLEDGE_DIR) { + $env:KNOWLEDGE_DIR = Join-Path $ProjectRoot "knowledge" +} +if (-not $env:OLLAMA_URL) { + $env:OLLAMA_URL = "http://localhost:11434" +} + +New-Item -ItemType Directory -Force -Path $env:DATA_DIR | Out-Null + + +Write-Host "GLPI AI Agent (native Windows)" +Write-Host " DATA_DIR = $env:DATA_DIR" +Write-Host " KNOWLEDGE_DIR = $env:KNOWLEDGE_DIR" +Write-Host " OLLAMA_URL = $env:OLLAMA_URL" +Write-Host "" + +go run ./cmd/env-controller +exit $LASTEXITCODE