This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.env
|
||||
backups
|
||||
controller.password
|
||||
controller.json
|
||||
env-controller
|
||||
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 }}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
controller.password
|
||||
controller.json
|
||||
backups/
|
||||
env-controller
|
||||
*.log
|
||||
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal file
@@ -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.
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -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"]
|
||||
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.
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -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 .
|
||||
316
README.md
316
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/<projekt>
|
||||
└── Docker CLI
|
||||
├── docker restart <allowlisted-name>
|
||||
└── docker compose ... up -d --no-deps --force-recreate <service>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
25
SECURITY.md
Normal file
25
SECURITY.md
Normal file
@@ -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.
|
||||
37
compose.yml
Normal file
37
compose.yml
Normal file
@@ -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
|
||||
64
controller.example.json
Normal file
64
controller.example.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
controller.password.example
Normal file
1
controller.password.example
Normal file
@@ -0,0 +1 @@
|
||||
replace-with-a-long-random-password
|
||||
35
controller.windows.example.json
Normal file
35
controller.windows.example.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
dist/env-controller-linux-amd64
vendored
Normal file
BIN
dist/env-controller-linux-amd64
vendored
Normal file
Binary file not shown.
BIN
dist/env-controller-windows-amd64.exe
vendored
Normal file
BIN
dist/env-controller-windows-amd64.exe
vendored
Normal file
Binary file not shown.
8
examples/compose.controller-names.yml
Normal file
8
examples/compose.controller-names.yml
Normal file
@@ -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
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/example/glpi-env-controller
|
||||
|
||||
go 1.26
|
||||
115
internal/app/config.go
Normal file
115
internal/app/config.go
Normal file
@@ -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, "-")
|
||||
}
|
||||
93
internal/app/configpath.go
Normal file
93
internal/app/configpath.go
Normal file
@@ -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 <path> 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
|
||||
}
|
||||
76
internal/app/configpath_test.go
Normal file
76
internal/app/configpath_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
23
internal/app/ownership_unix.go
Normal file
23
internal/app/ownership_unix.go
Normal file
@@ -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
|
||||
}
|
||||
11
internal/app/ownership_windows.go
Normal file
11
internal/app/ownership_windows.go
Normal file
@@ -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
|
||||
}
|
||||
678
internal/app/server.go
Normal file
678
internal/app/server.go
Normal file
@@ -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])
|
||||
}
|
||||
88
internal/app/server_test.go
Normal file
88
internal/app/server_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
162
internal/backup/store.go
Normal file
162
internal/backup/store.go
Normal file
@@ -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()
|
||||
}
|
||||
28
internal/backup/store_test.go
Normal file
28
internal/backup/store_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
239
internal/dockerctl/controller.go
Normal file
239
internal/dockerctl/controller.go
Normal file
@@ -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
|
||||
}
|
||||
52
internal/dockerctl/controller_test.go
Normal file
52
internal/dockerctl/controller_test.go
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
342
internal/envfile/document.go
Normal file
342
internal/envfile/document.go
Normal file
@@ -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()))
|
||||
}
|
||||
46
internal/envfile/document_test.go
Normal file
46
internal/envfile/document_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
run.ps1
Normal file
79
run.ps1
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user