diff --git a/internal/clientcache/cmd/daemon/start.go b/internal/clientcache/cmd/daemon/start.go index d0e76b2c5d..bde1ee488c 100644 --- a/internal/clientcache/cmd/daemon/start.go +++ b/internal/clientcache/cmd/daemon/start.go @@ -201,6 +201,7 @@ func (c *StartCommand) Run(args []string) int { LogWriter: io.MultiWriter(writers...), LogFileName: logFileName, DotDirectory: dotDir, + RunningInBackground: os.Getenv(backgroundEnvName) == backgroundEnvVal, } srv, err := daemon.New(ctx, cfg) diff --git a/internal/clientcache/cmd/daemon/status.go b/internal/clientcache/cmd/daemon/status.go index 4e50720f5c..e8be597f83 100644 --- a/internal/clientcache/cmd/daemon/status.go +++ b/internal/clientcache/cmd/daemon/status.go @@ -144,6 +144,9 @@ func printStatusTable(status *daemon.StatusResult) string { if len(status.LogLocation) > 0 { nonAttributeMap["Log Location"] = status.LogLocation } + if len(status.Version) > 0 { + nonAttributeMap["Version"] = status.Version + } if status.Uptime > 0 { nonAttributeMap["Uptime"] = status.Uptime.Round(time.Second) } diff --git a/internal/clientcache/internal/daemon/log_handler.go b/internal/clientcache/internal/daemon/log_handler.go new file mode 100644 index 0000000000..2a973731a8 --- /dev/null +++ b/internal/clientcache/internal/daemon/log_handler.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/hashicorp/boundary/internal/event" +) + +// LogRequest is the request body to this handler. +type LogRequest struct { + // Message is a required field for all requests + Message string `json:"message,omitempty"` + Op string `json:"op,omitempty"` +} + +// newLogHandlerFunc creates a handler that logs a system event using the +// daemon's eventer. +func newLogHandlerFunc(ctx context.Context) (http.HandlerFunc, error) { + const op = "daemon.newLogHandlerFunc" + + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, "only method POST allowed", http.StatusMethodNotAllowed) + } + + var perReq LogRequest + data, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, "unable to read request body", http.StatusBadRequest) + return + } + if err := json.Unmarshal(data, &perReq); err != nil { + // If, for whatever reason, we can't parse the request body as json + // can still log that the request to log was received and print out + // the body of the request. + event.WriteError(ctx, op, err, event.WithInfo("body", string(data))) + writeError(w, "unable to parse request body", http.StatusBadRequest) + return + } + + event.WriteSysEvent(ctx, op, perReq.Message, "requester_op", perReq.Op) + w.WriteHeader(http.StatusNoContent) + }, nil +} diff --git a/internal/clientcache/internal/daemon/log_handler_test.go b/internal/clientcache/internal/daemon/log_handler_test.go new file mode 100644 index 0000000000..7abaf8d257 --- /dev/null +++ b/internal/clientcache/internal/daemon/log_handler_test.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/eventlogger/formatter_filters/cloudevents" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogHandler(t *testing.T) { + ctx := context.Background() + lh, err := newLogHandlerFunc(ctx) + require.NoError(t, err) + expectedErroringMux := http.NewServeMux() + expectedErroringMux.HandleFunc("/v1/log", lh) + + t.Run("get", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/log", nil) + expectedErroringMux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Result().StatusCode) + }) + + t.Run("delete", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/v1/log", nil) + expectedErroringMux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Result().StatusCode) + }) + + t.Run("success", func(t *testing.T) { + c := event.TestEventerConfig(t, "TestLogHandler_success") + testLock := &sync.Mutex{} + testLogger := hclog.New(&hclog.LoggerOptions{ + Mutex: testLock, + Name: "test", + JSONFormat: true, + }) + require.NoError(t, event.InitSysEventer(testLogger, testLock, "TestLogHandler_success", event.WithEventerConfig(&c.EventerConfig))) + ctx, err := event.NewEventerContext(context.Background(), event.SysEventer()) + require.NoError(t, err) + + lh, err := newLogHandlerFunc(ctx) + require.NoError(t, err) + mux := http.NewServeMux() + mux.HandleFunc("/v1/log", lh) + + rec := httptest.NewRecorder() + b, err := json.Marshal(&LogRequest{ + Message: "test message", + Op: "test op", + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/v1/log", bytes.NewReader(b)) + mux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNoContent, rec.Result().StatusCode) + + sinkFileName := c.AllEvents.Name() + defer func() { _ = os.WriteFile(sinkFileName, nil, 0o666) }() + b, err = os.ReadFile(sinkFileName) + require.NoError(t, err) + gotEvent := &cloudevents.Event{} + err = json.Unmarshal(b, gotEvent) + require.NoError(t, err) + + gotData := gotEvent.Data.(map[string]any)["data"].(map[string]any) + assert.Equal(t, "test message", gotData["msg"]) + assert.Equal(t, "test op", gotData["requester_op"]) + }) + + t.Run("success failed unmarshaling", func(t *testing.T) { + c := event.TestEventerConfig(t, "TestLogHandler_success") + testLock := &sync.Mutex{} + testLogger := hclog.New(&hclog.LoggerOptions{ + Mutex: testLock, + Name: "test", + JSONFormat: true, + }) + require.NoError(t, event.InitSysEventer(testLogger, testLock, "TestLogHandler_success", event.WithEventerConfig(&c.EventerConfig))) + ctx, err := event.NewEventerContext(context.Background(), event.SysEventer()) + require.NoError(t, err) + + lh, err := newLogHandlerFunc(ctx) + require.NoError(t, err) + mux := http.NewServeMux() + mux.HandleFunc("/v1/log", lh) + + rec := httptest.NewRecorder() + b := []byte("not json") + req := httptest.NewRequest(http.MethodPost, "/v1/log", bytes.NewReader(b)) + mux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Result().StatusCode) + + sinkFileName := c.AllEvents.Name() + defer func() { _ = os.WriteFile(sinkFileName, nil, 0o666) }() + b, err = os.ReadFile(sinkFileName) + require.NoError(t, err) + gotEvent := &cloudevents.Event{} + err = json.Unmarshal(b, gotEvent) + require.NoError(t, err) + + gotData := gotEvent.Data.(map[string]any) + assert.NotEmpty(t, gotData["error"]) + assert.Equal(t, "not json", gotData["info"].(map[string]any)["body"]) + }) +} diff --git a/internal/clientcache/internal/daemon/server.go b/internal/clientcache/internal/daemon/server.go index 506c9da1a5..e83fef9844 100644 --- a/internal/clientcache/internal/daemon/server.go +++ b/internal/clientcache/internal/daemon/server.go @@ -81,6 +81,7 @@ type Config struct { LogWriter io.Writer LogFileName string DotDirectory string + RunningInBackground bool // The amount of time since the last refresh that must have passed for a // search query to trigger an inline refresh. MaxSearchStaleness time.Duration @@ -286,28 +287,34 @@ func (s *CacheServer) Serve(ctx context.Context, cmd Commander, opt ...Option) e if err != nil { return errors.Wrap(ctx, err, op) } - mux.Handle("/v1/search", versionEnforcement(searchFn)) + mux.Handle("/v1/search", serverMetadataInterceptor(searchFn, s.conf.RunningInBackground)) statusFn, err := newStatusHandlerFunc(ctx, repo, l.Addr().String(), s.conf.LogFileName) if err != nil { return errors.Wrap(ctx, err, op) } - mux.Handle("/v1/status", versionEnforcement(statusFn)) + mux.Handle("/v1/status", serverMetadataInterceptor(statusFn, s.conf.RunningInBackground)) + + logHandlerFn, err := newLogHandlerFunc(ctx) + if err != nil { + return errors.Wrap(ctx, err, op) + } + mux.Handle("/v1/log", serverMetadataInterceptor(logHandlerFn, s.conf.RunningInBackground)) tokenFn, err := newTokenHandlerFunc(ctx, repo, tic) if err != nil { return errors.Wrap(ctx, err, op) } - mux.Handle("/v1/tokens", versionEnforcement(tokenFn)) + mux.Handle("/v1/tokens", serverMetadataInterceptor(tokenFn, s.conf.RunningInBackground)) stopFn, err := newStopHandlerFunc(ctx, s.conf.ContextCancel) if err != nil { return errors.Wrap(ctx, err, op) } - mux.Handle("/v1/stop", versionEnforcement(stopFn)) + mux.Handle("/v1/stop", serverMetadataInterceptor(stopFn, s.conf.RunningInBackground)) // Return custom 404 message when requests don't map to any known path. - mux.Handle("/", new404Func(ctx)) + mux.Handle("/", serverMetadataInterceptor(new404Func(ctx), s.conf.RunningInBackground)) logger, err := event.SysEventer().StandardLogger(ctx, "daemon.serve: ", event.ErrorType) if err != nil { diff --git a/internal/clientcache/internal/daemon/status_handler.go b/internal/clientcache/internal/daemon/status_handler.go index 562cd8d8a6..d96ff9544f 100644 --- a/internal/clientcache/internal/daemon/status_handler.go +++ b/internal/clientcache/internal/daemon/status_handler.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/boundary/internal/clientcache/internal/cache" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/version" ) // RefreshTokenStatus is the status of a resource token @@ -69,6 +70,7 @@ type StatusResult struct { Uptime time.Duration `json:"uptime,omitempty"` SocketAddress string `json:"socket_address,omitempty"` LogLocation string `json:"log_location,omitempty"` + Version string `json:"version,omitempty"` Users []UserStatus `json:"users,omitempty"` } @@ -123,6 +125,7 @@ func toApiStatus(in *cache.Status, started time.Time, socketAddr, logLocation st out := &StatusResult{ Uptime: time.Since(started), SocketAddress: socketAddr, + Version: version.Get().FullVersionNumber(true), LogLocation: logLocation, } diff --git a/internal/clientcache/internal/daemon/version_interceptor.go b/internal/clientcache/internal/daemon/version_interceptor.go index 5bda623516..2b07a016d5 100644 --- a/internal/clientcache/internal/daemon/version_interceptor.go +++ b/internal/clientcache/internal/daemon/version_interceptor.go @@ -4,7 +4,6 @@ package daemon import ( - "fmt" "net/http" "github.com/hashicorp/boundary/internal/util" @@ -13,23 +12,24 @@ import ( const ( VersionHeaderKey = "boundary_version" + BackgroundKey = "background" ) -// versionEnforcement is an interceptor which, if the boundary version is included -// in a request, enforces that it matches the version of the daemon currently -// running. If no version is provided, the inteceptor passes the request through. -func versionEnforcement(h http.Handler) http.Handler { +// serverMetadataInterceptor is an interceptor which attaches the daemon's version +// number to all responses that it intercepts +func serverMetadataInterceptor(h http.Handler, inBackground bool) http.Handler { if util.IsNil(h) { return nil } + background := "false" + if inBackground { + background = "true" + } needVer := version.Get().VersionNumber() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotVer := r.Header.Get(VersionHeaderKey) - if gotVer != "" && needVer != gotVer { - writeError(w, fmt.Sprintf("Version mismatch between requester (%q) and daemon (%q). You may need to restart your daemon.", gotVer, needVer), http.StatusBadRequest) - return - } + w.Header().Add(BackgroundKey, background) + w.Header().Add(VersionHeaderKey, needVer) h.ServeHTTP(w, r) }) } diff --git a/internal/clientcache/internal/daemon/version_interceptor_test.go b/internal/clientcache/internal/daemon/version_interceptor_test.go deleted file mode 100644 index d157b91270..0000000000 --- a/internal/clientcache/internal/daemon/version_interceptor_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package daemon - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/hashicorp/boundary/version" - "github.com/stretchr/testify/assert" -) - -func TestVersionEnforcement(t *testing.T) { - var called bool - calledHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - w.WriteHeader(http.StatusNoContent) - }) - h := versionEnforcement(calledHandler) - - t.Run("no version provided", func(t *testing.T) { - called = false - req := httptest.NewRequest("", "/test", nil) - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - assert.True(t, called) - assert.Equal(t, http.StatusNoContent, w.Result().StatusCode) - }) - - t.Run("bad version provided", func(t *testing.T) { - called = false - req := httptest.NewRequest("", "/test", nil) - req.Header.Set(VersionHeaderKey, "badversion") - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - assert.False(t, called) - assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) - }) - - t.Run("correct version provided", func(t *testing.T) { - called = false - req := httptest.NewRequest("", "/test", nil) - req.Header.Set(VersionHeaderKey, version.Get().VersionNumber()) - w := httptest.NewRecorder() - h.ServeHTTP(w, req) - assert.True(t, called) - assert.Equal(t, http.StatusNoContent, w.Result().StatusCode) - }) -}