commit af40e0890f53f9b214239cc7448ef8c8f0ccb86a Author: mappu Date: Sat Mar 25 15:41:36 2017 +1300 initial commit diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..21ed276 --- /dev/null +++ b/.hgignore @@ -0,0 +1,3 @@ +mode:regex + +^webcmd\. \ No newline at end of file diff --git a/App.go b/App.go new file mode 100644 index 0000000..ef8e698 --- /dev/null +++ b/App.go @@ -0,0 +1,97 @@ +package webcmd + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "regexp" + "sync" +) + +type App struct { + cfg AppConfig + + rxTaskInfo *regexp.Regexp + + tasksMtx sync.RWMutex + tasks map[string]Task +} + +func NewApp(configPath string) (*App, error) { + + confBytes, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("Couldn't open configuration file '%s': %s", configPath, err.Error()) + } + + cfg := AppConfig{} + err = json.Unmarshal(confBytes, &cfg) + if err != nil { + return nil, fmt.Errorf("Invalid configuration file: %s", err.Error()) + } + + return NewAppFromConfig(cfg), nil +} + +func NewAppFromConfig(cfg AppConfig) *App { + return &App{ + cfg: cfg, + rxTaskInfo: regexp.MustCompile(`^/task/([A-Z0-9]+)$`), + tasks: make(map[string]Task), + } +} + +func (this *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "webcmd/1.0") + + if r.Method == "GET" { + if r.URL.Path == "/" { + this.Serve_Homepage(w) + + } else if r.URL.Path == "/style.css" { + this.Serve_StyleCSS(w) + + } else if r.URL.Path == "/tasks" { + this.Serve_Tasks(w) + + } else if matches := this.rxTaskInfo.FindStringSubmatch(r.URL.Path); len(matches) == 2 { + this.Serve_TaskInfo(w, matches[1]) + + } else { + http.Error(w, "No matching route for request", 404) + + } + + } else if r.Method == "POST" { + if r.URL.Path == "/x-new-task" { + this.Action_NewTask(w, r) + + } else if r.URL.Path == "/x-abandon-task" { + this.Action_AbandonTask(w, r) + + } else if r.URL.Path == "/x-clear-completed-tasks" { + this.Action_ClearCompleted(w, r) + + } else { + http.Error(w, "No matching route for request", 404) + + } + + } else { + http.Error(w, "Invalid method", 400) + + } +} + +func (this *App) Run() { + mux := http.NewServeMux() + mux.Handle("/", this) + + log.Printf("Listening on '%s'...", this.cfg.ListenAddress) + err := http.ListenAndServe(this.cfg.ListenAddress, mux) + if err != nil { + log.Fatalf("Network error: %s", err.Error()) + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ea8525 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: all clean + +OBJS := $(addprefix webcmd.,linux64 linux32 win64 win32) + +all: $(OBJS) + +define compile + /bin/bash -c 'GOARCH=$(1) GOOS=$(2) go build -o ./webcmd.$(3) -ldflags "-s -w" ./cmd/webcmd' +endef + +webcmd.linux64: + $(call compile,amd64,linux,linux64) + +webcmd.linux32: + $(call compile,386,linux,linux32) + +webcmd.win64: + $(call compile,amd64,windows,win64) + +webcmd.win32: + $(call compile,386,windows,win32) + +clean: + rm $(OBJS) \ No newline at end of file diff --git a/Task.go b/Task.go new file mode 100644 index 0000000..30786cc --- /dev/null +++ b/Task.go @@ -0,0 +1,121 @@ +package webcmd + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base32" + "errors" + "io" + "os/exec" + "sync" + "time" +) + +type OutputLine struct { + isError bool + text string +} + +type Task struct { + cmd *exec.Cmd + output []OutputLine + started int64 + stopped int64 + exitCode int + cancel context.CancelFunc +} + +func (t Task) Finished() bool { + return t.stopped != 0 +} + +func uuid() string { + buff := make([]byte, 15) // multiples of five are best for base32 + _, err := rand.Read(buff) + if err != nil { + panic(err) + } + + return base32.StdEncoding.EncodeToString(buff) +} + +// LaunchTask creates a new task from the given command parameters. +func (this *App) LaunchTask(params []string) (taskRef string, err error) { + if len(params) == 0 { + return "", errors.New("No parameters for task") + } + + ref := uuid() + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, params[0], params[1:]...) + + errPipe, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + outPipe, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + + err = cmd.Start() + if err != nil { + return "", err + } + + this.tasksMtx.Lock() + this.tasks[ref] = Task{ + cmd: cmd, + output: make([]OutputLine, 0), + started: time.Now().Unix(), + stopped: 0, + exitCode: 0, + cancel: cancel, + } + this.tasksMtx.Unlock() + + wg := sync.WaitGroup{} + wg.Add(2) + + writeline := func(text string, isError bool) { + this.tasksMtx.Lock() + defer this.tasksMtx.Unlock() + + task := this.tasks[ref] + task.output = append(task.output, OutputLine{isError: isError, text: text}) + this.tasks[ref] = task + } + + pipe2line := func(rc io.ReadCloser, isError bool) { + defer wg.Done() + sc := bufio.NewScanner(rc) + for sc.Scan() { + writeline(sc.Text(), isError) + } + rc.Close() + } + go pipe2line(errPipe, true) + go pipe2line(outPipe, false) + + go func() { + wg.Wait() + err := cmd.Wait() + stopTime := time.Now().Unix() + exitCode := 0 + if err != nil { + writeline(err.Error(), true) + exitCode = 1 + } + + this.tasksMtx.Lock() + defer this.tasksMtx.Unlock() + task := this.tasks[ref] + task.stopped = stopTime + task.exitCode = exitCode + this.tasks[ref] = task + }() + + return ref, nil +} diff --git a/cmd/webcmd/main.go b/cmd/webcmd/main.go new file mode 100644 index 0000000..c42376a --- /dev/null +++ b/cmd/webcmd/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "flag" + "log" + + "code.ivysaur.me/webcmd" +) + +func main() { + confPath := flag.String("config", "webcmd.conf", "Path to configuration file") + flag.Parse() + + app, err := webcmd.NewApp(*confPath) + if err != nil { + log.Fatalf(err.Error()) + } + + app.Run() +} diff --git a/cmd/webcmd/webcmd.conf b/cmd/webcmd/webcmd.conf new file mode 100644 index 0000000..7a6927a --- /dev/null +++ b/cmd/webcmd/webcmd.conf @@ -0,0 +1,20 @@ +{ + "AppTitle": "Looking Glass", + + "ListenAddress": ":8192", + + "Commands": [ + + { + "Title": "Ping", + "Execution": [ + {"ParamType": 0, "Value": "/bin/ping"}, + {"ParamType": 0, "Value": "-c"}, + {"ParamType": 0, "Value": "4"}, + {"ParamType": 0, "Value": "--"}, + {"ParamType": 1, "Value": "example.com", "Description": "Target host"} + ] + } + + ] +} \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..85cbf8a --- /dev/null +++ b/config.go @@ -0,0 +1,33 @@ +package webcmd + +type ParamType int + +const ( + PARAMTYPE_CONST ParamType = 0 + PARAMTYPE_STRING ParamType = 1 + PARAMTYPE_OPTIONAL ParamType = 2 + // bool 1/0 + // list + // k/v list + // file upload (temporary path passed to binary) + // nested parse subgroup (e.g. ffmpeg filters) + // one optional to control a whole subgroup (e.g. --timeout 4) + // String validations (regexp, min-length, ...) +) + +type InputParam struct { + Description string `json:",omitempty"` // only use for editable parameters + ParamType ParamType + Value string +} + +type CommandConfig struct { + Title string + Execution []InputParam // TODO allow plain strings as a shorthand for PARAMTYPE_CONST +} + +type AppConfig struct { + ListenAddress string + AppTitle string + Commands []CommandConfig +} diff --git a/html.go b/html.go new file mode 100644 index 0000000..883fe94 --- /dev/null +++ b/html.go @@ -0,0 +1,16 @@ +package webcmd + +import ( + "html" + "log" + "net/http" +) + +func hesc(in string) string { + return html.EscapeString(in) +} + +func fail(w http.ResponseWriter, r *http.Request, s string) { + log.Printf("[%s] %s", r.RemoteAddr, s) + http.Error(w, "Malformed request", 400) +} diff --git a/tpl_Home.go b/tpl_Home.go new file mode 100644 index 0000000..ba97d78 --- /dev/null +++ b/tpl_Home.go @@ -0,0 +1,42 @@ +package webcmd + +import ( + "fmt" + "log" + "net/http" +) + +func (this *App) Serve_Homepage(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(200) + + this.ServePartial_Header(w, "/") + + for i, t := range this.cfg.Commands { + fmt.Fprint(w, + `

`+hesc(t.Title)+`

+
+ + `) + for i, param := range t.Execution { + switch param.ParamType { + case PARAMTYPE_CONST: + // not configurable parameter + case PARAMTYPE_STRING: + fmt.Fprintf(w, `
`, + i, hesc(param.Description), hesc(param.Description), hesc(param.Value)) + case PARAMTYPE_OPTIONAL: + fmt.Fprintf(w, `
`, + i, i, hesc(param.Description)) + default: + log.Fatalf("Unknown PARAMTYPE(%d)", param.ParamType) + } + } + fmt.Fprint(w, ` + +
+ `) + } + + this.ServePartial_Footer(w) +} diff --git a/tpl_Style.go b/tpl_Style.go new file mode 100644 index 0000000..1338f24 --- /dev/null +++ b/tpl_Style.go @@ -0,0 +1,50 @@ +package webcmd + +import ( + "fmt" + "net/http" +) + +func (this *App) Serve_StyleCSS(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/css") + w.WriteHeader(200) + fmt.Fprint(w, ` +/* Global styles */ +html,body { + font-family: sans-serif; +} +a { + color:green; +} +th { + text-align:left; +} +table { + width:100%; + border-collapse: collapse; + margin-top:1em; +} +td { + padding:2px; +} +tr:hover td { + background:lightyellow; +} + +/* Specific elements */ +.task-state-finished { + font-weight:bold; +} +.task-state-running { + color:darkgreen; +} +#task-output tbody { + font-family: monospace; +} +#task-output .stdout { +} +#task-output .stderr { + color:red; +} +`) +} diff --git a/tpl_Taskinfo.go b/tpl_Taskinfo.go new file mode 100644 index 0000000..90674ce --- /dev/null +++ b/tpl_Taskinfo.go @@ -0,0 +1,81 @@ +package webcmd + +import ( + "fmt" + "net/http" + "time" +) + +func (this *App) Serve_TaskInfo(w http.ResponseWriter, task_ref string) { + + this.tasksMtx.RLock() + taskinfo, ok := this.tasks[task_ref] + this.tasksMtx.RUnlock() + + if !ok { + http.Error(w, "Unknown task.", 404) + return + } + + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(200) + + this.ServePartial_Header(w, "/taskinfo/...") + + fmt.Fprintf(w, ` + + + + + + + + + + `) + + for _, line := range taskinfo.output { + messageClass := "stdout" + if line.isError { + messageClass = "stderr" + } + fmt.Fprintf(w, + ` + + + `, messageClass, hesc(line.text), + ) + } + + fmt.Fprintf(w, ` + +
Message
%s
+
+ + +
+ `, + hesc(task_ref), + ) + + this.ServePartial_Footer(w) +} diff --git a/tpl_Tasks.go b/tpl_Tasks.go new file mode 100644 index 0000000..f253a2f --- /dev/null +++ b/tpl_Tasks.go @@ -0,0 +1,61 @@ +package webcmd + +import ( + "fmt" + "net/http" + "time" +) + +func (this *App) Serve_Tasks(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + + w.WriteHeader(200) + this.ServePartial_Header(w, "/tasks") + + fmt.Fprint(w, ` + + + + + + + + + + `) + + this.tasksMtx.RLock() + defer this.tasksMtx.RUnlock() + for ref, t := range this.tasks { + fmt.Fprintf(w, + ` + + + + + `) + } + + fmt.Fprint(w, ` + +
TaskStartedState
%s%s + `, + hesc(ref), hesc(ref), + hesc(time.Unix(t.started, 0).Format(time.RFC822Z)), + ) + + if t.Finished() { + fmt.Fprint(w, `Finished`) + } else { + fmt.Fprint(w, `Running`) + } + + fmt.Fprint(w, ` +
+ +
+ +
+ `) + this.ServePartial_Footer(w) +} diff --git a/tpt_Header.go b/tpt_Header.go new file mode 100644 index 0000000..aed5c6b --- /dev/null +++ b/tpt_Header.go @@ -0,0 +1,41 @@ +package webcmd + +import ( + "fmt" + "net/http" +) + +func (this *App) ServePartial_Header(w http.ResponseWriter, slug string) { + + fmt.Fprint(w, ` + + + + `+hesc(this.cfg.AppTitle)+` + + + +

`+hesc(this.cfg.AppTitle)+`

+ `) + + if slug == "/" { + fmt.Fprint(w, "New task") + } else { + fmt.Fprint(w, `New task`) + } + + fmt.Fprint(w, ` | `) + + if slug == "/tasks" { + fmt.Fprint(w, "Current tasks") + } else { + fmt.Fprint(w, `Current tasks`) + } +} + +func (this *App) ServePartial_Footer(w http.ResponseWriter) { + fmt.Fprint(w, ` + + + `) +} diff --git a/twa_AbandonTask.go b/twa_AbandonTask.go new file mode 100644 index 0000000..108f397 --- /dev/null +++ b/twa_AbandonTask.go @@ -0,0 +1,27 @@ +package webcmd + +import ( + "net/http" +) + +func (this *App) Action_AbandonTask(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + fail(w, r, err.Error()) + return + } + + taskRef := r.Form.Get("task_ref") + + this.tasksMtx.RLock() + task, ok := this.tasks[taskRef] + this.tasksMtx.RUnlock() + + if !ok { + http.Error(w, "Invalid task specified", 404) + return + } + + task.cancel() + http.Redirect(w, r, "/task/"+taskRef, 302) +} diff --git a/twa_ClearCompleted.go b/twa_ClearCompleted.go new file mode 100644 index 0000000..fd88b7c --- /dev/null +++ b/twa_ClearCompleted.go @@ -0,0 +1,19 @@ +package webcmd + +import ( + "net/http" +) + +func (this *App) Action_ClearCompleted(w http.ResponseWriter, r *http.Request) { + + this.tasksMtx.Lock() + defer this.tasksMtx.Unlock() + + for k, v := range this.tasks { + if v.Finished() { + delete(this.tasks, k) + } + } + + http.Redirect(w, r, "/tasks", 302) +} diff --git a/twa_NewTask.go b/twa_NewTask.go new file mode 100644 index 0000000..9ba24ee --- /dev/null +++ b/twa_NewTask.go @@ -0,0 +1,68 @@ +package webcmd + +import ( + "fmt" + "net/http" + "strconv" +) + +func (this *App) Action_NewTask(w http.ResponseWriter, r *http.Request) { + + err := r.ParseForm() + if err != nil { + fail(w, r, fmt.Sprintf("Bad form data: %s", err.Error())) + return + } + + taskIdStr := r.Form.Get("task_id") + if len(taskIdStr) == 0 { + fail(w, r, "Missing task ID in request") + return + } + + taskId, err := strconv.Atoi(taskIdStr) + if err != nil { + fail(w, r, err.Error()) + return + } + + if taskId < 0 || taskId >= len(this.cfg.Commands) { + fail(w, r, fmt.Sprintf("Invalid task ID %d", taskId)) + return + } + + taskInfo := this.cfg.Commands[taskId] + + params := make([]string, 0, len(taskInfo.Execution)) + for i, prop := range taskInfo.Execution { + switch prop.ParamType { + case PARAMTYPE_CONST: + params = append(params, prop.Value) + + case PARAMTYPE_STRING: + val := r.Form.Get(fmt.Sprintf("param[%d]", i)) + params = append(params, val) + + case PARAMTYPE_OPTIONAL: + val := r.Form.Get(fmt.Sprintf("param[%d]", i)) + if val == "on" { + params = append(params, prop.Value) + } else if val == "off" { + // nothing + } else { + fail(w, r, "Unexpected value for parameter") + return + } + + } + } + + // Create new command from supplied values + taskRef, err := this.LaunchTask(params) + if err != nil { + fail(w, r, err.Error()) + return + } + + http.Redirect(w, r, "/task/"+taskRef, 302) +}