init
Some checks failed
release-tag / release-image (push) Failing after 55s

This commit is contained in:
2026-08-01 23:54:45 +02:00
parent 16834f2b03
commit fe32431d21
33 changed files with 2651 additions and 1 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
.env
backups
controller.password
controller.json
env-controller

1
.env Normal file
View File

@@ -0,0 +1 @@
ENV_CONTROLLER_PASSWORD = "admin"

View File

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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
controller.password
controller.json
backups/
env-controller
*.log

16
CHANGELOG.md Normal file
View 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
View 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
View File

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

16
Makefile Normal file
View 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
View File

@@ -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
View 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.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.2

37
compose.yml Normal file
View 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
View 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"
}
]
}
]
}

View File

@@ -0,0 +1 @@
replace-with-a-long-random-password

View 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

Binary file not shown.

BIN
dist/env-controller-windows-amd64.exe vendored Normal file

Binary file not shown.

View 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
View File

@@ -0,0 +1,3 @@
module github.com/example/glpi-env-controller
go 1.26

115
internal/app/config.go Normal file
View 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, "-")
}

View 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
}

View 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)
}
}

View 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
}

View 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
View 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])
}

View 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
View 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()
}

View 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)
}
}

View 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
}

View 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])
}
}

View 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()))
}

View 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
View 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