From 9f4c081e069c5c0f220558d51e8df9a43546535f Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 7 Oct 2025 19:31:41 +0200 Subject: [PATCH] init --- README.md | 1 - go.mod | 3 + main.go | 502 +++++++++++++++++++++++++++++++++++++++++++++++++ nssm.txt | 53 ++++++ tasks.json | 23 +++ web/index.html | 204 ++++++++++++++++++++ 6 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 go.mod create mode 100644 main.go create mode 100644 nssm.txt create mode 100644 tasks.json create mode 100644 web/index.html diff --git a/README.md b/README.md index 1730b2a..fd9984f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1 @@ # script-runner - diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..73d7830 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/script-runner + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9ed9c3d --- /dev/null +++ b/main.go @@ -0,0 +1,502 @@ +package main + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "html/template" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "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 +) + +//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) + 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() + 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 loadConfig() { + f, err := os.Open("tasks.json") + if err != nil { + log.Fatalf("tasks.json nicht gefunden: %v", err) + } + defer f.Close() + dec := json.NewDecoder(f) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + log.Fatalf("tasks.json fehlerhaft: %v", 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, + } + log.Printf("Weboberfläche: http://%s (Basic Auth aktiv)", addr) + log.Fatal(server.ListenAndServe()) +} + +/* ====== Embedded HTML (Tailwind-lite via CDN) ====== */ diff --git a/nssm.txt b/nssm.txt new file mode 100644 index 0000000..024f175 --- /dev/null +++ b/nssm.txt @@ -0,0 +1,53 @@ +# ======= Einstellungen anpassen ======= +$SvcName = "ScriptRunner" +$Display = "Script Runner" +$InstallDir = "C:\apps\script-runner" # Ordner mit EXE & tasks.json +$ExePath = "$InstallDir\script-runner.exe" +$LogsDir = "$InstallDir\logs" +$NSSM = "C:\tools\nssm\nssm.exe" # Pfad zu nssm.exe +$AutoStart = "SERVICE_AUTO_START" # oder SERVICE_DEMAND_START +$Port = 8080 # nur für Firewall-Beispiel + +# (optional) Dienstkonto statt LocalSystem nutzen – empfohlen: +# Einmalig Benutzer anlegen (lokal): +# $SvcUser = "$env:COMPUTERNAME\svc-scriptrunner" +# $SvcPwd = Read-Host -AsSecureString "Passwort für $SvcUser" +# New-LocalUser -Name "svc-scriptrunner" -Password $SvcPwd -NoPasswordExpiration -AccountNeverExpires +# $PlainPwd = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($SvcPwd)) + +# ======= Ordner/Logs anlegen ======= +New-Item -Type Directory -Force $InstallDir | Out-Null +New-Item -Type Directory -Force $LogsDir | Out-Null + +# ======= Dienst installieren ======= +& $NSSM install $SvcName $ExePath +& $NSSM set $SvcName AppDirectory $InstallDir +& $NSSM set $SvcName DisplayName $Display +& $NSSM set $SvcName Start $AutoStart + +# (optional) Dienstkonto setzen: +# & $NSSM set $SvcName ObjectName $SvcUser $PlainPwd + +# ======= Logs & Rotation ======= +& $NSSM set $SvcName AppStdout "$LogsDir\out.log" +& $NSSM set $SvcName AppStderr "$LogsDir\err.log" +& $NSSM set $SvcName AppRotateFiles 1 +& $NSSM set $SvcName AppRotateOnline 1 +& $NSSM set $SvcName AppRotateBytes 10485760 # 10 MB +& $NSSM set $SvcName AppRotateSeconds 86400 # 1 Tag + +# ======= Sauberes Stop/Restart-Verhalten ======= +& $NSSM set $SvcName AppStopMethodConsole 1500 # CTRL+C nach 1,5s +& $NSSM set $SvcName AppStopMethodSkip 0 +& $NSSM set $SvcName AppKillProcessTree 1 +& $NSSM set $SvcName AppExit Default Restart # bei Fehler neu starten + +# ======= (optional) Firewallregel – nur nötig, wenn NICHT auf 127.0.0.1 gebunden ======= +# New-NetFirewallRule -DisplayName "Script Runner $Port" -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port + +# ======= Start & Check ======= +Start-Service $SvcName +Start-Sleep 1 +Get-Service $SvcName | Format-Table -Auto + +Write-Host "Logs: $LogsDir" diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..7db79d7 --- /dev/null +++ b/tasks.json @@ -0,0 +1,23 @@ +{ + "listen": "127.0.0.1:8080", + "username": "admin", + "password": "change-me", + "scripts_dir": "C:\\scripts", + "csrf_secret": "ersetzenMitEigenemSecret", + "tasks": [ + { + "name": "Nacht-Backup", + "path": "C:\\scripts\\backup.ps1", + "interval": "24h", + "enabled": true, + "timeout": "2h" + }, + { + "name": "IIS-Log-Rotation", + "path": "C:\\scripts\\rotate.ps1", + "interval": "0", + "enabled": false, + "timeout": "30m" + } + ] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4b31116 --- /dev/null +++ b/web/index.html @@ -0,0 +1,204 @@ + + + + + + {{.Title}} + + + +

Script Runner

+

Gestartet: {{.Started}} · Lauscht auf: {{.ListenHost}}

+ + + + + + + + + + + + + + {{range .Tasks}} + + + + + + + + + {{end}} + +
NamePfadStatusIntervallNächster LaufAktionen
{{.Name}}{{.Path}} + + {{if .Enabled}}Aktiv{{else}}Deaktiviert{{end}} + + + +
+ + +
+
leer/0 = aus
+
+ + +
+ + + + + +
+
+ + +
+

Logs

+ +
+
+
+ + + +