package main import ( "bytes" "context" "embed" "encoding/json" "errors" "html/template" "io" "log" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "sync" "syscall" "time" ) /* ====== Konfiguration & Datenmodelle ====== */ type Config struct { Listen string `json:"listen"` // z.B. "127.0.0.1:8080" Username string `json:"username"` // Basic Auth Password string `json:"password"` // Basic Auth ScriptsDir string `json:"scripts_dir"` // optional: Basisordner für Skripte Tasks []*Task `json:"tasks"` // Optionales CSRF-Secret; wenn leer, wird eins generiert (nur pro Prozesslauf) CSRFSecret string `json:"csrf_secret,omitempty"` } type Task struct { Name string `json:"name"` Path string `json:"path"` // .ps1 Interval string `json:"interval,omitempty"` // z.B. "5m", "1h", "0" = aus Enabled bool `json:"enabled,omitempty"` // ob Intervall aktiv ist Timeout string `json:"timeout,omitempty"` // optional, z.B. "30m" // Laufzeitfelder nextRun time.Time `json:"-"` running bool `json:"-"` timer *time.Timer `json:"-"` mutex sync.Mutex `json:"-"` history []RunEntry `json:"-"` maxLogs int `json:"-"` cancelRun context.CancelFunc `json:"-"` } type RunEntry struct { Time time.Time `json:"time"` Manual bool `json:"manual"` Success bool `json:"success"` Duration time.Duration `json:"duration"` Output string `json:"output"` // stdout+stderr } var ( cfg Config taskIndex = map[string]*Task{} started time.Time csrfKey []byte cfgPath = "tasks.json" // <- Dateipfad zentral cfgMu sync.Mutex // <- schützt Speichervorgang ) //go:embed web/* var webFS embed.FS /* ====== Utilities ====== */ func must[T any](v T, err error) T { if err != nil { log.Fatal(err) } return v } func parseDurationOrZero(s string) time.Duration { if s == "" { return 0 } d, err := time.ParseDuration(s) if err != nil { return 0 } return d } func (t *Task) scheduleNext() { t.mutex.Lock() defer t.mutex.Unlock() if !t.Enabled { if t.timer != nil { t.timer.Stop() t.timer = nil } return } ival := parseDurationOrZero(t.Interval) if ival <= 0 { // Deaktiviert if t.timer != nil { t.timer.Stop() t.timer = nil } return } // Planen now := time.Now() t.nextRun = now.Add(ival) if t.timer != nil { t.timer.Stop() } t.timer = time.AfterFunc(ival, func() { go func() { _ = runTask(t, false) // geplante Ausführung t.scheduleNext() // danach erneut planen }() }) } func (t *Task) setInterval(newInterval string, enabled bool) { t.mutex.Lock() t.Interval = newInterval t.Enabled = enabled t.mutex.Unlock() t.scheduleNext() } func (t *Task) timeout() time.Duration { d := parseDurationOrZero(t.Timeout) if d == 0 { return 2 * time.Hour // Default großzügig } return d } func powershellPath() string { if runtime.GOOS == "windows" { // Bevorzugt pwsh, wenn vorhanden if pwsh, err := exec.LookPath("pwsh.exe"); err == nil { return pwsh } if psh, err := exec.LookPath("powershell.exe"); err == nil { return psh } // Fallback – häufiges Standardpfad return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` } // Für Tests auf anderen OS (WSL/CI/etc.) return "pwsh" } func runTask(t *Task, manual bool) error { t.mutex.Lock() if t.running { t.mutex.Unlock() return errors.New("Task läuft bereits") } t.running = true t.mutex.Unlock() defer func() { t.mutex.Lock() t.running = false t.cancelRun = nil t.mutex.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), t.timeout()) t.mutex.Lock() t.cancelRun = cancel t.mutex.Unlock() defer cancel() ps := powershellPath() // Sicherstellen, dass die Datei existiert path := t.Path if !filepath.IsAbs(path) && cfg.ScriptsDir != "" { path = filepath.Join(cfg.ScriptsDir, path) } if _, err := os.Stat(path); err != nil { out := "Script nicht gefunden: " + path + " (" + err.Error() + ")" t.appendLog(RunEntry{Time: time.Now(), Manual: manual, Success: false, Duration: 0, Output: out}) return err } args := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path} cmd := exec.CommandContext(ctx, ps, args...) var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf start := time.Now() err := cmd.Run() dur := time.Since(start) outStr := sanitizeOutput(buf.String()) success := err == nil && ctx.Err() == nil if ctx.Err() == context.DeadlineExceeded { outStr += "\n[Abgebrochen: Timeout]" } if err != nil && !success { outStr += "\n[Fehler] " + err.Error() } t.appendLog(RunEntry{Time: start, Manual: manual, Success: success, Duration: dur, Output: outStr}) return err } func sanitizeOutput(s string) string { // Trimmen und begrenzen (z.B. 1 MB) const max = 1 << 20 if len(s) > max { return s[:max] + "\n...[gekürzt]..." } return s } func (t *Task) appendLog(e RunEntry) { t.mutex.Lock() defer t.mutex.Unlock() if t.maxLogs == 0 { t.maxLogs = 200 } t.history = append([]RunEntry{e}, t.history...) // neu oben if len(t.history) > t.maxLogs { t.history = t.history[:t.maxLogs] } } /* ====== Web (UI + API) ====== */ func basicAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || u != cfg.Username || p != cfg.Password { w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func requirePOST(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } if !validateCSRF(r) { http.Error(w, "CSRF token invalid", http.StatusForbidden) return } next(w, r) } } func validateCSRF(r *http.Request) bool { // simpler stateless Ansatz: Token in Form/FETCH muss mit serverseitigem Secret beginnen // (nicht mega-sicher, aber besser als nix bei Basic Auth + localhost) formToken := r.FormValue("csrf_token") if formToken == "" { // Header-Fallback für fetch() formToken = r.Header.Get("X-CSRF-Token") } return formToken != "" && strings.HasPrefix(formToken, string(csrfKey)) } func csrfToken() string { return string(csrfKey) + ":1" } type pageData struct { Title string Started time.Time Tasks []*Task Now time.Time CSRFToken string ListenHost string } // Templates aus embed var ( tmplIndex = template.Must(template.ParseFS(webFS, "web/index.html")) ) func handleIndex(w http.ResponseWriter, r *http.Request) { // kleine Gesundheitsprüfung: nur lokale Adresse? host, _, _ := net.SplitHostPort(cfg.Listen) _ = host data := pageData{ Title: "Script Runner", Started: started, Tasks: cfg.Tasks, Now: time.Now(), CSRFToken: csrfToken(), ListenHost: cfg.Listen, } if err := tmplIndex.Execute(w, data); err != nil { http.Error(w, err.Error(), 500) } } func handleRun(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") t := taskIndex[name] if t == nil { http.Error(w, "Task not found", http.StatusNotFound) return } if err := runTask(t, true); err != nil { http.Error(w, "Run failed: "+err.Error(), 500) return } io.WriteString(w, "OK") } func handleSetInterval(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") interval := strings.TrimSpace(r.FormValue("interval")) enable := r.FormValue("enable") == "1" t := taskIndex[name] if t == nil { http.Error(w, "Task not found", http.StatusNotFound) return } t.setInterval(interval, enable) // PERSISTENZ // auch die Werte im cfg.Tasks stecken bereits in t, also genügt: persistOrLog() io.WriteString(w, "OK") } func handleToggle(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") enable := r.FormValue("enable") == "1" t := taskIndex[name] if t == nil { http.Error(w, "Task not found", http.StatusNotFound) return } t.mutex.Lock() t.Enabled = enable t.mutex.Unlock() t.scheduleNext() // PERSISTENZ persistOrLog() io.WriteString(w, "OK") } func handleCancel(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") t := taskIndex[name] if t == nil { http.Error(w, "Task not found", http.StatusNotFound) return } t.mutex.Lock() cancel := t.cancelRun t.mutex.Unlock() if cancel != nil { cancel() io.WriteString(w, "Cancel signal sent") return } io.WriteString(w, "Nothing to cancel") } func handleLogs(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") t := taskIndex[name] if t == nil { http.Error(w, "Task not found", http.StatusNotFound) return } t.mutex.Lock() defer t.mutex.Unlock() type resp struct { Name string `json:"name"` Enabled bool `json:"enabled"` Running bool `json:"running"` NextRun *time.Time `json:"next_run,omitempty"` History []RunEntry `json:"history"` } var nr *time.Time if !t.nextRun.IsZero() { tmp := t.nextRun nr = &tmp } out := resp{ Name: t.Name, Enabled: t.Enabled, Running: t.running, NextRun: nr, History: t.history, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) } /* ====== Init/Load/Serve ====== */ func saveConfigAtomic() error { cfgMu.Lock() defer cfgMu.Unlock() // Schön formatiert schreiben b, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } // In dasselbe Verzeichnis wie cfgPath schreiben (wichtig für Rename-Atomizität) dir := filepath.Dir(cfgPath) base := filepath.Base(cfgPath) tmp := filepath.Join(dir, "."+base+".tmp") // Temp-Datei erzeugen, schreiben, flushen f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return err } if _, err := f.Write(b); err != nil { f.Close() return err } if err := f.Sync(); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } // Atomisch ersetzen if err := os.Rename(tmp, cfgPath); err != nil { return err } return nil } // bequemer Helfer zum Loggen statt Abbrechen func persistOrLog() { if err := saveConfigAtomic(); err != nil { log.Printf("WARN: Konnte %s nicht speichern: %v", cfgPath, err) } } func loadConfig() { f, err := os.Open(cfgPath) if err != nil { log.Fatalf("%s nicht gefunden: %v", cfgPath, err) } defer f.Close() dec := json.NewDecoder(f) dec.DisallowUnknownFields() if err := dec.Decode(&cfg); err != nil { log.Fatalf("%s fehlerhaft: %v", cfgPath, err) } if cfg.Listen == "" { cfg.Listen = "127.0.0.1:8080" } if cfg.Username == "" || cfg.Password == "" { log.Fatal("Bitte Username/Password in tasks.json setzen (Basic Auth).") } if cfg.CSRFSecret == "" { // pro Start generieren (hier simpel) cfg.CSRFSecret = randomString(24) } csrfKey = []byte(cfg.CSRFSecret) taskIndex = map[string]*Task{} for _, t := range cfg.Tasks { if t.Name == "" || t.Path == "" { log.Fatalf("Task ohne Name oder Path: %+v", t) } if !strings.HasSuffix(strings.ToLower(t.Path), ".ps1") { log.Printf("Warnung: Task %q hat keinen .ps1 Pfad: %s", t.Name, t.Path) } if t.maxLogs == 0 { t.maxLogs = 200 } taskIndex[t.Name] = t } } func randomString(n int) string { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) f, _ := os.OpenFile("/dev/urandom", os.O_RDONLY, 0) defer func() { if f != nil { f.Close() } }() if f != nil { _, _ = io.ReadFull(f, b) for i := range b { b[i] = alphabet[int(b[i])%len(alphabet)] } return string(b) } // Fallback for i := range b { b[i] = alphabet[time.Now().Nanosecond()%len(alphabet)] time.Sleep(time.Nanosecond) } return string(b) } func main() { if runtime.GOOS != "windows" { log.Println("Hinweis: Dieses Programm ist für Windows/Powershell optimiert, läuft aber eingeschränkt auch anderswo.") } loadConfig() started = time.Now() // Scheduler starten for _, t := range cfg.Tasks { if t.Enabled && parseDurationOrZero(t.Interval) > 0 { t.scheduleNext() } } // Routen mux := http.NewServeMux() mux.HandleFunc("/", handleIndex) mux.HandleFunc("/api/run", requirePOST(handleRun)) mux.HandleFunc("/api/set-interval", requirePOST(handleSetInterval)) mux.HandleFunc("/api/toggle", requirePOST(handleToggle)) mux.HandleFunc("/api/cancel", requirePOST(handleCancel)) mux.HandleFunc("/api/logs", handleLogs) // Statische Dateien (CSS/JS aus embed) fs := http.FS(webFS) mux.Handle("/static/", http.FileServer(fs)) addr := cfg.Listen server := &http.Server{ Addr: addr, Handler: basicAuth(mux), ReadHeaderTimeout: 10 * time.Second, } // GRACEFUL SHUTDOWN + PERSISTENZ stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) go func() { <-stop log.Println("Beende... speichere Konfiguration.") persistOrLog() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(ctx) }() log.Printf("Weboberfläche: http://%s (Basic Auth aktiv)", addr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } log.Println("Server gestoppt.") } /* ====== Embedded HTML (Tailwind-lite via CDN) ====== */