Files
script-runner/main.go
2025-10-07 20:02:33 +02:00

579 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) ====== */