From 7b4536d6b7873805de72f907427e335249619a3b Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Tue, 18 Mar 2025 17:45:52 +0100 Subject: [PATCH] more JSON responses --- .gitignore | 3 ++- README.md | 56 +++++++++++++++++++++----------------- systemapi-config.toml | 4 +-- systemapi/constants.go | 10 +++++++ systemapi/middleware.go | 11 +++++++- systemapi/server.go | 59 ++++++++++++++++++++++++++++++++--------- 6 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 systemapi/constants.go diff --git a/.gitignore b/.gitignore index 508c1a8..2780d07 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ /cert.pem /key.pem /pipe.fifo -/basic-auth-secret.txt \ No newline at end of file +/basic-auth-secret.txt +/basic-auth-hash.txt \ No newline at end of file diff --git a/README.md b/README.md index 98bac9c..01bea46 100644 --- a/README.md +++ b/README.md @@ -25,27 +25,33 @@ Notes: ## Getting started ```bash -# start the server +# Start the server make run -# add events +# Add events echo "hello world" > pipe.fifo -curl localhost:3535/api/v1/new_event?message=this+is+a+test +curl --insecure https://localhost:3535/api/v1/new_event?message=this+is+a+test -# execute actions -curl -v localhost:3535/api/v1/actions/echo_test +# Execute actions +curl --insecure https://localhost:3535/api/v1/actions/echo_test -# upload files -curl -v --data-binary "@README.md" localhost:3535/api/v1/file-upload/testfile +# Upload files +curl --insecure --data-binary "@README.md" https://localhost:3535/api/v1/file-upload/testfile -# get event log -curl localhost:3535/logs +# Get event log +curl --insecure https://localhost:3535/logs 2024-11-05T22:03:23Z hello world 2024-11-05T22:03:26Z this is a test 2024-11-05T22:03:29Z [system-api] executing action: echo_test = echo test 2024-11-05T22:03:29Z [system-api] executing action success: echo_test = echo test 2024-11-05T22:03:31Z [system-api] file upload: testfile = /tmp/testfile.txt 2024-11-05T22:03:31Z [system-api] file upload success: testfile = /tmp/testfile.txt - content: 1991 bytes + +# Set basic auth secret +curl --insecure --data "foobar" https://localhost:3535/api/v1/set-basic-auth + +# Get logs with basic auth (otherwise will be rejected with Unauthorized) +curl --insecure --user admin:foobar https://localhost:3535/logs ``` --- @@ -56,14 +62,14 @@ Events can be added via local named pipe (i.e. file `pipe.fifo`) or through HTTP ```bash # Start the server -$ go run cmd/system-api/main.go +$ make run # Add events $ echo "hello world" > pipe.fifo -$ curl localhost:3535/api/v1/new_event?message=this+is+a+test +$ curl --insecure https://localhost:3535/api/v1/new_event?message=this+is+a+test # Query events (plain text or JSON is supported) -$ curl localhost:3535/logs +$ curl --insecure https://localhost:3535/logs 2024-10-23T12:04:01Z hello world 2024-10-23T12:04:07Z this is a test ``` @@ -79,10 +85,10 @@ Actions are recorded in the event log. ```bash # Start the server -$ go run cmd/system-api/main.go --config systemapi-config.toml +$ make run # Execute the example action -$ curl -v localhost:3535/api/v1/actions/echo_test +$ curl --insecure https://localhost:3535/api/v1/actions/echo_test ``` --- @@ -95,10 +101,10 @@ File uploads are recorded in the event log. ```bash # Start the server -$ go run cmd/system-api/main.go --config systemapi-config.toml +$ make run -# Execute the example action -$ curl -v --data-binary "@README.md" localhost:3535/api/v1/file-upload/testfile +# Upload the file +$ curl --insecure --data-binary "@README.md" https://localhost:3535/api/v1/file-upload/testfile ``` --- @@ -124,28 +130,28 @@ Example: cat systemapi-config.toml # Start the server -go run cmd/system-api/main.go --config systemapi-config.toml +make run # Initially, requests are unauthenticated -curl -v --insecure https://localhost:3535/livez +curl --insecure https://localhost:3535/livez # Set the basic auth secret. From here on, authentication is required for all API requests. -curl -v --insecure --data "foobar" https://localhost:3535/api/v1/set-basic-auth +curl --insecure --data "foobar" https://localhost:3535/api/v1/set-basic-auth # Check that hash was written to the file -cat basic-auth-secret.txt +cat basic-auth-hash.txt # API calls with no basic auth credentials are provided fail now, with '401 Unauthorized' because -curl -v --insecure https://localhost:3535/livez +curl --insecure https://localhost:3535/livez # API calls work if correct basic auth credentials are provided -curl -v --user admin:foobar https://localhost:3535/livez +curl --insecure --user admin:foobar https://localhost:3535/livez # The update also shows up in the logs -curl --user admin:foobar https://localhost:3535/logs +curl --insecure --user admin:foobar https://localhost:3535/logs # You can also update the basic auth secret: -curl -v --insecure --user admin:foobar --data "new_secret" https://localhost:3535/api/v1/set-basic-auth +curl --insecure --user admin:foobar --data "new_secret" https://localhost:3535/api/v1/set-basic-auth ``` --- diff --git a/systemapi-config.toml b/systemapi-config.toml index 03c222e..e52dbbc 100644 --- a/systemapi-config.toml +++ b/systemapi-config.toml @@ -9,8 +9,8 @@ log_debug = true log_max_entries = 1000 # HTTP Basic Auth -basic_auth_secret_path = "basic-auth-secret.txt" # basic auth is supported if a path is provided -basic_auth_secret_salt = "D;%yL9TS:5PalS/d" # use a random string for the salt +basic_auth_secret_path = "basic-auth-hash.txt" # basic auth is supported if a path is provided +basic_auth_secret_salt = "D;%yL9TS:5PalS/d" # use a random string for the salt # HTTP server timeouts # http_read_timeout_ms = 2500 diff --git a/systemapi/constants.go b/systemapi/constants.go new file mode 100644 index 0000000..d86b63a --- /dev/null +++ b/systemapi/constants.go @@ -0,0 +1,10 @@ +package systemapi + +const ( + HeaderAccept = "Accept" + HeaderContentType = "Content-Type" + HeaderWWWAuthenticate = "WWW-Authenticate" + + MediaTypeJSON = "application/json" + MediaTypeOctetStream = "application/octet-stream" +) diff --git a/systemapi/middleware.go b/systemapi/middleware.go index c7ee1d7..d211a74 100644 --- a/systemapi/middleware.go +++ b/systemapi/middleware.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/hex" + "encoding/json" "fmt" "net/http" ) @@ -47,6 +48,14 @@ func BasicAuth(realm, salt string, getHashedCredentials func() map[string]string } func basicAuthFailed(w http.ResponseWriter, realm string) { - w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm)) + w.Header().Set(HeaderWWWAuthenticate, fmt.Sprintf(`Basic realm="%s"`, realm)) + w.Header().Set(HeaderContentType, MediaTypeJSON) w.WriteHeader(http.StatusUnauthorized) + resp := httpErrorResp{ + Code: http.StatusUnauthorized, + Message: "Unauthorized", + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "", http.StatusInternalServerError) + } } diff --git a/systemapi/server.go b/systemapi/server.go index 2e4d11c..c37d7f0 100644 --- a/systemapi/server.go +++ b/systemapi/server.go @@ -39,6 +39,11 @@ type Server struct { basicAuthHash string } +type httpErrorResp struct { + Code int `json:"code"` + Message string `json:"message"` +} + func NewServer(log *httplog.Logger, cfg *SystemAPIConfig) (server *Server, err error) { server = &Server{ cfg: cfg, @@ -217,7 +222,9 @@ func (s *Server) Shutdown(ctx context.Context) error { } func (s *Server) handleLivenessCheck(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + s.respondOKJSON(w, map[string]string{ + "status": "ok", + }) } func (s *Server) addEvent(event Event) { @@ -284,6 +291,25 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (s *Server) respondErrorJSON(w http.ResponseWriter, code int, message string) { + w.Header().Set(HeaderContentType, MediaTypeJSON) + w.WriteHeader(code) + resp := httpErrorResp{code, message} + if err := json.NewEncoder(w).Encode(resp); err != nil { + s.log.With("response", resp, "error", err).Error("could not write error response") + http.Error(w, "", http.StatusInternalServerError) + } +} + +func (s *Server) respondOKJSON(w http.ResponseWriter, response any) { + w.Header().Set(HeaderContentType, MediaTypeJSON) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + s.log.With("response", response, "error", err).Error("could not write OK response") + http.Error(w, "", http.StatusInternalServerError) + } +} + func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { s.writeEventsAsText(w) } @@ -293,13 +319,13 @@ func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { s.log.Info("Received action", "action", action) if s.cfg == nil { - w.WriteHeader(http.StatusNotImplemented) + s.respondErrorJSON(w, http.StatusNotImplemented, "Action not configured") return } cmd, ok := s.cfg.Actions[action] if !ok { - w.WriteHeader(http.StatusBadRequest) + s.respondErrorJSON(w, http.StatusBadRequest, "Specified action not configured") return } @@ -310,13 +336,15 @@ func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { if err != nil { s.log.Error("Failed to execute action", "action", action, "cmd", cmd, "err", err, "stderr", stderr) s.addInternalEvent("error executing action: " + action + " - error: " + err.Error() + " (stderr: " + stderr + ")") - w.WriteHeader(http.StatusInternalServerError) + s.respondErrorJSON(w, http.StatusInternalServerError, "Failed to execute action: "+action+" - error: "+err.Error()) return } s.log.Info("Action executed", "action", action, "cmd", cmd, "stdout", stdout, "stderr", stderr) s.addInternalEvent("executing action success: " + action + " = " + cmd) - w.WriteHeader(http.StatusOK) + s.respondOKJSON(w, map[string]string{ + "message": "Action executed successfully", + }) } func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { @@ -325,13 +353,13 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { log.Info("Receiving file upload") if s.cfg == nil { - w.WriteHeader(http.StatusNotImplemented) + s.respondErrorJSON(w, http.StatusNotImplemented, "File upload not configured") return } filename, ok := s.cfg.FileUploads[fileArg] if !ok { - w.WriteHeader(http.StatusBadRequest) + s.respondErrorJSON(w, http.StatusBadRequest, "Specified file upload not configured") return } @@ -343,7 +371,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { if err != nil { log.Error("Failed to read content from payload", "err", err) s.addInternalEvent("file upload error (failed to read): " + fileArg + " = " + filename + " - error: " + err.Error()) - w.WriteHeader(http.StatusInternalServerError) + s.respondErrorJSON(w, http.StatusInternalServerError, "Failed to read content from payload") return } @@ -354,17 +382,22 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { if err != nil { log.Error("Failed to write content to file", "err", err) s.addInternalEvent("file upload error (failed to write): " + fileArg + " = " + filename + " - error: " + err.Error()) - w.WriteHeader(http.StatusInternalServerError) + s.respondErrorJSON(w, http.StatusInternalServerError, "Failed to write content to file") return } log.Info("File uploaded") s.addInternalEvent(fmt.Sprintf("file upload success: %s = %s - content: %d bytes", fileArg, filename, len(content))) - w.WriteHeader(http.StatusOK) + s.respondOKJSON(w, map[string]string{ + "message": "File uploaded successfully", + "file": filename, + "size_bytes": fmt.Sprint(len(content)), //nolint:perfsprint + }) } +// getBasicAuthHashedCredentials returns the hashed credentials for the basic auth middleware (on every request). +// It is dynamic because the secret can be set/updated during runtime. func (s *Server) getBasicAuthHashedCredentials() map[string]string { - // dynamic because can be set at runtime hashedCredentials := make(map[string]string) if s.basicAuthHash != "" { hashedCredentials["admin"] = s.basicAuthHash @@ -404,5 +437,7 @@ func (s *Server) handleSetBasicAuthCreds(w http.ResponseWriter, r *http.Request) s.basicAuthHash = secretHash s.log.Info("Basic auth secret updated") s.addInternalEvent("basic auth secret updated. new hash: " + secretHash) - w.WriteHeader(http.StatusOK) + s.respondOKJSON(w, map[string]string{ + "message": "Basic auth secret updated", + }) }