package main import ( "log" "regexp" "bufio" "encoding/json" "fmt" "html/template" "io/ioutil" "net/http" "os" "os/exec" "strings" "crypto/rand" "encoding/hex" "sync" "io" "syscall" "sort" "time" "golang.org/x/crypto/bcrypt" ) type Endpoint struct { Name string `json:"name"` Parameters []string `json:"parameters"` Tasks []Task `json:"tasks"` } type Task struct { Name string `json:"name"` Command string `json:"command"` NextName map[string]string `json:"next-name"` } type TaskOutput struct { Name string `json:"name"` Lines []string `json:"lines,omitempty"` } type userEntry struct { User string Pass string } type Job struct { ID string Endpoint *Endpoint Params map[string]string Result chan []TaskOutput Cmd *exec.Cmd Cancel chan struct{} } type JobStatus struct { ID string Output []TaskOutput Done bool Running bool Job *Job FinishedAt time.Time } var jobs = map[string]*JobStatus{} var jobsLock sync.Mutex var jobQueue = make(chan Job, 100) var safeNameRegexp = regexp.MustCompile(`[^a-z0-9]+`) var userList []userEntry var users = map[string]string{} // --- Template laden oder erstellen --- var templates *template.Template const templateFile = "template.html" // Worker: Jobs abarbeiten func init() { go func() { for job := range jobQueue { // JobStatus markieren jobsLock.Lock() status, ok := jobs[job.ID] if ok { status.Running = true status.Job = &job status.Output = []TaskOutput{} } jobsLock.Unlock() currentName := job.Endpoint.Tasks[0].Name outputs := []TaskOutput{} for { // Prüfen, ob Job gecancelt wurde jobsLock.Lock() if ok && status.Done { jobsLock.Unlock() break } jobsLock.Unlock() // Task suchen var task *Task for i := range job.Endpoint.Tasks { if job.Endpoint.Tasks[i].Name == currentName { task = &job.Endpoint.Tasks[i] break } } if task == nil { break } // Command ersetzen cmdStr := task.Command for k, v := range job.Params { cmdStr = strings.ReplaceAll(cmdStr, "[["+k+"]]", v) } cmdStr = strings.ReplaceAll(cmdStr, "[[jobid]]", job.ID) // TaskOutput vorbereiten var taskOutput TaskOutput taskOutput.Name = currentName taskOutput.Lines = []string{} // Task ausführen mit Zeilen-Callback _, _ = execCommand(cmdStr, &job, task.Name, func(line string) { line = strings.TrimSpace(line) if line == "" { return } // lokale Kopie erweitern taskOutput.Lines = append(taskOutput.Lines, line) // Status live updaten jobsLock.Lock() if s, ok := jobs[job.ID]; ok { if len(s.Output) == 0 || s.Output[len(s.Output)-1].Name != currentName { s.Output = append(s.Output, TaskOutput{ Name: currentName, Lines: []string{line}, }) } else { s.Output[len(s.Output)-1].Lines = append(s.Output[len(s.Output)-1].Lines, line) } } jobsLock.Unlock() }) outputs = append(outputs, taskOutput) // Nächsten Task bestimmen nextName := "" for cond, nid := range task.NextName { condTrimmed := strings.TrimSpace(cond) if condTrimmed == "" && len(taskOutput.Lines) == 0 { nextName = nid break } else if condTrimmed != "" { for _, l := range taskOutput.Lines { if strings.Contains(l, condTrimmed) { nextName = nid break } } if nextName != "" { break } } } if nextName == "" { break } currentName = nextName } // JobStatus final updaten jobsLock.Lock() if ok { status.Output = outputs status.Done = true status.Running = false status.FinishedAt = time.Now() } jobsLock.Unlock() // Result channel füllen job.Result <- outputs } }() } func main() { _ = os.MkdirAll("./endpoints", 0755) loadTemplate() loadOrCreateHtUsers() // Management-Server Routen http.HandleFunc("/", basicAuth(indexHandler)) http.HandleFunc("/add-endpoint", basicAuth(addEndpointHandler)) http.HandleFunc("/delete-endpoint", basicAuth(deleteEndpointHandler)) http.HandleFunc("/edit", basicAuth(editHandler)) http.HandleFunc("/add-parameter", basicAuth(addParameterHandler)) http.HandleFunc("/delete-parameter", basicAuth(deleteParameterHandler)) http.HandleFunc("/add-task", basicAuth(addTaskHandler)) http.HandleFunc("/edit-task", basicAuth(editTaskHandler)) http.HandleFunc("/delete-task", basicAuth(deleteTaskHandler)) http.HandleFunc("/add-nextid", basicAuth(addNextIDHandler)) http.HandleFunc("/delete-nextid", basicAuth(deleteNextIDHandler)) http.HandleFunc("/logout", logoutHandler) http.HandleFunc("/kill/", basicAuth(killHandler)) // API-Server (8081) – nur localhost apiMux := http.NewServeMux() apiMux.HandleFunc("/api/", apiHandler) apiMux.HandleFunc("/jobs", jobsHandler) apiMux.HandleFunc("/jobs/", jobsHandler) // HTTPS Management-Server starten go func() { certFile := "server.crt" keyFile := "server.key" if _, err := os.Stat(certFile); os.IsNotExist(err) || os.IsNotExist(func() error { _, err := os.Stat(keyFile); return err }()) { fmt.Println("Zertifikat nicht gefunden, wird erstellt...") cmd := exec.Command("openssl", "req", "-x509", "-newkey", "rsa:4096", "-nodes", "-keyout", keyFile, "-out", certFile, "-days", "365", "-subj", "/CN=localhost") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { panic("Fehler beim Erstellen des Zertifikats: " + err.Error()) } } fmt.Println("Management running on :8080 (HTTPS)") if err := http.ListenAndServeTLS(":8080", certFile, keyFile, nil); err != nil { fmt.Println("Fehler beim Starten des Management-Servers:", err) } }() // HTTP API-Server go func() { fmt.Println("API running on 127.0.0.1:8081") if err := http.ListenAndServe("127.0.0.1:8081", apiMux); err != nil { fmt.Println("Fehler beim Starten des API-Servers:", err) } }() select {} // blockiert main, damit beide Server laufen } // --- Basic Auth --- func basicAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } authorized := false for _, u := range userList { if u.User == user && checkPassword(u.Pass, pass) { authorized = true break } } if !authorized { w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next(w, r) } } func logoutHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) var dummyUser, dummyPass string for { dummyUser = randomString(8) dummyPass = randomString(12) if _, exists := users[dummyUser]; !exists { break } } fmt.Fprintf(w, ` Logged Out

Logging out...

`, dummyUser, dummyPass, r.Host) } func randomString(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return hex.EncodeToString(b)[:n] } func hashPassword(password string) string { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { panic(err) } return string(hash) } func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } func loadOrCreateHtUsers() { file := ".htusers" if _, err := os.Stat(file); os.IsNotExist(err) { reader := bufio.NewReader(os.Stdin) fmt.Print("Admin Benutzername: ") user, _ := reader.ReadString('\n') fmt.Print("Admin Passwort: ") pass, _ := reader.ReadString('\n') user = strings.TrimSpace(user) pass = strings.TrimSpace(pass) hash := hashPassword(pass) entry := fmt.Sprintf("%s:%s\n", user, hash) _ = ioutil.WriteFile(file, []byte(entry), 0600) userList = append(userList, userEntry{User: user, Pass: hash}) fmt.Println(".htusers Datei erstellt (Passwort gehasht)") } else { data, _ := ioutil.ReadFile(file) lines := strings.Split(strings.TrimSpace(string(data)), "\n") for _, line := range lines { parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { panic(".htusers Datei fehlerhaft") } userList = append(userList, userEntry{User: parts[0], Pass: parts[1]}) } } } // --- Endpoint & Task Management --- func listEndpoints() []string { files, _ := ioutil.ReadDir("./endpoints") var eps []string for _, f := range files { if !f.IsDir() && strings.HasSuffix(f.Name(), ".json") { eps = append(eps, f.Name()[:len(f.Name())-5]) } } return eps } func loadEndpoint(name string) (*Endpoint, error) { data, err := ioutil.ReadFile("./endpoints/" + name + ".json") if err != nil { return &Endpoint{Name: name}, nil } var ep Endpoint err = json.Unmarshal(data, &ep) return &ep, err } func saveEndpoint(ep *Endpoint) error { data, err := json.MarshalIndent(ep, "", " ") if err != nil { return err } tmpFile := "./endpoints/" + ep.Name + ".json.tmp" finalFile := "./endpoints/" + ep.Name + ".json" // erst in Temp-Datei schreiben if err := ioutil.WriteFile(tmpFile, data, 0644); err != nil { return err } // dann atomar umbenennen if err := os.Rename(tmpFile, finalFile); err != nil { return err } return nil } // --- Handlers --- func indexHandler(w http.ResponseWriter, r *http.Request) { renderTemplate(w, listEndpoints(), nil, "") } func sanitizeName(input string) string { // Alles klein input = strings.ToLower(input) // Alles was nicht a–z oder 0–9 ist, entfernen return safeNameRegexp.ReplaceAllString(input, "") } func addEndpointHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") name = sanitizeName(name) if name == "" { http.Error(w, "Invalid endpoint name", http.StatusBadRequest) return } if err := saveEndpoint(&Endpoint{Name: name}); err != nil { http.Error(w, "Cannot save endpoint: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func deleteEndpointHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") _ = os.Remove("./endpoints/" + name + ".json") http.Redirect(w, r, "/", http.StatusSeeOther) } func editHandler(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") ep, _ := loadEndpoint(name) // API immer auf localhost:8081 preview := "http://127.0.0.1:8081" + buildPreview(ep) renderTemplate(w, listEndpoints(), ep, preview) } func addParameterHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") param := r.FormValue("param") // "jobid" ignorieren if strings.ToLower(param) == "jobid" { http.Redirect(w, r, "/edit?name="+name+"#parameters", http.StatusSeeOther) return } ep, _ := loadEndpoint(name) ep.Parameters = append(ep.Parameters, param) saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#parameters", http.StatusSeeOther) } func deleteParameterHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("endpoint") param := r.FormValue("param") ep, _ := loadEndpoint(name) var newParams []string for _, p := range ep.Parameters { if p != param { newParams = append(newParams, p) } } ep.Parameters = newParams saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#parameters", http.StatusSeeOther) } func addTaskHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") taskname := r.FormValue("taskname") cmd := r.FormValue("command") ep, _ := loadEndpoint(name) ep.Tasks = append(ep.Tasks, Task{Name: taskname, Command: cmd, NextName: map[string]string{}}) saveEndpoint(ep) //http.Redirect(w, r, "/edit?name="+name+"#task-"+taskname, http.StatusSeeOther) http.Redirect(w, r, "/edit?name="+name, http.StatusSeeOther) } func editTaskHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("endpoint") taskname := r.FormValue("taskname") cmd := r.FormValue("command") ep, _ := loadEndpoint(name) for i := range ep.Tasks { if ep.Tasks[i].Name == taskname { ep.Tasks[i].Command = cmd break } } saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#task-"+taskname, http.StatusSeeOther) } func deleteTaskHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("endpoint") taskname := r.FormValue("taskname") ep, _ := loadEndpoint(name) var newTasks []Task for _, t := range ep.Tasks { if t.Name != taskname { newTasks = append(newTasks, t) } } ep.Tasks = newTasks saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#tasks", http.StatusSeeOther) } func addNextIDHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("endpoint") taskname := r.FormValue("taskname") cond := strings.TrimSpace(r.FormValue("cond")) next := r.FormValue("next") ep, _ := loadEndpoint(name) for i := range ep.Tasks { if ep.Tasks[i].Name == taskname { ep.Tasks[i].NextName[cond] = next break } } saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#nextname-"+taskname, http.StatusSeeOther) } func deleteNextIDHandler(w http.ResponseWriter, r *http.Request) { name := r.FormValue("endpoint") taskname := r.FormValue("taskname") cond := r.FormValue("cond") ep, _ := loadEndpoint(name) for i := range ep.Tasks { if ep.Tasks[i].Name == taskname { delete(ep.Tasks[i].NextName, cond) break } } saveEndpoint(ep) http.Redirect(w, r, "/edit?name="+name+"#nextname-"+taskname, http.StatusSeeOther) } // --- API --- func apiHandler(w http.ResponseWriter, r *http.Request) { // Alte Jobs löschen //1440 Minutes cleanupJobs(1440) name := strings.TrimPrefix(r.URL.Path, "/api/") name = strings.TrimSuffix(name, "/") ep, err := loadEndpoint(name) if err != nil { http.Error(w, "Endpoint not found", 404) return } if len(ep.Tasks) == 0 { http.Error(w, "No tasks defined", 400) return } // Parameter prüfen params := map[string]string{} for _, p := range ep.Parameters { v := r.URL.Query().Get(p) if v == "" { http.Error(w, "Missing parameter: "+p, 400) return } params[p] = v } // Job-ID generieren jobID := randomString(12) resultCh := make(chan []TaskOutput, 1) job := Job{ ID: jobID, Endpoint: ep, Params: params, Result: resultCh, } jobsLock.Lock() jobs[jobID] = &JobStatus{ID: jobID, Done: false} jobsLock.Unlock() // Job in Queue legen jobQueue <- job // Job-ID zurückgeben w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID}) } // execCommand mit Live-Callback func execCommand(cmdStr string, job *Job, taskName string, callback func(string)) (string, error) { cmdStr = strings.ReplaceAll(cmdStr, "\r", "") filename := fmt.Sprintf("/tmp/task-%s-%s.sh", job.ID, taskName) // Script schreiben if err := ioutil.WriteFile(filename, []byte(cmdStr), 0755); err != nil { return "", err } // Script nach Funktion beenden löschen defer os.Remove(filename) cmd := exec.Command("bash", filename) job.Cmd = cmd cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stdoutPipe, _ := cmd.StdoutPipe() stderrPipe, _ := cmd.StderrPipe() if err := cmd.Start(); err != nil { return "", err } var outputBuilder strings.Builder var wg sync.WaitGroup wg.Add(2) readPipe := func(pipe io.Reader) { defer wg.Done() scanner := bufio.NewScanner(pipe) for scanner.Scan() { line := scanner.Text() line = strings.TrimSpace(line) if callback != nil { callback(line) // Live-Zeilen-Callback } outputBuilder.WriteString(line) outputBuilder.WriteByte('\n') } } go readPipe(stdoutPipe) go readPipe(stderrPipe) wg.Wait() _ = cmd.Wait() return strings.TrimSpace(outputBuilder.String()), nil } func expand(s string, r *http.Request, params []string) string { for _, p := range params { s = strings.ReplaceAll(s, "[["+p+"]]", r.URL.Query().Get(p)) } return s } func buildPreview(ep *Endpoint) string { url := "/api/" + ep.Name + "/?" for i, p := range ep.Parameters { url += p + "=" if i < len(ep.Parameters)-1 { url += "&" } } return url } // --- Jobs anzeigen --- func jobsHandler(w http.ResponseWriter, r *http.Request) { jobsLock.Lock() defer jobsLock.Unlock() path := strings.TrimPrefix(r.URL.Path, "/jobs") path = strings.Trim(path, "/") type JobDetail struct { ID string `json:"id"` Output []TaskOutput `json:"output"` Done bool `json:"done"` Running bool `json:"running"` Params map[string]string `json:"params,omitempty"` Endpoint string `json:"endpoint,omitempty"` } if path == "" { // Alle Jobs var all []*JobDetail for _, js := range jobs { detail := &JobDetail{ ID: js.ID, Output: js.Output, Done: js.Done, Running: js.Running, } if js.Job != nil { detail.Params = js.Job.Params detail.Endpoint = js.Job.Endpoint.Name } all = append(all, detail) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(all) return } // Einzelner Job js, exists := jobs[path] if !exists { http.Error(w, "Job not found", http.StatusNotFound) return } detail := &JobDetail{ ID: js.ID, Output: js.Output, Done: js.Done, Running: js.Running, } if js.Job != nil { detail.Params = js.Job.Params detail.Endpoint = js.Job.Endpoint.Name } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(detail) } func killHandler(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/kill/") if id == "" { http.Error(w, "Missing job id", http.StatusBadRequest) return } jobsLock.Lock() js, ok := jobs[id] if ok { // Wenn Job gerade läuft, Prozess killen if js.Job != nil && js.Running && js.Job.Cmd != nil && js.Job.Cmd.Process != nil { _ = js.Job.Cmd.Process.Kill() } // Job als abgebrochen markieren js.Done = true js.Running = false } jobsLock.Unlock() w.Header().Set("Content-Type", "application/json") if ok { _ = json.NewEncoder(w).Encode(map[string]string{"status": "killed"}) } else { http.Error(w, "Job not found", http.StatusNotFound) } } // Hilfsfunktion: laufenden Job in Queue suchen func findRunningJob(id string) *Job { jobsLock.Lock() defer jobsLock.Unlock() for _, js := range jobs { if js.ID == id && js.Job != nil { return js.Job } } return nil } // Template rendern func renderTemplate(w http.ResponseWriter, endpoints []string, current *Endpoint, preview string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") // Template bei jeder Anfrage neu laden tmpl, err := template.ParseFiles(templateFile) if err != nil { log.Printf("Fehler beim Laden des Templates: %v", err) http.Error(w, "Template konnte nicht geladen werden", http.StatusInternalServerError) return } // Laufende Jobs sammeln jobsLock.Lock() var running []*JobStatus for _, js := range jobs { if !js.Done { running = append(running, js) } } jobsLock.Unlock() // Sortieren: laufende Jobs (*) oben sort.SliceStable(running, func(i, j int) bool { return running[i].Running && !running[j].Running }) // Template-Daten zusammenstellen data := map[string]interface{}{ "Endpoints": endpoints, "Current": current, "Preview": preview, "RunningJobs": running, } // Template rendern if err := tmpl.Execute(w, data); err != nil { log.Printf("Fehler beim Rendern des Templates: %v", err) http.Error(w, "Template konnte nicht gerendert werden", http.StatusInternalServerError) } } func cleanupJobs(minutes int) { cutoff := time.Now().Add(-time.Duration(minutes) * time.Minute) jobsLock.Lock() defer jobsLock.Unlock() for id, js := range jobs { if js.Done && !js.FinishedAt.IsZero() && js.FinishedAt.Before(cutoff) { delete(jobs, id) } } } func loadTemplate() { if _, err := os.Stat(templateFile); os.IsNotExist(err) { // Template existiert nicht → Default schreiben defaultTemplate := ` Workflow Manager

Workflow Manager

Endpoints

    {{range .Endpoints}}
  • {{.}}
  • {{end}}

Active Jobs

{{if .RunningJobs}} {{range .RunningJobs}} {{end}}
Job ID Action
{{if .Running}}{{else}} {{end}} {{.ID}}
{{else}}

No running jobs

{{end}}
{{if .Current}}

Editing: {{.Current.Name}}

Parameters

Parameters can be used as [[param1]] in Tasks.

Note: [[jobid]] cannot be added as a parameter;
it is reserved for referencing the current Job ID in Tasks.

    {{range .Current.Parameters}}
  • {{.}}
  • {{end}}

Tasks

{{range .Current.Tasks}}
+ Task {{.Name}}
Next-Task:
    {{ $task := . }} {{range $cond,$nid := $task.NextName}}
  • {{if eq $cond ""}}""{{else}}{{$cond}}{{end}} → {{$nid}}
  • {{end}}
{{end}}

API Preview

{{.Preview}}
{{end}}
` if err := ioutil.WriteFile(templateFile, []byte(defaultTemplate), 0644); err != nil { log.Fatalf("Fehler beim Erstellen der Template-Datei: %v", err) } } tmpl, err := template.ParseFiles(templateFile) if err != nil { log.Fatalf("Fehler beim Laden der Template-Datei: %v", err) } templates = tmpl }