@@ -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, "-")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user