diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a05ab9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM fedora + +USER 1001 + +CMD mkdir -p /config /var/www/olivetin/ + +EXPOSE 1337/tcp +EXPOSE 1338/tcp +EXPOSE 1339/tcp + +VOLUME /config + +COPY OliveTin /usr/bin/OliveTin +COPY webui /var/www/olivetin/ + +ENTRYPOINT [ "/usr/bin/OliveTin" ] diff --git a/Makefile b/Makefile index ac75593..7875c36 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,17 @@ -default: +compile: go build -o OliveTin github.com/jamesread/OliveTin/cmd/OliveTin grpc: protoc -I.:/usr/share/gocode/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis/ --go_out=plugins=grpc:gen/grpc/ OliveTin.proto protoc -I.:/usr/share/gocode/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis/ --grpc-gateway_out=gen/grpc --grpc-gateway_opt paths=source_relative OliveTin.proto +podman-image: + buildah bud -t olivetin + +podman-container: + podman kill olivetin + podman rm olivetin + podman create --name olivetin -p 1337:1337 -p 1338:1338 -p 1339:1339 -v /etc/OliveTin/:/config:ro olivetin + podman start olivetin + .PHONY: grpc diff --git a/OliveTin.proto b/OliveTin.proto index b93fd60..33bb702 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -22,7 +22,7 @@ message StartActionResponse { string stdout = 1; string stderr = 2; bool timedOut = 3; - int32 exitCode = 4; + int64 exitCode = 4; } service OliveTinApi { diff --git a/README.md b/README.md index 671d0a6..f1b5c56 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,19 @@ listenAddressRestActions: :1337 # Listen on all addresses available, port 1337 listenAddressWebUi: :1339 logLevel: "INFO" ``` + +## Building the container + +### Podman/Docker + +``` +podman create --name olivetin -p 1337 -p 1338 -p 1339 -v /etc/olivetin/:/config:ro olivetin + +``` + +### Buildah/Docker + +``` +buildah bud -t olivetin +``` + diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index e4c6828..3f49526 100644 --- a/cmd/OliveTin/main.go +++ b/cmd/OliveTin/main.go @@ -25,6 +25,7 @@ func init() { viper.SetConfigName("config.yaml") viper.SetConfigType("yaml") viper.AddConfigPath(".") + viper.AddConfigPath("/config") // For containers. viper.AddConfigPath("/etc/OliveTin/") if err := viper.ReadInConfig(); err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index f1ce2b8..14a993a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ type ActionButton struct { Icon string Shell string Css map[string]string `mapstructure:omitempty` + Timeout int } type Entity struct { diff --git a/pkg/cors/cors.go b/pkg/cors/cors.go new file mode 100644 index 0000000..0c41c99 --- /dev/null +++ b/pkg/cors/cors.go @@ -0,0 +1,33 @@ +package cors + +import ( + "net/http" + "strings" + log "github.com/sirupsen/logrus" +) + +func AllowCors(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + log.Infof("Setting CORS header: %v", origin) + + w.Header().Set("Access-Control-Allow-Origin", origin) + + if r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" { + preflightHandler(w, r) + return + } + } + + h.ServeHTTP(w, r) + }) +} + +func preflightHandler(w http.ResponseWriter, r *http.Request) { + log.Infof("preflight request for %s", r.URL.Path) + + headers := []string{"Content-Type", "Accept"} + w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ",")) + methods := []string{"GET", "HEAD", "POST", "PUT", "DELETE"} + w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ",")) +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index bdb93b4..d8dedfe 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -9,7 +9,6 @@ import ( "os/exec" "context" "time" - "fmt" ) var ( @@ -31,30 +30,51 @@ func ExecAction(action string) (*pb.StartActionResponse) { return res } - log.Infof("Found action %s", actualAction.Title) + log.WithFields(log.Fields { + "title": actualAction.Title, + "timeout": actualAction.Timeout, + }).Infof("Found action") - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) defer cancel() cmd := exec.CommandContext(ctx, "sh", "-c", actualAction.Shell) stdout, stderr := cmd.Output() + res.ExitCode = int64(cmd.ProcessState.ExitCode()) res.Stdout = string(stdout) - res.Stderr = fmt.Sprintf("%s", stderr) + + if stderr == nil { + res.Stderr = "" + } else { + res.Stderr = stderr.Error() + } if ctx.Err() == context.DeadlineExceeded { res.TimedOut = true } - log.Infof("Command %v stdout %v", actualAction.Title, res.Stdout) - log.Infof("Command %v stderr %v", actualAction.Title, res.Stderr) + log.WithFields(log.Fields { + "stdout": res.Stdout, + "stderr": res.Stderr, + "timedOut": res.TimedOut, + "exit": res.ExitCode, + }).Infof("Finished command.") return res } +func sanitizeAction(action *config.ActionButton) { + if action.Timeout < 3 { + action.Timeout = 3 + } +} + func findAction(actionTitle string) (*config.ActionButton, error) { for _, action := range Cfg.ActionButtons { if action.Title == actionTitle { + sanitizeAction(&action) + return &action, nil } } diff --git a/pkg/grpcApi/grpcApi.go b/pkg/grpcApi/grpcApi.go index 1323931..e5c73eb 100644 --- a/pkg/grpcApi/grpcApi.go +++ b/pkg/grpcApi/grpcApi.go @@ -21,11 +21,7 @@ type OliveTinApi struct { } func (api *OliveTinApi) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) { - res := &pb.StartActionResponse{} - - executor.ExecAction(req.ActionName) - - return res, nil + return executor.ExecAction(req.ActionName), nil } func (api *OliveTinApi) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) (*pb.GetButtonsResponse, error) { @@ -40,6 +36,7 @@ func (api *OliveTinApi) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) ( res.Actions = append(res.Actions, &btn); } + log.Infof("getButtons: %v", res) return res, nil } diff --git a/pkg/restApi/restapi.go b/pkg/restApi/restapi.go index 052a6aa..0444f9d 100644 --- a/pkg/restApi/restapi.go +++ b/pkg/restApi/restapi.go @@ -6,10 +6,11 @@ import ( "context" "github.com/grpc-ecosystem/grpc-gateway/runtime" "net/http" - "strings" gw "github.com/jamesread/OliveTin/gen/grpc" + cors "github.com/jamesread/OliveTin/pkg/cors" + config "github.com/jamesread/OliveTin/pkg/config" ) @@ -25,7 +26,8 @@ func Start(listenAddressRest string, listenAddressGrpc string, globalConfig *con ctx, cancel := context.WithCancel(ctx) defer cancel() - mux := runtime.NewServeMux() + // The JSONPb.EmitDefaults is necssary, so "empty" fields are returned in JSON. + mux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true})) opts := []grpc.DialOption{grpc.WithInsecure()} err := gw.RegisterOliveTinApiHandlerFromEndpoint(ctx, mux, listenAddressGrpc, opts) @@ -34,27 +36,5 @@ func Start(listenAddressRest string, listenAddressGrpc string, globalConfig *con log.Fatalf("gw error %v", err) } - return http.ListenAndServe(listenAddressRest, allowCors(mux)) -} - -func allowCors(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if origin := r.Header.Get("Origin"); origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) - if r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" { - preflightHandler(w, r) - return - } - } - h.ServeHTTP(w, r) - }) -} - -func preflightHandler(w http.ResponseWriter, r *http.Request) { - headers := []string{"Content-Type", "Accept"} - w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ",")) - methods := []string{"GET", "HEAD", "POST", "PUT", "DELETE"} - w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ",")) - log.Infof("preflight request for %s", r.URL.Path) - return + return http.ListenAndServe(listenAddressRest, cors.AllowCors(mux)) } diff --git a/pkg/webuiServer/webuiServer.go b/pkg/webuiServer/webuiServer.go index 4297d2c..b1757cc 100644 --- a/pkg/webuiServer/webuiServer.go +++ b/pkg/webuiServer/webuiServer.go @@ -4,14 +4,35 @@ import ( "net/http" "encoding/json" log "github.com/sirupsen/logrus" + cors "github.com/jamesread/OliveTin/pkg/cors" + "os" ) type WebUiSettings struct { Rest string } +func findWebuiDir() string { + directoriesToSearch := []string{ + "./webui", + "/var/www/olivetin/", + } + + for _, dir := range directoriesToSearch { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + log.Infof("Found the webui directory here: %v", dir) + + return dir + } + } + + log.Warnf("Did not find the webui directory, you will probably get 404 errors.") + + return "./webui" // Should not exist +} + func Start(listenAddress string, listenAddressRest string) { - http.Handle("/", http.FileServer(http.Dir("webui"))) + http.Handle("/", cors.AllowCors(http.FileServer(http.Dir(findWebuiDir())))) http.HandleFunc("/webUiSettings.json", func(w http.ResponseWriter, r *http.Request) { ret := WebUiSettings { @@ -23,5 +44,5 @@ func Start(listenAddress string, listenAddressRest string) { w.Write([]byte(jsonRet)); }) - log.Fatal(http.ListenAndServe(listenAddress, nil)) + log.Fatal(http.ListenAndServe(listenAddress, nil)); } diff --git a/webui/js/classes.js b/webui/js/ActionButton.js similarity index 86% rename from webui/js/classes.js rename to webui/js/ActionButton.js index 779a38a..5211b74 100644 --- a/webui/js/classes.js +++ b/webui/js/ActionButton.js @@ -7,10 +7,10 @@ class ActionButton extends window.HTMLButtonElement { this.isWaiting = false this.actionCallUrl = window.restBaseUrl + 'StartAction?actionName=' + this.title - if (json.icon !== undefined) { - this.unicodeIcon = unescape(json.icon) - } else { + if (json.icon == "") { this.unicodeIcon = '💩' + } else { + this.unicodeIcon = unescape(json.icon) } this.onclick = () => { this.startAction() } @@ -30,17 +30,23 @@ class ActionButton extends window.HTMLButtonElement { return res.json() } }).then(json => { - this.onActionResult() + if (json.timedOut) { + this.onActionResult('actionTimedOut') + } else if (json.exitCode != 0) { + this.onActionResult('actionNonZeroExit') + } else { + this.onActionResult('actionSuccess') + } }).catch(err => { this.onActionError(err) }) } - onActionResult (json) { + onActionResult (cssClass) { this.disabled = false this.isWaiting = false this.updateHtml() - this.classList.add('actionSuccess') + this.classList.add(cssClass) } onActionError (err) { diff --git a/webui/js/loader.js b/webui/js/marshaller.js similarity index 66% rename from webui/js/loader.js rename to webui/js/marshaller.js index bc5cf8d..ff74535 100644 --- a/webui/js/loader.js +++ b/webui/js/marshaller.js @@ -1,6 +1,6 @@ -import './classes.js' // To define action-button +import './ActionButton.js' // To define action-button -export function loadContents (json) { +export function marshalActionButtonsJsonToHtml(json) { for (const jsonButton of json.actions) { const a = document.createElement('button', { is: 'action-button' }) a.constructFromJson(jsonButton) diff --git a/webui/main.js b/webui/main.js index 3edb073..28dbaf8 100644 --- a/webui/main.js +++ b/webui/main.js @@ -1,6 +1,6 @@ 'use strict' -import { loadContents } from './js/loader.js' +import { marshalActionButtonsJsonToHtml } from './js/marshaller.js' /** * Design choice; define this as a "global function" (on window) so that it can @@ -14,18 +14,21 @@ window.showBigError = (type, friendlyType, message) => { err.classList.add("error") err.innerHTML = "
" + message + "
"; - document.getElementById('rootGroup').appendChild(err) + document.getElementById('rootGroup').appendChild(err) } function onInitialLoad(res) { window.restBaseUrl = res.Rest; - window.fetch(window.restBaseUrl + "GetButtons").then(res => { - return res.json() + window.fetch(window.restBaseUrl + "GetButtons", { + cors: 'cors', + // No fetch options }).then(res => { - loadContents(res) + return res.json() + }).then(res => { + marshalActionButtonsJsonToHtml(res) }).catch(err => { - showBigError("fetch-initial-buttons", "getting initial buttons", err, "blat") + showBigError("fetch-initial-buttons", "getting initial buttons", err, "blat") }); } diff --git a/webui/style.css b/webui/style.css index dd232ae..118ab7a 100644 --- a/webui/style.css +++ b/webui/style.css @@ -1,6 +1,6 @@ body { - background-color: #333; - color: white; + background-color: #efefef; + color: black; text-align: center; font-family: sans-serif; padding: 0; @@ -9,7 +9,7 @@ body { .group { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); grid-template-rows: auto auto auto auto; padding: 1em; grid-gap: 1em; @@ -37,12 +37,12 @@ div.entity h2 { button { padding: 1em; - color: white; + color: black; display: table-cell; text-align: center; - border: 1px solid #666; - background-color: #222; - box-shadow: 0 0 10px 0 #444; + border: 1px solid #999; + background-color: #dee3e7; + box-shadow: 0 0 6px 0 #aaa; user-select: none; } @@ -85,6 +85,27 @@ span.icon { 0% { background-color: inherit; } } +.actionNonZeroExit { + animation: kfActionNonZeroExit 1s; +} + +@keyframes kfActionNonZeroExit { + 0% { background-color: black; } + 20% { background-color: blue; } + 0% { background-color: inherit; } +} + +.actionTimeout { + animation: kfActionTimeout 1s; +} + +@keyframes kfActionTimeout { + 0% { background-color: black; } + 20% { background-color: cyan; } + 0% { background-color: inherit; } +} + + footer, footer a { color: gray; } @@ -94,3 +115,26 @@ img.logo { height: 1em; vertical-align: middle; } + +@media (max-width: 320px) { + .group { + grid-template-columns: auto; + background-color: red; + grid-gap: 0; + padding: 0; + } +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #333; + color: white; + } + + button { + border: 1px solid #666; + background-color: #222; + box-shadow: 0 0 6px 0 #444; + color: white; + } +}