diff --git a/Dockerfile b/Dockerfile index 644b1dd..634d3d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,22 @@ -FROM alpine:3.7 +# Build stage +FROM golang:1.26.4-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +ARG GIT_COMMIT=undefined +ARG BUILD_DATE=undefined +RUN CGO_ENABLED=0 go build \ + -ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \ + -o /go-apish ./app + +# Runtime stage +FROM alpine:3.23 RUN apk --no-cache add bash jq curl -COPY go-apish /go-apish -CMD ["/go-apish"] +COPY --from=build /go-apish /go-apish + +EXPOSE 4242 +ENTRYPOINT ["/go-apish"] diff --git a/Makefile b/Makefile index 84c888b..70689af 100644 --- a/Makefile +++ b/Makefile @@ -1,32 +1,42 @@ GIT_COMMIT = $(shell git rev-parse --short HEAD) BUILD_DATE = $(shell date '+%Y%m%d-%H%M%S') +LDFLAGS = -X main.gitCommit=$(GIT_COMMIT) -X main.buildDate=$(BUILD_DATE) -build: build-binary build-image +binary: + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o go-apish ./app -build-binary: - docker run --rm \ - -w /go/src/github.com/thbkrkr/go-apish \ - -v $(shell pwd):/go/src/github.com/thbkrkr/go-apish \ - -e CGO_ENABLED=0 -e GOOS=linux \ - -ti golang:1.6.2 \ - go build -a -installsuffix cgo \ - -ldflags "-X=main.gitCommit=$(GIT_COMMIT) -X=main.buildDate=$(BUILD_DATE)" - -build-image: - @docker build --rm -t krkr/apish . - -release: - ./release.sh $(GIT_COMMIT) +test: + go test ./... push: docker push krkr/apish +build: + docker build --rm \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + -t krkr/apish . + +HELM_RELEASE = apish + +helm-package: + helm package helm + +helm-install: + helm install $(HELM_RELEASE) helm + +helm-upgrade: + helm upgrade $(HELM_RELEASE) helm + +helm-uninstall: + helm uninstall $(HELM_RELEASE) + +helm-render: + helm template $(HELM_RELEASE) helm + run: docker run -d \ -v $$(pwd)/example:/api \ -p 80:4242 \ krkr/apish -golive: - gohere - golive -apiDir=example/api diff --git a/PR.md b/PR.md new file mode 100644 index 0000000..f11eb53 --- /dev/null +++ b/PR.md @@ -0,0 +1,18 @@ +## Summary + +- **Security:** prevent path traversal in script execution; fix wildcard+credentials CORS combo; compare API key in constant time; gate `/docker` behind a flag then remove it entirely; configurable basic-auth user with no default API key; warn when running unauthenticated +- **Fixes:** handle errors in POST handler; return 204 for favicon; treat any stat error as missing index; log and exit on server error; derive `/ls` URL scheme from the request +- **Refactor:** expose `/version` without auth; deduplicate GET/POST exec handlers; unify logging on logrus; single static walk with real error propagation; drop favicon route and slim CORS headers +- **Build:** multi-stage Dockerfile with Go 1.25; drop dead `release` target +- **Docs:** KISS README rewrite with inline flag comments, real curl output, layout tree; example scripts build JSON safely with `jq` +- **Tests:** add coverage for path traversal, POST, invalid JSON, `/ls`, script failure, wrong credentials; fix broken `apiDir` path after restructuring; simplify HTTP helpers +- **Helm:** minimal chart (Deployment + Service) with configurable image, port, auth flags; `helm-*` Makefile targets using `apish` as release name +- **Build:** bump Go 1.25 → 1.26.4 and alpine 3.20 → 3.23; tag example image as `krkr/apish:example`; add `push` and `port-forward` targets to example Makefile + +## Test plan + +- [ ] `go test ./app/...` passes +- [ ] `helm template` renders without errors (`make helm-render`) +- [ ] `./go-apish -apiDir=example` serves scripts and static files +- [ ] Auth is required when `-password` is set; server warns when it is not +- [ ] `make -C example build && make -C example run` serves the example image on port 80 diff --git a/README.md b/README.md index 8bf1aee..4b1a028 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,77 @@ -# apish - Rest API for shell scripts +# apish — REST API for shell scripts -Write shell scripts that return JSON ([example](example/api/time/date.sh)). +Write shell scripts that return JSON ([example](example/time/date.sh)). -Serve static files from [api/_static](example/api/_static) directory. +Serve static files from [_static](example/_static) directory. -Build +## Run - make build +```sh +./go-apish \ + -port=4242 \ # HTTP port + -apiDir=example \ # directory of .sh scripts and _static files + -user=zuperadmin \ # basic-auth username + -password=secret \ # basic-auth password (empty = no auth) + -apiKey=mykey # X-Auth header key (empty = disabled) +``` -Run +```sh +make binary # build ./go-apish +make build # build krkr/apish Docker image +make run # run image, mounting ./example as /api on port 80 +``` - make run +## Example + +### Layout + +``` +/ + time/date.sh → GET /api/time/date + test/param.sh → GET /api/test/param?q= + test/post.sh → POST /api/test/post + _static/index.html → GET /s/ +``` + +### Endpoints + +```sh +# build info (no auth) +❯ curl localhost:4242/version +{"build_date":"20260602-233836","git_commit":"cacfab6"} + +# list available API URLs +❯ curl -s localhost:4242/ls | jq '.api[]' -r +http://localhost:4242/api/test/invalid-json +http://localhost:4242/api/test/param +http://localhost:4242/api/test/post +http://localhost:4242/api/time/date + +# run a script (GET) +❯ curl localhost:4242/api/time/date +{"date":1780435972,"human_date":"Tue Jun 2 23:32:52 CEST 2026"} + +# run a script (GET, optional ?q= passed as $1) +❯ curl localhost:4242/api/test/param?q=hello +{"param":"hello"} + +# run a script (POST, request body piped to stdin) +❯ curl -d '{"key":"42"}' localhost:4242/api/test/post +{"jackpot": "42"} + +# serve static files from /_static +❯ curl localhost:4242/s/ -s | head -1 + +``` + +Invalid JSON from a script → `400`. Non-zero exit → `500`. + +```sh +❯ curl localhost:4242/api/test/invalid-json +< HTTP/1.1 400 Bad Request +{"error":"Invalid JSON"} + +❯ curl localhost:4242/api/test/fail +< HTTP/1.1 500 Internal Server Error +{"error":"exit status 1"} +``` \ No newline at end of file diff --git a/app/auth.go b/app/auth.go new file mode 100644 index 0000000..daeffc0 --- /dev/null +++ b/app/auth.go @@ -0,0 +1,27 @@ +package main + +import ( + "crypto/subtle" + + "github.com/gin-gonic/gin" +) + +var AuthHeaderKey = "X-Auth" + +func AuthMiddleware(apiKey string, accounts gin.Accounts) gin.HandlerFunc { + basicAuth := gin.BasicAuthForRealm(accounts, "") + + return func(c *gin.Context) { + // Try header auth when a key is configured, comparing in constant time + // to avoid leaking the key through response-timing differences. An + // empty key disables header auth (so it can't match a missing header). + if apiKey != "" { + got := c.Request.Header.Get(AuthHeaderKey) + if subtle.ConstantTimeCompare([]byte(got), []byte(apiKey)) == 1 { + return + } + } + // Fall back to basic auth + basicAuth(c) + } +} diff --git a/app/cors.go b/app/cors.go new file mode 100644 index 0000000..1c4eeaf --- /dev/null +++ b/app/cors.go @@ -0,0 +1,20 @@ +package main + +import "github.com/gin-gonic/gin" + +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Open API: wildcard origin (no credentials, which browsers would + // reject alongside "*"). Only the methods and headers this API + // actually uses are advertised. + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(200) + } else { + c.Next() + } + } +} diff --git a/app/exec.go b/app/exec.go new file mode 100644 index 0000000..62f8b7d --- /dev/null +++ b/app/exec.go @@ -0,0 +1,102 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +type ExecHandler struct { + ApiDir string +} + +// scriptPath resolves the wildcard request path to an absolute `.sh` path and +// guarantees it stays within ApiDir, preventing path-traversal escapes such as +// `/api/../../../tmp/evil`. It returns false when the path escapes ApiDir. +func (h *ExecHandler) scriptPath(reqPath string) (string, bool) { + base, err := filepath.Abs(h.ApiDir) + if err != nil { + return "", false + } + // filepath.Join cleans the result, collapsing any `..` segments. + script := filepath.Join(base, reqPath+".sh") + if script != base && !strings.HasPrefix(script, base+string(os.PathSeparator)) { + return "", false + } + return script, true +} + +// resolve validates the request path and ensures the target script exists, +// writing a 404 and returning ok=false when it cannot be served. +func (h *ExecHandler) resolve(c *gin.Context) (string, bool) { + path := c.Param("path") + script, ok := h.scriptPath(path) + if !ok { + c.JSON(404, gin.H{"error": "Resource not found"}) + logrus.Errorf("invalid resource path: %s", path) + return "", false + } + if _, err := os.Stat(script); os.IsNotExist(err) { + c.JSON(404, gin.H{"error": "Resource not found"}) + logrus.Errorf("resource not found: %s", script) + return "", false + } + return script, true +} + +// run executes cmd, which is expected to print JSON to stdout, and writes the +// parsed JSON (or an appropriate error) to the response. +func (h *ExecHandler) run(c *gin.Context, script string, cmd *exec.Cmd) { + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + logrus.Errorf("executing `%s`: %s: %s", script, err, stderr.String()) + return + } + + var payload any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + c.JSON(400, gin.H{"error": "Invalid JSON"}) + logrus.Errorf("invalid JSON for `%s`: %s", script, stdout.Bytes()) + return + } + + c.JSON(200, payload) +} + +// ExecScript runs the script for a GET request, optionally passing the `q` +// query parameter as the script's first argument. +func (h *ExecHandler) ExecScript(c *gin.Context) { + script, ok := h.resolve(c) + if !ok { + return + } + + cmd := exec.Command(script) + if q, isParam := c.Request.URL.Query()["q"]; isParam { + cmd = exec.Command(script, q[0]) + } + h.run(c, script, cmd) +} + +// PostExecScript runs the script for a POST request, piping the request body +// to the script's stdin. +func (h *ExecHandler) PostExecScript(c *gin.Context) { + script, ok := h.resolve(c) + if !ok { + return + } + + cmd := exec.Command(script) + cmd.Stdin = c.Request.Body + h.run(c, script, cmd) +} diff --git a/app/exec_test.go b/app/exec_test.go new file mode 100644 index 0000000..b59e5ff --- /dev/null +++ b/app/exec_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func TestScriptPathStaysWithinApiDir(t *testing.T) { + h := &ExecHandler{ApiDir: "../example"} + + cases := map[string]bool{ + "/time/date": true, // normal script + "/test/param": true, // nested script + "/../secret": false, // climbs out of ApiDir + "/../../../etc/passwd": false, // deep traversal + "/time/../../../tmp/x": false, // traversal after a valid segment + } + + for reqPath, wantOK := range cases { + if _, ok := h.scriptPath(reqPath); ok != wantOK { + t.Errorf("scriptPath(%q) ok = %v, want %v", reqPath, ok, wantOK) + } + } +} diff --git a/app/ls.go b/app/ls.go new file mode 100644 index 0000000..45af7af --- /dev/null +++ b/app/ls.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +type LsHandler struct { + ApiDir string +} + +type resources struct { + Scripts []string `json:"api"` + Pages []string `json:"html"` + Static []string `json:"static"` +} + +func (h *LsHandler) ListResources(c *gin.Context) { + scripts := make([]string, 0) + pages := make([]string, 0) + static := make([]string, 0) + + hostname := strings.ReplaceAll(c.Request.Host, "/", "") + baseURL := requestScheme(c) + "://" + hostname + + apiDir, err := filepath.Abs(h.ApiDir) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + staticDir := apiDir + "/_static" + + // List API scripts (every .sh outside _static), propagating walk errors. + err = filepath.Walk(apiDir, func(path string, f os.FileInfo, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(path, "sh") && !strings.Contains(path, "_static") { + scripts = append(scripts, fileToUrl(baseURL, "api", path, apiDir)) + } + return nil + }) + if err != nil { + c.JSON(500, gin.H{ + "error": err.Error(), + }) + return + } + + // List static resources in a single pass, splitting HTML pages from other + // files. The _static directory is optional, so skip it when absent. + if _, statErr := os.Stat(staticDir); statErr == nil { + err = filepath.Walk(staticDir, func(path string, f os.FileInfo, err error) error { + if err != nil { + return err + } + if f.IsDir() { + return nil + } + url := fileToUrl(baseURL, "s", path, staticDir) + if strings.HasSuffix(path, "html") { + pages = append(pages, url) + } else { + static = append(static, url) + } + return nil + }) + if err != nil { + c.JSON(500, gin.H{ + "error": err.Error(), + }) + return + } + } + + c.JSON(200, resources{ + Scripts: scripts, + Pages: pages, + Static: static, + }) +} + +// requestScheme reports whether the request arrived over https, honoring a +// reverse proxy's X-Forwarded-Proto header. +func requestScheme(c *gin.Context) string { + if c.Request.TLS != nil || c.Request.Header.Get("X-Forwarded-Proto") == "https" { + return "https" + } + return "http" +} + +func fileToUrl(baseURL string, prefix string, path string, apiDir string) string { + filePath := strings.ReplaceAll(path, apiDir, prefix) + if strings.Contains(path, "_static") { + return fmt.Sprintf("%v/%v", baseURL, filePath) + } + return fmt.Sprintf("%v/%v", baseURL, strings.ReplaceAll(filePath, ".sh", "")) +} diff --git a/main.go b/app/main.go similarity index 60% rename from main.go rename to app/main.go index 37e8c04..aad28f6 100644 --- a/main.go +++ b/app/main.go @@ -3,12 +3,11 @@ package main import ( "flag" "fmt" - "log" "net/http" - "runtime" "time" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) var ( @@ -16,17 +15,12 @@ var ( buildDate = "undefined" port = flag.Int("port", 4242, "HTTP port to listen") + user = flag.String("user", "zuperadmin", "Username for basic auth") password = flag.String("password", "", "Admin password for basic auth") - apiKey = flag.String("apiKey", "42", "API key for header auth") + apiKey = flag.String("apiKey", "", "API key for X-Auth header auth (empty disables header auth)") apiDir = flag.String("apiDir", "./api", "API directory (sh scripts and html pages)") ) -func ConfigRuntime() { - nuCPU := runtime.NumCPU() - runtime.GOMAXPROCS(nuCPU) - fmt.Printf("[info] Running with %d CPUs\n", nuCPU) -} - func StartGin() { start := time.Now() gin.SetMode(gin.ReleaseMode) @@ -41,15 +35,16 @@ func StartGin() { MaxHeaderBytes: 1 << 20, } - log.Printf("[info] API started in %v on %s\n", time.Since(start), sport) + logrus.Infof("API ready in %v, listening on %s", time.Since(start), sport) - for { - s.ListenAndServe() + // ListenAndServe only returns on error; log it and exit instead of + // silently busy-looping a restart. + if err := s.ListenAndServe(); err != nil { + logrus.Fatalf("server stopped: %v", err) } } func main() { flag.Parse() - ConfigRuntime() StartGin() } diff --git a/app/main_test.go b/app/main_test.go new file mode 100644 index 0000000..2f73564 --- /dev/null +++ b/app/main_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +var server *httptest.Server + +var auth = &basicAuth{Username: "zuperadmin", Password: "42"} + +func init() { + gin.SetMode(gin.TestMode) + *apiDir = "../example" + *password = "42" + *apiKey = "42" + server = httptest.NewServer(Router()) +} + +type basicAuth struct { + Username string + Password string +} + +func do(t *testing.T, verb, path, body string, auth *basicAuth, apiKey string) (int, string) { + req, err := http.NewRequest(verb, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if auth != nil { + req.SetBasicAuth(auth.Username, auth.Password) + } + if apiKey != "" { + req.Header.Set("X-Auth", apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, string(out) +} + +func get(t *testing.T, path string, auth *basicAuth) (int, string) { + return do(t, "GET", path, "", auth, "") +} + +func getWithKey(t *testing.T, path, apiKey string) (int, string) { + return do(t, "GET", path, "", nil, apiKey) +} + +func post(t *testing.T, path, body string, auth *basicAuth) (int, string) { + return do(t, "POST", path, body, auth, "") +} + +func TestBase(t *testing.T) { + // / redirects to /s (index.html exists in the example), which is behind + // auth, so the request must carry credentials to follow through to 200. + status, _ := get(t, "/", auth) + assert.Equal(t, 200, status, "should get a 200") + + // /version is exposed without auth. + status, _ = get(t, "/version", nil) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = get(t, "/blablabla", nil) + assert.Equal(t, 404, status, "should get a 404") +} + +func TestAuthentication(t *testing.T) { + status, _ := get(t, "/api/time/date", nil) + assert.Equal(t, 401, status, "should get a 401") + + status, _ = get(t, "/api/time/date", auth) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = getWithKey(t, "/api/time/date", "42") + assert.Equal(t, 200, status, "should get a 200") +} + +func TestScripts(t *testing.T) { + status, _ := get(t, "/api/nothing", auth) + assert.Equal(t, 404, status, "should get a 404") + + status, _ = get(t, "/api/time/date", auth) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = get(t, "/api/test/param?q=hello", auth) + assert.Equal(t, 200, status, "should get a 200") +} + +func TestPages(t *testing.T) { + status, _ := get(t, "/s/", auth) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = get(t, "/s/date.html", auth) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = get(t, "/s/css/styles.css", auth) + assert.Equal(t, 200, status, "should get a 200") + + status, _ = get(t, "/s/js/script.js", auth) + assert.Equal(t, 200, status, "should get a 200") +} + +func TestPost(t *testing.T) { + status, body := post(t, "/api/test/post", `{"o": 42}`, auth) + assert.Equal(t, 200, status, "should get a 200") + assert.Contains(t, body, "jackpot") +} + +func TestInvalidJSON(t *testing.T) { + status, _ := get(t, "/api/test/invalid-json", auth) + assert.Equal(t, 400, status, "invalid JSON output should yield a 400") +} + +func TestListResources(t *testing.T) { + status, body := get(t, "/ls", auth) + assert.Equal(t, 200, status, "should get a 200") + assert.Contains(t, body, "/api/time/date") +} + +func TestScriptFailure(t *testing.T) { + status, body := get(t, "/api/test/fail", auth) + assert.Equal(t, 500, status) + assert.Contains(t, body, "error") +} + +func TestAuthWrongPassword(t *testing.T) { + status, _ := do(t, "GET", "/api/time/date", "", &basicAuth{Username: "zuperadmin", Password: "wrong"}, "") + assert.Equal(t, 401, status) +} + +func TestAuthWrongApiKey(t *testing.T) { + status, _ := getWithKey(t, "/api/time/date", "wrong") + assert.Equal(t, 401, status) +} + +func TestGetWithoutQueryParam(t *testing.T) { + status, body := get(t, "/api/test/param", auth) + assert.Equal(t, 200, status) + assert.Contains(t, body, "param") +} + +func TestPostNotFound(t *testing.T) { + status, _ := post(t, "/api/nothing", "", auth) + assert.Equal(t, 404, status) +} + +func TestVersionBody(t *testing.T) { + status, body := get(t, "/version", nil) + assert.Equal(t, 200, status) + assert.Contains(t, body, "git_commit") + assert.Contains(t, body, "build_date") +} + +func TestListResourcesBody(t *testing.T) { + status, body := get(t, "/ls", auth) + assert.Equal(t, 200, status) + assert.Contains(t, body, "/api/time/date") + assert.Contains(t, body, "/api/test/param") + assert.Contains(t, body, "/s/") +} + +func TestPathTraversal(t *testing.T) { + // Attempting to escape apiDir must not execute an arbitrary script. + status, _ := get(t, "/api/../../../../etc/hostname", auth) + assert.NotEqual(t, 200, status, "path traversal must be rejected") +} diff --git a/router.go b/app/router.go similarity index 58% rename from router.go rename to app/router.go index 8969b86..e13100a 100644 --- a/router.go +++ b/app/router.go @@ -5,38 +5,34 @@ import ( "os" "github.com/gin-gonic/gin" - h "github.com/thbkrkr/go-apish/handlers" - m "github.com/thbkrkr/go-apish/middlewares" + "github.com/sirupsen/logrus" ) -var basicAuthUser = "zuperadmin" - func Router() *gin.Engine { router := gin.Default() - router.Use(m.CORSMiddleware()) + router.Use(CORSMiddleware()) - // Default routes + // Default routes (no auth: useful for health checks) router.GET("/", index) - router.GET("/favicon.ico", favicon) + router.GET("/version", version) // Authentication authorized := router.Group("/") if *password != "" { - authorized = router.Group("/", m.AuthMiddleware( + authorized = router.Group("/", AuthMiddleware( *apiKey, gin.Accounts{ - basicAuthUser: *password, + *user: *password, }, )) + } else { + logrus.Warn("no -password set: authentication is DISABLED and all endpoints are publicly accessible") } - // Version (commit and date) - authorized.GET("/version", version) - - lsHandler := &h.LsHandler{ApiDir: apiDir} - execHandler := &h.ExecHandler{ApiDir: apiDir} + lsHandler := &LsHandler{ApiDir: *apiDir} + execHandler := &ExecHandler{ApiDir: *apiDir} // List resources authorized.GET("/ls", func(c *gin.Context) { @@ -47,8 +43,6 @@ func Router() *gin.Engine { authorized.GET("/api/*path", execHandler.ExecScript) authorized.POST("/api/*path", execHandler.PostExecScript) - authorized.POST("/docker", h.DockerRun) - // Static files authorized.Static("/s/", *apiDir+"/_static") @@ -58,12 +52,10 @@ func Router() *gin.Engine { /** Base routes */ func indexExists() bool { - if _, err := os.Stat(*apiDir + "/_static/index.html"); err != nil { - if os.IsNotExist(err) { - return false - } - } - return true + // Any stat error (not found, permission, ...) means we can't serve it, + // so treat it as absent rather than redirecting to an unreadable file. + _, err := os.Stat(*apiDir + "/_static/index.html") + return err == nil } func index(c *gin.Context) { @@ -78,10 +70,6 @@ func index(c *gin.Context) { } } -func favicon(c *gin.Context) { - c.JSON(200, nil) -} - func version(c *gin.Context) { c.JSON(200, gin.H{ "git_commit": gitCommit, diff --git a/example/Dockerfile b/example/Dockerfile index be594c6..cabfede 100644 --- a/example/Dockerfile +++ b/example/Dockerfile @@ -1,3 +1,5 @@ FROM krkr/apish -COPY . / \ No newline at end of file +COPY . /api + +CMD ["/api/entrypoint.sh"] \ No newline at end of file diff --git a/example/Makefile b/example/Makefile index 7078514..e515539 100644 --- a/example/Makefile +++ b/example/Makefile @@ -1,9 +1,15 @@ build: - docker build --rm -t apish-example . + docker build --rm -t krkr/apish:example . -test: +push: + docker --config ~/.docker-config-krkr push krkr/apish:example + +run: docker run --rm -ti \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /usr/bin/docker:/usr/bin/docker \ - -p 80:4242 \ - apish-example + -p 4242:4242 \ + apish:example + +port-forward: + kubectl port-forward svc/apish 4242:80 diff --git a/example/api/_static/css/styles.css b/example/_static/css/styles.css similarity index 100% rename from example/api/_static/css/styles.css rename to example/_static/css/styles.css diff --git a/example/api/_static/date.html b/example/_static/date.html similarity index 96% rename from example/api/_static/date.html rename to example/_static/date.html index 729a8f5..098255d 100644 --- a/example/api/_static/date.html +++ b/example/_static/date.html @@ -22,7 +22,7 @@ - + - - - - - - - - diff --git a/example/api/docker/ps.sh b/example/api/docker/ps.sh deleted file mode 100755 index 73bf764..0000000 --- a/example/api/docker/ps.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -eu - -docker ps \ - --format '{"id":"{{.ID}}", "name": "{{.Names}}", - "created_at": "{{.CreatedAt}}", "status": "{{.Status}}", - "ports": "{{.Ports}}", "size":"{{.Size}}"}' \ - | jq -s . \ No newline at end of file diff --git a/example/api/test/inception/get.sh b/example/api/test/inception/get.sh deleted file mode 100755 index 2d31440..0000000 --- a/example/api/test/inception/get.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -set -eu - -c1="curl -s -m 1 172.17.42.1" -c2="curl -s -m 1 172.17.42.1/api/time/date" -c3='curl -s -m 1 -u zuperadmin:42 172.17.42.1/api/time/date' -c4='curl -s -m 1 -u zuperadmin:42 172.17.42.1/api/test/param?q=hello' - -w='{"status":"%{http_code}","time":"%{time_total}"}' - -slurp () { jq -s .; } - -echo '{ - "1": '$($c1 -w $w | slurp)', - "2": '$($c2 -w $w | slurp)', - "3": '$($c3 -w $w | slurp)', - "4": '$($c4 -w $w | slurp)' -}' diff --git a/example/api/test/param.sh b/example/api/test/param.sh deleted file mode 100755 index 94fbc40..0000000 --- a/example/api/test/param.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -set -eu - -echo '{ - "param": "'$1'" -}' diff --git a/example/api/time/date.sh b/example/api/time/date.sh deleted file mode 100755 index 8c55ba0..0000000 --- a/example/api/time/date.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -eu - -echo '{ - "date": '$(date +%s)', - "human_date": "'$(date)'" -}' - diff --git a/example/test/fail.sh b/example/test/fail.sh new file mode 100755 index 0000000..266bceb --- /dev/null +++ b/example/test/fail.sh @@ -0,0 +1,3 @@ +#!/bin/bash +echo "something went wrong" >&2 +exit 1 diff --git a/example/api/test/invalid-json.sh b/example/test/invalid-json.sh similarity index 100% rename from example/api/test/invalid-json.sh rename to example/test/invalid-json.sh diff --git a/example/test/param.sh b/example/test/param.sh new file mode 100755 index 0000000..6ea02d3 --- /dev/null +++ b/example/test/param.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +# Build JSON with jq so the parameter is always safely quoted/escaped, +# rather than interpolating it straight into the output. +jq -n --arg param "${1:-}" '{param: $param}' diff --git a/example/api/test/post.sh b/example/test/post.sh similarity index 60% rename from example/api/test/post.sh rename to example/test/post.sh index 172b5c9..0909569 100755 --- a/example/api/test/post.sh +++ b/example/test/post.sh @@ -3,5 +3,5 @@ IN="$(cat /dev/stdin)" echo '{ - "jackpot": '$(jq .o <<< $IN)' + "jackpot": '$(jq .key <<< $IN)' }' \ No newline at end of file diff --git a/example/time/date.sh b/example/time/date.sh new file mode 100755 index 0000000..921bab5 --- /dev/null +++ b/example/time/date.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +# Build JSON with jq so values are always correctly typed and escaped. +jq -n \ + --argjson date "$(date +%s)" \ + --arg human_date "$(date)" \ + '{date: $date, human_date: $human_date}' diff --git a/handlers/docker.go b/handlers/docker.go deleted file mode 100644 index 11cd584..0000000 --- a/handlers/docker.go +++ /dev/null @@ -1,48 +0,0 @@ -package handlers - -import ( - "encoding/json" - "os/exec" - "strings" - - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" -) - -// DockerRun makes possible the execution of any docker run command -func DockerRun(c *gin.Context) { - // Parse command in json body - var form struct { - Cmd string `json:"run"` - } - if err := c.BindJSON(&form); err != nil { - logrus.Error(err) - c.JSON(400, gin.H{"type": "error", "message": "Invalid docker run command"}) - return - } - - // Invalid empty command - if form.Cmd == "" { - c.JSON(400, gin.H{"type": "error", "message": "Docker run command empty"}) - return - } - - // Exec docker run - args := append([]string{"run"}, strings.Split(form.Cmd, " ")...) - output, err := exec.Command("docker", args...).CombinedOutput() - if err != nil { - message := err.Error() + ": " + strings.Replace(string(output), "\n", " ", -1) - c.JSON(400, gin.H{"type": "error", "message": message}) - return - } - - // Try to unmarshal the output to format json - var obj interface{} - err = json.Unmarshal(output, &obj) - if err == nil { - c.JSON(200, obj) - return - } - - c.String(200, string(output)) -} diff --git a/handlers/exec.go b/handlers/exec.go deleted file mode 100644 index 4a109a8..0000000 --- a/handlers/exec.go +++ /dev/null @@ -1,123 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - - "github.com/gin-gonic/gin" -) - -type ExecHandler struct { - ApiDir *string -} - -func (h *ExecHandler) ExecScript(c *gin.Context) { - var stdout []byte - var err error - - // Build script name - path := c.Param("path") - script := fmt.Sprintf("%s%s%s", *h.ApiDir, path, ".sh") - - // Check script exists - if _, err := os.Stat(script); os.IsNotExist(err) { - c.JSON(404, gin.H{ - "error": "Resource not found", - }) - fmt.Printf("[error] resource not found: %s", script) - return - } - - // Exec script with or without param - q := c.Request.URL.Query() - param, isParam := q["q"] - if isParam { - stdout, err = exec.Command(script, param[0]).Output() - } else { - stdout, err = exec.Command(script).Output() - } - - if err != nil { - serr := err.Error() - c.JSON(500, gin.H{ - "error": serr, - }) - log.Printf("[error] executing `%s`: %s", path, serr) - return - } - - // Try to unmarshal JSON - var someJson interface{} - err = json.Unmarshal(stdout, &someJson) - - if err != nil { - c.JSON(400, gin.H{ - "error": "Invalid JSON", - }) - log.Printf("[error] invalid JSON for `%s`: %s", script, stdout) - return - } - - c.JSON(200, someJson) - //log.Printf("[info] executing `%s`: %s", script, stdout) -} - -func (h *ExecHandler) PostExecScript(c *gin.Context) { - var stdout []byte - var err error - - // Build script name - path := c.Param("path") - script := fmt.Sprintf("%s%s%s", *h.ApiDir, path, ".sh") - - // Check script exists - if _, err := os.Stat(script); os.IsNotExist(err) { - c.JSON(404, gin.H{ - "error": "Resource not found", - }) - fmt.Printf("[error] resource not found: %s", script) - return - } - - // Exec script with or without body - - c1 := exec.Command(script) - - body := c.Request.Body - var buf bytes.Buffer - - c1.Stdin = body - c1.Stdout = &buf - _ = c1.Start() - _ = c1.Wait() - - if err != nil { - serr := err.Error() - c.JSON(500, gin.H{ - "error": serr, - }) - log.Printf("[error] executing `%s`: %s", path, serr) - return - } - - stdout = buf.Bytes() - - // Try to unmarshal JSON - var someJson interface{} - err = json.Unmarshal(stdout, &someJson) - - if err != nil { - c.JSON(400, gin.H{ - "error": "Invalid JSON", - }) - log.Printf("[error] invalid JSON for `%s`: %s", script, stdout) - return - } - - c.JSON(200, someJson) - //log.Printf("[info] executing `%s`: %s", script, stdout) -} diff --git a/handlers/ls.go b/handlers/ls.go deleted file mode 100644 index c3c2ba1..0000000 --- a/handlers/ls.go +++ /dev/null @@ -1,97 +0,0 @@ -package handlers - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" -) - -type LsHandler struct { - ApiDir *string -} - -type resources struct { - Scripts []string `json:"api"` - Pages []string `json:"html"` - Static []string `json:"static"` -} - -func (h *LsHandler) ListResources(c *gin.Context) { - scripts := make([]string, 0) - pages := make([]string, 0) - static := make([]string, 0) - - hostname := strings.Replace(c.Request.Host, "/", "", -1) - - // List scripts - err := filepath.Walk(*h.ApiDir, func(path string, f os.FileInfo, err error) error { - if strings.HasSuffix(path, "sh") && !strings.Contains(path, "_static") { - url := fileToUrl(hostname, "api", path, *h.ApiDir) - scripts = append(scripts, url) - } - return nil - }) - if err != nil { - c.JSON(500, gin.H{ - "error": err.Error(), - }) - return - } - - staticDir := "_static" - htmlDir := fmt.Sprintf("%s/%s", *h.ApiDir, staticDir) - - // List html files - err = filepath.Walk(htmlDir, func(path string, f os.FileInfo, err error) error { - if strings.HasSuffix(path, "html") { - url := fileToUrl(hostname, "s", path, *h.ApiDir+"/_static") - pages = append(pages, url) - } - return nil - }) - if err != nil { - c.JSON(500, gin.H{ - "error": err.Error(), - }) - return - } - - // List static files - err = filepath.Walk(htmlDir, func(path string, f os.FileInfo, err error) error { - if f != nil && !f.IsDir() && !strings.HasSuffix(path, "html") { - url := fileToUrl(hostname, "s", path, *h.ApiDir+"/_static") - static = append(static, url) - } - return nil - }) - if err != nil { - c.JSON(500, gin.H{ - "error": err.Error(), - }) - return - } - - c.JSON(200, resources{ - Scripts: scripts, - Pages: pages, - Static: static, - }) -} - -func fileToUrl(hostname string, prefix string, path string, apiDir string) string { - // Remove ./ from apiDir - apiDir = strings.Replace(apiDir, "./", "", -1) - // Replace $apiDir by prefix - filePath := strings.Replace(path, apiDir, prefix, -1) - baseUrl := fmt.Sprintf("http://%v", hostname) - - if strings.Contains(path, "_static") { - return fmt.Sprintf("%v/%v", baseUrl, filePath) - } else { - return fmt.Sprintf("%v/%v", baseUrl, strings.Replace(filePath, ".sh", "", -1)) - } - -} diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..6359e77 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: apish +description: REST API for shell scripts +version: 0.1.0 +appVersion: "latest" diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml new file mode 100644 index 0000000..37f4621 --- /dev/null +++ b/helm/templates/deployment.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} + labels: + app: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ .Release.Name }} + spec: + containers: + - name: apish + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - -port={{ .Values.apish.port }} + - -apiDir={{ .Values.apish.apiDir }} + - -user={{ .Values.apish.user }} + {{- if .Values.apish.password }} + - -password={{ .Values.apish.password }} + {{- end }} + {{- if .Values.apish.apiKey }} + - -apiKey={{ .Values.apish.apiKey }} + {{- end }} + ports: + - containerPort: {{ .Values.apish.port }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml new file mode 100644 index 0000000..9e3199b --- /dev/null +++ b/helm/templates/service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} +spec: + selector: + app: {{ .Release.Name }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.apish.port }} diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..29ae7d4 --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,16 @@ +replicaCount: 1 + +image: + repository: krkr/apish + tag: example + pullPolicy: Always + +apish: + port: 4242 + apiDir: /api + user: zuperadmin + password: "" + apiKey: "42" + +service: + port: 80 diff --git a/main_test.go b/main_test.go deleted file mode 100644 index b6f329a..0000000 --- a/main_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "io" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - - test "github.com/thbkrkr/go-apish/test" -) - -var ( - server *httptest.Server - reader io.Reader //Ignore this for now -) - -var auth = &test.BasicAuth{"zuperadmin", "42"} - -func init() { - gin.SetMode(gin.TestMode) - *apiDir = "example/api" - *password = "42" - server = httptest.NewServer(Router()) - - test.ServerURL = server.URL -} - -func TestBase(t *testing.T) { - test.PrefixURL = "" - status, _ := test.Get(t, "/", nil) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/favicon.ico", nil) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/blablabla", nil) - assert.Equal(t, 404, status, "should get a 404") -} - -func TestAuthentication(t *testing.T) { - status, _ := test.Get(t, "/api/time/date", nil) - assert.Equal(t, 401, status, "should get a 401") - - auth := &test.BasicAuth{"zuperadmin", "42"} - status, _ = test.Get(t, "/api/time/date", auth) - assert.Equal(t, 200, status, "should get a 200") - - apiKey := new(string) - *apiKey = "42" - status, _ = test.Get2(t, "/api/time/date", apiKey) - assert.Equal(t, 200, status, "should get a 200") -} - -func TestScripts(t *testing.T) { - status, _ := test.Get(t, "/api/nothing", auth) - assert.Equal(t, 404, status, "should get a 404") - - status, _ = test.Get(t, "/api/time/date", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/api/test/param?q=hello", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/api/time/date", auth) - assert.Equal(t, 200, status, "should get a 200") -} - -func TestPages(t *testing.T) { - status, _ := test.Get(t, "/s/", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/s/date.html", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/s/css/styles.css", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/s/css/styles.css", auth) - assert.Equal(t, 200, status, "should get a 200") - - status, _ = test.Get(t, "/s/js/script.js", auth) - assert.Equal(t, 200, status, "should get a 200") -} diff --git a/middlewares/auth.go b/middlewares/auth.go deleted file mode 100644 index 4611da2..0000000 --- a/middlewares/auth.go +++ /dev/null @@ -1,19 +0,0 @@ -package middlewares - -import "github.com/gin-gonic/gin" - -var AuthHeaderKey = "X-Auth" - -func AuthMiddleware(apiKey string, accounts gin.Accounts) gin.HandlerFunc { - basicAuth := gin.BasicAuthForRealm(accounts, "") - - return func(c *gin.Context) { - // Try header auth - if c.Request.Header.Get(AuthHeaderKey) == apiKey { - return - } else { - // Try basic auth - basicAuth(c) - } - } -} diff --git a/middlewares/cors.go b/middlewares/cors.go deleted file mode 100644 index 95abe34..0000000 --- a/middlewares/cors.go +++ /dev/null @@ -1,21 +0,0 @@ -package middlewares - -import "github.com/gin-gonic/gin" - -func CORSMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - domain := "*" - c.Writer.Header().Set("Access-Control-Allow-Origin", domain) - c.Writer.Header().Set("Access-Control-Max-Age", "86400") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") - c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length") - c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(200) - } else { - c.Next() - } - } -} diff --git a/test/test.go b/test/test.go deleted file mode 100644 index 03a75ef..0000000 --- a/test/test.go +++ /dev/null @@ -1,61 +0,0 @@ -package test - -import ( - "fmt" - "io/ioutil" - "net/http" - "strings" - "testing" -) - -var ( - ServerURL string - PrefixURL string -) - -type BasicAuth struct { - Username string - Password string -} - -func MakeHttp(t *testing.T, verb string, path string, json string, auth *BasicAuth, apiKey *string) (int, string) { - reader := strings.NewReader(json) - - url := fmt.Sprintf("%s%s%s", ServerURL, path, PrefixURL) - req, err := http.NewRequest(verb, url, reader) - - if auth != nil { - req.SetBasicAuth(auth.Username, auth.Password) - } - if apiKey != nil { - req.Header.Set("X-Auth", *apiKey) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Error(err) - } - - if resp.Body == nil { - t.Error(err) - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - t.Error(err) - } - - return resp.StatusCode, string(body) -} - -func Get(t *testing.T, path string, auth *BasicAuth) (int, string) { - return MakeHttp(t, "GET", path, "", auth, nil) -} - -func Get2(t *testing.T, path string, apiKey *string) (int, string) { - return MakeHttp(t, "GET", path, "", nil, apiKey) -} - -func Post(t *testing.T, path string, json string, auth *BasicAuth) (int, string) { - return MakeHttp(t, "POST", path, json, auth, nil) -}