Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@
/cert.pem
/key.pem
/pipe.fifo
/basic-auth-secret.txt
/basic-auth-secret.txt
/basic-auth-hash.txt
56 changes: 31 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand All @@ -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
```
Expand All @@ -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
```

---
Expand All @@ -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
```

---
Expand All @@ -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
```

---
Expand Down
4 changes: 2 additions & 2 deletions systemapi-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions systemapi/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package systemapi

const (
HeaderAccept = "Accept"
HeaderContentType = "Content-Type"
HeaderWWWAuthenticate = "WWW-Authenticate"

MediaTypeJSON = "application/json"
MediaTypeOctetStream = "application/octet-stream"
)
11 changes: 10 additions & 1 deletion systemapi/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
)
Expand Down Expand Up @@ -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",
}
Comment on lines +54 to +57
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The httpErrorResp struct is referenced here but not defined in this file. This creates a dependency on server.go that should be avoided. Consider moving the struct to a shared location like constants.go or creating a dedicated types file.

Copilot uses AI. Check for mistakes.

if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "", http.StatusInternalServerError)
}
}
59 changes: 47 additions & 12 deletions systemapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}

Expand All @@ -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) {
Expand All @@ -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
}

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

Expand All @@ -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
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nolint comment suggests awareness of a performance issue with fmt.Sprint. Consider using strconv.Itoa(len(content)) instead, which is more efficient for integer-to-string conversion.

Copilot uses AI. Check for mistakes.

})
}

// 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
Expand Down Expand Up @@ -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",
})
}