initial commit

This commit is contained in:
mappu 2017-03-25 15:41:36 +13:00
commit af40e0890f
16 changed files with 723 additions and 0 deletions

3
.hgignore Normal file
View File

@ -0,0 +1,3 @@
mode:regex
^webcmd\.

97
App.go Normal file
View File

@ -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())
}
}

24
Makefile Normal file
View File

@ -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)

121
Task.go Normal file
View File

@ -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
}

20
cmd/webcmd/main.go Normal file
View File

@ -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()
}

20
cmd/webcmd/webcmd.conf Normal file
View File

@ -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"}
]
}
]
}

33
config.go Normal file
View File

@ -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
}

16
html.go Normal file
View File

@ -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)
}

42
tpl_Home.go Normal file
View File

@ -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,
`<h3>`+hesc(t.Title)+`</h3>
<form method="POST" action="/x-new-task">
<input type="hidden" name="task_id" value="`+fmt.Sprintf("%d", i)+`">
`)
for i, param := range t.Execution {
switch param.ParamType {
case PARAMTYPE_CONST:
// not configurable parameter
case PARAMTYPE_STRING:
fmt.Fprintf(w, `<input type="text" name="param[%d]" placeholder="%s" title="%s" value="%s"><br>`,
i, hesc(param.Description), hesc(param.Description), hesc(param.Value))
case PARAMTYPE_OPTIONAL:
fmt.Fprintf(w, `<input type="hidden" name="param[%d]" value="off"><label><input type="checkbox" name="param[%d]" value="on">%s</label><br>`,
i, i, hesc(param.Description))
default:
log.Fatalf("Unknown PARAMTYPE(%d)", param.ParamType)
}
}
fmt.Fprint(w, `
<input type="submit" value="Run &raquo;">
</form>
`)
}
this.ServePartial_Footer(w)
}

50
tpl_Style.go Normal file
View File

@ -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;
}
`)
}

81
tpl_Taskinfo.go Normal file
View File

@ -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, `
<ul>
<li>Type: %s</li>
<li>Start time: %s</li>
`,
"???",
hesc(time.Unix(taskinfo.started, 0).Format(time.RFC822Z)),
)
if taskinfo.Finished() {
fmt.Fprintf(w, `
<li>Completed at: %s</li>
<li>Process exit code: %d</li>
`,
hesc(time.Unix(taskinfo.stopped, 0).Format(time.RFC822Z)),
taskinfo.exitCode,
)
}
fmt.Fprint(w, `
</ul>
<table id="task-output">
<thead>
<tr>
<th>Message</th>
</tr>
</thead>
<tbody>
`)
for _, line := range taskinfo.output {
messageClass := "stdout"
if line.isError {
messageClass = "stderr"
}
fmt.Fprintf(w,
`<tr class="%s">
<td>%s</td>
</tr>
`, messageClass, hesc(line.text),
)
}
fmt.Fprintf(w, `
</tbody>
</table>
<form method="POST" action="/x-abandon-task">
<input type="hidden" name="task_ref" value="%s">
<input type="submit" value="Cancel &raquo;">
</form>
`,
hesc(task_ref),
)
this.ServePartial_Footer(w)
}

61
tpl_Tasks.go Normal file
View File

@ -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, `
<table>
<thead>
<tr>
<th>Task</th>
<th>Started</th>
<th>State</th>
</tr>
</thead>
<tbody>
`)
this.tasksMtx.RLock()
defer this.tasksMtx.RUnlock()
for ref, t := range this.tasks {
fmt.Fprintf(w,
`<tr>
<td><a href="/task/%s">%s</td>
<td>%s</td>
<td>
`,
hesc(ref), hesc(ref),
hesc(time.Unix(t.started, 0).Format(time.RFC822Z)),
)
if t.Finished() {
fmt.Fprint(w, `<span class="task-state-finished">Finished</span>`)
} else {
fmt.Fprint(w, `<span class="task-state-running">Running</span>`)
}
fmt.Fprint(w, `
</td>
</tr>
`)
}
fmt.Fprint(w, `
</tbody>
</table>
<form method="POST" action="/x-clear-completed-tasks">
<input type="submit" value="Clear completed tasks &raquo;">
</form>
`)
this.ServePartial_Footer(w)
}

41
tpt_Header.go Normal file
View File

@ -0,0 +1,41 @@
package webcmd
import (
"fmt"
"net/http"
)
func (this *App) ServePartial_Header(w http.ResponseWriter, slug string) {
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>`+hesc(this.cfg.AppTitle)+`</title>
<link rel="stylesheet" type="text/css" href="/style.css">
</head>
<body>
<h2>`+hesc(this.cfg.AppTitle)+`</h2>
`)
if slug == "/" {
fmt.Fprint(w, "New task")
} else {
fmt.Fprint(w, `<a href="/">New task</a>`)
}
fmt.Fprint(w, ` | `)
if slug == "/tasks" {
fmt.Fprint(w, "Current tasks")
} else {
fmt.Fprint(w, `<a href="/tasks">Current tasks</a>`)
}
}
func (this *App) ServePartial_Footer(w http.ResponseWriter) {
fmt.Fprint(w, `
</body>
</html>
`)
}

27
twa_AbandonTask.go Normal file
View File

@ -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)
}

19
twa_ClearCompleted.go Normal file
View File

@ -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)
}

68
twa_NewTask.go Normal file
View File

@ -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)
}