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