503 lines
12 KiB
Go
503 lines
12 KiB
Go
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) ====== */
|