Files
glpi-ai-agent-controller/internal/app/server.go
jbergner fe32431d21
Some checks failed
release-tag / release-image (push) Failing after 55s
init
2026-08-01 23:54:45 +02:00

679 lines
19 KiB
Go

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