diff --git a/api/client.go b/api/client.go index c4e84f0b6..cfb20229e 100644 --- a/api/client.go +++ b/api/client.go @@ -16,7 +16,6 @@ import ( "net/url" "os" "path" - "path/filepath" "strconv" "strings" "sync" @@ -58,12 +57,7 @@ type Config struct { // HttpClient. Addr string - // Set when the Addr is a unix socket. The parsing of this field takes - // a filename approach instead of a URL approach since parsing file names - // as a URL may error out on some platforms. - socketAddr string - - // Token is the client token that results from authentication and can be + // Token is the client token that reuslts from authentication and can be // used to make calls into Boundary Token string @@ -260,16 +254,6 @@ func (c *Config) ConfigureTLS() error { // This also removes any trailing "/v1"; we'll use that in our commands so we // don't require it from users. func (c *Config) setAddr(addr string) error { - if strings.HasPrefix(addr, "unix://") { - c.socketAddr = strings.TrimPrefix(addr, "unix://") - c.socketAddr = filepath.Clean(c.socketAddr) - - // Update the specified address to the cleaned up version - c.Addr = fmt.Sprintf("unix://%s", c.socketAddr) - return nil - } - c.socketAddr = "" - u, err := url.Parse(addr) if err != nil { return fmt.Errorf("error parsing address: %w", err) @@ -598,7 +582,6 @@ func (c *Client) Clone() *Client { config := c.config newConfig := &Config{ - socketAddr: config.socketAddr, Addr: config.Addr, Token: config.Token, RecoveryKmsWrapper: config.RecoveryKmsWrapper, @@ -662,29 +645,31 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod } c.modifyLock.RLock() + addr := c.config.Addr srvLookup := c.config.SRVLookup token := c.config.Token httpClient := c.config.HttpClient headers := copyHeaders(c.config.Headers) c.modifyLock.RUnlock() - var hostHeader string - var u *url.URL - switch { - case c.config.socketAddr != "": + u, err := url.Parse(addr) + if err != nil { + return nil, err + } + + if strings.HasPrefix(addr, "unix://") { + socket := strings.TrimPrefix(addr, "unix://") transport := httpClient.Transport.(*http.Transport) transport.DialContext = func(context.Context, string, string) (net.Conn, error) { dialer := net.Dialer{} - return dialer.DialContext(ctx, "unix", c.config.socketAddr) + return dialer.DialContext(ctx, "unix", socket) } // Since the address points to a unix domain socket, the scheme in the // *URL would be set to `unix`. The *URL in the client is expected to // be pointing to the protocol used in the application layer and not to // the transport layer. Hence, setting the fields accordingly. - u = &url.URL{} u.Scheme = "http" - u.Host = c.config.socketAddr u.Path = "" // Go 1.21.0 introduced strict host header validation for clients. @@ -695,17 +680,7 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod // that isn't the actual unix domain socket address. Following // Docker's lead (https://github.com/moby/moby/pull/45942), // use a localhost TLD. - hostHeader = "api.boundary.localhost" - case c.config.Addr != "": - addr := c.config.Addr - var err error - u, err = url.Parse(addr) - if err != nil { - return nil, err - } - hostHeader = u.Host - default: - return nil, errors.New("no valid address discovered") + u.Host = "api.boundary.localhost" } host := u.Host @@ -732,9 +707,8 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod Host: host, Path: path.Join(u.Path, "/v1/", requestPath), }, - Host: hostHeader, + Host: u.Host, } - req.Header = headers req.Header.Set("authorization", "Bearer "+token) req.Header.Set("content-type", "application/json") diff --git a/api/client_test.go b/api/client_test.go index 3ef9a74f3..a886bf322 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -11,70 +11,60 @@ import ( func TestConfigSetAddress(t *testing.T) { type test struct { - name string - input string - address string - socketAddr string + name string + input string + address string + err string } tests := []test{ { - name: "bare", - input: "http://127.0.0.1:9200", - address: "http://127.0.0.1:9200", + "bare", + "http://127.0.0.1:9200", + "http://127.0.0.1:9200", + "", }, { - name: "bare with version", - input: "http://127.0.0.1:9200/v1", - address: "http://127.0.0.1:9200", + "bare with version", + "http://127.0.0.1:9200/v1", + "http://127.0.0.1:9200", + "", }, { - name: "bare with version and trailing slash", - input: "http://127.0.0.1:9200/v1/", - address: "http://127.0.0.1:9200", + "bare with version and trailing slash", + "http://127.0.0.1:9200/v1/", + "http://127.0.0.1:9200", + "", }, { - name: "valid top level scope", - input: "http://127.0.0.1:9200/v1/scopes", - address: "http://127.0.0.1:9200", + "valid top level scope", + "http://127.0.0.1:9200/v1/scopes", + "http://127.0.0.1:9200", + "", }, { - name: "valid scope", - input: "http://127.0.0.1:9200/v1/scopes/scopeid", - address: "http://127.0.0.1:9200", + "valid scope", + "http://127.0.0.1:9200/v1/scopes/scopeid", + "http://127.0.0.1:9200", + "", }, { - name: "longer path project", - input: "http://127.0.0.1:9200/v1/auth-methods", - address: "http://127.0.0.1:9200", + "longer path project", + "http://127.0.0.1:9200/v1/auth-methods", + "http://127.0.0.1:9200", + "", }, { - name: "valid project", - input: "http://127.0.0.1:9200/my-install", - address: "http://127.0.0.1:9200/my-install", + "valid project", + "http://127.0.0.1:9200/my-install", + "http://127.0.0.1:9200/my-install", + "", }, { - name: "valid project path containing v1", - input: "http://127.0.0.1:9200/randomPathHasv1InIt", - address: "http://127.0.0.1:9200/randomPathHasv1InIt", - }, - { - name: "unix socket with linux pathing", - input: "unix:///home/username/.boundary/socketdir/boundary.sock", - address: "unix:///home/username/.boundary/socketdir/boundary.sock", - socketAddr: "/home/username/.boundary/socketdir/boundary.sock", - }, - { - name: "unix socket with weird linux pathing", - input: "unix:///home/username/.boundary/something/../socketdir/boundary.sock", - address: "unix:///home/username/.boundary/socketdir/boundary.sock", - socketAddr: "/home/username/.boundary/socketdir/boundary.sock", - }, - { - name: "unix socket with windows pathing", - input: "unix://C:\\Users\\Admin\\AppData\\boundary.sock", - address: "unix://C:\\Users\\Admin\\AppData\\boundary.sock", - socketAddr: "C:\\Users\\Admin\\AppData\\boundary.sock", + "valid project path containing v1", + "http://127.0.0.1:9200/randomPathHasv1InIt", + "http://127.0.0.1:9200/randomPathHasv1InIt", + "", }, } @@ -82,9 +72,10 @@ func TestConfigSetAddress(t *testing.T) { t.Run(v.name, func(t *testing.T) { var c Config err := c.setAddr(v.input) - assert.NoError(t, err) + if err != nil { + assert.Equal(t, v.err, err.Error()) + } assert.Equal(t, v.address, c.Addr) - assert.Equal(t, v.socketAddr, c.socketAddr) }) } } diff --git a/api/response.go b/api/response.go index b17287c5d..e7565f3be 100644 --- a/api/response.go +++ b/api/response.go @@ -20,6 +20,13 @@ type Response struct { Map map[string]any } +// NewResponse returns a new *Response based on the provided http.Response. +// Just as when constructing the Response directly, Body and Map will be +// populated after Decode is called. +func NewResponse(r *http.Response) *Response { + return &Response{resp: r} +} + // HttpResponse returns the underlying HTTP response func (r *Response) HttpResponse() *http.Response { return r.resp diff --git a/internal/clientcache/cmd/daemon/addtoken.go b/internal/clientcache/cmd/daemon/addtoken.go index b3c35a2db..8c16818e3 100644 --- a/internal/clientcache/cmd/daemon/addtoken.go +++ b/internal/clientcache/cmd/daemon/addtoken.go @@ -5,14 +5,15 @@ package daemon import ( "context" + "errors" + "fmt" "os" "strings" "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/clientcache/internal/client" "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" "github.com/hashicorp/boundary/internal/cmd/base" - "github.com/hashicorp/boundary/internal/errors" - "github.com/hashicorp/boundary/version" "github.com/mitchellh/cli" "github.com/posener/complete" ) @@ -76,7 +77,7 @@ func (c *AddTokenCommand) Run(args []string) int { return base.CommandCliError } - apiErr, err := c.Add(ctx, client, keyringType, tokenName) + resp, apiErr, err := c.Add(ctx, client, keyringType, tokenName) if err != nil { c.PrintCliError(err) return base.CommandCliError @@ -84,30 +85,35 @@ func (c *AddTokenCommand) Run(args []string) int { if apiErr != nil { c.PrintApiError(apiErr, "Error from daemon when adding a token") return base.CommandApiError - + } + switch base.Format(c.UI) { + case "json": + if ok := c.PrintJsonItem(resp); !ok { + return base.CommandCliError + } + case "table": + c.UI.Output("The daemon add-token operation completed successfully.") } return base.CommandSuccess } -func (c *AddTokenCommand) Add(ctx context.Context, client *api.Client, keyringType, tokenName string) (*api.Error, error) { - const op = "daemon.(AddTokenCommand).Add" - +func (c *AddTokenCommand) Add(ctx context.Context, apiClient *api.Client, keyringType, tokenName string) (*api.Response, *api.Error, error) { pa := daemon.UpsertTokenRequest{ - BoundaryAddr: client.Addr(), + BoundaryAddr: apiClient.Addr(), } switch keyringType { case "", base.NoneKeyring: - token := client.Token() + token := apiClient.Token() if parts := strings.SplitN(token, "_", 4); len(parts) == 3 { pa.AuthTokenId = strings.Join(parts[:2], "_") } else { - return nil, errors.New(ctx, errors.InvalidParameter, op, "found auth token is not in the proper format") + return nil, nil, errors.New("The found auth token is not in the proper format.") } pa.AuthToken = token default: at := c.ReadTokenFromKeyring(keyringType, tokenName) if at == nil { - return nil, errors.New(ctx, errors.Conflict, op, "no auth token available to send to daemon") + return nil, nil, errors.New("No auth token could be read from the keyring to send to daemon.") } pa.Keyring = &daemon.KeyringToken{ KeyringType: keyringType, @@ -118,42 +124,29 @@ func (c *AddTokenCommand) Add(ctx context.Context, client *api.Client, keyringTy dotPath, err := DefaultDotDirectory(ctx) if err != nil { - return nil, err + return nil, nil, err } - return addToken(ctx, dotPath, &pa) } -func addToken(ctx context.Context, daemonPath string, p *daemon.UpsertTokenRequest) (*api.Error, error) { - const op = "daemon.addToken" - client, err := api.NewClient(nil) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } +func addToken(ctx context.Context, daemonPath string, p *daemon.UpsertTokenRequest) (*api.Response, *api.Error, error) { addr, err := daemon.SocketAddress(daemonPath) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, nil, fmt.Errorf("Error when retrieving the socket address: %w", err) } _, err = os.Stat(addr.Path) - if strings.EqualFold(addr.Scheme, "unix") && err != nil { - return nil, errors.Wrap(ctx, err, op) - } - if err := client.SetAddr(addr.String()); err != nil { - return nil, errors.Wrap(ctx, err, op) - } - // Because this is using the real lib it can pick up from stored locations - // like the system keychain. Explicitly clear the token for now - client.SetToken("") - - req, err := client.NewRequest(ctx, "POST", "/tokens", p) - if err != nil { - return nil, err - } - req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber()) - resp, err := client.Do(req) - if err != nil { - return nil, err + if addr.Scheme == "unix" && err != nil { + return nil, nil, fmt.Errorf("Error when detecting if the domain socket is present: %w.", err) } - return resp.Decode(&struct{}{}) + c, err := client.New(ctx, addr) + if err != nil { + return nil, nil, fmt.Errorf("Error when making a new client: %w.", err) + } + resp, err := c.Post(ctx, "/v1/tokens", p) + if err != nil { + return nil, nil, fmt.Errorf("Error when sending request to the daemon: %w.", err) + } + apiErr, err := resp.Decode(nil) + return resp, apiErr, err } diff --git a/internal/clientcache/cmd/daemon/command_wrapper.go b/internal/clientcache/cmd/daemon/command_wrapper.go index 0b139c709..cdb372ecb 100644 --- a/internal/clientcache/cmd/daemon/command_wrapper.go +++ b/internal/clientcache/cmd/daemon/command_wrapper.go @@ -103,7 +103,7 @@ func (w *CommandWrapper) addTokenToCache(ctx context.Context, token string) bool return false } - apiErr, err := com.Add(ctx, client, keyringType, tokName) + _, apiErr, err := com.Add(ctx, client, keyringType, tokName) return err == nil && apiErr == nil } diff --git a/internal/clientcache/cmd/daemon/start.go b/internal/clientcache/cmd/daemon/start.go index b07f93725..8cd524e34 100644 --- a/internal/clientcache/cmd/daemon/start.go +++ b/internal/clientcache/cmd/daemon/start.go @@ -5,6 +5,7 @@ package daemon import ( "context" + stderrors "errors" "fmt" "io" "net" @@ -136,7 +137,6 @@ func (c *StartCommand) AutocompleteFlags() complete.Flags { } func (c *StartCommand) Run(args []string) int { - const op = "daemon.(StartCommand).Run" ctx, cancel := context.WithCancel(c.Context) defer cancel() @@ -265,9 +265,9 @@ func makeBackground(ctx context.Context, dotDir string, runBackgroundFlag bool) writers := []io.Writer{} pidPath := filepath.Join(dotDir, pidFileName) if running, err := pidFileInUse(ctx, pidPath); running != nil { - return false, writers, noopPidCleanup, errors.New(ctx, errors.Conflict, op, "daemon already running") + return false, writers, noopPidCleanup, stderrors.New("The daemon is already running.") } else if err != nil && !errors.Match(errors.T(errors.NotFound), err) { - return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op) + return false, writers, noopPidCleanup, fmt.Errorf("Error when checking if the daemon pid is in use: %w.", err) } if !runBackgroundFlag && os.Getenv(backgroundEnvName) != backgroundEnvVal { diff --git a/internal/clientcache/cmd/daemon/status.go b/internal/clientcache/cmd/daemon/status.go index 73fc88967..b6a8064e1 100644 --- a/internal/clientcache/cmd/daemon/status.go +++ b/internal/clientcache/cmd/daemon/status.go @@ -12,9 +12,10 @@ import ( "time" "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/clientcache/internal/client" "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" "github.com/hashicorp/boundary/internal/cmd/base" - "github.com/hashicorp/boundary/version" + "github.com/hashicorp/boundary/internal/errors" "github.com/mitchellh/cli" "github.com/posener/complete" ) @@ -69,14 +70,7 @@ func (c *StatusCommand) Run(args []string) int { return base.CommandUserError } - resp, err := c.Status(ctx) - if err != nil { - c.PrintCliError(err) - return base.CommandCliError - } - - res := &daemon.StatusResult{} - apiErr, err := resp.Decode(res) + resp, result, apiErr, err := c.Status(ctx) if err != nil { c.PrintCliError(err) return base.CommandCliError @@ -84,56 +78,57 @@ func (c *StatusCommand) Run(args []string) int { if apiErr != nil { c.PrintApiError(apiErr, "Error from daemon when getting the status") return base.CommandApiError - } + switch base.Format(c.UI) { case "json": - c.UI.Output(resp.Body.String()) + if ok := c.PrintJsonItem(resp); !ok { + return base.CommandCliError + } default: - c.UI.Output(printStatusTable(res)) + c.UI.Output(printStatusTable(result)) } return base.CommandSuccess } -func (c *StatusCommand) Status(ctx context.Context) (*api.Response, error) { +func (c *StatusCommand) Status(ctx context.Context) (*api.Response, *daemon.StatusResult, *api.Error, error) { dotPath, err := DefaultDotDirectory(ctx) if err != nil { - return nil, err + return nil, nil, nil, err } return status(ctx, dotPath) } -func status(ctx context.Context, daemonPath string) (*api.Response, error) { - client, err := api.NewClient(nil) - if err != nil { - return nil, fmt.Errorf("Error creating a new API client: %w", err) - } +func status(ctx context.Context, daemonPath string) (*api.Response, *daemon.StatusResult, *api.Error, error) { + const op = "daemon.status" addr, err := daemon.SocketAddress(daemonPath) if err != nil { - return nil, fmt.Errorf("Error getting socket address: %w", err) + return nil, nil, nil, fmt.Errorf("Error getting socket address: %w", err) } _, err = os.Stat(addr.Path) if addr.Scheme == "unix" && err != nil { - return nil, errDaemonNotRunning + return nil, nil, nil, errDaemonNotRunning } - if err := client.SetAddr(addr.String()); err != nil { - return nil, fmt.Errorf("Error when setting the client's address: %w", err) + c, err := client.New(ctx, addr) + if err != nil { + return nil, nil, nil, err } - // Because this is using the real lib it can pick up from stored locations - // like the system keychain. Explicitly clear the token for now - client.SetToken("") - req, err := client.NewRequest(ctx, "GET", "/status", nil) + resp, err := c.Get(ctx, "/v1/status", nil) if err != nil { - return nil, err + return nil, nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("client do failed")) } - req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber()) - resp, err := client.Do(req) + + res := &daemon.StatusResult{} + apiErr, err := resp.Decode(&res) if err != nil { - return nil, err + return nil, nil, nil, fmt.Errorf("Error when sending request to the daemon: %w.", err) } - return resp, nil + if apiErr != nil { + return resp, nil, apiErr, nil + } + return resp, res, nil, nil } func printStatusTable(status *daemon.StatusResult) string { diff --git a/internal/clientcache/cmd/daemon/stop.go b/internal/clientcache/cmd/daemon/stop.go index ec601cce3..1b11b0b15 100644 --- a/internal/clientcache/cmd/daemon/stop.go +++ b/internal/clientcache/cmd/daemon/stop.go @@ -60,7 +60,7 @@ func (c *StopCommand) Run(args []string) int { } if err := c.stop(c.Context); err != nil { if errors.Match(errors.T(errors.NotFound), err) { - c.PrintCliError(stderr.New("deamon process is not running")) + c.PrintCliError(stderr.New("The deamon process is not running.")) } else { c.PrintCliError(err) } diff --git a/internal/clientcache/cmd/daemon/stop_nonwindows.go b/internal/clientcache/cmd/daemon/stop_nonwindows.go index fb4f2c9c0..8a4264514 100644 --- a/internal/clientcache/cmd/daemon/stop_nonwindows.go +++ b/internal/clientcache/cmd/daemon/stop_nonwindows.go @@ -8,35 +8,35 @@ package daemon import ( "context" + "errors" + "fmt" "path/filepath" "syscall" - "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/util" ) // stop will send a term signal to the daemon to shut down. func (s *StopCommand) stop(ctx context.Context) error { - const op = "daemon.(StopCommand).stop" switch { case util.IsNil(ctx): - return errors.New(ctx, errors.InvalidParameter, op, "context is missing") + return errors.New("Invalid parameter provided to stop: context is missing") } dotPath, err := DefaultDotDirectory(ctx) if err != nil { - return errors.Wrap(ctx, err, op) + return fmt.Errorf("Error when getting default dot directory: %w", err) } pidPath := filepath.Join(dotPath, pidFileName) p, err := pidFileInUse(ctx, pidPath) if err != nil { - return errors.Wrap(ctx, err, op) + return fmt.Errorf("Error when checking if the daemon's pid file is in use: %w", err) } if p == nil { - return errors.New(ctx, errors.NotFound, op, "daemon not running") + return errors.New("The daemon is not running.") } if err := p.Signal(syscall.SIGTERM); err != nil { - return errors.Wrap(ctx, err, op, errors.WithMsg("sending sigterm to process")) + return fmt.Errorf("Error when sending sigterm to daemon process: %w", err) } return nil } diff --git a/internal/clientcache/cmd/daemon/stop_windows.go b/internal/clientcache/cmd/daemon/stop_windows.go index 20b4442d9..0aef235b5 100644 --- a/internal/clientcache/cmd/daemon/stop_windows.go +++ b/internal/clientcache/cmd/daemon/stop_windows.go @@ -8,28 +8,27 @@ package daemon import ( "context" + "errors" "fmt" "os" "path/filepath" "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/clientcache/internal/client" "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" - "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/util" - "github.com/hashicorp/boundary/version" ) // stop will send a term signal to the daemon to shut down. func (s *StopCommand) stop(ctx context.Context) error { - const op = "daemon.(StopCommand).stop" switch { case util.IsNil(ctx): - return errors.New(ctx, errors.InvalidParameter, op, "context is missing") + return errors.New("Invalid parameter provided to stop: context is missing") } dotPath, err := DefaultDotDirectory(ctx) if err != nil { - return errors.Wrap(ctx, err, op) + return fmt.Errorf("Error when getting default dot directory: %w", err) } apiErr, err := stopThroughHandler(ctx, dotPath) @@ -41,7 +40,7 @@ func (s *StopCommand) stop(ctx context.Context) error { } else if apiErr != nil { errMsg = apiErr.Message } - s.UI.Warn(fmt.Sprintf("failed stopping the daemon through the handler: %q, now killing the process", errMsg)) + s.UI.Warn(fmt.Sprintf("Failed stopping the daemon through the handler: %q, now killing the process", errMsg)) default: // there wasn't an error stopping it through the handler. No need to // force kill the process @@ -52,46 +51,34 @@ func (s *StopCommand) stop(ctx context.Context) error { p, err := pidFileInUse(ctx, pidPath) if err != nil { if os.IsNotExist(err) { - return errors.Wrap(ctx, err, op, errors.WithCode(errors.NotFound)) + return errors.New("Unable to stop the daemon: pid file not found.") } - return errors.Wrap(ctx, err, op, errors.WithMsg("Unable to stop the daemon")) + return fmt.Errorf("Error when trying to stop the daemon: %w", err) } if p == nil { - return errors.New(ctx, errors.NotFound, op, "daemon process was not found") + return errors.New("Daemon process was not found.") } if err := p.Kill(); err != nil { - return errors.Wrap(ctx, err, op, errors.WithMsg("killing the daemon")) + return fmt.Errorf("Error when killing the process: %w.", err) } return nil } func stopThroughHandler(ctx context.Context, dotPath string) (*api.Error, error) { - const op = "daemon.stopThroughHandler" - - sockAddr, err := daemon.SocketAddress(dotPath) + addr, err := daemon.SocketAddress(dotPath) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, fmt.Errorf("Error when retrieving the socket address: %w", err) } - client, err := api.NewClient(nil) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - if err := client.SetAddr(sockAddr.String()); err != nil { - return nil, errors.Wrap(ctx, err, op) - } - // Because this is using the real lib it can pick up from stored locations - // like the system keychain. Explicitly clear the token for now - client.SetToken("") - req, err := client.NewRequest(ctx, "POST", "/stop", nil) + c, err := client.New(ctx, addr) if err != nil { return nil, err } - req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber()) - resp, err := client.Do(req) + resp, err := c.Post(ctx, "/v1/stop", nil) if err != nil { - return nil, err + return nil, fmt.Errorf("Error when sending request to the daemon: %w.", err) } - return resp.Decode(&struct{}{}) + apiErr, err := resp.Decode(nil) + return apiErr, err } diff --git a/internal/clientcache/cmd/search/search.go b/internal/clientcache/cmd/search/search.go index 467ce08cd..4feadcea7 100644 --- a/internal/clientcache/cmd/search/search.go +++ b/internal/clientcache/cmd/search/search.go @@ -16,10 +16,9 @@ import ( "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" daemoncmd "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon" + "github.com/hashicorp/boundary/internal/clientcache/internal/client" "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" "github.com/hashicorp/boundary/internal/cmd/base" - "github.com/hashicorp/boundary/internal/errors" - "github.com/hashicorp/boundary/version" "github.com/mitchellh/cli" "github.com/posener/complete" "golang.org/x/exp/slices" @@ -104,13 +103,7 @@ func (c *SearchCommand) Run(args []string) int { return base.CommandUserError } - resp, err := c.Search(ctx) - if err != nil { - c.PrintCliError(err) - return base.CommandCliError - } - res := &daemon.SearchResult{} - apiErr, err := resp.Decode(res) + resp, result, apiErr, err := c.Search(ctx) if err != nil { c.PrintCliError(err) return base.CommandCliError @@ -119,15 +112,18 @@ func (c *SearchCommand) Run(args []string) int { c.PrintApiError(apiErr, "Error from daemon when performing search") return base.CommandApiError } + switch base.Format(c.UI) { case "json": - c.UI.Output(resp.Body.String()) + if ok := c.PrintJsonItem(resp); !ok { + return base.CommandCliError + } default: switch { - case len(res.Targets) > 0: - c.UI.Output(printTargetListTable(res.Targets)) - case len(res.Sessions) > 0: - c.UI.Output(printSessionListTable(res.Sessions)) + case len(result.Targets) > 0: + c.UI.Output(printTargetListTable(result.Targets)) + case len(result.Sessions) > 0: + c.UI.Output(printSessionListTable(result.Sessions)) default: c.UI.Output("No items found") } @@ -135,18 +131,18 @@ func (c *SearchCommand) Run(args []string) int { return base.CommandSuccess } -func (c *SearchCommand) Search(ctx context.Context) (*api.Response, error) { +func (c *SearchCommand) Search(ctx context.Context) (*api.Response, *daemon.SearchResult, *api.Error, error) { client, err := c.Client() if err != nil { - return nil, err + return nil, nil, nil, err } t := client.Token() if t == "" { - return nil, fmt.Errorf("Auth Token selected for searching is empty.") + return nil, nil, nil, fmt.Errorf("Auth Token selected for searching is empty.") } tSlice := strings.SplitN(t, "_", 3) if len(tSlice) != 3 { - return nil, fmt.Errorf("Auth Token selected for searching is in an unexpected format.") + return nil, nil, nil, fmt.Errorf("Auth Token selected for searching is in an unexpected format.") } tf := filterBy{ @@ -157,43 +153,41 @@ func (c *SearchCommand) Search(ctx context.Context) (*api.Response, error) { dotPath, err := daemoncmd.DefaultDotDirectory(ctx) if err != nil { - return nil, err + return nil, nil, nil, err } return search(ctx, dotPath, tf) } -func search(ctx context.Context, daemonPath string, fb filterBy) (*api.Response, error) { - const op = "search.search" - client, err := api.NewClient(nil) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } +func search(ctx context.Context, daemonPath string, fb filterBy) (*api.Response, *daemon.SearchResult, *api.Error, error) { addr, err := daemon.SocketAddress(daemonPath) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, nil, nil, fmt.Errorf("Error when retrieving the socket address: %w", err) } if _, err := os.Stat(addr.Path); addr.Scheme == "unix" && err == os.ErrNotExist { - return nil, errors.New(ctx, errors.Internal, op, "daemon unix socket is not setup") + return nil, nil, nil, fmt.Errorf("Error when detecting if the domain socket is present: %w.", err) } - if err := client.SetAddr(addr.String()); err != nil { - return nil, errors.Wrap(ctx, err, op) + c, err := client.New(ctx, addr) + if err != nil { + return nil, nil, nil, err } - req, err := client.NewRequest(ctx, "GET", "/search", nil) - if err != nil { - return nil, errors.Wrap(ctx, err, op, errors.WithMsg("new client request error")) - } - req.Header.Add(daemon.VersionHeaderKey, version.Get().VersionNumber()) - q := url.Values{} + q := &url.Values{} q.Add("auth_token_id", fb.authTokenId) q.Add("resource", fb.resource) q.Add("query", fb.flagQuery) - req.URL.RawQuery = q.Encode() - resp, err := client.Do(req) + resp, err := c.Get(ctx, "/v1/search", q) if err != nil { - return nil, errors.Wrap(ctx, err, op, errors.WithMsg("client do failed")) + return nil, nil, nil, fmt.Errorf("Error when sending request to the daemon: %w.", err) } - return resp, err + res := &daemon.SearchResult{} + apiErr, err := resp.Decode(&res) + if err != nil { + return nil, nil, nil, fmt.Errorf("Error when decoding request from the daemon: %w.", err) + } + if apiErr != nil { + return resp, nil, apiErr, nil + } + return resp, res, nil, nil } func printTargetListTable(items []*targets.Target) string { diff --git a/internal/clientcache/cmd/search/search_test.go b/internal/clientcache/cmd/search/search_test.go index 00b84d68a..d30d16031 100644 --- a/internal/clientcache/cmd/search/search_test.go +++ b/internal/clientcache/cmd/search/search_test.go @@ -106,43 +106,40 @@ func TestSearch(t *testing.T) { for _, tc := range errorCases { t.Run(tc.name, func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), tc.fb) + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), tc.fb) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) - assert.NoError(t, err) assert.NotNil(t, apiErr) assert.Contains(t, apiErr.Message, tc.apiErrContains) + assert.NotNil(t, resp) + assert.Nil(t, r) }) } t.Run("empty response from list", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, resource: "targets", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) - assert.EqualValues(t, r, daemon.SearchResult{}) + assert.EqualValues(t, r, &daemon.SearchResult{}) }) t.Run("empty response from query", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, - flagQuery: "name=name", + flagQuery: "name='name'", resource: "targets", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) - assert.EqualValues(t, r, daemon.SearchResult{}) + assert.EqualValues(t, r, &daemon.SearchResult{}) }) srv.AddResources(t, cmd.at, []*targets.Target{ @@ -154,87 +151,81 @@ func TestSearch(t *testing.T) { }, boundaryTokenReaderFn) t.Run("target response from list", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, resource: "targets", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Targets, 2) }) t.Run("full target response from query", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, - flagQuery: "id % ttcp", + flagQuery: "id % 'ttcp'", resource: "targets", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Targets, 2) }) t.Run("partial target response from query", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, - flagQuery: "id % ttcp_1234567890", + flagQuery: "id % 'ttcp_1234567890'", resource: "targets", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Sessions, 0) assert.Len(t, r.Targets, 1) }) t.Run("session response from list", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, resource: "sessions", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Targets, 0) assert.Len(t, r.Sessions, 2) }) t.Run("full session response from query", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, - flagQuery: "id % sess", + flagQuery: "id % 'sess'", resource: "sessions", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Sessions, 2) }) t.Run("partial session response from query", func(t *testing.T) { - resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + resp, r, apiErr, err := search(ctx, srv.BaseDotDir(), filterBy{ authTokenId: at.Id, - flagQuery: "id % sess_1234567890", + flagQuery: "id % 'sess_1234567890'", resource: "sessions", }) require.NoError(t, err) - r := daemon.SearchResult{} - apiErr, err := resp.Decode(&r) assert.NoError(t, err) assert.Nil(t, apiErr) + assert.NotNil(t, resp) assert.NotNil(t, r) assert.Len(t, r.Sessions, 1) }) diff --git a/internal/clientcache/internal/cache/repository_targets_test.go b/internal/clientcache/internal/cache/repository_targets_test.go index 99cc9f87d..502c0a2f7 100644 --- a/internal/clientcache/internal/cache/repository_targets_test.go +++ b/internal/clientcache/internal/cache/repository_targets_test.go @@ -350,7 +350,7 @@ func TestRepository_QueryTargets(t *testing.T) { require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) - query := `(name % name1 or name % name2) and scope_id = "p_123"` + query := `(name % 'name1' or name % 'name2') and scope_id = "p_123"` errorCases := []struct { name string diff --git a/internal/clientcache/internal/client/client.go b/internal/clientcache/internal/client/client.go new file mode 100644 index 000000000..ed6afe349 --- /dev/null +++ b/internal/clientcache/internal/client/client.go @@ -0,0 +1,108 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/version" + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/go-retryablehttp" +) + +const hostHeader = "api.boundary.localhost" + +type Client struct { + client *retryablehttp.Client + domainSocketPath string +} + +// New returns a client which will always send requests over the unix socket +// specified in the provided address. If the address doesn't have the "unix" +// schema or the path is unset, an error is returned. +func New(ctx context.Context, address *url.URL) (*Client, error) { + const op = "client.New" + switch { + case address == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "address is nil") + case address.Scheme != "unix": + return nil, errors.New(ctx, errors.InvalidParameter, op, "address does not have a unix schema") + case address.Path == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "address path is empty") + } + c := &retryablehttp.Client{ + HTTPClient: cleanhttp.DefaultClient(), + RetryWaitMin: 100 * time.Millisecond, + RetryWaitMax: 1500 * time.Millisecond, + RetryMax: 6, + CheckRetry: retryablehttp.DefaultRetryPolicy, + Backoff: retryablehttp.DefaultBackoff, + ErrorHandler: retryablehttp.PassthroughErrorHandler, + } + transport := c.HTTPClient.Transport.(*http.Transport) + transport.DialContext = func(ctx context.Context, n string, a string) (net.Conn, error) { + dialer := net.Dialer{} + return dialer.DialContext(ctx, "unix", address.Path) + } + return &Client{client: c, domainSocketPath: address.Path}, nil +} + +// Get sends a GET http request to the provided path. The vals provided are +// encoded and attached to the request if present. +func (c *Client) Get(ctx context.Context, path string, vals *url.Values) (*api.Response, error) { + req := request(ctx, "GET", path) + if vals != nil { + req.URL.RawQuery = vals.Encode() + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + return api.NewResponse(resp), nil +} + +// Post sends a POST http request to the provided path. The body is marshaled +// to json and added to the request body. +func (c *Client) Post(ctx context.Context, path string, body any) (*api.Response, error) { + req := request(ctx, "POST", path) + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("error marshaling body: %w", err) + } + req.SetBody(b) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + return api.NewResponse(resp), nil +} + +// request returns a retryablehttp.Request with the url set to the domain socket +// associated with this client and proper headers set for client daemon requests. +func request(ctx context.Context, method, path string) *retryablehttp.Request { + req := &http.Request{ + Method: method, + URL: &url.URL{ + Scheme: "http", + Host: hostHeader, + Path: path, + }, + Host: hostHeader, + } + req.Header = http.Header{} + req.Header.Set(daemon.VersionHeaderKey, version.Get().VersionNumber()) + req.Header.Set("content-type", "application/json") + + return &retryablehttp.Request{ + Request: req.Clone(ctx), + } +} diff --git a/internal/clientcache/internal/client/client_test.go b/internal/clientcache/internal/client/client_test.go new file mode 100644 index 000000000..96f0de647 --- /dev/null +++ b/internal/clientcache/internal/client/client_test.go @@ -0,0 +1,168 @@ +package client + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path/filepath" + "sync" + "testing" + + "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_New(t *testing.T) { + ctx := context.Background() + + t.Run("nil addr", func(t *testing.T) { + _, err := New(ctx, nil) + assert.ErrorContains(t, err, "address is nil") + }) + t.Run("schema not unix", func(t *testing.T) { + u, err := url.Parse("http://127.0.0.1/v2/something") + require.NoError(t, err) + _, err = New(ctx, u) + assert.ErrorContains(t, err, "address does not have a unix schema") + }) + t.Run("schema not unix", func(t *testing.T) { + u, err := url.Parse("unix://") + require.NoError(t, err) + _, err = New(ctx, u) + assert.ErrorContains(t, err, "address path is empty") + }) + t.Run("success", func(t *testing.T) { + u, err := url.Parse("unix:///User/boundary/test.sock") + require.NoError(t, err) + c, err := New(ctx, u) + assert.NoError(t, err) + assert.NotNil(t, c) + }) +} + +func TestClient(t *testing.T) { + ctx := context.Background() + testPath := "/v1/test_endpoint" + + tmpDir := t.TempDir() + unixSocket := filepath.Join(tmpDir, "test.sock") + l, err := net.Listen("unix", unixSocket) + require.NoError(t, err) + + var assertRequest func(*http.Request) + var writeResp func(http.ResponseWriter) = func(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) + } + mux := http.NewServeMux() + mux.HandleFunc(testPath, http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { // Mock for the proxy handler. + assertRequest(req) + writeResp(rw) + })) + + var serverShutdown sync.WaitGroup + serverShutdown.Add(1) + go func() { + defer serverShutdown.Done() + http.Serve(l, mux) + }() + + u, err := url.Parse(fmt.Sprintf("unix://%s", unixSocket)) + require.NoError(t, err) + c, err := New(ctx, u) + require.NoError(t, err) + + t.Run("not found", func(t *testing.T) { + r, err := c.Get(ctx, "/unknown/path", nil) + assert.NoError(t, err) + assert.NotNil(t, r) + assert.Equal(t, http.StatusNotFound, r.HttpResponse().StatusCode) + }) + + t.Run("empty response", func(t *testing.T) { + assertRequest = func(r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet) + assert.NotEmpty(t, r.Header.Get(daemon.VersionHeaderKey)) + } + r, err := c.Get(ctx, testPath, nil) + assert.NoError(t, err) + apiErr, err := r.Decode(nil) + assert.NoError(t, err) + assert.Nil(t, apiErr) + assert.NotNil(t, r) + }) + + t.Run("empty response", func(t *testing.T) { + assertRequest = func(r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet) + assert.NotEmpty(t, r.Header.Get(daemon.VersionHeaderKey)) + } + r, err := c.Get(ctx, testPath, nil) + assert.NoError(t, err) + assert.NotNil(t, r) + + apiErr, err := r.Decode(nil) + assert.NoError(t, err) + assert.Nil(t, apiErr) + assert.Empty(t, r.Body) + }) + + t.Run("empty json object", func(t *testing.T) { + assertRequest = func(r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet) + assert.NotEmpty(t, r.Header.Get(daemon.VersionHeaderKey)) + } + writeResp = func(w http.ResponseWriter) { + w.Write([]byte("{}")) + w.WriteHeader(http.StatusOK) + } + r, err := c.Get(ctx, testPath, nil) + assert.NoError(t, err) + assert.NotNil(t, r) + + apiErr, err := r.Decode(nil) + assert.NoError(t, err) + assert.Nil(t, apiErr) + assert.Equal(t, "{}", string(r.Body.Bytes())) + }) + + t.Run("query params", func(t *testing.T) { + v := url.Values{} + v.Add("foo", "bar") + + assertRequest = func(r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet) + assert.Equal(t, v, r.URL.Query()) + assert.NotEmpty(t, r.Header.Get(daemon.VersionHeaderKey)) + } + r, err := c.Get(ctx, testPath, &v) + assert.NoError(t, err) + apiErr, err := r.Decode(nil) + assert.NoError(t, err) + assert.Nil(t, apiErr) + assert.NotNil(t, r) + }) + + t.Run("empty json object", func(t *testing.T) { + assertRequest = func(r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + assert.NotEmpty(t, r.Header.Get(daemon.VersionHeaderKey)) + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, string(b), `{"foo":"bar"}`) + } + r, err := c.Post(ctx, testPath, map[string]interface{}{"foo": "bar"}) + assert.NoError(t, err) + assert.NotNil(t, r) + + apiErr, err := r.Decode(nil) + assert.NoError(t, err) + assert.Nil(t, apiErr) + }) + + l.Close() + serverShutdown.Wait() +} diff --git a/internal/clientcache/internal/daemon/404_handler.go b/internal/clientcache/internal/daemon/404_handler.go new file mode 100644 index 000000000..e373319af --- /dev/null +++ b/internal/clientcache/internal/daemon/404_handler.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "net/http" +) + +// new404Func creates a handler that returns a custom 404 error message. +func new404Func(ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeError(w, "Not found", http.StatusNotFound) + } +} diff --git a/internal/clientcache/internal/daemon/server.go b/internal/clientcache/internal/daemon/server.go index 8da512129..f28488bab 100644 --- a/internal/clientcache/internal/daemon/server.go +++ b/internal/clientcache/internal/daemon/server.go @@ -260,6 +260,9 @@ func (s *CacheServer) Serve(ctx context.Context, cmd Commander, opt ...Option) e } mux.Handle("/v1/stop", versionEnforcement(stopFn)) + // Return custom 404 message when requests don't map to any known path. + mux.Handle("/", new404Func(ctx)) + logger, err := event.SysEventer().StandardLogger(ctx, "daemon.serve: ", event.ErrorType) if err != nil { return errors.Wrap(ctx, err, op)