diff --git a/api/client.go b/api/client.go index cfb20229e..c4e84f0b6 100644 --- a/api/client.go +++ b/api/client.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "path" + "path/filepath" "strconv" "strings" "sync" @@ -57,7 +58,12 @@ type Config struct { // HttpClient. Addr string - // Token is the client token that reuslts from authentication and can be + // 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 // used to make calls into Boundary Token string @@ -254,6 +260,16 @@ 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) @@ -582,6 +598,7 @@ func (c *Client) Clone() *Client { config := c.config newConfig := &Config{ + socketAddr: config.socketAddr, Addr: config.Addr, Token: config.Token, RecoveryKmsWrapper: config.RecoveryKmsWrapper, @@ -645,31 +662,29 @@ 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() - u, err := url.Parse(addr) - if err != nil { - return nil, err - } - - if strings.HasPrefix(addr, "unix://") { - socket := strings.TrimPrefix(addr, "unix://") + var hostHeader string + var u *url.URL + switch { + case c.config.socketAddr != "": transport := httpClient.Transport.(*http.Transport) transport.DialContext = func(context.Context, string, string) (net.Conn, error) { dialer := net.Dialer{} - return dialer.DialContext(ctx, "unix", socket) + return dialer.DialContext(ctx, "unix", c.config.socketAddr) } // 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. @@ -680,7 +695,17 @@ 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. - u.Host = "api.boundary.localhost" + 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") } host := u.Host @@ -707,8 +732,9 @@ func (c *Client) NewRequest(ctx context.Context, method, requestPath string, bod Host: host, Path: path.Join(u.Path, "/v1/", requestPath), }, - Host: u.Host, + Host: hostHeader, } + 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 a886bf322..3ef9a74f3 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -11,60 +11,70 @@ import ( func TestConfigSetAddress(t *testing.T) { type test struct { - name string - input string - address string - err string + name string + input string + address string + socketAddr string } tests := []test{ { - "bare", - "http://127.0.0.1:9200", - "http://127.0.0.1:9200", - "", + name: "bare", + input: "http://127.0.0.1:9200", + 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", + 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: "bare with version and trailing slash", + input: "http://127.0.0.1:9200/v1/", + 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 top level scope", + input: "http://127.0.0.1:9200/v1/scopes", + 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: "valid scope", + input: "http://127.0.0.1:9200/v1/scopes/scopeid", + 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: "longer path project", + input: "http://127.0.0.1:9200/v1/auth-methods", + address: "http://127.0.0.1:9200", }, { - "valid project", - "http://127.0.0.1:9200/my-install", - "http://127.0.0.1:9200/my-install", - "", + name: "valid project", + input: "http://127.0.0.1:9200/my-install", + address: "http://127.0.0.1:9200/my-install", }, { - "valid project path containing v1", - "http://127.0.0.1:9200/randomPathHasv1InIt", - "http://127.0.0.1:9200/randomPathHasv1InIt", - "", + 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", }, } @@ -72,10 +82,9 @@ func TestConfigSetAddress(t *testing.T) { t.Run(v.name, func(t *testing.T) { var c Config err := c.setAddr(v.input) - if err != nil { - assert.Equal(t, v.err, err.Error()) - } + assert.NoError(t, err) assert.Equal(t, v.address, c.Addr) + assert.Equal(t, v.socketAddr, c.socketAddr) }) } } diff --git a/go.mod b/go.mod index 0dc95d8f4..eda94030c 100644 --- a/go.mod +++ b/go.mod @@ -88,8 +88,9 @@ require github.com/hashicorp/go-dbw v0.1.1 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 - github.com/cenkalti/backoff/v4 v4.2.1 - github.com/creack/pty v1.1.20 + github.com/cenkalti/backoff/v4 v4.2.0 + github.com/creack/pty v1.1.18 + github.com/glebarez/sqlite v1.9.0 github.com/golang/protobuf v1.5.3 github.com/hashicorp/cap/ldap v0.0.0-20231012003312-273118a6e3b8 github.com/hashicorp/dbassert v0.0.0-20231012105025-1bc1bd88e22b @@ -102,12 +103,30 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/miekg/dns v1.1.56 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a + github.com/mitchellh/go-homedir v1.1.0 + github.com/sevlyar/go-daemon v0.1.6 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/net v0.17.0 google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 gopkg.in/square/go-jose.v2 v2.6.0 ) +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) + +require ( + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect + gorm.io/driver/sqlite v1.5.4 // indirect +) + require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/AlecAivazis/survey/v2 v2.3.2 // indirect @@ -148,6 +167,7 @@ require ( github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5 // indirect github.com/hashicorp/go-plugin v1.5.2 // indirect github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 // indirect + github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f github.com/hashicorp/vault/sdk v0.3.0 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect @@ -160,16 +180,14 @@ require ( github.com/jackc/pgtype v1.14.0 // indirect github.com/jefferai/go-libsecret v0.0.0-20210525195240-b53481abef97 // indirect github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f // indirect - github.com/jinzhu/gorm v1.9.12 // indirect + github.com/jinzhu/gorm v1.9.16 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/term v0.5.0 // indirect @@ -202,5 +220,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/sqlite v1.5.4 // indirect ) diff --git a/go.sum b/go.sum index 44c2385bb..b6eaf6553 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,7 @@ github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nB github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -34,6 +35,7 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0= github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA= @@ -61,8 +63,8 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= +github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -90,8 +92,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4= -github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= @@ -113,6 +115,8 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -138,6 +142,10 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= +github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= @@ -152,7 +160,7 @@ github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8V github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -205,6 +213,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -311,6 +321,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f h1:hpvyk8Al2vfAtoqDOPJg9Z8WKMfpYcsDG85TEELbgmM= +github.com/hashicorp/mql v0.0.0-20230814234610-74af33a21d7f/go.mod h1:pSghNOmPNBD9/Bvx0M4EC2JB/mEVA6qlB9IKpNCiqOA= github.com/hashicorp/nodeenrollment v0.2.9 h1:pFdMjd6VqDpqWtzr2+p//7DHKKPwxmbNoENCKCOYuvs= github.com/hashicorp/nodeenrollment v0.2.9/go.mod h1:7lR/2H0cHO9ZsEQj7ixoaPYB7vNu5yP+8jyoCZtHeAY= github.com/hashicorp/vault/api v1.10.0 h1:/US7sIjWN6Imp4o/Rj1Ce2Nr5bki/AXi9vAW3p2tOJQ= @@ -396,8 +408,8 @@ github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgf github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jimlambrt/gldap v0.1.9 h1:OPIRGQ/zdjKNLZYgLhNq1B6kMSB0aFmfgssWsOO0Brw= github.com/jimlambrt/gldap v0.1.9/go.mod h1:wQXacI2If7+C8z/IaTIf6Sbb+tqgFoqzujN2AaGzyck= -github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= -github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= +github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -408,6 +420,8 @@ github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgb github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -455,6 +469,7 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -558,6 +573,9 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -578,6 +596,8 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sethvargo/go-diceware v0.3.0 h1:UVVEfmN/uF50JfWAN7nbY6CiAlp5xeSx+5U0lWKkMCQ= github.com/sethvargo/go-diceware v0.3.0/go.mod h1:lH5Q/oSPMivseNdhMERAC7Ti5oOPqsaVddU1BcN1CY0= +github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs= +github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= @@ -707,6 +727,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -719,7 +740,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -920,6 +943,14 @@ gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48= mvdan.cc/gofumpt v0.5.0 h1:0EQ+Z56k8tXjj/6TQD25BFNKQXpCvT0rnansIc7Ug5E= mvdan.cc/gofumpt v0.5.0/go.mod h1:HBeVDtMKRZpXyxFciAirzdKklDlGu8aAy1wEbH5Y9js= diff --git a/internal/clientcache/cmd/daemon/addtoken.go b/internal/clientcache/cmd/daemon/addtoken.go new file mode 100644 index 000000000..3082d230f --- /dev/null +++ b/internal/clientcache/cmd/daemon/addtoken.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "os" + "strings" + + "github.com/hashicorp/boundary/api" + "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" +) + +var ( + _ cli.Command = (*AddTokenCommand)(nil) + _ cli.CommandAutocomplete = (*AddTokenCommand)(nil) +) + +type AddTokenCommand struct { + *base.Command +} + +func (c *AddTokenCommand) Synopsis() string { + return "Add an auth token to a running boundary daemon" +} + +func (c *AddTokenCommand) Help() string { + helpText := ` +Usage: boundary daemon add-token [options] + + Add an auth token to the daemon: + + $ boundary daemon add-token + + For a full list of examples, please see the documentation. + +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *AddTokenCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetClient | base.FlagSetOutputFormat) + return set +} + +func (c *AddTokenCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *AddTokenCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *AddTokenCommand) Run(args []string) int { + ctx := c.Context + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + apiErr, err := c.Add(ctx) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + if apiErr != nil { + c.PrintApiError(apiErr, "Error from daemon when adding a token") + return base.CommandApiError + + } + return base.CommandSuccess +} + +func (c *AddTokenCommand) Add(ctx context.Context) (*api.Error, error) { + const op = "daemon.(AddTokenCommand).Add" + client, err := c.Client() + if err != nil { + return nil, err + } + + keyringType, tokenName, err := c.DiscoverKeyringTokenInfo() + if err != nil { + return nil, err + } + + pa := daemon.UpsertTokenRequest{ + BoundaryAddr: client.Addr(), + } + switch keyringType { + case "", base.NoneKeyring: + keyringType = base.NoneKeyring + token := client.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") + } + 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") + } + pa.Keyring = &daemon.KeyringToken{ + KeyringType: keyringType, + TokenName: tokenName, + } + pa.AuthTokenId = at.Id + } + + dotPath, err := DefaultDotDirectory(ctx) + if err != nil { + return 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) + } + addr := daemon.SocketAddress(daemonPath) + _, err = os.Stat(strings.TrimPrefix(addr, "unix://")) + if strings.HasPrefix(addr, "unix://") && err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if err := client.SetAddr(addr); 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 + } + + return resp.Decode(&struct{}{}) +} diff --git a/internal/clientcache/cmd/daemon/command_wrapper.go b/internal/clientcache/cmd/daemon/command_wrapper.go new file mode 100644 index 000000000..57a872096 --- /dev/null +++ b/internal/clientcache/cmd/daemon/command_wrapper.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +// CommandWrapper starts the boundary daemon after the command was Run and attempts +// to send the current persona to any running daemon. +type CommandWrapper struct { + cli.Command + ui cli.Ui +} + +// Wrap returns a cli.CommandFactory that returns a command wrapped in the CommandWrapper. +func Wrap(ui cli.Ui, wrapped cli.Command) cli.CommandFactory { + return func() (cli.Command, error) { + return &CommandWrapper{ + Command: wrapped, + ui: ui, + }, nil + } +} + +// Run runs the wrapped command and then attempts to start the boundary daemon and send +// the current persona +func (w *CommandWrapper) Run(args []string) int { + r := w.Command.Run(args) + + ctx := context.Background() + if w.startDaemon(ctx) { + w.addPersonaInCache(ctx) + } + return r +} + +// startDaemon attempts to start a daemon and returns true if we have attempted to start +// the daemon and either it was successful or it was already running. +func (w *CommandWrapper) startDaemon(ctx context.Context) bool { + cmdName, err := os.Executable() + if err != nil { + w.ui.Error(fmt.Sprintf("unable to find boundary binary for daemon startup: %s", err.Error())) + return false + } + + var stdErr bytes.Buffer + cmd := exec.Command(cmdName, "daemon", "start", "-background") + cmd.Stderr = &stdErr + + // We use Run here instead of Start because the command spawns off a subprocess and returns. + // We do not want to send the request to add a persona to the cache until we know the daemon + // has started up. + err = cmd.Run() + return err == nil || strings.Contains(stdErr.String(), "already running") +} + +// addPersonaInCache runs AddPersonaCommand +func (w *CommandWrapper) addPersonaInCache(ctx context.Context) bool { + c := AddTokenCommand{Command: base.NewCommand(w.ui)} + c.Flags() + apiErr, err := c.Add(ctx) + return err == nil && apiErr == nil +} diff --git a/internal/clientcache/cmd/daemon/pidfile_nonwindows.go b/internal/clientcache/cmd/daemon/pidfile_nonwindows.go new file mode 100644 index 000000000..3a1d2ce16 --- /dev/null +++ b/internal/clientcache/cmd/daemon/pidfile_nonwindows.go @@ -0,0 +1,69 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows +// +build !windows + +package daemon + +import ( + "context" + stderrors "errors" + "os" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/sevlyar/go-daemon" +) + +func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) { + const op = "daemon.writePidFile" + + // Determine if we should clean up the file after we are done or if + // it should stick around in the case of lock aquision error since this + // file didn't create it. + var pidExists bool + _, err := os.Stat(pidFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return noopPidCleanup, errors.Wrap(ctx, err, op) + } + if err == nil { + pidExists = true + } + + f, err := os.OpenFile(pidFile, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return noopPidCleanup, errors.Wrap(ctx, err, op, errors.WithMsg("opening file")) + } + closeAndDeleteFileFn := func() error { + err := f.Close() + if !pidExists { + err = stderrors.Join(err, os.Remove(pidFile)) + } + return err + } + l := daemon.NewLockFile(f) + if err := l.Lock(); err != nil { + return closeAndDeleteFileFn, errors.Wrap(ctx, err, op) + } + // Now that we have aquired the lock and verified we own the pid file + // we can remove it always when cleaning up. + unlockAndCleanFn := func() error { + return l.Remove() + } + if err := l.WritePid(); err != nil { + return unlockAndCleanFn, errors.Wrap(ctx, err, op) + } + return unlockAndCleanFn, nil +} + +func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) { + const op = "daemon.pidFileInUse" + if pidFile == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty") + } + proc, err := (&daemon.Context{PidFileName: pidFile}).Search() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + return proc, nil +} diff --git a/internal/clientcache/cmd/daemon/pidfile_test.go b/internal/clientcache/cmd/daemon/pidfile_test.go new file mode 100644 index 000000000..333f119fc --- /dev/null +++ b/internal/clientcache/cmd/daemon/pidfile_test.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPidInUse(t *testing.T) { + ctx := context.Background() + dotPath := t.TempDir() + pidPath := filepath.Join(dotPath, "boundary.pid") + + used, err := pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.Nil(t, used) + + workingPidCleanup1, err := writePidFile(ctx, pidPath) + assert.NoError(t, err) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.NotNil(t, used) + + failingPidCleanup, err := writePidFile(ctx, pidPath) + assert.Error(t, err) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.NotNil(t, used) + + assert.NoError(t, failingPidCleanup()) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.NotNil(t, used) + + assert.NoError(t, workingPidCleanup1()) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.Nil(t, used) + + workingPidCleanup2, err := writePidFile(ctx, pidPath) + assert.NoError(t, err) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.NotNil(t, used) + + assert.NoError(t, workingPidCleanup2()) + + used, err = pidFileInUse(ctx, pidPath) + assert.NoError(t, err) + assert.Nil(t, used) +} diff --git a/internal/clientcache/cmd/daemon/pidfile_windows.go b/internal/clientcache/cmd/daemon/pidfile_windows.go new file mode 100644 index 000000000..38b02f46c --- /dev/null +++ b/internal/clientcache/cmd/daemon/pidfile_windows.go @@ -0,0 +1,100 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build windows +// +build windows + +package daemon + +import ( + "bytes" + "context" + stderrors "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/hashicorp/boundary/internal/errors" + "golang.org/x/sys/windows" +) + +func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) { + const op = "daemon.writePidFile" + // Create the file for writing, and set shared read so following processes + // cannot open this file for writing. + fd, err := windows.CreateFile(&(windows.StringToUTF16(pidFile)[0]), windows.GENERIC_WRITE, + windows.FILE_SHARE_READ, nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return noopPidCleanup, errors.Wrap(ctx, err, op) + } + + cleanup := func() error { + var err error + if inErr := windows.CloseHandle(fd); inErr != nil { + err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("handler close"))) + } + if inErr := windows.DeleteFile(&(windows.StringToUTF16(pidFile)[0])); inErr != nil { + err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("removing file"))) + } + return err + } + + if _, err := windows.Seek(fd, 0, windows.FILE_BEGIN); err != nil { + return cleanup, errors.Wrap(ctx, err, op) + } + b := bytes.NewBuffer(nil) + if _, err := fmt.Fprint(b, os.Getpid()); err != nil { + return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing file buffer")) + } + var fileLen int + if fileLen, err = windows.Write(fd, b.Bytes()); err != nil { + return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing buffer to file")) + } + if err = windows.Ftruncate(fd, int64(fileLen)); err != nil { + return cleanup, errors.Wrap(ctx, err, op) + } + + return cleanup, windows.Fsync(fd) +} + +func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) { + const op = "daemon.pidFileInUse" + if pidFile == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty") + } + + var err error + var file *os.File + if file, err = os.OpenFile(pidFile, os.O_RDONLY, 0o640); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("OpenFile")) + } + if file == nil { + return nil, nil + } + defer func() { + file.Close() + }() + + if _, err = file.Seek(0, io.SeekStart); err != nil { + return nil, err + } + var pid int + _, err = fmt.Fscan(file, &pid) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Fscan")) + } + + p, err := os.FindProcess(pid) + if err != nil { + if strings.Contains(err.Error(), "The parameter is incorrect") { + return nil, errors.New(ctx, errors.NotFound, op, "cannot find process") + } + // we failed to get the process for whatever reason + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("FindProcess %d", pid)) + } + if p == nil { + return nil, errors.New(ctx, errors.NotFound, op, "cannot find process") + } + return p, nil +} diff --git a/internal/clientcache/cmd/daemon/start.go b/internal/clientcache/cmd/daemon/start.go new file mode 100644 index 000000000..16b28f753 --- /dev/null +++ b/internal/clientcache/cmd/daemon/start.go @@ -0,0 +1,273 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/errors" + "github.com/mitchellh/cli" + "github.com/mitchellh/go-homedir" + "github.com/posener/complete" +) + +const ( + dotDirname = ".boundary" + pidFileName = "cache.pid" + logFileName = "cache.log" + + // Mark of process as having been started in the background + backgroundEnvName = "_BOUNDARY_DAEMON_BACKGROUND" + backgroundEnvVal = "1" +) + +var ( + _ cli.Command = (*StartCommand)(nil) + _ cli.CommandAutocomplete = (*StartCommand)(nil) +) + +type server interface { + setupLogging(context.Context, io.Writer) error + serve(context.Context, daemon.Commander, net.Listener) error + shutdown() error +} + +type StartCommand struct { + *base.Command + + flagRefreshIntervalSeconds int64 + flagDatabaseUrl string + flagLogLevel string + flagLogFormat string + flagStoreDebug bool + flagBackground bool +} + +func (c *StartCommand) Synopsis() string { + return "Start a Boundary daemon" +} + +func (c *StartCommand) Help() string { + helpText := ` +Usage: boundary daemon start [options] + + Start a daemon: + + $ boundary daemon start + + For a full list of examples, please see the documentation. + +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *StartCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient) + + f := set.NewFlagSet("Command Options") + f.StringVar(&base.StringVar{ + Name: "log-level", + Target: &c.flagLogLevel, + EnvVar: "BOUNDARY_LOG_LEVEL", + Completion: complete.PredictSet("trace", "debug", "info", "warn", "err"), + Usage: "Log verbosity level, mostly as a fallback for events. Supported values (in order of more detail to less) are " + + "\"trace\", \"debug\", \"info\", \"warn\", and \"err\".", + }) + f.StringVar(&base.StringVar{ + Name: "log-format", + Target: &c.flagLogFormat, + Completion: complete.PredictSet("standard", "json"), + Usage: `Log format, mostly as a fallback for events. Supported values are "standard" and "json".`, + }) + f.StringVar(&base.StringVar{ + Name: "database-url", + Target: &c.flagDatabaseUrl, + Usage: `If set, specifies the URL used to connect to the sqlite database (store) for caching. This can refer to a file on disk (file://) from which a URL will be read; an env var (env://) from which the URL will be read; or a direct database URL.`, + }) + f.Int64Var(&base.Int64Var{ + Name: "refresh-interval-seconds", + Target: &c.flagRefreshIntervalSeconds, + Usage: `If set, specifies the number of seconds between cache refreshes. Default: 5 minutes`, + Aliases: []string{"r"}, + Default: daemon.DefaultRefreshIntervalSeconds, + }) + f.BoolVar(&base.BoolVar{ + Name: "store-debug", + Target: &c.flagStoreDebug, + Default: false, + Usage: `Turn on store debugging`, + Aliases: []string{"d"}, + }) + f.BoolVar(&base.BoolVar{ + Name: "background", + Target: &c.flagBackground, + Default: false, + Usage: `Turn on store debugging`, + }) + + return set +} + +func (c *StartCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *StartCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *StartCommand) Run(args []string) int { + const op = "daemon.(StartCommand).Run" + ctx, cancel := context.WithCancel(c.Context) + defer cancel() + + var err error + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + dotDir, err := DefaultDotDirectory(ctx) + if err != nil { + return base.CommandCliError + } + + continueRun, writers, cleanup, err := makeBackground(ctx, dotDir, c.flagBackground) + defer func() { + if cleanup != nil { + cleanup() + } + }() + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + if !continueRun { + return base.CommandSuccess + } + + // TODO: print something out for the spawner to consume in case they can easily + // report if the daemon started or not. + + logFilePath := filepath.Join(dotDir, logFileName) + logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + defer logFile.Close() + if _, err := logFile.Seek(0, io.SeekEnd); err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + writers = append(writers, logFile) + + cfg := &daemon.Config{ + ContextCancel: cancel, + RefreshIntervalSeconds: c.flagRefreshIntervalSeconds, + DatabaseUrl: c.flagDatabaseUrl, + StoreDebug: c.flagStoreDebug, + LogLevel: c.flagLogLevel, + LogFormat: c.flagLogFormat, + LogWriter: io.MultiWriter(writers...), + DotDirectory: dotDir, + } + + srv, err := daemon.New(ctx, cfg) + if err != nil { + c.UI.Error(err.Error()) + return base.CommandUserError + } + + var srvErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + srvErr = srv.Serve(ctx, c) + }() + + // This is a blocking call. We rely on the c.ShutdownCh to cancel this + // context when sigterm or sigint is received. + <-ctx.Done() + if err := srv.Shutdown(ctx); err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + wg.Wait() + if srvErr != nil { + c.PrintCliError(srvErr) + return base.CommandCliError + } + + return base.CommandSuccess +} + +// DefaultDotDirectory returns the default path to the boundary dot directory. +func DefaultDotDirectory(ctx context.Context) (string, error) { + const op = "daemon.DefaultDotDirectory" + homeDir, err := homedir.Dir() + if err != nil { + return "", errors.Wrap(ctx, err, op) + } + return filepath.Join(homeDir, dotDirname), nil +} + +func makeBackground(ctx context.Context, dotDir string, runBackgroundFlag bool) (bool, []io.Writer, pidCleanup, error) { + const op = "daemon.makeBackground" + + 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") + } else if err != nil && !errors.Match(errors.T(errors.NotFound), err) { + return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op) + } + + if !runBackgroundFlag && os.Getenv(backgroundEnvName) != backgroundEnvVal { + writers = append(writers, os.Stderr) + } + + if !runBackgroundFlag || os.Getenv(backgroundEnvName) == backgroundEnvVal { + // We are either already running in the background or background was + // not requested. Write the pid file and continue. + cleanup, err := writePidFile(ctx, pidPath) + if err != nil { + return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op) + } + return true, writers, cleanup, nil + } + + absPath, err := os.Executable() + if err != nil { + return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op) + } + + env := os.Environ() + env = append(env, fmt.Sprintf("%s=%s", backgroundEnvName, backgroundEnvVal)) + cmd := exec.Command(absPath, "daemon", "start") + cmd.Env = env + if err = cmd.Start(); err != nil { + return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op) + } + + // TODO: Read the output from the child process for a brief time + // to see if we can identify any errors that might arise. + return false, writers, noopPidCleanup, nil +} + +type pidCleanup func() error + +var noopPidCleanup pidCleanup = func() error { return nil } diff --git a/internal/clientcache/cmd/daemon/stop.go b/internal/clientcache/cmd/daemon/stop.go new file mode 100644 index 000000000..ec601cce3 --- /dev/null +++ b/internal/clientcache/cmd/daemon/stop.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + stderr "errors" + "strings" + + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/errors" + "github.com/posener/complete" +) + +type StopCommand struct { + *base.Command +} + +func (c *StopCommand) Synopsis() string { + return "Stop a Boundary daemon" +} + +func (c *StopCommand) Help() string { + helpText := ` +Usage: boundary daemon stop + + Stop a daemon: + + $ boundary daemon stop + + For a full list of examples, please see the documentation. + +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *StopCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient) + return set +} + +func (c *StopCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *StopCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *StopCommand) Run(args []string) int { + ctx, cancel := context.WithCancel(c.Context) + c.Context = ctx + c.ContextCancel = cancel + + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + 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")) + } else { + c.PrintCliError(err) + } + return base.CommandUserError + } + + return base.CommandSuccess +} diff --git a/internal/clientcache/cmd/daemon/stop_nonwindows.go b/internal/clientcache/cmd/daemon/stop_nonwindows.go new file mode 100644 index 000000000..fb4f2c9c0 --- /dev/null +++ b/internal/clientcache/cmd/daemon/stop_nonwindows.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows +// +build !windows + +package daemon + +import ( + "context" + "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") + } + + dotPath, err := DefaultDotDirectory(ctx) + if err != nil { + return errors.Wrap(ctx, err, op) + } + pidPath := filepath.Join(dotPath, pidFileName) + p, err := pidFileInUse(ctx, pidPath) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if p == nil { + return errors.New(ctx, errors.NotFound, op, "daemon not running") + } + if err := p.Signal(syscall.SIGTERM); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("sending sigterm to process")) + } + return nil +} diff --git a/internal/clientcache/cmd/daemon/stop_windows.go b/internal/clientcache/cmd/daemon/stop_windows.go new file mode 100644 index 000000000..3410ddd1e --- /dev/null +++ b/internal/clientcache/cmd/daemon/stop_windows.go @@ -0,0 +1,94 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build windows +// +build windows + +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/hashicorp/boundary/api" + "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") + } + + dotPath, err := DefaultDotDirectory(ctx) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + apiErr, err := stopThroughHandler(ctx, dotPath) + switch { + case err != nil, apiErr != nil: + var errMsg string + if err != nil { + errMsg = err.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)) + default: + // there wasn't an error stopping it through the handler. No need to + // force kill the process + return nil + } + + pidPath := filepath.Join(dotPath, pidFileName) + p, err := pidFileInUse(ctx, pidPath) + if err != nil { + if os.IsNotExist(err) { + return errors.Wrap(ctx, err, op, errors.WithCode(errors.NotFound)) + } + return errors.Wrap(ctx, err, op, errors.WithMsg("Unable to stop the daemon")) + } + if p == nil { + return errors.New(ctx, errors.NotFound, op, "daemon process was not found") + } + + if err := p.Kill(); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("killing the daemon")) + } + return nil +} + +func stopThroughHandler(ctx context.Context, dotPath string) (*api.Error, error) { + const op = "daemon.stopThroughHandler" + + sockAddr := daemon.SocketAddress(dotPath) + client, err := api.NewClient(nil) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if err := client.SetAddr(sockAddr); 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) + 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 + } + return resp.Decode(&struct{}{}) +} diff --git a/internal/clientcache/cmd/doc.go b/internal/clientcache/cmd/doc.go new file mode 100644 index 000000000..d8ca46f88 --- /dev/null +++ b/internal/clientcache/cmd/doc.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +/* +Package cmd contains the command packages to the client cache. Adding +these commands to the boundary cli is the only integration between the boundary +binary and the client cache. +*/ +package cmd \ No newline at end of file diff --git a/internal/clientcache/cmd/search/search.go b/internal/clientcache/cmd/search/search.go new file mode 100644 index 000000000..0e89d9963 --- /dev/null +++ b/internal/clientcache/cmd/search/search.go @@ -0,0 +1,335 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package search + +import ( + "context" + stderrors "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/hashicorp/boundary/api" + "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/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" +) + +var ( + _ cli.Command = (*SearchCommand)(nil) + _ cli.CommandAutocomplete = (*SearchCommand)(nil) + + supportedResourceTypes = []string{ + "targets", + "sessions", + } +) + +type SearchCommand struct { + *base.Command + flagNameStartsWith string + flagQuery string + flagResource string +} + +func (c *SearchCommand) Synopsis() string { + return "Search resources in boundary" +} + +func (c *SearchCommand) Help() string { + helpText := ` +Usage: boundary search [options] + + Search a boundary resource: + + $ boundary search -resource targets + + For a full list of examples, please see the documentation. + +` + c.Flags().Help() + return strings.TrimSpace(helpText) +} + +func (c *SearchCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetClient | base.FlagSetOutputFormat) + + f := set.NewFlagSet("Command Options") + f.StringVar(&base.StringVar{ + Name: "name-starts-with", + Target: &c.flagNameStartsWith, + Usage: `If set, specifies to search for a target that starts with`, + }) + f.StringVar(&base.StringVar{ + Name: "query", + Target: &c.flagQuery, + Usage: `If set, specifies the target search query`, + }) + f.StringVar(&base.StringVar{ + Name: "resource", + Target: &c.flagResource, + Usage: `Specifies the resource type to search over`, + Completion: complete.PredictSet(supportedResourceTypes...), + }) + + return set +} + +func (c *SearchCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *SearchCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *SearchCommand) Run(args []string) int { + ctx := c.Context + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + switch { + case slices.Contains(supportedResourceTypes, c.flagResource): + case c.flagResource == "": + c.PrintCliError(stderrors.New("Resource is required but not passed in via -resource")) + return base.CommandUserError + default: + c.PrintCliError(stderrors.New("The value passed in with -resource is not currently supported in search")) + 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) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + if apiErr != nil { + 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()) + default: + switch { + case len(res.Targets) > 0: + c.UI.Output(printTargetListTable(res.Targets)) + case len(res.Sessions) > 0: + c.UI.Output(printSessionListTable(res.Sessions)) + default: + c.UI.Output("No items found") + } + } + return base.CommandSuccess +} + +func (c *SearchCommand) Search(ctx context.Context) (*api.Response, error) { + keyringType, tokenName, err := c.DiscoverKeyringTokenInfo() + if err != nil { + return nil, err + } + + tf := filterBy{ + flagQuery: c.flagQuery, + resource: c.flagResource, + } + at := c.ReadTokenFromKeyring(keyringType, tokenName) + if at != nil { + tf.authTokenId = at.Id + } + + dotPath, err := daemoncmd.DefaultDotDirectory(ctx) + if err != nil { + return 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) + } + addr := daemon.SocketAddress(daemonPath) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if _, err := os.Stat(strings.TrimPrefix(addr, "unix://")); strings.HasPrefix(addr, "unix://") && err == os.ErrNotExist { + return nil, errors.New(ctx, errors.Internal, op, "daemon unix socket is not setup") + } + if err := client.SetAddr(addr); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + 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.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) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("client do failed")) + } + return resp, err +} + +func printTargetListTable(items []*targets.Target) string { + if len(items) == 0 { + return "No targets found" + } + var output []string + output = []string{ + "", + "Target information:", + } + for i, item := range items { + if i > 0 { + output = append(output, "") + } + if item.Id != "" { + output = append(output, + fmt.Sprintf(" ID: %s", item.Id), + ) + } else { + output = append(output, + fmt.Sprintf(" ID: %s", "(not available)"), + ) + } + if item.ScopeId != "" { + output = append(output, + fmt.Sprintf(" Scope ID: %s", item.ScopeId), + ) + } + if item.Version > 0 { + output = append(output, + fmt.Sprintf(" Version: %d", item.Version), + ) + } + if item.Type != "" { + output = append(output, + fmt.Sprintf(" Type: %s", item.Type), + ) + } + if item.Name != "" { + output = append(output, + fmt.Sprintf(" Name: %s", item.Name), + ) + } + if item.Description != "" { + output = append(output, + fmt.Sprintf(" Description: %s", item.Description), + ) + } + if item.Address != "" { + output = append(output, + fmt.Sprintf(" Address: %s", item.Address), + ) + } + if len(item.AuthorizedActions) > 0 { + output = append(output, + " Authorized Actions:", + base.WrapSlice(6, item.AuthorizedActions), + ) + } + } + + return base.WrapForHelpText(output) +} + +func printSessionListTable(items []*sessions.Session) string { + if len(items) == 0 { + return "No sessions found" + } + var output []string + output = []string{ + "", + "Session information:", + } + for i, item := range items { + if i > 0 { + output = append(output, "") + } + if item.Id != "" { + output = append(output, + fmt.Sprintf(" ID: %s", item.Id), + ) + } else { + output = append(output, + fmt.Sprintf(" ID: %s", "(not available)"), + ) + } + if item.ScopeId != "" { + output = append(output, + fmt.Sprintf(" Scope ID: %s", item.ScopeId), + ) + } + if item.Status != "" { + output = append(output, + fmt.Sprintf(" Status: %s", item.Status), + ) + } + if !item.CreatedTime.IsZero() { + output = append(output, + fmt.Sprintf(" Created Time: %s", item.CreatedTime.Local().Format(time.RFC1123)), + ) + } + if !item.ExpirationTime.IsZero() { + output = append(output, + fmt.Sprintf(" Expiration Time: %s", item.ExpirationTime.Local().Format(time.RFC1123)), + ) + } + if !item.UpdatedTime.IsZero() { + output = append(output, + fmt.Sprintf(" Updated Time: %s", item.UpdatedTime.Local().Format(time.RFC1123)), + ) + } + if item.UserId != "" { + output = append(output, + fmt.Sprintf(" User ID: %s", item.UserId), + ) + } + if item.TargetId != "" { + output = append(output, + fmt.Sprintf(" Target ID: %s", item.TargetId), + ) + } + if len(item.AuthorizedActions) > 0 { + output = append(output, + " Authorized Actions:", + base.WrapSlice(6, item.AuthorizedActions), + ) + } + } + + return base.WrapForHelpText(output) +} + +type filterBy struct { + flagQuery string + authTokenId string + resource string +} diff --git a/internal/clientcache/cmd/search/search_test.go b/internal/clientcache/cmd/search/search_test.go new file mode 100644 index 000000000..a2d3ee173 --- /dev/null +++ b/internal/clientcache/cmd/search/search_test.go @@ -0,0 +1,252 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package search + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/clientcache/internal/daemon" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testCommander struct { + t *testing.T + at *authtokens.AuthToken +} + +func (r *testCommander) keyring() string { + return r.at.Id +} + +func (r *testCommander) tokenName() string { + return r.at.Id +} + +func (r *testCommander) Client(opt ...base.Option) (*api.Client, error) { + client, err := api.NewClient(nil) + require.NoError(r.t, err) + return client, nil +} + +func (r *testCommander) DiscoverKeyringTokenInfo() (string, string, error) { + return r.keyring(), r.tokenName(), nil +} + +func (r *testCommander) ReadTokenFromKeyring(k, a string) *authtokens.AuthToken { + return r.at +} + +func TestSearch(t *testing.T) { + ctx := context.Background() + at := &authtokens.AuthToken{ + Id: "at_1", + UserId: "user_1", + Token: "at_1_token", + ExpirationTime: time.Now().Add(time.Minute), + } + cmd := &testCommander{t: t, at: at} + boundaryTokenReaderFn := func(ctx context.Context, addr, authToken string) (*authtokens.AuthToken, error) { + if authToken == at.Token { + return at, nil + } + return nil, errors.New("test not found error") + } + + srv := daemon.NewTestServer(t, cmd) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + srv.Serve(t, daemon.WithBoundaryTokenReaderFunc(ctx, boundaryTokenReaderFn)) + }() + // Give the store some time to get initialized + time.Sleep(100 * time.Millisecond) + + errorCases := []struct { + name string + fb filterBy + apiErrContains string + }{ + { + name: "no resource", + fb: filterBy{ + flagQuery: "name=name", + authTokenId: at.Id, + }, + apiErrContains: "resource is a required field but was empty", + }, + { + name: "bad resource", + fb: filterBy{ + authTokenId: at.Id, + flagQuery: "name=name", + resource: "hosts", + }, + apiErrContains: "provided resource is not a valid searchable resource", + }, + { + name: "unknown auth token id", + fb: filterBy{ + authTokenId: "unknown", + flagQuery: "description % tar", + resource: "targets", + }, + apiErrContains: "Forbidden", + }, + { + name: "unsupported column", + fb: filterBy{ + authTokenId: at.Id, + flagQuery: "item % 'tar'", + resource: "targets", + }, + apiErrContains: "invalid column \"item\"", + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + resp, 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) + }) + } + + t.Run("empty response from list", func(t *testing.T) { + resp, 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, r) + assert.EqualValues(t, r, daemon.SearchResult{}) + }) + + t.Run("empty response from query", func(t *testing.T) { + resp, err := search(ctx, srv.BaseDotDir(), filterBy{ + authTokenId: at.Id, + 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, r) + assert.EqualValues(t, r, daemon.SearchResult{}) + }) + + srv.AddResources(t, cmd.at, []*targets.Target{ + {Id: "ttcp_1234567890", Name: "name1", Description: "description1"}, + {Id: "ttcp_0987654321", Name: "name2", Description: "description2"}, + }, []*sessions.Session{ + {Id: "sess_1234567890", TargetId: "ttcp_1234567890", Status: "pending"}, + {Id: "sess_0987654321", TargetId: "ttcp_0987654321", Status: "pending"}, + }, boundaryTokenReaderFn) + + t.Run("target response from list", func(t *testing.T) { + resp, 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, 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{ + authTokenId: at.Id, + 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, 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{ + authTokenId: at.Id, + 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, 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{ + 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, 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{ + authTokenId: at.Id, + 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, 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{ + authTokenId: at.Id, + 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, r) + assert.Len(t, r.Sessions, 1) + }) +} diff --git a/internal/clientcache/doc.go b/internal/clientcache/doc.go new file mode 100644 index 000000000..d4c3a74ac --- /dev/null +++ b/internal/clientcache/doc.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +/* +Package clientcache contains the boundary client cache. +The client cache is a cache that runs on a client's machine and caches a subset +of boundary resources and makes them available for functionality like searching. +*/ +package clientcache diff --git a/internal/clientcache/internal/cache/doc.go b/internal/clientcache/internal/cache/doc.go new file mode 100644 index 000000000..d5911b269 --- /dev/null +++ b/internal/clientcache/internal/cache/doc.go @@ -0,0 +1,7 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +/* +Package cache contains the domain logic for the client cache. +*/ +package cache diff --git a/internal/clientcache/internal/cache/options.go b/internal/clientcache/internal/cache/options.go new file mode 100644 index 000000000..7a4e04b78 --- /dev/null +++ b/internal/clientcache/internal/cache/options.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "github.com/hashicorp/go-dbw" +) + +type options struct { + withUpdateLastAccessedTime bool + withDbType dbw.DbType + withTargetRetrievalFunc TargetRetrievalFunc + withSessionRetrievalFunc SessionRetrievalFunc +} + +// Option - how options are passed as args +type Option func(*options) error + +func getDefaultOptions() options { + return options{ + withDbType: dbw.Sqlite, + } +} + +func getOpts(opt ...Option) (options, error) { + opts := getDefaultOptions() + + for _, o := range opt { + if err := o(&opts); err != nil { + return opts, err + } + } + return opts, nil +} + +// WithUpdateLastAccessedTime provides an option for updating the last access time +func WithUpdateLastAccessedTime(b bool) Option { + return func(o *options) error { + o.withUpdateLastAccessedTime = b + return nil + } +} + +// WithTargetRetrievalFunc provides an option for specifying a targetRetrievalFunc +func WithTargetRetrievalFunc(fn TargetRetrievalFunc) Option { + return func(o *options) error { + o.withTargetRetrievalFunc = fn + return nil + } +} + +// WithSessionRetrievalFunc provides an option for specifying a sessionRetrievalFunc +func WithSessionRetrievalFunc(fn SessionRetrievalFunc) Option { + return func(o *options) error { + o.withSessionRetrievalFunc = fn + return nil + } +} diff --git a/internal/clientcache/internal/cache/options_test.go b/internal/clientcache/internal/cache/options_test.go new file mode 100644 index 000000000..9e8e491ba --- /dev/null +++ b/internal/clientcache/internal/cache/options_test.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/go-dbw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + + t.Run("default", func(t *testing.T) { + opts, err := getOpts() + require.NoError(t, err) + testOpts := options{ + withDbType: dbw.Sqlite, + } + assert.Equal(t, opts, testOpts) + }) + t.Run("WithUpdateLastAccessedTime", func(t *testing.T) { + opts, err := getOpts(WithUpdateLastAccessedTime(true)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.withUpdateLastAccessedTime = true + assert.Equal(t, opts, testOpts) + }) + t.Run("WithTargetRetrievalFunc", func(t *testing.T) { + var f TargetRetrievalFunc = func(ctx context.Context, keyringstring, tokenName string) ([]*targets.Target, error) { return nil, nil } + opts, err := getOpts(WithTargetRetrievalFunc(f)) + require.NoError(t, err) + + assert.NotNil(t, opts.withTargetRetrievalFunc) + opts.withTargetRetrievalFunc = nil + + testOpts := getDefaultOptions() + assert.Equal(t, opts, testOpts) + }) + t.Run("WithSessionRetrievalFunc", func(t *testing.T) { + var f SessionRetrievalFunc = func(ctx context.Context, keyringstring, tokenName string) ([]*sessions.Session, error) { + return nil, nil + } + opts, err := getOpts(WithSessionRetrievalFunc(f)) + require.NoError(t, err) + + assert.NotNil(t, opts.withSessionRetrievalFunc) + opts.withSessionRetrievalFunc = nil + + testOpts := getDefaultOptions() + assert.Equal(t, opts, testOpts) + }) +} diff --git a/internal/clientcache/internal/cache/refresh.go b/internal/clientcache/internal/cache/refresh.go new file mode 100644 index 000000000..342f0ab7e --- /dev/null +++ b/internal/clientcache/internal/cache/refresh.go @@ -0,0 +1,126 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + stderrors "errors" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/util" +) + +type RefreshService struct { + repo *Repository +} + +func NewRefreshService(ctx context.Context, r *Repository) (*RefreshService, error) { + return &RefreshService{repo: r}, nil +} + +// cleanAndPickAuthTokens removes from the cache all auth tokens which are +// evicted from the cache or no longer stored in a keyring and returns the +// remaining ones. +// The returned auth tokens have not been validated against boundary +func (r *RefreshService) cleanAndPickAuthTokens(ctx context.Context, u *user) (map[AuthToken]string, error) { + const op = "cache.(RefreshService).cleanAndPickAuthTokens" + switch { + case util.IsNil(u): + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + } + ret := make(map[AuthToken]string) + + tokens, err := r.repo.listTokens(ctx, u) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + for _, t := range tokens { + keyringTokens, err := r.repo.listKeyringTokens(ctx, t) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("for user %v, auth token %q", u, t.Id)) + } + for _, kt := range keyringTokens { + at := r.repo.tokenKeyringFn(kt.KeyringType, kt.TokenName) + switch { + case at == nil, at.Id != kt.AuthTokenId, at.UserId != t.UserId: + // delete the keyring token if the auth token in the keyring + // has changed since it was stored in the cache. + if err := r.repo.deleteKeyringToken(ctx, *kt); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + case at != nil: + _, err := r.repo.tokenReadFromBoundaryFn(ctx, u.Address, at.Token) + var apiErr *api.Error + switch { + case err != nil && api.ErrUnauthorized.Is(err): + if err := r.repo.deleteKeyringToken(ctx, *kt); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + continue + case err != nil && !errors.Is(err, apiErr): + event.WriteError(ctx, op, err, event.WithInfoMsg("validating keyring stored token against boundary", "auth token id", at.Id)) + continue + } + ret[*t] = at.Token + } + } + if atv, ok := r.repo.idToKeyringlessAuthToken.Load(t.Id); ok { + if at, ok := atv.(*authtokens.AuthToken); ok { + _, err := r.repo.tokenReadFromBoundaryFn(ctx, u.Address, at.Token) + var apiErr *api.Error + switch { + case err != nil && api.ErrUnauthorized.Is(err): + r.repo.idToKeyringlessAuthToken.Delete(t.Id) + continue + case err != nil && !errors.Is(err, apiErr): + event.WriteError(ctx, op, err, event.WithInfoMsg("validating in memory stored token against boundary", "auth token id", at.Id)) + continue + } + + ret[*t] = at.Token + } + } + } + return ret, nil +} + +// Refresh iterates over all tokens in the cache, attempts to read the +// resources for those tokens from boundary and updates the cache with +// the values retrieved there. Refresh accepts the options +// WithTarget which overwrites the default function used to retrieve the +// targets from a boundary address. +func (r *RefreshService) Refresh(ctx context.Context, opt ...Option) error { + const op = "cache.(RefreshService).Refresh" + if err := r.repo.cleanExpiredOrOrphanedAuthTokens(ctx); err != nil { + return errors.Wrap(ctx, err, op) + } + if err := r.repo.syncKeyringlessTokensWithDb(ctx); err != nil { + return errors.Wrap(ctx, err, op) + } + + us, err := r.repo.listUsers(ctx) + if err != nil { + return errors.Wrap(ctx, err, op) + } + var retErr error + for _, u := range us { + tokens, err := r.cleanAndPickAuthTokens(ctx, u) + if err != nil { + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op)) + continue + } + + if err := r.repo.refreshTargets(ctx, u, tokens, opt...); err != nil { + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op)) + } + if err := r.repo.refreshSessions(ctx, u, tokens, opt...); err != nil { + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op)) + } + + } + return retErr +} diff --git a/internal/clientcache/internal/cache/refresh_test.go b/internal/clientcache/internal/cache/refresh_test.go new file mode 100644 index 000000000..98c01f20e --- /dev/null +++ b/internal/clientcache/internal/cache/refresh_test.go @@ -0,0 +1,417 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +// testStaticResourceRetrievalFunc returns a function that always returns the +// provided slice and a nil error. The returned function can be passed into the +// options that provide a resource retrieval func such as +// WithTargetRetrievalFunc and WithSessionRetrievalFunc. +func testStaticResourceRetrievalFunc[T any](ret []T) func(context.Context, string, string) ([]T, error) { + return func(ctx context.Context, s1, s2 string) ([]T, error) { + return ret, nil + } +} + +func TestCleanAndPickTokens(t *testing.T) { + ctx := context.Background() + s, err := db.Open(ctx) + require.NoError(t, err) + + boundaryAddr := "address" + u1 := &user{Id: "u1", Address: boundaryAddr} + at1a := &authtokens.AuthToken{ + Id: "at_1a", + Token: "at_1a_token", + UserId: u1.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + at1b := &authtokens.AuthToken{ + Id: "at_1b", + Token: "at_1b_token", + UserId: u1.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + + keyringOnlyUser := &user{Id: "keyringUser", Address: boundaryAddr} + keyringAuthToken1 := &authtokens.AuthToken{ + Id: "at_2a", + Token: "at_2a_token", + UserId: keyringOnlyUser.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + keyringAuthToken2 := &authtokens.AuthToken{ + Id: "at_2b", + Token: "at_2b_token", + UserId: keyringOnlyUser.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1a, keyringAuthToken1, at1b, keyringAuthToken2} + unauthorizedAuthTokens := []*authtokens.AuthToken{} + randomErrorAuthTokens := []*authtokens.AuthToken{} + fakeBoundaryLookupFn := func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) { + for _, v := range randomErrorAuthTokens { + if at == v.Token { + return nil, errors.New("test error") + } + } + for _, v := range unauthorizedAuthTokens { + if at == v.Token { + return nil, api.ErrUnauthorized + } + } + for _, v := range boundaryAuthTokens { + if at == v.Token { + return v, nil + } + } + return nil, errors.New("not found") + } + + atMap := make(map[ringToken]*authtokens.AuthToken) + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(atMap), + fakeBoundaryLookupFn) + require.NoError(t, err) + rs, err := NewRefreshService(ctx, r) + require.NoError(t, err) + + t.Run("unknown user", func(t *testing.T) { + got, err := rs.cleanAndPickAuthTokens(ctx, &user{Id: "unknownuser", Address: "unknown"}) + assert.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("both memory and keyring stored token", func(t *testing.T) { + key := ringToken{"k1", "t1"} + atMap[key] = at1a + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key.k, + TokenName: key.t, + AuthTokenId: at1a.Id, + })) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token)) + + got, err := rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token}) + + // delete the keyringToken from the keyring and see it get removed from the response + delete(atMap, key) + got, err = rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1b.Token}) + }) + + t.Run("2 memory tokens", func(t *testing.T) { + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token)) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token)) + + got, err := rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token}) + }) + + t.Run("boundary in memory auth token expires", func(t *testing.T) { + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token)) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token)) + + got, err := rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token}) + + t.Cleanup(func() { + unauthorizedAuthTokens = nil + }) + + unauthorizedAuthTokens = []*authtokens.AuthToken{at1b} + got, err = rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token}) + }) + + t.Run("boundary keyring auths token expires", func(t *testing.T) { + key1 := ringToken{"k1", "t1"} + atMap[key1] = keyringAuthToken1 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key1.k, + TokenName: key1.t, + AuthTokenId: keyringAuthToken1.Id, + })) + key2 := ringToken{"k2", "t2"} + atMap[key2] = keyringAuthToken2 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key2.k, + TokenName: key2.t, + AuthTokenId: keyringAuthToken2.Id, + })) + + got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token}) + + t.Cleanup(func() { + unauthorizedAuthTokens = nil + }) + + unauthorizedAuthTokens = []*authtokens.AuthToken{keyringAuthToken2} + got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token}) + }) + + t.Run("boundary in memory auth token check errors", func(t *testing.T) { + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1a.Token)) + require.NoError(t, r.AddRawToken(ctx, boundaryAddr, at1b.Token)) + + got, err := rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token, at1b.Token}) + + t.Cleanup(func() { + randomErrorAuthTokens = nil + }) + + randomErrorAuthTokens = []*authtokens.AuthToken{at1b} + got, err = rs.cleanAndPickAuthTokens(ctx, u1) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{at1a.Token}) + }) + + t.Run("boundary keyring auths token check errors", func(t *testing.T) { + key1 := ringToken{"k1", "t1"} + atMap[key1] = keyringAuthToken1 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key1.k, + TokenName: key1.t, + AuthTokenId: keyringAuthToken1.Id, + })) + key2 := ringToken{"k2", "t2"} + atMap[key2] = keyringAuthToken2 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key2.k, + TokenName: key2.t, + AuthTokenId: keyringAuthToken2.Id, + })) + + got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token}) + + t.Cleanup(func() { + randomErrorAuthTokens = nil + }) + + randomErrorAuthTokens = []*authtokens.AuthToken{keyringAuthToken2} + got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token}) + }) + + t.Run("2 keyring tokens", func(t *testing.T) { + key1 := ringToken{"k1", "t1"} + atMap[key1] = keyringAuthToken1 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key1.k, + TokenName: key1.t, + AuthTokenId: keyringAuthToken1.Id, + })) + key2 := ringToken{"k2", "t2"} + atMap[key2] = keyringAuthToken2 + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{ + KeyringType: key2.k, + TokenName: key2.t, + AuthTokenId: keyringAuthToken2.Id, + })) + + got, err := rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.ElementsMatch(t, maps.Values(got), []string{keyringAuthToken1.Token, keyringAuthToken2.Token}) + + // Removing all keyring references and then cleaning auth tokens + // removes all auth tokens, along with the user + gotU, err := r.listUsers(ctx) + assert.NoError(t, err) + assert.Contains(t, gotU, keyringOnlyUser) + + delete(atMap, key1) + delete(atMap, key2) + got, err = rs.cleanAndPickAuthTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.Empty(t, got) + + gotT, err := r.listTokens(ctx, keyringOnlyUser) + assert.NoError(t, err) + assert.Empty(t, gotT) + gotU, err = r.listUsers(ctx) + assert.NoError(t, err) + assert.NotContains(t, gotU, keyringOnlyUser) + }) +} + +func TestRefresh(t *testing.T) { + ctx := context.Background() + s, err := db.Open(ctx) + require.NoError(t, err) + + boundaryAddr := "address" + u := &user{Id: "u1", Address: boundaryAddr} + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + ExpirationTime: time.Now().Add(time.Minute), + } + + boundaryAuthTokens := []*authtokens.AuthToken{at} + atMap := make(map[ringToken]*authtokens.AuthToken) + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + rs, err := NewRefreshService(ctx, r) + require.NoError(t, err) + + atMap[ringToken{"k", "t"}] = at + require.NoError(t, r.AddKeyringToken(ctx, boundaryAddr, KeyringToken{KeyringType: "k", TokenName: "t", AuthTokenId: at.Id})) + + t.Run("set targets", func(t *testing.T) { + retTargets := []*targets.Target{ + target("1"), + target("2"), + target("3"), + } + assert.NoError(t, rs.Refresh(ctx, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), + WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { + require.Equal(t, boundaryAddr, addr) + require.Equal(t, at.Token, token) + return retTargets, nil + }))) + + cachedTargets, err := r.ListTargets(ctx, at.Id) + assert.NoError(t, err) + assert.ElementsMatch(t, retTargets, cachedTargets) + + t.Run("empty response clears it out", func(t *testing.T) { + assert.NoError(t, rs.Refresh(ctx, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), + WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { + require.Equal(t, boundaryAddr, addr) + require.Equal(t, at.Token, token) + return nil, nil + }))) + + cachedTargets, err := r.ListTargets(ctx, at.Id) + assert.NoError(t, err) + assert.Empty(t, cachedTargets) + }) + }) + + t.Run("set sessions", func(t *testing.T) { + retSess := []*sessions.Session{ + session("1"), + session("2"), + session("3"), + } + assert.NoError(t, rs.Refresh(ctx, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(retSess)))) + + cachedSessions, err := r.ListSessions(ctx, at.Id) + assert.NoError(t, err) + assert.ElementsMatch(t, retSess, cachedSessions) + + t.Run("empty response clears it out", func(t *testing.T) { + assert.NoError(t, rs.Refresh(ctx, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)))) + + cachedTargets, err := r.ListSessions(ctx, at.Id) + assert.NoError(t, err) + assert.Empty(t, cachedTargets) + }) + }) + + t.Run("error propogates up", func(t *testing.T) { + innerErr := errors.New("test error") + err := rs.Refresh(ctx, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), + WithTargetRetrievalFunc(func(ctx context.Context, addr, token string) ([]*targets.Target, error) { + require.Equal(t, boundaryAddr, addr) + require.Equal(t, at.Token, token) + return nil, innerErr + })) + assert.ErrorContains(t, err, innerErr.Error()) + err = rs.Refresh(ctx, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil)), + WithSessionRetrievalFunc(func(ctx context.Context, addr, token string) ([]*sessions.Session, error) { + require.Equal(t, boundaryAddr, addr) + require.Equal(t, at.Token, token) + return nil, innerErr + })) + assert.ErrorContains(t, err, innerErr.Error()) + }) + + t.Run("tokens that are no longer in the ring is deleted", func(t *testing.T) { + // Remove the token from the keyring, see that we can still see the + // token and then user until a Refresh happens which causes them to be + // cleaned up. + delete(atMap, ringToken{"k", "t"}) + + ps, err := r.listTokens(ctx, u) + require.NoError(t, err) + assert.Len(t, ps, 1) + + us, err := r.listUsers(ctx) + require.NoError(t, err) + assert.Len(t, us, 1) + + rs.Refresh(ctx, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc[*sessions.Session](nil)), + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc[*targets.Target](nil))) + + ps, err = r.listTokens(ctx, u) + require.NoError(t, err) + assert.Empty(t, ps) + + // And since the last token was deleted, the user also was deleted + us, err = r.listUsers(ctx) + require.NoError(t, err) + assert.Empty(t, us) + }) +} + +func target(suffix string) *targets.Target { + return &targets.Target{ + Id: fmt.Sprintf("target_%s", suffix), + Name: fmt.Sprintf("name_%s", suffix), + Description: fmt.Sprintf("description_%s", suffix), + Address: fmt.Sprintf("address_%s", suffix), + Type: "tcp", + } +} + +func session(suffix string) *sessions.Session { + return &sessions.Session{ + Id: fmt.Sprintf("session_%s", suffix), + Type: "tcp", + } +} diff --git a/internal/clientcache/internal/cache/repository.go b/internal/clientcache/internal/cache/repository.go new file mode 100644 index 000000000..7fd528b7f --- /dev/null +++ b/internal/clientcache/internal/cache/repository.go @@ -0,0 +1,95 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "time" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +const ( + usersLimit = 50 + tokenStalenessLimit = 36 * time.Hour +) + +// KeyringTokenLookupFn takes a token name and returns the token from the keyring +type KeyringTokenLookupFn func(keyring string, tokenName string) *authtokens.AuthToken + +// BoundaryTokenReaderFn reads an auth token's resource information from boundary +type BoundaryTokenReaderFn func(ctx context.Context, addr string, authToken string) (*authtokens.AuthToken, error) + +type Repository struct { + rw *db.Db + tokenKeyringFn KeyringTokenLookupFn + tokenReadFromBoundaryFn BoundaryTokenReaderFn + // idToKeyringlessAuthToken maps an auth token id to an *authtokens.AuthToken + idToKeyringlessAuthToken *sync.Map +} + +// NewRepository returns a cache repository. The provided Store must be +func NewRepository(ctx context.Context, conn *db.DB, idToAuthToken *sync.Map, keyringFn KeyringTokenLookupFn, atReadFn BoundaryTokenReaderFn, opt ...Option) (*Repository, error) { + const op = "cache.NewRepository" + switch { + case util.IsNil(conn): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing store connection") + case util.IsNil(idToAuthToken): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing keyringless auth token map") + case util.IsNil(keyringFn): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token keyring function") + case util.IsNil(atReadFn): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token read function") + } + return &Repository{ + rw: db.New(conn), + tokenKeyringFn: keyringFn, + tokenReadFromBoundaryFn: atReadFn, + // This is passed in instead of being fully owned by the repo so multiple + // instances of the repo can operate on the same backing data + idToKeyringlessAuthToken: idToAuthToken, + }, nil +} + +func (r *Repository) SaveError(ctx context.Context, u *user, resourceType string, err error) error { + const op = "cache.(Repository).StoreError" + switch { + case resourceType == "": + return errors.New(ctx, errors.InvalidParameter, op, "resource type is empty") + case err == nil: + return errors.New(ctx, errors.InvalidParameter, op, "error is nil") + case u == nil: + return errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return errors.New(ctx, errors.InvalidParameter, op, "user id is empty") + } + apiErr := &ApiError{ + UserId: u.Id, + ResourceType: resourceType, + Error: err.Error(), + } + onConflict := db.OnConflict{ + Target: db.Columns{"user_id", "resource_type"}, + Action: db.SetColumns([]string{"error", "create_time"}), + } + if err := r.rw.Create(ctx, apiErr, db.WithOnConflict(&onConflict)); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil +} + +type ApiError struct { + UserId string `gorm:"primaryKey"` + ResourceType string `gorm:"primaryKey"` + Error string + CreateTime time.Time +} + +func (*ApiError) TableName() string { + return "api_error" +} diff --git a/internal/clientcache/internal/cache/repository_sessions.go b/internal/clientcache/internal/cache/repository_sessions.go new file mode 100644 index 000000000..1b3b13ab0 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_sessions.go @@ -0,0 +1,196 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "database/sql" + "encoding/json" + stderrors "errors" + "fmt" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/mql" +) + +// SessionRetrievalFunc is a function that retrieves sessions +// from the provided boundary addr using the provided token. +type SessionRetrievalFunc func(ctx context.Context, addr, token string) ([]*sessions.Session, error) + +func defaultSessionFunc(ctx context.Context, addr, token string) ([]*sessions.Session, error) { + const op = "cache.defaultSessionFunc" + client, err := api.NewClient(&api.Config{ + Addr: addr, + Token: token, + }) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + sClient := sessions.NewClient(client) + l, err := sClient.List(ctx, "global", sessions.WithRecursive(true)) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return l.Items, nil +} + +func (r *Repository) refreshSessions(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error { + const op = "cache.(Repository).refreshSessions" + switch { + case util.IsNil(u): + return errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return errors.New(ctx, errors.InvalidParameter, op, "user id is missing") + case u.Address == "": + return errors.New(ctx, errors.InvalidParameter, op, "user boundary address is missing") + } + + opts, err := getOpts(opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if opts.withSessionRetrievalFunc == nil { + opts.withSessionRetrievalFunc = defaultSessionFunc + } + + // Find and use a token for retrieving sessions + var gotResponse bool + var resp []*sessions.Session + var retErr error + for at, t := range tokens { + resp, err = opts.withSessionRetrievalFunc(ctx, u.Address, t) + if err != nil { + // TODO: If we get an error about the token no longer having + // permissions, remove it. + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) + continue + } + gotResponse = true + break + } + + if retErr != nil { + if saveErr := r.SaveError(ctx, u, resource.Session.String(), retErr); saveErr != nil { + return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) + } + } + if !gotResponse { + return retErr + } + + event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d sessions for user %v", len(resp), u)) + _, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + // TODO: Instead of deleting everything, use refresh tokens and apply the delta + if _, err := w.Exec(ctx, "delete from session where user_id = @user_id", + []any{sql.Named("user_id", u.Id)}); err != nil { + return err + } + + for _, s := range resp { + item, err := json.Marshal(s) + if err != nil { + return err + } + newSession := &Session{ + UserId: u.Id, + Id: s.Id, + Type: s.Type, + Status: s.Status, + Endpoint: s.Endpoint, + Item: string(item), + } + onConflict := db.OnConflict{ + Target: db.Columns{"user_id", "id"}, + Action: db.UpdateAll(true), + } + if err := w.Create(ctx, newSession, db.WithOnConflict(&onConflict)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return errors.Wrap(ctx, err, op) + } + return nil +} + +func (r *Repository) ListSessions(ctx context.Context, authTokenId string) ([]*sessions.Session, error) { + const op = "cache.(Repository).ListSessions" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + } + ret, err := r.searchSessions(ctx, authTokenId, "true", nil) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +func (r *Repository) QuerySessions(ctx context.Context, authTokenId, query string) ([]*sessions.Session, error) { + const op = "cache.(Repository).QuerySessions" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + case query == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "query is missing") + } + + w, err := mql.Parse(query, Session{}, mql.WithIgnoredFields("UserId", "Item")) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter)) + } + ret, err := r.searchSessions(ctx, authTokenId, w.Condition, w.Args) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +func (r *Repository) searchSessions(ctx context.Context, authTokenId, condition string, searchArgs []any) ([]*sessions.Session, error) { + const op = "cache.(Repository).searchSessions" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + case condition == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing") + } + + condition = fmt.Sprintf("%s and user_id in (select user_id from auth_token where id = ?)", condition) + args := append(searchArgs, authTokenId) + var cachedSessions []*Session + if err := r.rw.SearchWhere(ctx, &cachedSessions, condition, args, db.WithLimit(-1)); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + retSessions := make([]*sessions.Session, 0, len(cachedSessions)) + for _, cachedSess := range cachedSessions { + var sess sessions.Session + if err := json.Unmarshal([]byte(cachedSess.Item), &sess); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + retSessions = append(retSessions, &sess) + } + return retSessions, nil +} + +type Session struct { + UserId string `gorm:"primaryKey"` + Id string `gorm:"primaryKey"` + Type string `gorm:"default:null"` + Endpoint string `gorm:"default:null"` + Status string `gorm:"default:null"` + Item string `gorm:"default:null"` +} + +func (*Session) TableName() string { + return "session" +} diff --git a/internal/clientcache/internal/cache/repository_sessions_test.go b/internal/clientcache/internal/cache/repository_sessions_test.go new file mode 100644 index 000000000..cfd307de4 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_sessions_test.go @@ -0,0 +1,343 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/daemon/controller" + "github.com/hashicorp/boundary/internal/daemon/worker" + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets/tcp" +) + +func TestRepository_refreshSessions(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u := user{ + Id: "u1", + Address: addr, + } + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + } + kt := KeyringToken{ + KeyringType: "keyring", + TokenName: "token", + AuthTokenId: at.Id, + } + atMap := map[ringToken]*authtokens.AuthToken{ + {kt.KeyringType, kt.TokenName}: at, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + + ss := []*sessions.Session{ + { + Id: "ttcp_1", + Status: "status1", + Endpoint: "address1", + Type: "tcp", + }, + { + Id: "ttcp_2", + Status: "status2", + Endpoint: "address2", + Type: "tcp", + }, + { + Id: "ttcp_3", + Status: "status3", + Endpoint: "address3", + Type: "tcp", + }, + } + cases := []struct { + name string + u *user + sess []*sessions.Session + wantCount int + errorContains string + }{ + { + name: "Success", + u: &user{ + Address: addr, + Id: at.UserId, + }, + sess: ss, + wantCount: len(ss), + }, + { + name: "repeated session with different values", + u: &user{ + Address: addr, + Id: at.UserId, + }, + sess: append(ss, &sessions.Session{ + Id: ss[0].Id, + Status: "a different status", + }), + wantCount: len(ss), + }, + { + name: "nil user", + u: nil, + sess: ss, + errorContains: "user is nil", + }, + { + name: "missing user Id", + u: &user{ + Address: addr, + }, + sess: ss, + errorContains: "user id is missing", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := r.refreshSessions(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(tc.sess))) + if tc.errorContains == "" { + assert.NoError(t, err) + rw := db.New(s) + var got []*Session + require.NoError(t, rw.SearchWhere(ctx, &got, "true", nil)) + assert.Len(t, got, tc.wantCount) + } else { + assert.ErrorContains(t, err, tc.errorContains) + } + }) + } +} + +func TestRepository_ListSessions(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u1 := &user{ + Id: "u1", + Address: addr, + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u1.Id, + } + kt1 := KeyringToken{ + KeyringType: "k1", + TokenName: "t1", + AuthTokenId: at1.Id, + } + u2 := &user{ + Id: "u2", + Address: addr, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + } + kt2 := KeyringToken{ + KeyringType: "k2", + TokenName: "t2", + AuthTokenId: at2.Id, + } + atMap := map[ringToken]*authtokens.AuthToken{ + {"k1", "t1"}: at1, + {"k2", "t2"}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + t.Run("auth token id is missing", func(t *testing.T) { + l, err := r.ListSessions(ctx, "") + assert.Nil(t, l) + assert.ErrorContains(t, err, "auth token id is missing") + }) + + ss := []*sessions.Session{ + { + Id: "ttcp_1", + Status: "status1", + Endpoint: "address1", + Type: "tcp", + }, + { + Id: "ttcp_2", + Status: "status2", + Endpoint: "address2", + Type: "tcp", + }, + { + Id: "ttcp_3", + Status: "status3", + Endpoint: "address3", + Type: "tcp", + }, + } + require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss)))) + + t.Run("wrong user gets no sessions", func(t *testing.T) { + l, err := r.ListSessions(ctx, kt2.AuthTokenId) + assert.NoError(t, err) + assert.Empty(t, l) + }) + t.Run("correct token gets sessions", func(t *testing.T) { + l, err := r.ListSessions(ctx, kt1.AuthTokenId) + assert.NoError(t, err) + assert.Len(t, l, len(ss)) + assert.ElementsMatch(t, l, ss) + }) +} + +func TestRepository_QuerySessions(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u1 := &user{ + Id: "u1", + Address: addr, + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u1.Id, + } + kt1 := KeyringToken{ + KeyringType: "k1", + TokenName: "t1", + AuthTokenId: at1.Id, + } + u2 := &user{ + Id: "u2", + Address: addr, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + } + kt2 := KeyringToken{ + KeyringType: "k2", + TokenName: "t2", + AuthTokenId: at2.Id, + } + atMap := map[ringToken]*authtokens.AuthToken{ + {"k1", "t1"}: at1, + {"k2", "t2"}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + query := "status % status1 or status % status2" + + errorCases := []struct { + name string + t string + query string + errContains string + }{ + { + name: "auth token id is missing", + t: "", + query: query, + errContains: "auth token id is missing", + }, + { + name: "query is missing", + t: "token id", + errContains: "query is missing", + }, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + l, err := r.QuerySessions(ctx, tc.t, tc.query) + assert.Nil(t, l) + assert.ErrorContains(t, err, tc.errContains) + }) + } + + ss := []*sessions.Session{ + { + Id: "ttcp_1", + Status: "status1", + Endpoint: "address1", + Type: "tcp", + }, + { + Id: "ttcp_2", + Status: "status2", + Endpoint: "address2", + Type: "tcp", + }, + { + Id: "ttcp_3", + Status: "status3", + Endpoint: "address3", + Type: "tcp", + }, + } + require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(ss)))) + + t.Run("wrong token gets no sessions", func(t *testing.T) { + l, err := r.QuerySessions(ctx, kt2.AuthTokenId, query) + assert.NoError(t, err) + assert.Empty(t, l) + }) + t.Run("correct token gets sessions", func(t *testing.T) { + l, err := r.QuerySessions(ctx, kt1.AuthTokenId, query) + assert.NoError(t, err) + assert.Len(t, l, 2) + assert.ElementsMatch(t, l, ss[0:2]) + }) +} + +func TestDefaultSessionRetrievalFunc(t *testing.T) { + tc := controller.NewTestController(t, nil) + tc.Client().SetToken(tc.Token().Token) + tarClient := targets.NewClient(tc.Client()) + _ = worker.NewTestWorker(t, &worker.TestWorkerOpts{ + InitialUpstreams: tc.ClusterAddrs(), + WorkerAuthKms: tc.Config().WorkerAuthKms, + }) + + tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1), targets.WithAddress("address")) + require.NoError(t, err) + require.NotNil(t, tar1) + _, err = tarClient.AuthorizeSession(tc.Context(), tar1.Item.Id) + assert.NoError(t, err) + + got, err := defaultSessionFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) + assert.NoError(t, err) + assert.Len(t, got, 1) +} diff --git a/internal/clientcache/internal/cache/repository_targets.go b/internal/clientcache/internal/cache/repository_targets.go new file mode 100644 index 000000000..df0693ee3 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_targets.go @@ -0,0 +1,193 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "database/sql" + "encoding/json" + stderrors "errors" + "fmt" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/mql" +) + +// TargetRetrievalFunc is a function that retrieves targets +// from the provided boundary addr using the provided token. +type TargetRetrievalFunc func(ctx context.Context, addr, token string) ([]*targets.Target, error) + +func defaultTargetFunc(ctx context.Context, addr, token string) ([]*targets.Target, error) { + const op = "cache.defaultTargetFunc" + client, err := api.NewClient(&api.Config{ + Addr: addr, + Token: token, + }) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + tarClient := targets.NewClient(client) + l, err := tarClient.List(ctx, "global", targets.WithRecursive(true)) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return l.Items, nil +} + +func (r *Repository) refreshTargets(ctx context.Context, u *user, tokens map[AuthToken]string, opt ...Option) error { + const op = "cache.(Repository).refreshTargets" + switch { + case util.IsNil(u): + return errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return errors.New(ctx, errors.InvalidParameter, op, "user id is missing") + } + + opts, err := getOpts(opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if opts.withTargetRetrievalFunc == nil { + opts.withTargetRetrievalFunc = defaultTargetFunc + } + + // Find and use a token for retrieving targets + var gotResponse bool + var resp []*targets.Target + var retErr error + for at, t := range tokens { + resp, err = opts.withTargetRetrievalFunc(ctx, u.Address, t) + if err != nil { + // TODO: If we get an error about the token no longer having + // permissions, remove it. + retErr = stderrors.Join(retErr, errors.Wrap(ctx, err, op, errors.WithMsg("for token %q", at.Id))) + continue + } + gotResponse = true + break + } + if retErr != nil { + if saveErr := r.SaveError(ctx, u, resource.Target.String(), retErr); saveErr != nil { + return stderrors.Join(err, errors.Wrap(ctx, saveErr, op)) + } + } + if !gotResponse { + return retErr + } + + event.WriteSysEvent(ctx, op, fmt.Sprintf("updating %d targets for user %v", len(resp), u)) + _, err = r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + // TODO: Instead of deleting everything, use refresh tokens and apply the delta + if _, err := w.Exec(ctx, "delete from target where user_id = @user_id", + []any{sql.Named("user_id", u.Id)}); err != nil { + return err + } + + for _, t := range resp { + item, err := json.Marshal(t) + if err != nil { + return err + } + newTarget := &Target{ + UserId: u.Id, + Id: t.Id, + Name: t.Name, + Description: t.Description, + Address: t.Address, + Item: string(item), + } + onConflict := db.OnConflict{ + Target: db.Columns{"user_id", "id"}, + Action: db.UpdateAll(true), + } + if err := w.Create(ctx, newTarget, db.WithOnConflict(&onConflict)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return errors.Wrap(ctx, err, op) + } + return nil +} + +func (r *Repository) ListTargets(ctx context.Context, authTokenId string) ([]*targets.Target, error) { + const op = "cache.(Repository).ListTargets" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + } + ret, err := r.searchTargets(ctx, authTokenId, "true", nil) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +func (r *Repository) QueryTargets(ctx context.Context, authTokenId, query string) ([]*targets.Target, error) { + const op = "cache.(Repository).QueryTargets" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + case query == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "query is missing") + } + + w, err := mql.Parse(query, Target{}, mql.WithIgnoredFields("UserId", "Item")) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter)) + } + ret, err := r.searchTargets(ctx, authTokenId, w.Condition, w.Args) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +func (r *Repository) searchTargets(ctx context.Context, authTokenId, condition string, searchArgs []any) ([]*targets.Target, error) { + const op = "cache.(Repository).searchTargets" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is missing") + case condition == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing") + } + + condition = fmt.Sprintf("%s and user_id in (select user_id from auth_token where id = ?)", condition) + args := append(searchArgs, authTokenId) + var cachedTargets []*Target + if err := r.rw.SearchWhere(ctx, &cachedTargets, condition, args, db.WithLimit(-1)); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + retTargets := make([]*targets.Target, 0, len(cachedTargets)) + for _, cachedTar := range cachedTargets { + var tar targets.Target + if err := json.Unmarshal([]byte(cachedTar.Item), &tar); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + retTargets = append(retTargets, &tar) + } + return retTargets, nil +} + +type Target struct { + UserId string `gorm:"primaryKey"` + Id string `gorm:"primaryKey"` + Name string `gorm:"default:null"` + Description string `gorm:"default:null"` + Address string `gorm:"default:null"` + Item string `gorm:"default:null"` +} + +func (*Target) TableName() string { + return "target" +} diff --git a/internal/clientcache/internal/cache/repository_targets_test.go b/internal/clientcache/internal/cache/repository_targets_test.go new file mode 100644 index 000000000..ed3a1a9da --- /dev/null +++ b/internal/clientcache/internal/cache/repository_targets_test.go @@ -0,0 +1,328 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/targets" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/daemon/controller" + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +func TestRepository_refreshTargets(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u := user{ + Id: "u1", + Address: addr, + } + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + } + kt := KeyringToken{KeyringType: "k", TokenName: "t", AuthTokenId: at.Id} + atMap := map[ringToken]*authtokens.AuthToken{ + {"k", "t"}: at, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + + ts := []*targets.Target{ + { + Id: "ttcp_1", + Name: "name1", + Type: "tcp", + SessionMaxSeconds: 111, + }, + { + Id: "ttcp_2", + Name: "name2", + Address: "address2", + Type: "tcp", + SessionMaxSeconds: 222, + }, + { + Id: "ttcp_3", + Name: "name3", + Address: "address3", + Type: "tcp", + SessionMaxSeconds: 333, + }, + } + cases := []struct { + name string + u *user + targets []*targets.Target + wantCount int + errorContains string + }{ + { + name: "Success", + u: &user{ + Id: at.UserId, + Address: addr, + }, + targets: ts, + wantCount: len(ts), + }, + { + name: "repeated target with different values", + u: &user{ + Address: addr, + Id: at.UserId, + }, + targets: append(ts, &targets.Target{ + Id: ts[0].Id, + Name: "a different name", + }), + wantCount: len(ts), + }, + { + name: "nil user", + u: nil, + targets: ts, + errorContains: "user is nil", + }, + { + name: "missing user Id", + u: &user{ + Address: addr, + }, + targets: ts, + errorContains: "user id is missing", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := r.refreshTargets(ctx, tc.u, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(tc.targets))) + if tc.errorContains == "" { + assert.NoError(t, err) + rw := db.New(s) + var got []*Target + require.NoError(t, rw.SearchWhere(ctx, &got, "true", nil)) + assert.Len(t, got, tc.wantCount) + } else { + assert.ErrorContains(t, err, tc.errorContains) + } + }) + } +} + +func TestRepository_ListTargets(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u1 := &user{ + Id: "u1", + Address: addr, + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u1.Id, + } + kt1 := KeyringToken{KeyringType: "k1", TokenName: "t1", AuthTokenId: at1.Id} + + u2 := &user{ + Id: "u2", + Address: addr, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + } + kt2 := KeyringToken{KeyringType: "k2", TokenName: "t2", AuthTokenId: at2.Id} + atMap := map[ringToken]*authtokens.AuthToken{ + {"k1", "t1"}: at1, + {"k2", "t2"}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + t.Run("token is missing", func(t *testing.T) { + l, err := r.ListTargets(ctx, "") + assert.Nil(t, l) + assert.ErrorContains(t, err, "auth token id is missing") + }) + + ts := []*targets.Target{ + { + Id: "ttcp_1", + Name: "name1", + Address: "address1", + Type: "tcp", + SessionMaxSeconds: 111, + }, + { + Id: "ttcp_2", + Name: "name2", + Address: "address2", + Type: "tcp", + SessionMaxSeconds: 222, + }, + { + Id: "ttcp_3", + Name: "name3", + Address: "address3", + Type: "tcp", + SessionMaxSeconds: 333, + }, + } + require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts)))) + + t.Run("wrong user gets no targets", func(t *testing.T) { + l, err := r.ListTargets(ctx, kt2.AuthTokenId) + assert.NoError(t, err) + assert.Empty(t, l) + }) + t.Run("correct token gets targets", func(t *testing.T) { + l, err := r.ListTargets(ctx, kt1.AuthTokenId) + assert.NoError(t, err) + assert.Len(t, l, len(ts)) + assert.ElementsMatch(t, l, ts) + }) +} + +func TestRepository_QueryTargets(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u1 := &user{ + Id: "u1", + Address: addr, + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u1.Id, + } + kt1 := KeyringToken{KeyringType: "k1", TokenName: "t1", AuthTokenId: at1.Id} + + u2 := &user{ + Id: "u2", + Address: addr, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + } + kt2 := KeyringToken{KeyringType: "k2", TokenName: "t2", AuthTokenId: at2.Id} + atMap := map[ringToken]*authtokens.AuthToken{ + {"k1", "t1"}: at1, + {"k2", "t2"}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + query := "name % name1 or name % name2" + + errorCases := []struct { + name string + p string + query string + errContains string + }{ + { + name: "auth token id is missing", + p: "", + query: query, + errContains: "auth token id is missing", + }, + { + name: "query is missing", + p: "authtokenid", + errContains: "query is missing", + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + l, err := r.QueryTargets(ctx, tc.p, tc.query) + assert.Nil(t, l) + assert.ErrorContains(t, err, tc.errContains) + }) + } + + ts := []*targets.Target{ + { + Id: "ttcp_1", + Name: "name1", + Address: "address1", + Type: "tcp", + SessionMaxSeconds: 111, + }, + { + Id: "ttcp_2", + Name: "name2", + Address: "address2", + Type: "tcp", + SessionMaxSeconds: 222, + }, + { + Id: "ttcp_3", + Name: "name3", + Address: "address3", + Type: "tcp", + SessionMaxSeconds: 333, + }, + } + require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(ts)))) + + t.Run("wrong token gets no targets", func(t *testing.T) { + l, err := r.QueryTargets(ctx, kt2.AuthTokenId, query) + assert.NoError(t, err) + assert.Empty(t, l) + }) + t.Run("correct token gets targets", func(t *testing.T) { + l, err := r.QueryTargets(ctx, kt1.AuthTokenId, query) + assert.NoError(t, err) + assert.Len(t, l, 2) + assert.ElementsMatch(t, l, ts[0:2]) + }) +} + +func TestDefaultTargetRetrievalFunc(t *testing.T) { + tc := controller.NewTestController(t, nil) + tc.Client().SetToken(tc.Token().Token) + tarClient := targets.NewClient(tc.Client()) + + tar1, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar1"), targets.WithTcpTargetDefaultPort(1)) + require.NoError(t, err) + require.NotNil(t, tar1) + tar2, err := tarClient.Create(tc.Context(), "tcp", "p_1234567890", targets.WithName("tar2"), targets.WithTcpTargetDefaultPort(2)) + require.NoError(t, err) + require.NotNil(t, tar2) + + got, err := defaultTargetFunc(tc.Context(), tc.ApiAddrs()[0], tc.Token().Token) + assert.NoError(t, err) + assert.Contains(t, got, tar1.Item) + assert.Contains(t, got, tar2.Item) +} diff --git a/internal/clientcache/internal/cache/repository_test.go b/internal/clientcache/internal/cache/repository_test.go new file mode 100644 index 000000000..dfd9dc3a4 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + stdErrors "errors" + "fmt" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ringToken is a test struct used to group a keyring type and token name +// so it can be used in an authtoken lookup function. +type ringToken struct { + k string + t string +} + +// mapBasedAuthTokenKeyringLookup provides a fake KeyringTokenLookupFn that uses +// the provided map to perform lookups for the tokens +func mapBasedAuthTokenKeyringLookup(m map[ringToken]*authtokens.AuthToken) KeyringTokenLookupFn { + return func(k, t string) *authtokens.AuthToken { + return m[ringToken{k, t}] + } +} + +// sliceBasedAuthTokenBoundaryReader provides a fake BoundaryTokenReaderFn that uses +// the provided map to lookup an auth tokens information. +func sliceBasedAuthTokenBoundaryReader(s []*authtokens.AuthToken) BoundaryTokenReaderFn { + return func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) { + for _, v := range s { + if at == v.Token { + return v, nil + } + } + return nil, stdErrors.New("not found") + } +} + +func TestRepository_SaveError(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(map[ringToken]*authtokens.AuthToken{}), + sliceBasedAuthTokenBoundaryReader(nil)) + require.NoError(t, err) + + testResource := "test_resource_type" + testErr := fmt.Errorf("test error for %q", testResource) + + u := &user{ + Id: "u1", + Address: "addr", + } + require.NoError(t, r.rw.Create(ctx, u)) + + t.Run("empty resource type", func(t *testing.T) { + assert.ErrorContains(t, r.SaveError(ctx, u, "", testErr), "resource type is empty") + }) + t.Run("nil error", func(t *testing.T) { + assert.ErrorContains(t, r.SaveError(ctx, u, testResource, nil), "error is nil") + }) + t.Run("nil user", func(t *testing.T) { + assert.ErrorContains(t, r.SaveError(ctx, nil, testResource, testErr), "user is nil") + }) + t.Run("success", func(t *testing.T) { + assert.NoError(t, r.SaveError(ctx, u, testResource, testErr)) + }) +} diff --git a/internal/clientcache/internal/cache/repository_token.go b/internal/clientcache/internal/cache/repository_token.go new file mode 100644 index 000000000..aa592390f --- /dev/null +++ b/internal/clientcache/internal/cache/repository_token.go @@ -0,0 +1,527 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + "time" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// AuthTokenIdSegmentCount are the number of segments, delineated by "_", that +// make up the auth token id inside an auth token. +// For example, an authtoken format should look something like at_1234567890_sometokenpayload +const AuthTokenIdSegmentCount = 2 + +// upsertUserAndAuthToken upserts a user and authToken using the data in the provided authtoken. +// If creating this user results in the number of users exceeding the limit of +// allowed users it deletes the oldest one. +func upsertUserAndAuthToken(ctx context.Context, reader db.Reader, writer db.Writer, bAddr string, at *authtokens.AuthToken) error { + const op = "cache.upsertUserAndAuthToken" + switch { + case util.IsNil(reader): + return errors.New(ctx, errors.InvalidParameter, op, "reader is nil") + case util.IsNil(writer): + return errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case !writer.IsTx(ctx): + return errors.New(ctx, errors.InvalidParameter, op, "writer isn't part of an inflight transaction") + case util.IsNil(at): + return errors.New(ctx, errors.InvalidParameter, op, "auth token is nil") + case bAddr == "": + return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty") + case at.Id == "": + return errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty") + case at.UserId == "": + return errors.New(ctx, errors.InvalidParameter, op, "auth token user id is empty") + } + { + // always make sure the user exists when adding a token + u := &user{ + Id: at.UserId, + Address: bAddr, + } + onConflict := &db.OnConflict{ + Target: db.Columns{"id"}, + Action: db.DoNothing(true), + } + if err := writer.Create(ctx, u, db.WithOnConflict(onConflict)); err != nil { + return errors.Wrap(ctx, err, op) + } + } + + { + st := &AuthToken{ + Id: at.Id, + UserId: at.UserId, + LastAccessedTime: time.Now(), + ExpirationTime: at.ExpirationTime, + } + onConflict := &db.OnConflict{ + Target: db.Columns{"id"}, + Action: db.SetColumns([]string{"last_accessed_time", "expiration_time"}), + } + if err := writer.Create(ctx, st, db.WithOnConflict(onConflict)); err != nil { + return errors.Wrap(ctx, err, op) + } + } + + var users []*user + if err := reader.SearchWhere(ctx, &users, "true", []any{}, db.WithLimit(-1)); err != nil { + return errors.Wrap(ctx, err, op) + } + if len(users) <= usersLimit { + return nil + } + + var oldestUser *user + var oldestUsersTime *time.Time + for _, u := range users { + ats, err := listTokens(ctx, reader, u) + if err != nil { + return errors.Wrap(ctx, err, op) + } + for _, at := range ats { + if oldestUsersTime == nil || oldestUsersTime.After(at.LastAccessedTime) { + oldestUser = u + oldestUsersTime = &at.LastAccessedTime + } + } + } + if oldestUser != nil { + if _, err := deleteUser(ctx, writer, oldestUser); err != nil { + return errors.Wrap(ctx, err, op) + } + } + return nil +} + +// AddRawToken upserts the auth token's user and auth token in the db and +// stores the actual raw auth token in the repositories in memory storage. +// The raw token must be valid and present in boundary and be for a user that +// has permission to send a Read request for itself to boundary. +func (r *Repository) AddRawToken(ctx context.Context, bAddr string, rawToken string) error { + const op = "cache.(Repository).AddRawToken" + switch { + case rawToken == "": + return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token is empty") + case bAddr == "": + return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty") + } + + // rawToken should look something like at_1234567890_someencryptedpayload + atIdParts := strings.SplitN(rawToken, "_", 4) + if len(atIdParts) != 3 { + return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token is is malformed") + } + atId := strings.Join(atIdParts[:AuthTokenIdSegmentCount], "_") + + var at *authtokens.AuthToken + { + var inMemAuthToken *authtokens.AuthToken + atV, inMem := r.idToKeyringlessAuthToken.Load(atId) + if inMem { + var ok bool + inMemAuthToken, ok = atV.(*authtokens.AuthToken) + if !ok { + return errors.New(ctx, errors.Internal, op, "unable to cast in memory auth token to *authtoken.AuthToken") + } + } + t := &AuthToken{ + Id: atId, + } + err := r.rw.LookupById(ctx, t) + switch { + case err != nil && !errors.IsNotFoundError(err): + return errors.Wrap(ctx, err, op) + case errors.IsNotFoundError(err) || !inMem: + // if we don't know about it in the cache or we don't know about + // this auth token in memory, get it from boundary to sure up the + // cache information about this auth token. + at, err = r.tokenReadFromBoundaryFn(ctx, bAddr, rawToken) + if err != nil { + return errors.Wrap(ctx, err, op) + } + case t.UserId != inMemAuthToken.UserId: + return errors.New(ctx, errors.InvalidParameter, op, "user id doesn't match what is specified in the stored auth token") + case inMem: + at = inMemAuthToken + } + } + + if at != nil { + // The token is never returned from boundary except in the original auth + // request so we must set the token. + at.Token = rawToken + } + + _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error { + if err := upsertUserAndAuthToken(ctx, reader, writer, bAddr, at); err != nil { + return errors.Wrap(ctx, err, op) + } + r.idToKeyringlessAuthToken.Store(at.Id, at) + return nil + }) + if err != nil { + return err + } + + return nil +} + +// AddKeyringToken adds a token to the repository. If the token's id in the +// keyring doesn't match the id provided an error is returned. +// The token must be valid and present in boundary and be for a user that +// has permission to send a self-Read request to boundary. The user id +// stored in the keyring must also match the user id returned from boundary. +func (r *Repository) AddKeyringToken(ctx context.Context, bAddr string, token KeyringToken) error { + const op = "cache.(Repository).AddKeyringToken" + switch { + case token.TokenName == "": + return errors.New(ctx, errors.InvalidParameter, op, "token name is empty") + case token.KeyringType == "": + return errors.New(ctx, errors.InvalidParameter, op, "keyring type is empty") + case token.AuthTokenId == "": + return errors.New(ctx, errors.InvalidParameter, op, "boundary auth token id is empty") + case bAddr == "": + return errors.New(ctx, errors.InvalidParameter, op, "boundary address is empty") + } + kt := token.clone() + keyringStoredAt := r.tokenKeyringFn(kt.KeyringType, kt.TokenName) + if keyringStoredAt == nil { + return errors.New(ctx, errors.InvalidParameter, op, "unable to find token in the keyring specified") + } + if kt.AuthTokenId != keyringStoredAt.Id { + return errors.New(ctx, errors.InvalidParameter, op, "provided auth token id doesn't match the one stored") + } + + var at *authtokens.AuthToken + { + cachedAt := &AuthToken{ + Id: kt.AuthTokenId, + } + err := r.rw.LookupById(ctx, cachedAt) + switch { + case err != nil && !errors.IsNotFoundError(err): + return errors.Wrap(ctx, err, op) + case errors.IsNotFoundError(err): + at, err = r.tokenReadFromBoundaryFn(ctx, bAddr, keyringStoredAt.Token) + if err != nil { + return errors.Wrap(ctx, err, op) + } + case cachedAt.UserId != keyringStoredAt.UserId: + return errors.New(ctx, errors.InvalidParameter, op, "user id doesn't match what is specified in the stored auth token") + default: + at = keyringStoredAt + } + } + _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error { + if err := upsertUserAndAuthToken(ctx, reader, writer, bAddr, at); err != nil { + return errors.Wrap(ctx, err, op) + } + onConflict := &db.OnConflict{ + Target: db.Columns{"keyring_type", "token_name"}, + Action: db.SetColumns([]string{"auth_token_id"}), + } + if err := writer.Create(ctx, kt, db.WithOnConflict(onConflict)); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil + }) + if err != nil { + return err + } + + return nil +} + +// LookupToken returns the Token in the cache if one exists. +// Accepts withUpdateLastAccessedTime options. If withUpdateLastAccessedTime +// is provided, the last update time of the returned token will be updated to +// the current time and reflected in the db. The returned AuthToken will not +// have the updated time. +func (r *Repository) LookupToken(ctx context.Context, authTokenId string, opt ...Option) (*AuthToken, error) { + const op = "cache.(Repository).LookupToken" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty") + } + + at := &AuthToken{ + Id: authTokenId, + } + if err := r.rw.LookupById(ctx, at); err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } + return nil, errors.Wrap(ctx, err, op) + } + + opts, err := getOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + if opts.withUpdateLastAccessedTime { + updatedT := &AuthToken{ + Id: authTokenId, + LastAccessedTime: time.Now(), + } + if _, err := r.rw.Update(ctx, updatedT, []string{"LastAccessedTime"}, nil); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + } + return at, nil +} + +// deleteKeyringToken deletes a keyring token +func (r *Repository) deleteKeyringToken(ctx context.Context, kt KeyringToken) error { + const op = "cache.(Repository).deleteKeyringToken" + switch { + case kt.KeyringType == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing keyring type") + case kt.TokenName == "": + return errors.New(ctx, errors.InvalidParameter, op, "token name type") + } + + _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error { + // TODO(https://github.com/go-gorm/gorm/issues/4879): Use the + // writer.Delete() function once the gorm bug is fixed. Until then + // the gorm driver for sqlite has an error which wont execute a + // delete correctly. as a work around we manually execute the + // query here. + n, err := writer.Exec(ctx, "delete from keyring_token where (keyring_type, token_name) = (?, ?)", []any{kt.KeyringType, kt.TokenName}) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + switch n { + case 1: + if err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, r.idToKeyringlessAuthToken); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil + case 0: + return errors.New(ctx, errors.RecordNotFound, op, "token not found when attempting deletion") + default: + return errors.New(ctx, errors.MultipleRecords, op, "multiple tokens deleted when one was requested") + } + }) + if err != nil { + return err + } + return nil +} + +// cleanExpiredOrOrphanedAuthTokens removes all tokens which are older than the staleness limit +// or does not have either a keyring or keyringless reference to it. +func (r *Repository) cleanExpiredOrOrphanedAuthTokens(ctx context.Context) error { + const op = "cache.Repository.cleanExpiredOrOrphanedAuthTokens" + _, err := r.rw.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(reader db.Reader, writer db.Writer) error { + if err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, r.idToKeyringlessAuthToken); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil + }) + return err +} + +// cleanExpiredOrOrphanedAuthTokens removes all tokens which are older than the staleness limit +// or does not have either a keyring or keyringless reference to it. +func cleanExpiredOrOrphanedAuthTokens(ctx context.Context, writer db.Writer, idToKeyringlessAuthToken *sync.Map) error { + const op = "cache.cleanExpiredOrOrphanedAuthTokens" + switch { + case util.IsNil(writer): + return errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case idToKeyringlessAuthToken == nil: + return errors.New(ctx, errors.InvalidParameter, op, "keyringless auth token map is nil") + case !writer.IsTx(ctx): + return errors.New(ctx, errors.InvalidParameter, op, "writer isn't part of an inflight transaction") + } + + var keyringlessAuthTokens []string + idToKeyringlessAuthToken.Range(func(key, _ any) bool { + keyringlessAuthTokens = append(keyringlessAuthTokens, key.(string)) + return true + }) + + deleteOrphanedAuthTokens := ` + delete from auth_token + where + last_accessed_time < @last_accessed_time + or + -- sqlite stores expiration_time as a string in a format that might not match + -- what is being used by current_timestamp. Using datetime() makes the + -- formats match which allow the string comparison performed here to work + -- the same as a time comparison. + datetime(expiration_time) < current_timestamp + or + %s + ` + args := []any{sql.Named("last_accessed_time", time.Now().Add(-tokenStalenessLimit))} + + idInSection := "id not in (select auth_token_id from keyring_token)" + if len(keyringlessAuthTokens) > 0 { + // Note: We have to build the statement like this because if the slice of string + // is empty this gets converted to a query that says " and id not in (NULL)" + idInSection = fmt.Sprintf("(%s and id not in @keyringless_token_ids)", idInSection) + args = append(args, sql.Named("keyringless_token_ids", keyringlessAuthTokens)) + } + + if _, err := writer.Exec(ctx, fmt.Sprintf(deleteOrphanedAuthTokens, idInSection), args); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil +} + +// listUsers returns all known tokens in the cache +func (r *Repository) listUsers(ctx context.Context) ([]*user, error) { + const op = "cache.(Repository).listUsers" + var ret []*user + if err := r.rw.SearchWhere(ctx, &ret, "true", nil); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +// listTokens returns all known tokens in the cache for the provided user +func (r *Repository) listTokens(ctx context.Context, u *user) ([]*AuthToken, error) { + const op = "cache.(Repository).listTokens" + ret, err := listTokens(ctx, r.rw, u) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +// listTokens returns all known tokens in the cache for the provided user using the provided reader +func listTokens(ctx context.Context, reader db.Reader, u *user) ([]*AuthToken, error) { + const op = "cache.listTokens" + switch { + case util.IsNil(u): + return nil, errors.New(ctx, errors.InvalidParameter, op, "user is nil") + case u.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "user id is missing") + } + var ret []*AuthToken + if err := reader.SearchWhere(ctx, &ret, "user_id = ?", []any{u.Id}); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +// listKeyringTokens returns all known keyring tokens in the cache for the provided auth token +func (r *Repository) listKeyringTokens(ctx context.Context, at *AuthToken) ([]*KeyringToken, error) { + const op = "cache.(Repository).listTokens" + switch { + case util.IsNil(at): + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is nil") + case at.Id == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is empty") + } + var ret []*KeyringToken + if err := r.rw.SearchWhere(ctx, &ret, "auth_token_id = ?", []any{at.Id}); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +// syncKeyringlessTokensWithDb removes the in memory storage of auth tokens if +// they are no longer represented in the db. +func (r *Repository) syncKeyringlessTokensWithDb(ctx context.Context) error { + const op = "cache.(Repository).syncKeyringlessTokensWithDb" + var ret []*AuthToken + if err := r.rw.SearchWhere(ctx, &ret, "true", nil); err != nil { + return errors.Wrap(ctx, err, op) + } + authTokenIds := make(map[string]struct{}) + for _, at := range ret { + authTokenIds[at.Id] = struct{}{} + } + r.idToKeyringlessAuthToken.Range(func(key, value any) bool { + k := key.(string) + if _, ok := authTokenIds[k]; !ok { + r.idToKeyringlessAuthToken.Delete(key) + } + return true + }) + return nil +} + +// deleteUser executes a delete command using the provided db.Writer for the provided user. +func deleteUser(ctx context.Context, w db.Writer, u *user) (int, error) { + const op = "cache.deleteUser" + switch { + case util.IsNil(w): + return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "writer is nil") + case u.Id == "": + return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing id") + } + // TODO(https://github.com/go-gorm/gorm/issues/4879): Use the + // writer.Delete() function once the gorm bug is fixed. Until then + // the gorm driver for sqlite has an error which wont execute a + // delete correctly. as a work around we manually execute the + // query here. + n, err := w.Exec(ctx, "delete from user where id = ?", []any{u.Id}) + if err != nil { + err = errors.Wrap(ctx, err, op) + } + return n, err +} + +// user is a gorm model for the user table. It represents a user +type user struct { + Id string `gorm:"primaryKey"` + Address string `gorm:"default:null"` +} + +func (*user) TableName() string { + return "user" +} + +func (u *user) clone() *user { + return &user{ + Id: u.Id, + Address: u.Address, + } +} + +// AuthToken is a gorm model for the token table and is an auth token known by the +// boundary client side cache daemon. +type AuthToken struct { + Id string `gorm:"primaryKey"` + UserId string `gorm:"default:null"` + LastAccessedTime time.Time `gorm:"default:(strftime('%Y-%m-%d %H:%M:%f','now'))"` + ExpirationTime time.Time +} + +func (*AuthToken) TableName() string { + return "auth_token" +} + +// KeyringToken is a gorm model for the keyring stored token +type KeyringToken struct { + KeyringType string `gorm:"primaryKey"` + TokenName string `gorm:"primaryKey"` + AuthTokenId string `gorm:"default:null"` +} + +func (*KeyringToken) TableName() string { + return "keyring_token" +} + +func (kt *KeyringToken) clone() *KeyringToken { + return &KeyringToken{ + KeyringType: kt.KeyringType, + TokenName: kt.TokenName, + AuthTokenId: kt.AuthTokenId, + } +} diff --git a/internal/clientcache/internal/cache/repository_token_test.go b/internal/clientcache/internal/cache/repository_token_test.go new file mode 100644 index 000000000..08ffc3a77 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_token_test.go @@ -0,0 +1,822 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary/api/authtokens" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +func TestRepository_AddKeyringToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u := user{ + Id: "u1", + Address: addr, + } + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + } + // used to test mismatched user ids between keyring and cache + mismatchingAt := &authtokens.AuthToken{ + Id: "at_mismatch", + Token: "at_mismatch_token", + UserId: u.Id, + } + boundaryAuthTokens := []*authtokens.AuthToken{at, mismatchingAt} + keyring := "k" + tokenName := "t" + atMap := make(map[ringToken]*authtokens.AuthToken) + atMap[ringToken{keyring, tokenName}] = at + atMap[ringToken{"mismatch", "mismatch"}] = mismatchingAt + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + t.Run("userid mismatch between db and keyring", func(t *testing.T) { + require.NoError(t, r.AddKeyringToken(ctx, "address", KeyringToken{ + KeyringType: "mismatch", + TokenName: "mismatch", + AuthTokenId: mismatchingAt.Id, + })) + + mismatchingAt.UserId = "changedToMismatch" + assert.ErrorContains(t, r.AddKeyringToken(ctx, "address", KeyringToken{ + KeyringType: "mismatch", + TokenName: "mismatch", + AuthTokenId: mismatchingAt.Id, + }), "user id doesn't match what is specified in the stored auth token") + }) + + errCases := []struct { + name string + addr string + kt KeyringToken + errorContains string + }{ + { + name: "success", + addr: "address", + kt: KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + AuthTokenId: at.Id, + }, + errorContains: "", + }, + { + name: "not in keyring", + addr: "address", + kt: KeyringToken{ + KeyringType: keyring, + TokenName: "unknowntokenname", + AuthTokenId: at.Id, + }, + errorContains: "unable to find token in the keyring specified", + }, + { + name: "missing address", + kt: KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + AuthTokenId: at.Id, + }, + errorContains: "boundary address is empty", + }, + { + name: "missing token", + addr: "address", + kt: KeyringToken{ + KeyringType: keyring, + AuthTokenId: at.Id, + }, + errorContains: "token name is empty", + }, + { + name: "missing keyring type", + addr: "address", + kt: KeyringToken{ + TokenName: tokenName, + AuthTokenId: at.Id, + }, + errorContains: "keyring type is empty", + }, + { + name: "missing auth token id", + addr: "address", + kt: KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + }, + errorContains: "boundary auth token id is empty", + }, + { + name: "unmatching auth token id", + addr: "address", + kt: KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + AuthTokenId: "wrong auth token id", + }, + errorContains: "provided auth token id doesn't match", + }, + } + + for _, tc := range errCases { + t.Run(tc.name, func(t *testing.T) { + err := r.AddKeyringToken(ctx, tc.addr, tc.kt) + if tc.errorContains == "" { + require.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.errorContains) + } + }) + } +} + +func TestRepository_AddRawToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "u1", + } + existingAt := &authtokens.AuthToken{ + Id: "at_existing", + Token: "at_existing_token", + UserId: "u2", + } + boundaryAuthTokens := []*authtokens.AuthToken{at, existingAt} + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(map[ringToken]*authtokens.AuthToken{}), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + t.Run("mismatched userid between memory and db", func(t *testing.T) { + require.NoError(t, r.AddRawToken(ctx, "address", existingAt.Token)) + loadedExistingV, loaded := r.idToKeyringlessAuthToken.Load(existingAt.Id) + require.True(t, loaded) + loadedExistingAt := loadedExistingV.(*authtokens.AuthToken) + loadedExistingAt.UserId = "mismatchingUserId" + r.idToKeyringlessAuthToken.Store(existingAt.Id, loadedExistingAt) + + err := r.AddRawToken(ctx, "address", loadedExistingAt.Token) + assert.Error(t, err) + assert.ErrorContains(t, err, "user id doesn't match what is specified in the stored auth token") + }) + + errCases := []struct { + name string + addr string + rawAt string + errorContains string + }{ + { + name: "success", + addr: "address", + rawAt: at.Token, + errorContains: "", + }, + { + name: "missing address", + rawAt: at.Token, + errorContains: "boundary address is empty", + }, + { + name: "missing token", + addr: "address", + errorContains: "auth token is empty", + }, + { + name: "malformed auth token", + addr: "address", + rawAt: fmt.Sprintf("%s_extraunderscore", at.Token), + errorContains: "boundary auth token is is malformed", + }, + { + name: "not found in boundary", + addr: "address", + rawAt: "at_123_notfoundinboundary", + errorContains: "not found", + }, + } + + for _, tc := range errCases { + t.Run(tc.name, func(t *testing.T) { + err := r.AddRawToken(ctx, tc.addr, tc.rawAt) + if tc.errorContains == "" { + require.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.errorContains) + } + }) + } +} + +func TestRepository_AddToken_EvictsOverLimitUsers(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + boundaryAuthTokens := []*authtokens.AuthToken{ + { + UserId: "user_base", + Id: "at_base", + Token: "at_base_token", + }, + } + for i := 0; i < usersLimit; i++ { + iAt := &authtokens.AuthToken{ + UserId: fmt.Sprintf("user%d", i), + Id: fmt.Sprintf("at_%d", i), + } + iAt.Token = fmt.Sprintf("%s_token", iAt.Id) + boundaryAuthTokens = append(boundaryAuthTokens, iAt) + } + + atMap := make(map[ringToken]*authtokens.AuthToken) + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + addr := "address" + kt := KeyringToken{ + KeyringType: "keyring", + TokenName: "token", + AuthTokenId: boundaryAuthTokens[0].Id, + } + atMap[ringToken{kt.KeyringType, kt.TokenName}] = boundaryAuthTokens[0] + + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + + lastKtAdded := kt + for i, at := range boundaryAuthTokens[1:] { + kr := fmt.Sprintf("%s%d", kt.KeyringType, i) + tn := fmt.Sprintf("%s%d", kt.TokenName, i) + ikt := KeyringToken{KeyringType: kr, TokenName: tn, AuthTokenId: at.Id} + + atMap[ringToken{ikt.KeyringType, ikt.TokenName}] = at + assert.NoError(t, r.AddKeyringToken(ctx, addr, ikt)) + lastKtAdded = ikt + } + + // Lookup the first token added. It should have been deleted when the + // associated user was evicted for being used the least recently. + gotP, err := r.LookupToken(ctx, kt.AuthTokenId) + assert.NoError(t, err) + assert.Nil(t, gotP) + + gotP, err = r.LookupToken(ctx, lastKtAdded.AuthTokenId) + assert.NoError(t, err) + assert.NotEmpty(t, gotP) +} + +func TestRepository_AddToken_EvictsOverLimit_Keyringless(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + boundaryAuthTokens := []*authtokens.AuthToken{ + { + UserId: "user_base", + Id: "at_base", + Token: "at_base_token", + }, + } + for i := 0; i < usersLimit; i++ { + iAt := &authtokens.AuthToken{ + UserId: fmt.Sprintf("user%d", i), + Id: fmt.Sprintf("at_%d", i), + } + iAt.Token = fmt.Sprintf("%s_token", iAt.Id) + boundaryAuthTokens = append(boundaryAuthTokens, iAt) + } + + atMap := make(map[ringToken]*authtokens.AuthToken) + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + addr := "address" + + assert.NoError(t, r.AddRawToken(ctx, addr, boundaryAuthTokens[0].Token)) + assert.NoError(t, r.AddRawToken(ctx, addr, boundaryAuthTokens[0].Token)) + for _, at := range boundaryAuthTokens[1:] { + assert.NoError(t, r.AddRawToken(ctx, addr, at.Token)) + } + // Lookup the first persona added. It should have been evicted from the db + // for being used the least recently. It is only removed from the db once + // cleanAuthTokens is called. + gotP, err := r.LookupToken(ctx, boundaryAuthTokens[0].Id) + assert.NoError(t, err) + assert.Nil(t, gotP) + _, ok := r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[0].Id) + assert.True(t, ok) + + gotP, err = r.LookupToken(ctx, boundaryAuthTokens[len(boundaryAuthTokens)-1].Id) + assert.NoError(t, err) + assert.NotEmpty(t, gotP) + _, ok = r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[len(boundaryAuthTokens)-1].Id) + assert.True(t, ok) + + assert.NoError(t, r.syncKeyringlessTokensWithDb(ctx)) + _, ok = r.idToKeyringlessAuthToken.Load(boundaryAuthTokens[0].Id) + assert.False(t, ok) +} + +func TestRepository_CleanAuthTokens(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + at := &authtokens.AuthToken{ + UserId: "user_base", + Id: "at_base", + Token: "at_base_token", + } + boundaryAuthTokens := []*authtokens.AuthToken{at} + + atMap := make(map[ringToken]*authtokens.AuthToken) + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + assert.NoError(t, r.AddRawToken(ctx, "baddr", at.Token)) + _, present := r.idToKeyringlessAuthToken.Load(at.Id) + assert.True(t, present) + + _, err = r.rw.Delete(ctx, &user{Id: at.UserId}) + require.NoError(t, err) + + _, present = r.idToKeyringlessAuthToken.Load(at.Id) + assert.True(t, present) + + assert.NoError(t, r.syncKeyringlessTokensWithDb(ctx)) + + _, present = r.idToKeyringlessAuthToken.Load(at.Id) + assert.False(t, present) +} + +func TestRepository_AddKeyringToken_AddingExistingUpdatesLastAccessedTime(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "u_1", + } + kt1 := KeyringToken{ + TokenName: "t1", + KeyringType: "k1", + AuthTokenId: at1.Id, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: "u_2", + } + kt2 := KeyringToken{ + TokenName: "t2", + KeyringType: "k2", + AuthTokenId: "at_2", + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1, at2} + atMap := map[ringToken]*authtokens.AuthToken{ + {kt1.KeyringType, kt1.TokenName}: at1, + {kt2.KeyringType, kt2.TokenName}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + time.Sleep(10 * time.Millisecond) + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + + gotP1, err := r.LookupToken(ctx, kt1.AuthTokenId) + require.NoError(t, err) + require.NotNil(t, gotP1) + gotP2, err := r.LookupToken(ctx, kt2.AuthTokenId) + require.NoError(t, err) + require.NotNil(t, gotP2) + + assert.Greater(t, gotP1.LastAccessedTime, gotP2.LastAccessedTime) +} + +func TestRepository_AddRawToken_AddingExistingUpdatesLastAccessedTime(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "u_1", + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: "u_2", + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1, at2} + atMap := map[ringToken]*authtokens.AuthToken{} + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + assert.NoError(t, r.AddRawToken(ctx, addr, at1.Token)) + assert.NoError(t, r.AddRawToken(ctx, addr, at2.Token)) + + time.Sleep(10 * time.Millisecond) + assert.NoError(t, r.AddRawToken(ctx, addr, at1.Token)) + + gotP1, err := r.LookupToken(ctx, at1.Id) + require.NoError(t, err) + require.NotNil(t, gotP1) + gotP2, err := r.LookupToken(ctx, at2.Id) + require.NoError(t, err) + require.NotNil(t, gotP2) + + assert.Greater(t, gotP1.LastAccessedTime, gotP2.LastAccessedTime) +} + +func TestRepository_ListTokens(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u := &user{ + Id: "u1", + Address: addr, + } + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + } + + atMap := make(map[ringToken]*authtokens.AuthToken) + + ktTokenCount := 15 + for i := 0; i < ktTokenCount; i++ { + k := fmt.Sprintf("k%d", i) + t := fmt.Sprintf("t%d", i) + atMap[ringToken{k, t}] = at + } + + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + + for k, v := range atMap { + require.NoError(t, r.AddKeyringToken(ctx, addr, KeyringToken{ + KeyringType: k.k, + TokenName: k.t, + AuthTokenId: v.Id, + })) + } + + t.Run("no token", func(t *testing.T) { + gotP, err := r.listTokens(ctx, &user{Id: "tokenless"}) + assert.NoError(t, err) + assert.Empty(t, gotP) + }) + + t.Run("many tokens", func(t *testing.T) { + gotAt, err := r.listTokens(ctx, u) + assert.NoError(t, err) + assert.Len(t, gotAt, 1) + + gotKrt, err := r.listKeyringTokens(ctx, gotAt[0]) + assert.NoError(t, err) + assert.Len(t, gotKrt, ktTokenCount) + }) +} + +func TestRepository_DeleteKeyringToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "u_1", + } + kt1 := KeyringToken{ + TokenName: "t1", + KeyringType: "k1", + AuthTokenId: at1.Id, + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1} + atMap := map[ringToken]*authtokens.AuthToken{ + {kt1.KeyringType, kt1.TokenName}: at1, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + t.Run("delete non existing", func(t *testing.T) { + assert.ErrorContains(t, r.deleteKeyringToken(ctx, KeyringToken{KeyringType: "Unknown", TokenName: "Unknown"}), "not found") + }) + + t.Run("delete existing", func(t *testing.T) { + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + p, err := r.LookupToken(ctx, kt1.AuthTokenId) + require.NoError(t, err) + require.NotNil(t, p) + + assert.NoError(t, r.deleteKeyringToken(ctx, kt1)) + + got, err := r.LookupToken(ctx, kt1.AuthTokenId) + require.NoError(t, err) + require.Nil(t, got) + }) +} + +func TestRepository_LookupToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "u_1", + } + kt := KeyringToken{ + TokenName: "t1", + KeyringType: "k1", + AuthTokenId: at.Id, + } + + boundaryAuthTokens := []*authtokens.AuthToken{at} + atMap := map[ringToken]*authtokens.AuthToken{ + {kt.KeyringType, kt.TokenName}: at, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + t.Run("empty token id", func(t *testing.T) { + p, err := r.LookupToken(ctx, "") + assert.ErrorContains(t, err, "auth token id is empty") + assert.Nil(t, p) + }) + t.Run("not found", func(t *testing.T) { + p, err := r.LookupToken(ctx, "token") + assert.NoError(t, err) + assert.Nil(t, p) + }) + t.Run("found", func(t *testing.T) { + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + p, err := r.LookupToken(ctx, kt.AuthTokenId) + assert.NoError(t, err) + assert.NotEmpty(t, p) + }) + + t.Run("withUpdateLastAccessedTime", func(t *testing.T) { + assert.NoError(t, r.AddKeyringToken(ctx, addr, kt)) + time.Sleep(1 * time.Millisecond) + + beforeP, err := r.LookupToken(ctx, kt.AuthTokenId, WithUpdateLastAccessedTime(true)) + assert.NoError(t, err) + assert.NotEmpty(t, beforeP) + + afterP, err := r.LookupToken(ctx, kt.AuthTokenId) + assert.NoError(t, err) + assert.NotEmpty(t, afterP) + assert.Greater(t, afterP.LastAccessedTime, beforeP.LastAccessedTime) + }) +} + +func TestRepository_RemoveStaleTokens(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + u := &user{ + Id: "user", + Address: "address", + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u.Id, + ExpirationTime: time.Now().Add(1 * time.Minute), + } + kt1 := KeyringToken{ + TokenName: "t1", + KeyringType: "k1", + AuthTokenId: at1.Id, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u.Id, + ExpirationTime: time.Now().Add(1 * time.Minute), + } + kt2 := KeyringToken{ + TokenName: "t2", + KeyringType: "k2", + AuthTokenId: "at_2", + } + // this auth token expired a minute ago + at3 := &authtokens.AuthToken{ + Id: "at_3", + Token: "at_3_token", + UserId: u.Id, + ExpirationTime: time.Now().Add(-(1 * time.Minute)), + } + kt3 := KeyringToken{ + TokenName: "t3", + KeyringType: "k3", + AuthTokenId: "at_3", + } + + boundaryAuthTokens := []*authtokens.AuthToken{at1, at2, at3} + atMap := map[ringToken]*authtokens.AuthToken{ + {kt1.KeyringType, kt1.TokenName}: at1, + {kt2.KeyringType, kt2.TokenName}: at2, + {kt3.KeyringType, kt3.TokenName}: at3, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt1)) + assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt2)) + assert.NoError(t, r.AddKeyringToken(ctx, u.Address, kt3)) + + staleTime := time.Now().Add(-(tokenStalenessLimit + 1*time.Hour)) + freshTime := time.Now().Add(-(tokenStalenessLimit - 1*time.Hour)) + + freshAt := &AuthToken{ + Id: at1.Id, + LastAccessedTime: freshTime, + } + _, err = r.rw.Update(ctx, freshAt, []string{"LastAccessedTime"}, nil) + require.NoError(t, err) + anotherFreshAt := &AuthToken{ + Id: at3.Id, + LastAccessedTime: freshTime, + } + _, err = r.rw.Update(ctx, anotherFreshAt, []string{"LastAccessedTime"}, nil) + require.NoError(t, err) + + staleAt := &AuthToken{ + Id: at2.Id, + LastAccessedTime: staleTime, + } + _, err = r.rw.Update(ctx, staleAt, []string{"LastAccessedTime"}, nil) + require.NoError(t, err) + + lAt, err := r.listTokens(ctx, u) + assert.NoError(t, err) + assert.Len(t, lAt, 3) + + assert.NoError(t, r.cleanExpiredOrOrphanedAuthTokens(ctx)) + + lAt, err = r.listTokens(ctx, u) + assert.NoError(t, err) + assert.Len(t, lAt, 1) + assert.Equal(t, lAt[0].Id, at1.Id) +} + +func TestCleanExpiredOrOrphanedAuthTokens_Errors(t *testing.T) { + ctx := context.Background() + + err := cleanExpiredOrOrphanedAuthTokens(ctx, nil, &sync.Map{}) + assert.Error(t, err) + assert.ErrorContains(t, err, "writer is nil") + + s, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(s) + + err = cleanExpiredOrOrphanedAuthTokens(ctx, rw, &sync.Map{}) + assert.Error(t, err) + assert.ErrorContains(t, err, "writer isn't part of an inflight transaction") + + rw.DoTx(ctx, 1, db.ExpBackoff{}, func(_ db.Reader, writer db.Writer) error { + err := cleanExpiredOrOrphanedAuthTokens(ctx, writer, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "keyringless auth token map is nil") + return nil + }) +} + +func TestUpsertUserAndAuthToken(t *testing.T) { + ctx := context.Background() + + s, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(s) + + defaultAt := &authtokens.AuthToken{ + Id: "at_123", + Token: "at_123_token", + UserId: "u_123", + } + + rw.DoTx(ctx, 1, db.ExpBackoff{}, func(txReader db.Reader, txWriter db.Writer) error { + errorCases := []struct { + name string + reader db.Reader + writer db.Writer + addr string + at *authtokens.AuthToken + errorContains string + }{ + { + name: "nil reader", + reader: nil, + writer: txWriter, + addr: "address", + at: defaultAt, + errorContains: "reader is nil", + }, + { + name: "nil writer", + reader: txReader, + writer: nil, + addr: "address", + at: defaultAt, + errorContains: "writer is nil", + }, + { + name: "writer not in tx", + reader: txReader, + writer: rw, + addr: "address", + at: defaultAt, + errorContains: "writer isn't part of an inflight transaction", + }, + { + name: "empty address", + reader: txReader, + writer: txWriter, + addr: "", + at: defaultAt, + errorContains: "boundary address is empty", + }, + { + name: "auth token is nil", + reader: txReader, + writer: txWriter, + addr: "address", + at: nil, + errorContains: "auth token is nil", + }, + { + name: "auth token missing id", + reader: txReader, + writer: txWriter, + addr: "address", + at: &authtokens.AuthToken{ + Token: "at_123_token", + UserId: "u_123", + }, + errorContains: "auth token id is empty", + }, + { + name: "auth token missing user id", + reader: txReader, + writer: txWriter, + addr: "address", + at: &authtokens.AuthToken{ + Id: "at_123", + Token: "at_123_token", + }, + errorContains: "auth token user id is empty", + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + err := upsertUserAndAuthToken(ctx, tc.reader, tc.writer, tc.addr, tc.at) + assert.Error(t, err) + assert.ErrorContains(t, err, tc.errorContains) + }) + } + return nil + }) +} diff --git a/internal/clientcache/internal/cache/search.go b/internal/clientcache/internal/cache/search.go new file mode 100644 index 000000000..3d8bf47eb --- /dev/null +++ b/internal/clientcache/internal/cache/search.go @@ -0,0 +1,196 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/go-bexpr" +) + +type SearchableResource string + +const ( + Unknown SearchableResource = "unknown" + Targets SearchableResource = "targets" + Sessions SearchableResource = "sessions" +) + +func (r SearchableResource) Valid() bool { + switch r { + case Targets, Sessions: + return true + } + return false +} + +func ToSearchableResource(s string) SearchableResource { + switch { + case strings.EqualFold(s, string(Targets)): + return Targets + case strings.EqualFold(s, string(Sessions)): + return Sessions + } + return Unknown +} + +// SearchParams contains the parameters for searching in the cache +type SearchParams struct { + // the name of the resource. eg. "targets" or "sessions" + Resource SearchableResource + // the auth token id for the user id that has resources synced to the cache + AuthTokenId string + // the optional mql query to use when searching the resources. + Query string + // the optional bexpr filter string that all results will be filtered by + Filter string +} + +// SearchResult returns the results from searching the cache. +type SearchResult struct { + Targets []*targets.Target + Sessions []*sessions.Session +} + +// SearchService is a domain service that can search across all resources in the +// cache. +type SearchService struct { + searchableResources map[SearchableResource]resourceSearcher +} + +// QueryAndLister defines the methods needed for listing an querying the +// resources supported by the boundary frontend. +type QueryAndLister interface { + QueryTargets(context.Context, string, string) ([]*targets.Target, error) + ListTargets(context.Context, string) ([]*targets.Target, error) + QuerySessions(context.Context, string, string) ([]*sessions.Session, error) + ListSessions(context.Context, string) ([]*sessions.Session, error) +} + +func NewSearchService(ctx context.Context, repo QueryAndLister) (*SearchService, error) { + const op = "cache.NewSearchService" + switch { + case util.IsNil(repo): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repo is nil") + } + return &SearchService{ + searchableResources: map[SearchableResource]resourceSearcher{ + Targets: &resourceSearchFns[*targets.Target]{ + list: repo.ListTargets, + query: repo.QueryTargets, + searchResult: func(t []*targets.Target) *SearchResult { + return &SearchResult{Targets: t} + }, + }, + Sessions: &resourceSearchFns[*sessions.Session]{ + list: repo.ListSessions, + query: repo.QuerySessions, + searchResult: func(s []*sessions.Session) *SearchResult { + return &SearchResult{Sessions: s} + }, + }, + }, + }, nil +} + +// Search returns a SearchResult based on the provided SearchParams. If the +// SearchParams doesn't have a valid searchable resource or an auth token id +// an error is returned. If the auth token id is unrecognized or is associated +// with a user id which doesn't have any resources associated with it an empty +// SearchResult is returned. SearchResult will only have at most one field +// populated. +func (s *SearchService) Search(ctx context.Context, params SearchParams) (*SearchResult, error) { + const op = "cache.(SearchService).Search" + switch { + case !params.Resource.Valid(): + return nil, errors.New(ctx, errors.InvalidParameter, op, "invalid resource") + case params.AuthTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token id") + } + rSearcher, ok := s.searchableResources[params.Resource] + if !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("resource name %q is not recognized", params.Resource)) + } + resp, err := rSearcher.search(ctx, params) + if err != nil { + err = errors.Wrap(ctx, err, op) + } + return resp, err +} + +// resourceSearchFns is a struct that collects all the functions needed to +// perform a search on a specific resource type. +type resourceSearchFns[T any] struct { + // list takes a context and an auth token and returns all resources for the + // user of that auth token. If the provided auth token is not in the cache + // an empty slice and no error is returned. + list func(context.Context, string) ([]T, error) + // query takes a context, an auth token, and a query string and returns all + // resources for that auth token that matches the provided query parameter. + // If the provided auth token is not in the cache an empty slice and no + // error is returned. + query func(context.Context, string, string) ([]T, error) + // searchResult is a function which provides a SearchResult based on the + // type of T. SearchResult contains different fields for the different + // resource types returned, so for example if T is *targets.Target the + // returned SearchResult will have it's "Targets" field populated so the + // searchResult should take the passed in paramater and assign it to the + // appropriate field in the SearchResult. + searchResult func([]T) *SearchResult +} + +// resourceSearcher is an interface that only resourceSearchFns[T] is expected +// to satisfy. Specifying this interface allows the code to have a map with +// resourceSearchFns values which have different bound generic types. +type resourceSearcher interface { + search(ctx context.Context, p SearchParams) (*SearchResult, error) +} + +// search will perform a query using the provided query string or a list if the +// provided query string is empty and filter than based on the provided filter. +// The results are tied to the user id associated with the provided auth token id. +// If the auth token id or the associated user are not in the cache no error +// is returned and the returned SearchResults will be empty. +// search implements searcher. +func (l *resourceSearchFns[T]) search(ctx context.Context, p SearchParams) (*SearchResult, error) { + const op = "daemon.(resourceSearchFns).search" + + var found []T + var err error + switch p.Query { + case "": + found, err = l.list(ctx, p.AuthTokenId) + default: + found, err = l.query(ctx, p.AuthTokenId, p.Query) + } + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + if p.Filter == "" { + return l.searchResult(found), nil + } + + e, err := bexpr.CreateEvaluator(p.Filter, bexpr.WithTagName("json")) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("couldn't build filter"), errors.WithCode(errors.InvalidParameter)) + } + finalResults := make([]T, 0, len(found)) + for _, item := range found { + if m, err := e.Evaluate(filterItem{item}); err == nil && m { + finalResults = append(finalResults, item) + } + } + return l.searchResult(finalResults), nil +} + +type filterItem struct { + Item any `json:"item"` +} diff --git a/internal/clientcache/internal/cache/search_test.go b/internal/clientcache/internal/cache/search_test.go new file mode 100644 index 000000000..cbeaf491c --- /dev/null +++ b/internal/clientcache/internal/cache/search_test.go @@ -0,0 +1,234 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSearchService(t *testing.T) { + ctx := context.Background() + + t.Run("nil repo", func(t *testing.T) { + ss, err := NewSearchService(ctx, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "repo is nil") + assert.Nil(t, ss) + }) + + t.Run("success", func(t *testing.T) { + s, err := cachedb.Open(ctx) + require.NoError(t, err) + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + assert.NoError(t, err) + assert.NotNil(t, ss) + }) +} + +func TestSearch_Errors(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + require.NoError(t, err) + require.NotNil(t, ss) + + cases := []struct { + name string + params SearchParams + errorContains string + }{ + { + name: "missing resource", + params: SearchParams{ + Resource: "", + AuthTokenId: "at_1", + }, + errorContains: "invalid resource", + }, + { + name: "missing auth token id", + params: SearchParams{ + Resource: "targets", + AuthTokenId: "", + }, + errorContains: "missing auth token id", + }, + { + name: "unrecognized resource", + params: SearchParams{ + Resource: "unknown", + AuthTokenId: "at_1", + }, + errorContains: "invalid resource", + }, + { + name: "bad filter", + params: SearchParams{ + Resource: "targets", + AuthTokenId: "at_1", + Filter: "unknown=filter?syntax!", + }, + errorContains: "couldn't build filter", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := ss.Search(ctx, tc.params) + assert.Error(t, err) + assert.ErrorContains(t, err, tc.errorContains) + assert.Nil(t, res) + }) + } +} + +func TestSearch(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + at := &AuthToken{ + Id: "at_1", + UserId: "u_1", + } + { + u := &user{Id: at.UserId, Address: "address"} + rw := db.New(s) + require.NoError(t, rw.Create(ctx, u)) + require.NoError(t, rw.Create(ctx, at)) + + targets := []any{ + &Target{UserId: u.Id, Id: "t_1", Name: "one", Item: `{"id": "t_1", "name": "one"}`}, + &Target{UserId: u.Id, Id: "t_2", Name: "two", Item: `{"id": "t_2", "name": "two"}`}, + } + require.NoError(t, rw.CreateItems(ctx, targets)) + + sessions := []any{ + &Session{UserId: u.Id, Id: "s_1", Endpoint: "one", Item: `{"id": "s_1", "endpoint": "one"}`}, + &Session{UserId: u.Id, Id: "s_2", Endpoint: "two", Item: `{"id": "s_2", "endpoint": "two"}`}, + } + require.NoError(t, rw.CreateItems(ctx, sessions)) + } + + r, err := NewRepository(ctx, s, &sync.Map{}, + mapBasedAuthTokenKeyringLookup(nil), + sliceBasedAuthTokenBoundaryReader(nil)) + assert.NoError(t, err) + + ss, err := NewSearchService(ctx, r) + require.NoError(t, err) + require.NotNil(t, ss) + + t.Run("List targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + {Id: "t_2", Name: "two"}, + }}, got) + }) + + t.Run("query targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Query: `name="one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + }}, got) + }) + + t.Run("query targets bad column", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Query: `item % "one"`, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, `invalid column "item"`) + assert.Nil(t, got) + }) + + t.Run("Filter targets", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: at.Id, + Filter: `"/item/name" matches "one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Targets: []*targets.Target{ + {Id: "t_1", Name: "one"}, + }}, got) + }) + + t.Run("List sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + {Id: "s_2", Endpoint: "two"}, + }}, got) + }) + + t.Run("query sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + Query: `endpoint="one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + }}, got) + }) + + t.Run("Filter sessions", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "sessions", + AuthTokenId: at.Id, + Filter: `"/item/endpoint" matches "one"`, + }) + assert.NoError(t, err) + assert.EqualValues(t, &SearchResult{Sessions: []*sessions.Session{ + {Id: "s_1", Endpoint: "one"}, + }}, got) + }) + + t.Run("unrecognized auth token", func(t *testing.T) { + got, err := ss.Search(ctx, SearchParams{ + Resource: "targets", + AuthTokenId: "unrecognized", + }) + assert.NoError(t, err) + assert.Equal(t, &SearchResult{Targets: []*targets.Target{}}, got) + }) +} diff --git a/internal/clientcache/internal/cache/store_test.go b/internal/clientcache/internal/cache/store_test.go new file mode 100644 index 000000000..a7920de15 --- /dev/null +++ b/internal/clientcache/internal/cache/store_test.go @@ -0,0 +1,508 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "testing" + "time" + + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUser(t *testing.T) { + ctx := context.Background() + conn, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(conn) + + t.Run("missing address", func(t *testing.T) { + u := &user{ + Id: "u1", + } + assert.ErrorContains(t, rw.Create(ctx, &u), "constraint failed") + }) + + t.Run("missing id", func(t *testing.T) { + u := &user{ + Address: "address", + } + assert.ErrorContains(t, rw.Create(ctx, &u), "constraint failed") + }) + + t.Run("create success", func(t *testing.T) { + u := &user{ + Id: "created", + Address: "address", + } + assert.NoError(t, rw.Create(ctx, &u)) + }) + + t.Run("update", func(t *testing.T) { + u := &user{ + Id: "update", + Address: "address", + } + require.NoError(t, rw.Create(ctx, &u)) + updatedU := u.clone() + updatedU.Address = "updated" + _, err := rw.Update(ctx, updatedU, []string{"address"}, nil) + require.NoError(t, err) + + lookedUp := u.clone() + require.NoError(t, rw.LookupById(ctx, lookedUp)) + assert.Equal(t, updatedU.Address, lookedUp.Address) + }) +} + +func TestUser_NoMoreTokens(t *testing.T) { + ctx := context.Background() + conn, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(conn) + + u := &user{ + Id: "userId", + Address: "address", + } + require.NoError(t, rw.Create(ctx, u)) + + tok1 := &AuthToken{ + UserId: u.Id, + Id: "at_1", + } + require.NoError(t, rw.Create(ctx, tok1)) + tok2 := &AuthToken{ + UserId: u.Id, + Id: "at_2", + } + require.NoError(t, rw.Create(ctx, tok2)) + assert.NoError(t, rw.LookupById(ctx, u)) + + // deleting a single token doesn't remove the user + _, err = rw.Exec(ctx, "delete from auth_token where id = ?", []any{tok1.Id}) + require.NoError(t, err) + assert.NoError(t, rw.LookupById(ctx, u)) + + // deleting both tokens _does_ remove the user + _, err = rw.Exec(ctx, "delete from auth_token", nil) + require.NoError(t, err) + assert.True(t, errors.IsNotFoundError(rw.LookupById(ctx, u))) +} + +func TestAuthToken_NoMoreKeyringTokens(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(s) + + u := &user{ + Id: "userId", + Address: "address", + } + require.NoError(t, rw.Create(ctx, u)) + + at := &AuthToken{ + UserId: u.Id, + Id: "at_1234567890", + } + require.NoError(t, rw.Create(ctx, at)) + + kt1 := &KeyringToken{ + KeyringType: "k1", + TokenName: "n1", + AuthTokenId: at.Id, + } + require.NoError(t, rw.Create(ctx, kt1)) + kt2 := &KeyringToken{ + KeyringType: "k2", + TokenName: "n2", + AuthTokenId: at.Id, + } + require.NoError(t, rw.Create(ctx, kt2)) + assert.NoError(t, rw.LookupById(ctx, u)) + + // deleting the keyring tokens doesn't remove the user + _, err = rw.Exec(ctx, "delete from keyring_token", nil) + require.NoError(t, err) + assert.NoError(t, rw.LookupById(ctx, at)) + assert.NoError(t, rw.LookupById(ctx, u)) +} + +func TestAuthToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + rw := db.New(s) + + u := &user{ + Id: "userId", + Address: "address", + } + + t.Run("no user foreign key constraint", func(t *testing.T) { + tok := &AuthToken{ + UserId: u.Id, + Id: "at_1234567890", + } + require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed") + }) + + require.NoError(t, rw.Create(ctx, u)) + + t.Run("no user id", func(t *testing.T) { + tok := &AuthToken{ + Id: "at_1234567890", + } + require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed") + }) + + t.Run("create", func(t *testing.T) { + tok := &AuthToken{ + UserId: u.Id, + Id: "at_create", + } + before := time.Now().Truncate(1 * time.Millisecond) + require.NoError(t, rw.Create(ctx, tok)) + require.NoError(t, rw.LookupById(ctx, tok)) + assert.GreaterOrEqual(t, tok.LastAccessedTime, before) + }) + + t.Run("update", func(t *testing.T) { + tok := &AuthToken{ + UserId: u.Id, + Id: "at_update", + } + require.NoError(t, rw.Create(ctx, tok)) + + tok.LastAccessedTime = time.Now().Add(-(24 * 365 * time.Hour)) + n, err := rw.Update(ctx, tok, []string{"LastAccessedTime"}, nil) + assert.NoError(t, err) + assert.Equal(t, 1, n) + }) + + t.Run("delete user deletes token", func(t *testing.T) { + u := &user{ + Id: "deletethis", + Address: "deleted", + } + require.NoError(t, rw.Create(ctx, u)) + + tok := &AuthToken{ + UserId: u.Id, + Id: "at_deleted", + } + require.NoError(t, rw.Create(ctx, tok)) + + _, err = rw.Exec(ctx, "delete from user where id = ?", []any{u.Id}) + + require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, tok))) + }) + + // TODO: When gorm sqlite driver fixes it's delete, use rw.Delete instead of the Exec. + // n, err := rw.Delete(ctx, p) + _, err = rw.Exec(ctx, "delete from auth_token", nil) + assert.NoError(t, err) +} + +func TestKeyringToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + rw := db.New(s) + + u := &user{ + Id: "userId", + Address: "address", + } + require.NoError(t, rw.Create(ctx, u)) + + at := &AuthToken{ + Id: "at_1", + UserId: u.Id, + } + + t.Run("no token foreign key constraint", func(t *testing.T) { + kt := &KeyringToken{ + KeyringType: "keyring", + TokenName: "token", + AuthTokenId: at.Id, + } + require.ErrorContains(t, rw.Create(ctx, kt), "constraint failed") + }) + + require.NoError(t, rw.Create(ctx, at)) + + t.Run("no auth token id", func(t *testing.T) { + tok := &KeyringToken{ + KeyringType: "keyring", + TokenName: "token", + } + require.ErrorContains(t, rw.Create(ctx, tok), "constraint failed") + }) + + t.Run("create", func(t *testing.T) { + kt := &KeyringToken{ + KeyringType: "keyring", + TokenName: "create", + AuthTokenId: at.Id, + } + require.NoError(t, rw.Create(ctx, kt)) + require.NoError(t, rw.LookupById(ctx, kt)) + }) + + t.Run("delete user deletes keyring token", func(t *testing.T) { + u := &user{ + Id: "deletethis", + Address: "deleted", + } + require.NoError(t, rw.Create(ctx, u)) + + at := &AuthToken{ + UserId: u.Id, + Id: "at_deleted", + } + require.NoError(t, rw.Create(ctx, at)) + + kt := &KeyringToken{ + KeyringType: "keyring", + TokenName: "deleted", + AuthTokenId: at.Id, + } + require.NoError(t, rw.Create(ctx, kt)) + require.NoError(t, rw.LookupById(ctx, kt)) + + _, err = rw.Exec(ctx, "delete from user where id = ?", []any{u.Id}) + require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, kt))) + }) + + t.Run("delete auth token deletes keyring token", func(t *testing.T) { + u := &user{ + Id: "deletethis", + Address: "deleted", + } + require.NoError(t, rw.Create(ctx, u)) + + at := &AuthToken{ + UserId: u.Id, + Id: "at_deleted", + } + require.NoError(t, rw.Create(ctx, at)) + + kt := &KeyringToken{ + KeyringType: "keyring", + TokenName: "deleted", + AuthTokenId: at.Id, + } + require.NoError(t, rw.Create(ctx, kt)) + require.NoError(t, rw.LookupById(ctx, kt)) + + _, err = rw.Exec(ctx, "delete from auth_token where id = ?", []any{at.Id}) + require.True(t, errors.IsNotFoundError(rw.LookupById(ctx, kt))) + }) + + // TODO: When gorm sqlite driver fixes it's delete, use rw.Delete instead of the Exec. + // n, err := rw.Delete(ctx, p) + _, err = rw.Exec(ctx, "delete from keyring_token", nil) + assert.NoError(t, err) +} + +func TestTarget(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + rw := db.New(s) + + addr := "boundary" + userId := "u_12345" + u := &user{ + Id: userId, + Address: addr, + } + require.NoError(t, rw.Create(ctx, u)) + + t.Run("target without user id", func(t *testing.T) { + unknownTarget := &Target{ + Id: "tssh_1234567890", + Name: "target", + Description: "target desc", + Address: "some address", + Item: "{id:'tssh_1234567890'}", + } + require.ErrorContains(t, rw.Create(ctx, unknownTarget), "constraint failed") + }) + + t.Run("target actions", func(t *testing.T) { + target := &Target{ + UserId: u.Id, + Id: "tssh_1234567890", + Name: "target", + Description: "target desc", + Address: "some address", + Item: "{id:'tssh_1234567890'}", + } + + require.NoError(t, rw.Create(ctx, target)) + + require.NoError(t, rw.LookupById(ctx, target)) + + target.Address = "new address" + n, err := rw.Update(ctx, target, []string{"address"}, nil) + assert.NoError(t, err) + assert.Equal(t, 1, n) + + // TODO: Once the sqlite driver properly builds the delete query call + // n, err = rw.Delete(ctx, target) instead of the Exec call + n, err = rw.Exec(ctx, "delete from target where (user_id, id) IN (values (?, ?))", + []any{target.UserId, target.Id}) + assert.NoError(t, err) + assert.Equal(t, 1, n) + }) + + t.Run("lookup a target", func(t *testing.T) { + target := &Target{ + UserId: u.Id, + Id: "tssh_1234567890", + Name: "target", + Description: "target desc", + Address: "some address", + Item: "{id:'tssh_1234567890'}", + } + require.NoError(t, rw.Create(ctx, target)) + + lookTar := &Target{ + UserId: target.UserId, + Id: target.Id, + } + assert.NoError(t, rw.LookupById(ctx, lookTar)) + assert.NotNil(t, lookTar) + + // cleanup the targets + _, err := rw.Exec(ctx, "delete from target", nil) + require.NoError(t, err) + }) + + t.Run("deleting the user deletes the target", func(t *testing.T) { + target := &Target{ + UserId: u.Id, + Id: "tssh_1234567890", + Name: "target", + Description: "target desc", + Address: "some address", + Item: "{id:'tssh_1234567890'}", + } + require.NoError(t, rw.Create(ctx, target)) + // Deleting the user deletes the target + // TODO: Once the sqlite driver supports proper deletes change from the + // Exec call to .Delete + // n, err := rw.Delete(ctx, &u) + n, err := rw.Exec(ctx, "delete from user where id = ?", []any{userId}) + require.NoError(t, err) + require.Equal(t, 1, n) + + lookTar := &Target{ + UserId: target.UserId, + Id: target.Id, + } + assert.ErrorContains(t, rw.LookupById(ctx, lookTar), "not found") + }) +} + +func TestSession(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + rw := db.New(s) + + addr := "boundary" + userId := "u_12345" + u := &user{ + Id: userId, + Address: addr, + } + require.NoError(t, rw.Create(ctx, u)) + + t.Run("session without user id", func(t *testing.T) { + unknownSess := &Session{ + Id: "sess_1234567890", + Item: "{id:'sess_1234567890'}", + } + require.ErrorContains(t, rw.Create(ctx, unknownSess), "FOREIGN KEY constraint") + }) + t.Run("session actions", func(t *testing.T) { + session := &Session{ + UserId: u.Id, + Id: "s_1234567890", + Endpoint: "endpoint", + Item: "{id:'s_1234567890'}", + } + + require.NoError(t, rw.Create(ctx, session)) + + require.NoError(t, rw.LookupById(ctx, session)) + + session.Endpoint = "new endpoint" + n, err := rw.Update(ctx, session, []string{"endpoint"}, nil) + assert.NoError(t, err) + assert.Equal(t, 1, n) + + // TODO: Once the sqlite driver properly builds the delete query call + // n, err = rw.Delete(ctx, session) instead of the Exec call + n, err = rw.Exec(ctx, "delete from session where (user_id, id) IN (values (?, ?))", + []any{session.UserId, session.Id}) + assert.NoError(t, err) + assert.Equal(t, 1, n) + }) + + t.Run("lookup a session", func(t *testing.T) { + session := &Session{ + UserId: u.Id, + Id: "s_1234567890", + Endpoint: "endpoint", + Item: "{id:'s_1234567890'}", + } + require.NoError(t, rw.Create(ctx, session)) + + lookSess := &Session{ + UserId: u.Id, + Id: session.Id, + } + assert.NoError(t, rw.LookupById(ctx, lookSess)) + assert.NotNil(t, lookSess) + + // cleanup the sessions + _, err := rw.Exec(ctx, "delete from session", nil) + require.NoError(t, err) + }) + + t.Run("deleting the user deletes the session", func(t *testing.T) { + session := &Session{ + UserId: u.Id, + Id: "s_1234567890", + Endpoint: "endpoint", + Item: "{id:'s_1234567890'}", + } + require.NoError(t, rw.Create(ctx, session)) + // Deleting the user deletes the session + // TODO: Once the sqlite driver supports proper deletes change from the + // Exec call to .Delete + // n, err := rw.Delete(ctx, &u) + n, err := rw.Exec(ctx, "delete from user where id = ?", []any{userId}) + require.NoError(t, err) + require.Equal(t, 1, n) + + lookSess := &Session{ + UserId: session.UserId, + Id: session.Id, + } + assert.ErrorContains(t, rw.LookupById(ctx, lookSess), "not found") + }) +} diff --git a/internal/clientcache/internal/daemon/doc.go b/internal/clientcache/internal/daemon/doc.go new file mode 100644 index 000000000..4772d0159 --- /dev/null +++ b/internal/clientcache/internal/daemon/doc.go @@ -0,0 +1,7 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +/* +Package daemon contains the logic for managing the client cache daemon. +*/ +package daemon diff --git a/internal/clientcache/internal/daemon/handlers.go b/internal/clientcache/internal/daemon/handlers.go new file mode 100644 index 000000000..3562ecfe5 --- /dev/null +++ b/internal/clientcache/internal/daemon/handlers.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/hashicorp/boundary/api" +) + +func writeError(w http.ResponseWriter, msg string, s int) { + status := http.StatusText(s) + b, err := json.Marshal(&api.Error{ + Kind: status, + Message: msg, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Unable to marshal error {Kind: %s, Message: %q} into api error format: %s", status, msg, err.Error()), http.StatusInternalServerError) + return + } + http.Error(w, string(b), s) +} diff --git a/internal/clientcache/internal/daemon/listener.go b/internal/clientcache/internal/daemon/listener.go new file mode 100644 index 000000000..88c21236f --- /dev/null +++ b/internal/clientcache/internal/daemon/listener.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + + "github.com/hashicorp/boundary/internal/errors" +) + +const ( + sockAddr = "socket/daemon.sock" + + // Currently we only allow the same user that started the boundary daemon + // to connect to the socket to make requests to it. + // In linux, sockets visible to the FS honor the perms of the dir they are in. + // To create a new socket we must have read/write/list(execute) permissions on + // the directory it is being created in. + socketDirPerms = 0o700 + // To connect to a socket it must have read/write permissions + socketPerms = 0o600 +) + +// listener provides a Listener on the daemon unix socket. +func listener(ctx context.Context, path string) (net.Listener, error) { + const op = "daemon.listener" + socketName := filepath.Join(path, sockAddr) + if err := os.Remove(socketName); err != nil { + // If the socket existed before and wasn't cleaned up delete it now. + if !os.IsNotExist(err) { + return nil, errors.Wrap(ctx, err, op) + } + } + + socketPath := filepath.Dir(socketName) + if err := os.MkdirAll(socketPath, socketDirPerms); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to create boundary directory")) + } + + l, err := net.Listen("unix", socketName) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Failed listening")) + } + if err := os.Chmod(socketName, socketPerms); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("changing socket permissiosn")) + } + return l, nil +} + +// SocketAddress returns the unix socket filename with a 'unix://' prefix. +// The returned value can be passed directly into the api.Client +func SocketAddress(path string) string { + return fmt.Sprintf("unix://%s", filepath.Join(path, sockAddr)) +} diff --git a/internal/clientcache/internal/daemon/listener_nonwindows_test.go b/internal/clientcache/internal/daemon/listener_nonwindows_test.go new file mode 100644 index 000000000..20fa117a3 --- /dev/null +++ b/internal/clientcache/internal/daemon/listener_nonwindows_test.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !windows +// +build !windows + +package daemon + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TODO: Write a test for this that can run on windows. +func TestListenerSocketPermissions(t *testing.T) { + ctx := context.Background() + + path, err := os.MkdirTemp("", "*") + require.NoError(t, err) + l, err := listener(ctx, path) + require.NoError(t, err) + socketFile := l.Addr().String() + fi, err := os.Stat(socketFile) + require.NoError(t, err) + assert.Equal(t, fi.Mode().Type(), os.ModeSocket) + assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm(), "permissions were ", fi.Mode().Perm().String()) + + socketDirName := filepath.Dir(socketFile) + di, err := os.Stat(socketDirName) + require.NoError(t, err) + assert.Equal(t, di.Mode().Type(), os.ModeDir) + assert.Equal(t, fs.FileMode(0o700), di.Mode().Perm(), "permissions were ", fi.Mode().Perm().String()) +} diff --git a/internal/clientcache/internal/daemon/listener_test.go b/internal/clientcache/internal/daemon/listener_test.go new file mode 100644 index 000000000..bf2461809 --- /dev/null +++ b/internal/clientcache/internal/daemon/listener_test.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + "testing" + + "github.com/hashicorp/boundary/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Note: the name of this test must remain short because the temp dir created +// includes the name of the test and there is a 108 character limit in allowed +// unix socket path names. +func TestListenComms(t *testing.T) { + ctx := context.Background() + path := t.TempDir() + socketListener, err := listener(ctx, path) + + require.NoError(t, err) + mux := http.NewServeMux() + + payload := "Hello test" + mux.HandleFunc("/v1/test", func(w http.ResponseWriter, r *http.Request) { + _, err = fmt.Fprint(w, payload) + require.NoError(t, err) + }) + srv := &http.Server{ + Handler: mux, + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + assert.ErrorIs(t, srv.Serve(socketListener), http.ErrServerClosed) + }() + + client, err := api.NewClient(nil) + require.NoError(t, err) + require.NoError(t, client.SetAddr(SocketAddress(path))) + client.SetToken("") + req, err := client.NewRequest(ctx, "GET", "/test", nil) + require.NoError(t, err) + resp, err := client.Do(req) + assert.NoError(t, err) + got, err := io.ReadAll(resp.HttpResponse().Body) + require.NoError(t, err) + assert.Equal(t, string(got), payload) + + require.NoError(t, srv.Close()) + wg.Wait() +} diff --git a/internal/clientcache/internal/daemon/options.go b/internal/clientcache/internal/daemon/options.go new file mode 100644 index 000000000..5abeee384 --- /dev/null +++ b/internal/clientcache/internal/daemon/options.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" +) + +type options struct { + withDebug bool + withBoundaryTokenReaderFunc cache.BoundaryTokenReaderFn +} + +// Option - how options are passed as args +type Option func(*options) error + +func getDefaultOptions() options { + return options{} +} + +func getOpts(opt ...Option) (options, error) { + opts := getDefaultOptions() + + for _, o := range opt { + if err := o(&opts); err != nil { + return opts, err + } + } + return opts, nil +} + +// WithDebug provides an optional debug flag. +func WithDebug(_ context.Context, debug bool) Option { + return func(o *options) error { + o.withDebug = debug + return nil + } +} + +// WithBoundaryTokenReaderFunc provides an option for specifying a BoundaryTokenReaderFn +func WithBoundaryTokenReaderFunc(_ context.Context, fn cache.BoundaryTokenReaderFn) Option { + return func(o *options) error { + o.withBoundaryTokenReaderFunc = fn + return nil + } +} diff --git a/internal/clientcache/internal/daemon/options_test.go b/internal/clientcache/internal/daemon/options_test.go new file mode 100644 index 000000000..16c21b5a7 --- /dev/null +++ b/internal/clientcache/internal/daemon/options_test.go @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("default", func(t *testing.T) { + opts, err := getOpts() + require.NoError(t, err) + testOpts := options{} + assert.Equal(t, opts, testOpts) + }) + t.Run("WithDebug", func(t *testing.T) { + opts, err := getOpts(WithDebug(ctx, true)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.withDebug = true + assert.Equal(t, opts, testOpts) + }) + t.Run("WithBoundaryTokenReaderFunc", func(t *testing.T) { + var f cache.BoundaryTokenReaderFn = func(ctx context.Context, addr, token string) (*authtokens.AuthToken, error) { + return nil, nil + } + opts, err := getOpts(WithBoundaryTokenReaderFunc(ctx, f)) + require.NoError(t, err) + + assert.NotNil(t, opts.withBoundaryTokenReaderFunc) + opts.withBoundaryTokenReaderFunc = nil + + testOpts := getDefaultOptions() + assert.Equal(t, opts, testOpts) + }) +} diff --git a/internal/clientcache/internal/daemon/search_handler.go b/internal/clientcache/internal/daemon/search_handler.go new file mode 100644 index 000000000..56d67659a --- /dev/null +++ b/internal/clientcache/internal/daemon/search_handler.go @@ -0,0 +1,110 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// SearchResult is the struct returned to search requests. +type SearchResult struct { + Targets []*targets.Target `json:",omitempty"` + Sessions []*sessions.Session `json:",omitempty"` +} + +const ( + filterKey = "filter" + queryKey = "query" + resourceKey = "resource" + + boundaryAddrKey = "boundary_addr" + authTokenIdKey = "auth_token_id" +) + +func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository) (http.HandlerFunc, error) { + const op = "daemon.newSearchHandlerFunc" + switch { + case util.IsNil(repo): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is missing") + } + + s, err := cache.NewSearchService(ctx, repo) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resource := r.URL.Query().Get(resourceKey) + authTokenId := r.URL.Query().Get(authTokenIdKey) + + searchableResource := cache.ToSearchableResource(resource) + switch { + case resource == "": + writeError(w, "resource is a required field but was empty", http.StatusBadRequest) + return + case !searchableResource.Valid(): + writeError(w, "provided resource is not a valid searchable resource", http.StatusBadRequest) + return + case authTokenId == "": + writeError(w, fmt.Sprintf("%s is a required field but was empty", authTokenIdKey), http.StatusBadRequest) + return + } + + t, err := repo.LookupToken(ctx, authTokenId, cache.WithUpdateLastAccessedTime(true)) + if err != nil || t == nil { + writeError(w, "Forbidden", http.StatusForbidden) + return + } + + query := r.URL.Query().Get(queryKey) + filter := r.URL.Query().Get(filterKey) + + res, err := s.Search(ctx, cache.SearchParams{ + AuthTokenId: authTokenId, + Resource: searchableResource, + Query: query, + Filter: filter, + }) + if err != nil { + switch { + case errors.Match(errors.T(errors.InvalidParameter), err): + writeError(w, err.Error(), http.StatusBadRequest) + default: + writeError(w, err.Error(), http.StatusInternalServerError) + } + return + } + if res == nil { + writeError(w, "nil SearchResult generated", http.StatusInternalServerError) + return + } + + apiRes := toApiResult(res) + j, err := json.Marshal(apiRes) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(j) + }, nil +} + +// toApiResult converts a domain search result to an api search result +func toApiResult(sr *cache.SearchResult) *SearchResult { + return &SearchResult{ + Targets: sr.Targets, + Sessions: sr.Sessions, + } +} diff --git a/internal/clientcache/internal/daemon/server.go b/internal/clientcache/internal/daemon/server.go new file mode 100644 index 000000000..dd09f51f8 --- /dev/null +++ b/internal/clientcache/internal/daemon/server.go @@ -0,0 +1,453 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/base/logging" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/version" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-secure-stdlib/parseutil" +) + +// Commander is an interface that provides a way to get an apiClient +// and retrieve the keyring and token information used by a command. +type Commander interface { + ClientProvider + DiscoverKeyringTokenInfo() (string, string, error) + ReadTokenFromKeyring(keyringType, tokenName string) *authtokens.AuthToken +} + +// ClientProvider is an interface that provides an api.Client +type ClientProvider interface { + Client(opt ...base.Option) (*api.Client, error) +} + +type CacheServer struct { + conf *Config + + infoKeys []string + info map[string]string + + storeUrl string + store *db.DB + + tickerWg *sync.WaitGroup + httpSrv *http.Server + + shutdownOnce *sync.Once +} + +type Config struct { + ContextCancel context.CancelFunc + RefreshIntervalSeconds int64 + DatabaseUrl string + StoreDebug bool + LogLevel string + LogFormat string + LogWriter io.Writer + DotDirectory string +} + +func (sc *Config) validate(ctx context.Context) error { + const op = "daemon.(serverConfig).validate" + switch { + case util.IsNil(sc.LogWriter): + return errors.New(ctx, errors.InvalidParameter, op, "missing log writter") + case util.IsNil(sc.ContextCancel): + return errors.New(ctx, errors.InvalidParameter, op, "missing contextCancel") + case sc.DotDirectory == "": + return errors.New(ctx, errors.InvalidParameter, op, "missing dot directory") + } + return nil +} + +// can be called before eventing is setup +func New(ctx context.Context, conf *Config) (*CacheServer, error) { + const op = "daemon.newServer" + if err := conf.validate(ctx); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + s := &CacheServer{ + conf: conf, + info: make(map[string]string), + infoKeys: make([]string, 0, 20), + tickerWg: new(sync.WaitGroup), + shutdownOnce: new(sync.Once), + } + if err := s.setupLogging(ctx, conf.LogWriter); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return s, nil +} + +func (s *CacheServer) Shutdown(ctx context.Context) error { + const op = "daemon.(cacheServer).Shutdown" + + var shutdownErr error + s.shutdownOnce.Do(func() { + if s.conf.ContextCancel != nil { + s.conf.ContextCancel() + } + srvCtx, srvCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer srvCancel() + err := s.httpSrv.Shutdown(srvCtx) + if err != nil { + shutdownErr = fmt.Errorf("error shutting down server: %w", err) + return + } + s.tickerWg.Wait() + event.WriteSysEvent(context.Background(), op, "daemon server shutdown") + if err := event.SysEventer().FlushNodes(context.Background()); err != nil { + shutdownErr = fmt.Errorf("error flushing eventer nodes: %w", err) + return + } + }) + return shutdownErr +} + +func defaultBoundaryTokenReader(ctx context.Context, cp ClientProvider) (cache.BoundaryTokenReaderFn, error) { + const op = "daemon.defaultBoundaryTokenReader" + switch { + case util.IsNil(cp): + return nil, errors.New(ctx, errors.InvalidParameter, op, "client provider is nil") + } + return func(ctx context.Context, addr, tok string) (*authtokens.AuthToken, error) { + switch { + case addr == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "address is missing") + case tok == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is missing") + } + atIdParts := strings.SplitN(tok, "_", 4) + if len(atIdParts) != 3 { + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token is malformed") + } + atId := strings.Join(atIdParts[:cache.AuthTokenIdSegmentCount], "_") + + c, err := cp.Client() + if err != nil { + return nil, err + } + c.SetAddr(addr) + c.SetToken(tok) + atClient := authtokens.NewClient(c) + + at, err := atClient.Read(ctx, atId) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return at.GetItem(), nil + }, nil +} + +// Serve will fire up the refresh goroutine and the caching API http server as a +// daemon. The daemon bits are included so it's easy for CLI cmds to start the +// a cache server +func (s *CacheServer) Serve(ctx context.Context, cmd Commander, opt ...Option) error { + const op = "daemon.(cacheServer).Serve" + switch { + case util.IsNil(ctx): + return errors.New(ctx, errors.InvalidParameter, op, "context is missing") + } + + opts, err := getOpts(opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if opts.withBoundaryTokenReaderFunc == nil { + opts.withBoundaryTokenReaderFunc, err = defaultBoundaryTokenReader(ctx, cmd) + if err != nil { + return errors.Wrap(ctx, err, op) + } + } + + l, err := listener(ctx, s.conf.DotDirectory) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + s.info["Listening address"] = l.Addr().String() + s.infoKeys = append(s.infoKeys, "Listening address") + s.info["Store debug"] = strconv.FormatBool(s.conf.StoreDebug) + s.infoKeys = append(s.infoKeys, "Store debug") + + if s.store, s.storeUrl, err = openStore(ctx, s.conf.DatabaseUrl, s.conf.StoreDebug); err != nil { + return errors.Wrap(ctx, err, op) + } + s.info["Database URL"] = s.storeUrl + s.infoKeys = append(s.infoKeys, "Database URL") + + s.printInfo(ctx) + + repo, err := cache.NewRepository(ctx, s.store, &sync.Map{}, cmd.ReadTokenFromKeyring, opts.withBoundaryTokenReaderFunc) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + // If we have a token info already, add it to the repository immediately so + // it can start to get updated. + func() { + client, err := cmd.Client() + if err != nil { + event.WriteError(ctx, op, err) + return + } + krType, tokName, err := cmd.DiscoverKeyringTokenInfo() + if err != nil { + event.WriteError(ctx, op, err) + return + } + if at := cmd.ReadTokenFromKeyring(krType, tokName); at != nil { + if err := repo.AddKeyringToken(ctx, client.Addr(), cache.KeyringToken{KeyringType: krType, TokenName: tokName, AuthTokenId: at.Id}); err != nil { + event.WriteError(ctx, op, err) + return + } + } + }() + + refreshService, err := cache.NewRefreshService(ctx, repo) + if err != nil { + return errors.Wrap(ctx, err, op) + } + tic, err := newRefreshTicker(ctx, s.conf.RefreshIntervalSeconds, refreshService) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + tickingCtx, cancel := context.WithCancel(ctx) + defer cancel() + var tickerWg sync.WaitGroup + tickerWg.Add(1) + go func() { + defer tickerWg.Done() + tic.start(tickingCtx) + }() + + mux := http.NewServeMux() + searchTargetsFn, err := newSearchHandlerFunc(ctx, repo) + if err != nil { + return errors.Wrap(ctx, err, op) + } + mux.HandleFunc("/v1/search", searchTargetsFn) + + tokenFn, err := newTokenHandlerFunc(ctx, repo, tic) + if err != nil { + return errors.Wrap(ctx, err, op) + } + mux.HandleFunc("/v1/tokens", tokenFn) + + stopFn, err := newStopHandlerFunc(ctx, s.conf.ContextCancel) + if err != nil { + return errors.Wrap(ctx, err, op) + } + mux.Handle("/v1/stop", versionEnforcement(stopFn)) + + logger, err := event.SysEventer().StandardLogger(ctx, "daemon.serve: ", event.ErrorType) + if err != nil { + return errors.Wrap(ctx, err, op) + } + s.httpSrv = &http.Server{ + Handler: mux, + ErrorLog: logger, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + } + if err = s.httpSrv.Serve(l); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) { + event.WriteSysEvent(ctx, op, "error closing server", "err", err.Error()) + } + cancel() + tickerWg.Wait() + + return nil +} + +func (s *CacheServer) printInfo(ctx context.Context) { + const op = "daemon.(cacheServer).printInfo" + verInfo := version.Get() + if verInfo.Version != "" { + s.infoKeys = append(s.infoKeys, "version") + s.info["version"] = verInfo.FullVersionNumber(false) + } + if verInfo.Revision != "" { + s.info["version sha"] = strings.Trim(verInfo.Revision, "'") + s.infoKeys = append(s.infoKeys, "version sha") + } + s.infoKeys = append(s.infoKeys, "cgo") + s.info["cgo"] = "disabled" + if version.CgoEnabled { + s.info["cgo"] = "enabled" + } + + // Server configuration output + padding := 0 + for _, k := range s.infoKeys { + currPadding := padding - len(k) + if currPadding < 2 { + padding = len(k) + 2 + } + } + sort.Strings(s.infoKeys) + + output := []string{} + output = append(output, "==> cache configuration:\n") + for _, k := range s.infoKeys { + output = append(output, fmt.Sprintf( + "%s%s: %s", + strings.Repeat(" ", padding-len(k)), + strings.Title(k), + s.info[k])) + } + output = append(output, "") + + // Output the header that the server has started + output = append(output, "==> cache started! Log data will stream in below:\n") + event.WriteSysEvent(ctx, op, strings.Join(output, "\n")) +} + +func (s *CacheServer) setupLogging(ctx context.Context, w io.Writer) error { + const op = "daemon.(Command).setupLogging" + switch { + case util.IsNil(w): + return errors.New(ctx, errors.InvalidParameter, op, "log writer is nil") + } + + logFormat := logging.StandardFormat + if s.conf.LogFormat != "" { + var err error + logFormat, err = logging.ParseLogFormat(s.conf.LogFormat) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + } + + logLevel := strings.ToLower(strings.TrimSpace(s.conf.LogLevel)) + if logLevel == "" { + logLevel = "info" + } + + // Set up logging + // Set level based off text value + var level hclog.Level + switch logLevel { + case "trace": + level = hclog.Trace + case "debug": + level = hclog.Debug + case "notice", "info": + level = hclog.Info + case "warn", "warning": + level = hclog.Warn + case "err", "error": + level = hclog.Error + default: + return fmt.Errorf("%s: unknown log level: %s", op, logLevel) + } + var logLock sync.Mutex + logger := hclog.New(&hclog.LoggerOptions{ + Output: w, + Level: level, + JSONFormat: logFormat == logging.JSONFormat, + Mutex: &logLock, + }) + if err := event.InitFallbackLogger(logger); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + s.info["log level"] = level.String() + s.infoKeys = append(s.infoKeys, "log level") + s.info["log format"] = logFormat.String() + s.infoKeys = append(s.infoKeys, "log format") + + var err error + if err = setupEventing(ctx, logger, &logLock, logFormat, w); err != nil { + return errors.Wrap(ctx, err, op) + } + + return nil +} + +func setupEventing(ctx context.Context, logger hclog.Logger, serializationLock *sync.Mutex, logFormat logging.LogFormat, w io.Writer) error { + const op = "daemon.setupEventing" + switch { + case util.IsNil(logger): + return errors.New(ctx, errors.InvalidParameter, op, "logger is missing") + case util.IsNil(serializationLock): + return errors.New(ctx, errors.InvalidParameter, op, "serialization lock is missing") + } + serverName, err := os.Hostname() + if err != nil { + return fmt.Errorf("%s: unable to determine hostname: %w", op, err) + } + serverName = fmt.Sprintf("%s/%s", serverName, "cache") + + var sinkFormat event.SinkFormat + switch logFormat { + case logging.JSONFormat: + sinkFormat = event.JSONSinkFormat + default: + sinkFormat = event.TextHclogSinkFormat + } + + cfg := event.EventerConfig{ + AuditEnabled: false, + ObservationsEnabled: true, + SysEventsEnabled: true, + Sinks: []*event.SinkConfig{ + { + Name: "default", + EventTypes: []event.Type{event.EveryType}, + Format: sinkFormat, + Type: event.WriterSink, + WriterConfig: &event.WriterSinkTypeConfig{ + Writer: w, + }, + }, + }, + } + if err := event.InitSysEventer(logger, serializationLock, serverName, event.WithEventerConfig(&cfg)); err != nil { + return fmt.Errorf("%s: unable to initialize system eventer: %w", op, err) + } + return nil +} + +func openStore(ctx context.Context, url string, flagDebugStore bool) (*db.DB, string, error) { + const op = "daemon.openStore" + var err error + opts := []cachedb.Option{cachedb.WithDebug(flagDebugStore)} + switch { + case url != "": + url, err = parseutil.ParsePath(url) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return nil, "", errors.Wrap(ctx, err, op) + } + opts = append(opts, cachedb.WithUrl(url)) + } + store, err := cachedb.Open(ctx, opts...) + if err != nil { + return nil, "", errors.Wrap(ctx, err, op) + } + return store, url, nil +} diff --git a/internal/clientcache/internal/daemon/server_test.go b/internal/clientcache/internal/daemon/server_test.go new file mode 100644 index 000000000..273cd5c80 --- /dev/null +++ b/internal/clientcache/internal/daemon/server_test.go @@ -0,0 +1,97 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/daemon/controller" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Note: the name of this test must remain short because the temp dir created +// includes the name of the test and there is a 108 character limit in allowed +// unix socket path names. +func TestDefaultBoundaryTokenReader(t *testing.T) { + ctx := context.Background() + + t.Run("nil client provider", func(t *testing.T) { + resFn, err := defaultBoundaryTokenReader(ctx, nil) + assert.Error(t, err) + assert.ErrorContains(t, err, "client provider is nil") + assert.Nil(t, resFn) + }) + + tc := controller.NewTestController(t, nil) + cp := fakeClientProvider{tc} + + cases := []struct { + name string + address string + token string + errContains string + }{ + { + name: "success", + address: tc.ApiAddrs()[0], + token: tc.Token().Token, + errContains: "", + }, + { + name: "empty address", + address: "", + token: "at_123_testtoken", + errContains: "address is missing", + }, + { + name: "empty token", + address: tc.ApiAddrs()[0], + token: "", + errContains: "auth token is missing", + }, + { + name: "malformed token to many sections", + address: tc.ApiAddrs()[0], + token: "at_123_ignoredtoken_tomanysections", + errContains: "auth token is malformed", + }, + { + name: "malformed token to few sections", + address: tc.ApiAddrs()[0], + token: "at_123", + errContains: "auth token is malformed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + retFn, err := defaultBoundaryTokenReader(ctx, cp) + require.NoError(t, err) + require.NotNil(t, retFn) + + at, err := retFn(ctx, tc.address, tc.token) + switch tc.errContains { + case "": + assert.NoError(t, err) + assert.NotNil(t, at) + default: + assert.Error(t, err) + assert.ErrorContains(t, err, tc.errContains) + assert.Nil(t, at) + } + }) + } +} + +type fakeClientProvider struct { + *controller.TestController +} + +func (fcp fakeClientProvider) Client(opt ...base.Option) (*api.Client, error) { + return fcp.TestController.Client(), nil +} diff --git a/internal/clientcache/internal/daemon/stop_handler.go b/internal/clientcache/internal/daemon/stop_handler.go new file mode 100644 index 000000000..94d4067c8 --- /dev/null +++ b/internal/clientcache/internal/daemon/stop_handler.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "net/http" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +// newStopHandlerFunc creates a handler that cancels the server context. +// This is only expected to be used on windows currently since it cannot use +// POSIX signals to perform a graceful shutdown. +func newStopHandlerFunc(ctx context.Context, cancelFn context.CancelFunc) (http.HandlerFunc, error) { + const op = "daemon.newSearchTargetsHandlerFunc" + switch { + case util.IsNil(cancelFn): + return nil, errors.New(ctx, errors.InvalidParameter, op, "server cancelFn is missing") + } + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, "only method POST allowed", http.StatusMethodNotAllowed) + } + + w.WriteHeader(http.StatusNoContent) + cancelFn() + }, nil +} diff --git a/internal/clientcache/internal/daemon/stop_handler_test.go b/internal/clientcache/internal/daemon/stop_handler_test.go new file mode 100644 index 000000000..ce2b286ad --- /dev/null +++ b/internal/clientcache/internal/daemon/stop_handler_test.go @@ -0,0 +1,45 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStopHandler(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + ph, err := newStopHandlerFunc(ctx, cancel) + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/v1/stop", ph) + + t.Run("get", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/stop", nil) + mux.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/stop", nil) + mux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Result().StatusCode) + }) + + t.Run("success", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/stop", nil) + mux.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNoContent, rec.Result().StatusCode) + assert.Error(t, ctx.Err()) + }) +} diff --git a/internal/clientcache/internal/daemon/testing.go b/internal/clientcache/internal/daemon/testing.go new file mode 100644 index 000000000..e9fb44888 --- /dev/null +++ b/internal/clientcache/internal/daemon/testing.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/stretchr/testify/require" +) + +type TestServer struct { + *CacheServer + dotDir string + cmd Commander +} + +// NewTestServer creates a test cache server using reasonable defaults for +// tests. Supports the option WithDebugFlag to enable debug output for sql +func NewTestServer(t *testing.T, cmd Commander, opt ...Option) *TestServer { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + + opts, err := getOpts(opt...) + require.NoError(t, err) + dotDir := t.TempDir() + + cfg := &Config{ + ContextCancel: cancel, + RefreshIntervalSeconds: DefaultRefreshIntervalSeconds, + StoreDebug: opts.withDebug, + LogWriter: io.Discard, + DotDirectory: dotDir, + } + + s, err := New(ctx, cfg) + require.NoError(t, err) + return &TestServer{ + CacheServer: s, + dotDir: dotDir, + cmd: cmd, + } +} + +// BaseDotDir returns the base directory in which the daemon socket is +// created. +func (s *TestServer) BaseDotDir() string { + return s.dotDir +} + +// Serve runs the cache server. This is a blocking call and returns when the +// server is shutdown or stops for any other reason. +func (s *TestServer) Serve(t *testing.T, opt ...Option) error { + t.Helper() + ctx := context.Background() + + t.Cleanup(func() { + s.Shutdown(ctx) + }) + return s.CacheServer.Serve(ctx, s.cmd, opt...) +} + +// AddResources adds targets to the cache for the provided address, token name, +// and keyring type. They token info must already be known to the server. +func (s *TestServer) AddResources(t *testing.T, p *authtokens.AuthToken, tars []*targets.Target, sess []*sessions.Session, atReadFn cache.BoundaryTokenReaderFn) { + t.Helper() + ctx := context.Background() + r, err := cache.NewRepository(ctx, s.CacheServer.store, &sync.Map{}, s.cmd.ReadTokenFromKeyring, atReadFn) + require.NoError(t, err) + + tarFn := func(ctx context.Context, _, tok string) ([]*targets.Target, error) { + if tok != p.Token { + return nil, nil + } + return tars, nil + } + sessFn := func(ctx context.Context, _, tok string) ([]*sessions.Session, error) { + if tok != p.Token { + return nil, nil + } + return sess, nil + } + rs, err := cache.NewRefreshService(ctx, r) + require.NoError(t, err) + require.NoError(t, rs.Refresh(ctx, cache.WithTargetRetrievalFunc(tarFn), cache.WithSessionRetrievalFunc(sessFn))) +} diff --git a/internal/clientcache/internal/daemon/ticker.go b/internal/clientcache/internal/daemon/ticker.go new file mode 100644 index 000000000..d2068bbe9 --- /dev/null +++ b/internal/clientcache/internal/daemon/ticker.go @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/util" +) + +const ( + DefaultRefreshIntervalSeconds = 5 * 60 + defaultRefreshInterval = DefaultRefreshIntervalSeconds * time.Second +) + +type refreshService interface { + Refresh(context.Context, ...cache.Option) error +} + +type refreshTicker struct { + tickerCtx context.Context + refreshInterval time.Duration + refreshChan chan struct{} + refresher refreshService +} + +func newRefreshTicker(ctx context.Context, refreshIntervalSeconds int64, refresh refreshService) (*refreshTicker, error) { + const op = "daemon.newRefreshTicker" + switch { + case refreshIntervalSeconds == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "refresh interval seconds is missing") + case util.IsNil(refresh): + return nil, errors.New(ctx, errors.InvalidParameter, op, "refreshing function is missing") + } + + refreshInterval := defaultRefreshInterval + if refreshIntervalSeconds != 0 { + refreshInterval = time.Duration(refreshIntervalSeconds) * time.Second + } + return &refreshTicker{ + refreshInterval: refreshInterval, + refresher: refresh, + + // We make this channel size 1 so if something happens midway through the refresh + // we can immediately refresh again immediately to pick up something that might have been + // missed the first time through. + refreshChan: make(chan struct{}, 1), + }, nil +} + +// refresh starts the refresh logic if it is currently waiting. If refresh is +// currently happening then this queues a refresh for when the current refresh +// iteration is done, unless there is already a refresh queued in which case +// this is a noop. +func (rt *refreshTicker) refresh() { + select { + case rt.refreshChan <- struct{}{}: + default: + } +} + +func (rt *refreshTicker) start(ctx context.Context) { + const op = "daemon.(refreshTicker).start" + timer := time.NewTimer(0) + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + case <-rt.refreshChan: + } + if err := rt.refresher.Refresh(ctx); err != nil { + event.WriteError(rt.tickerCtx, op, err) + } + timer.Reset(rt.refreshInterval) + } +} diff --git a/internal/clientcache/internal/daemon/ticker_test.go b/internal/clientcache/internal/daemon/ticker_test.go new file mode 100644 index 000000000..e389ce2f3 --- /dev/null +++ b/internal/clientcache/internal/daemon/ticker_test.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTickerRefresh(t *testing.T) { + ctx := context.Background() + + refresher := &fakeRefresher{make(chan struct{})} + + rt, err := newRefreshTicker(ctx, 60, refresher) + require.NoError(t, err) + + tickerCtx, tickerCancel := context.WithCancel(ctx) + go rt.start(tickerCtx) + t.Cleanup(func() { + tickerCancel() + }) + + // let the normal start ticker refresh things, + <-refresher.called + + testCtx, testCancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer testCancel() + rt.refresh() + select { + case <-refresher.called: + case <-testCtx.Done(): + assert.Fail(t, "timed out waiting for the refresh ") + } + + // wait and make sure we don't get yet another call + select { + case <-refresher.called: + assert.Fail(t, "received an unexpected refresh call") + case <-testCtx.Done(): + } +} + +type fakeRefresher struct { + called chan struct{} +} + +func (r *fakeRefresher) Refresh(context.Context, ...cache.Option) error { + r.called <- struct{}{} + return nil +} diff --git a/internal/clientcache/internal/daemon/token_handler.go b/internal/clientcache/internal/daemon/token_handler.go new file mode 100644 index 000000000..fbdc62040 --- /dev/null +++ b/internal/clientcache/internal/daemon/token_handler.go @@ -0,0 +1,133 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/util" +) + +type refresher interface { + refresh() +} + +// KeyringToken has keyring held auth token information. +type KeyringToken struct { + // The keyring type used by boundary to access the auth token + KeyringType string + // The token identifier for the provided keyring type that holds the auth token + TokenName string +} + +// userTokenToAdd is the request body to this handler. +type UpsertTokenRequest struct { + // BoundaryAddr is a required field for all requests + BoundaryAddr string + // The id of the auth token asserted to be attempted to be added + AuthTokenId string + // The raw auth token for this user. Either this field or the Keyring field + // must be set but not both. + AuthToken string + // Keyring is the keyring info used when adding an auth token held in + // keyring to the daemon. + Keyring *KeyringToken +} + +func newTokenHandlerFunc(ctx context.Context, repo *cache.Repository, refresher refresher) (http.HandlerFunc, error) { + const op = "daemon.newPersonaHandlerFunc" + switch { + case util.IsNil(repo): + return nil, errors.New(ctx, errors.InvalidParameter, op, "repository is nil") + case util.IsNil(refresher): + return nil, errors.New(ctx, errors.InvalidParameter, op, "refresher is nil") + } + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.Method != http.MethodPost { + writeError(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var perReq UpsertTokenRequest + + 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 { + writeError(w, "unable to parse request body", http.StatusBadRequest) + return + } + + switch { + case perReq.BoundaryAddr == "": + writeError(w, "BoundaryAddr is a required field but was empty", http.StatusBadRequest) + return + case perReq.AuthTokenId == "": + writeError(w, "AuthTokenId is a required field but was empty", http.StatusBadRequest) + return + case perReq.Keyring == nil && perReq.AuthToken == "": + writeError(w, "Either keyring info or the authtoken must be provided but were empty", http.StatusBadRequest) + return + case perReq.Keyring != nil: + switch { + case perReq.Keyring.TokenName == "": + writeError(w, "TokenName is a required field but was empty", http.StatusBadRequest) + return + case perReq.Keyring.KeyringType == "": + writeError(w, "KeyringType is a required field but was empty", http.StatusBadRequest) + return + case perReq.Keyring.KeyringType == base.NoneKeyring: + writeError(w, fmt.Sprintf("KeyringType is set to %s which is not supported", perReq.Keyring.KeyringType), http.StatusBadRequest) + return + } + case perReq.AuthToken != "": + switch { + case !strings.HasPrefix(perReq.AuthToken, perReq.AuthTokenId): + writeError(w, "The auth token id doesn't match the auth token's prefix", http.StatusBadRequest) + return + } + } + + tok, err := repo.LookupToken(ctx, perReq.AuthTokenId) + if err != nil { + writeError(w, "error performing auth token lookup", http.StatusInternalServerError) + return + } + + switch { + case perReq.Keyring != nil: + kt := cache.KeyringToken{ + KeyringType: perReq.Keyring.KeyringType, + TokenName: perReq.Keyring.TokenName, + AuthTokenId: perReq.AuthTokenId, + } + if err = repo.AddKeyringToken(ctx, perReq.BoundaryAddr, kt); err != nil { + writeError(w, "Failed to add a keyring stored token", http.StatusInternalServerError) + return + } + case perReq.AuthToken != "": + if err = repo.AddRawToken(ctx, perReq.BoundaryAddr, perReq.AuthToken); err != nil { + writeError(w, "Failed to add a raw token", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) + + if tok == nil { + refresher.refresh() + } + }, nil +} diff --git a/internal/clientcache/internal/daemon/token_handler_test.go b/internal/clientcache/internal/daemon/token_handler_test.go new file mode 100644 index 000000000..26c3b9367 --- /dev/null +++ b/internal/clientcache/internal/daemon/token_handler_test.go @@ -0,0 +1,280 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/clientcache/internal/cache" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ringToken is a test struct used to group a keyring type and token name +// so it can be used in an authtoken lookup function. +type ringToken struct { + k string + t string +} + +// mapBasedAuthTokenKeyringLookup provides a fake KeyringTokenLookupFn that uses +// the provided map to perform lookups for the tokens +func mapBasedAuthTokenKeyringLookup(m map[ringToken]*authtokens.AuthToken) cache.KeyringTokenLookupFn { + return func(k, t string) *authtokens.AuthToken { + return m[ringToken{k, t}] + } +} + +// sliceBasedAuthTokenBoundaryReader provides a fake BoundaryTokenReaderFn that uses +// the provided map to lookup an auth tokens information. +func sliceBasedAuthTokenBoundaryReader(s []*authtokens.AuthToken) cache.BoundaryTokenReaderFn { + return func(ctx context.Context, addr, at string) (*authtokens.AuthToken, error) { + for _, v := range s { + if at == v.Token { + return v, nil + } + } + return nil, errors.New("not found") + } +} + +type testRefresher struct { + called bool +} + +func (r *testRefresher) refresh() { + r.called = true +} + +func addToken(t *testing.T, h http.Handler, tokReq *UpsertTokenRequest) *api.Error { + t.Helper() + b, err := json.Marshal(tokReq) + require.NoError(t, err) + rawBody := bytes.NewBuffer(b) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/tokens", rawBody) + h.ServeHTTP(rec, req) + if rec.Result().StatusCode == http.StatusNoContent { + return nil + } + apiErr := &api.Error{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), apiErr)) + return apiErr +} + +func TestKeyringToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "user", + } + boundaryAuthTokens := []*authtokens.AuthToken{at} + keyring := "k" + tokenName := "t" + atMap := map[ringToken]*authtokens.AuthToken{ + {keyring, tokenName}: at, + } + r, err := cache.NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + tr := &testRefresher{} + ph, err := newTokenHandlerFunc(ctx, r, tr) + require.NoError(t, err) + + t.Run("missing keyring", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: "", + TokenName: tokenName, + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: at.Id, + } + apiErr := addToken(t, ph, pa) + require.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "KeyringType is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("none keyring", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: base.NoneKeyring, + TokenName: tokenName, + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: at.Id, + } + apiErr := addToken(t, ph, pa) + require.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "KeyringType is set to none which is not supported") + assert.False(t, tr.called) + }) + + t.Run("missing token name", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: keyring, + TokenName: "", + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: at.Id, + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "TokenName is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("missing boundary address", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + }, + BoundaryAddr: "", + AuthTokenId: at.Id, + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "BoundaryAddr is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("missing auth token id", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: "", + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "AuthTokenId is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("mismatched auth token id", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: "at_doesntmatch", + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "Failed to add a keyring stored token") + assert.False(t, tr.called) + }) + + t.Run("success", func(t *testing.T) { + pa := &UpsertTokenRequest{ + Keyring: &KeyringToken{ + KeyringType: keyring, + TokenName: tokenName, + }, + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: at.Id, + } + apiErr := addToken(t, ph, pa) + assert.Nil(t, apiErr) + assert.True(t, tr.called) + + p, err := r.LookupToken(ctx, pa.AuthTokenId) + require.NoError(t, err) + assert.NotNil(t, p) + assert.Equal(t, at.Id, p.Id) + }) +} + +func TestKeyringlessToken(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + at := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: "user", + } + boundaryAuthTokens := []*authtokens.AuthToken{at} + atMap := map[ringToken]*authtokens.AuthToken{} + r, err := cache.NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(boundaryAuthTokens)) + require.NoError(t, err) + + tr := &testRefresher{} + ph, err := newTokenHandlerFunc(ctx, r, tr) + require.NoError(t, err) + + t.Run("missing boundary address", func(t *testing.T) { + pa := &UpsertTokenRequest{ + BoundaryAddr: "", + AuthTokenId: at.Id, + AuthToken: at.Token, + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "BoundaryAddr is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("missing auth token id", func(t *testing.T) { + pa := &UpsertTokenRequest{ + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: "", + AuthToken: at.Token, + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "AuthTokenId is a required field but was empty") + assert.False(t, tr.called) + }) + + t.Run("mismatched auth token id", func(t *testing.T) { + pa := &UpsertTokenRequest{ + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: "at_doesntmatch", + AuthToken: at.Token, + } + apiErr := addToken(t, ph, pa) + assert.NotNil(t, apiErr) + assert.Contains(t, apiErr.Message, "The auth token id doesn't match the auth token's prefix") + assert.False(t, tr.called) + }) + + t.Run("success", func(t *testing.T) { + pa := &UpsertTokenRequest{ + BoundaryAddr: "http://127.0.0.1", + AuthTokenId: at.Id, + AuthToken: at.Token, + } + apiErr := addToken(t, ph, pa) + assert.Nil(t, apiErr) + assert.True(t, tr.called) + + p, err := r.LookupToken(ctx, pa.AuthTokenId) + require.NoError(t, err) + assert.NotNil(t, p) + assert.Equal(t, at.Id, p.Id) + }) +} diff --git a/internal/clientcache/internal/daemon/version_interceptor.go b/internal/clientcache/internal/daemon/version_interceptor.go new file mode 100644 index 000000000..5bda62351 --- /dev/null +++ b/internal/clientcache/internal/daemon/version_interceptor.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package daemon + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/boundary/internal/util" + "github.com/hashicorp/boundary/version" +) + +const ( + VersionHeaderKey = "boundary_version" +) + +// 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 { + if util.IsNil(h) { + return nil + } + + 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 + } + h.ServeHTTP(w, r) + }) +} diff --git a/internal/clientcache/internal/daemon/version_interceptor_test.go b/internal/clientcache/internal/daemon/version_interceptor_test.go new file mode 100644 index 000000000..d157b9127 --- /dev/null +++ b/internal/clientcache/internal/daemon/version_interceptor_test.go @@ -0,0 +1,51 @@ +// 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) + }) +} diff --git a/internal/clientcache/internal/db/db.go b/internal/clientcache/internal/db/db.go new file mode 100644 index 000000000..2d44c7146 --- /dev/null +++ b/internal/clientcache/internal/db/db.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package db + +import ( + "context" + _ "embed" + "fmt" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/go-dbw" +) + +//go:embed schema.sql +var cacheSchema string + +// DefaultStoreUrl uses a temp in-memory sqlite database see: https://www.sqlite.org/inmemorydb.html +const DefaultStoreUrl = "file::memory:?_pragma=foreign_keys(1)" + +// Open creates a database connection. WithUrl is supported, but by default it +// uses an in memory sqlite table. Sqlite is the only supported dbtype. +func Open(ctx context.Context, opt ...Option) (*db.DB, error) { + const op = "db.Open" + opts, err := getOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + var url string + switch { + case opts.withUrl != "": + url = opts.withUrl + default: + url = DefaultStoreUrl + } + conn, err := db.Open(ctx, db.Sqlite, url) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + conn.Debug(opts.withDebug) + + switch { + case opts.withDbType == dbw.Sqlite: + if err := createTables(ctx, conn); err != nil { + errors.Wrap(ctx, err, op) + } + default: + return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("%q is not a supported cache store type", opts.withDbType)) + } + return conn, nil +} + +func createTables(ctx context.Context, conn *db.DB) error { + const op = "db.createTables" + rw := db.New(conn) + if _, err := rw.Exec(ctx, cacheSchema, nil); err != nil { + return errors.Wrap(ctx, err, op) + } + return nil +} diff --git a/internal/clientcache/internal/db/options.go b/internal/clientcache/internal/db/options.go new file mode 100644 index 000000000..a44c62292 --- /dev/null +++ b/internal/clientcache/internal/db/options.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package db + +import ( + "github.com/hashicorp/go-dbw" +) + +type options struct { + withDebug bool + withUrl string + withDbType dbw.DbType +} + +// Option - how options are passed as args +type Option func(*options) error + +func getDefaultOptions() options { + return options{ + withDbType: dbw.Sqlite, + } +} + +func getOpts(opt ...Option) (options, error) { + opts := getDefaultOptions() + + for _, o := range opt { + if err := o(&opts); err != nil { + return opts, err + } + } + return opts, nil +} + +// WithUrls provides optional url +func WithUrl(url string) Option { + return func(o *options) error { + o.withUrl = url + return nil + } +} + +// WithDebug provides an optional debug flag. +func WithDebug(debug bool) Option { + return func(o *options) error { + o.withDebug = debug + return nil + } +} diff --git a/internal/clientcache/internal/db/options_test.go b/internal/clientcache/internal/db/options_test.go new file mode 100644 index 000000000..f57591ef6 --- /dev/null +++ b/internal/clientcache/internal/db/options_test.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package db + +import ( + "testing" + + "github.com/hashicorp/go-dbw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + + t.Run("default", func(t *testing.T) { + opts, err := getOpts() + require.NoError(t, err) + testOpts := options{ + withDbType: dbw.Sqlite, + } + assert.Equal(t, opts, testOpts) + }) + t.Run("WithUrl", func(t *testing.T) { + url := "something" + opts, err := getOpts(WithUrl(url)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.withUrl = url + assert.Equal(t, opts, testOpts) + }) + t.Run("WithDebug", func(t *testing.T) { + opts, err := getOpts(WithDebug(true)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.withDebug = true + assert.Equal(t, opts, testOpts) + }) +} diff --git a/internal/clientcache/internal/db/schema.sql b/internal/clientcache/internal/db/schema.sql new file mode 100644 index 000000000..d6de11fd7 --- /dev/null +++ b/internal/clientcache/internal/db/schema.sql @@ -0,0 +1,116 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; +-- user contains the boundary user information for the boundary user that owns +-- the information in the cache. +create table if not exists user ( + -- The id of the user resource from boundary + id text not null primary key + check (length(id) > 0), + -- The address of the boundary instance that this user id comes from + address text not null + check (length(address) > 0) +); + +-- Contains the boundary auth token +create table if not exists auth_token ( + -- id is the boundary id of the auth token + id text not null primary key + check (length(id) > 0), + -- user id is the boundary user id the auth token is associated with + user_id text not null + references user(id) + on delete cascade, + -- the last time this the auth token was used on this machine to access + -- boundary outside of the context of the cache. + last_accessed_time timestamp not null + default (strftime('%Y-%m-%d %H:%M:%f','now')), + expiration_time timestamp not null +); + +-- *delete_orphaned_users triggers delete a user when it no longer has any +-- auth tokens associated with them +create trigger token_update_delete_orphaned_users after update on auth_token +begin +delete from user +where + id not in (select user_id from auth_token); +end; + +create trigger token_delete_delete_orphaned_users after delete on auth_token +begin +delete from user +where + id not in (select user_id from auth_token); +end; + +create table if not exists keyring_token ( + -- the name of the keyring type on the local machine + keyring_type text not null + check (length(keyring_type) > 0), + -- the name of the stored token on the keyring + token_name text not null + check (length(token_name) > 0), + -- the boundary auth token id stored at in this keyring using the token name + auth_token_id text not null + references auth_token(id) + on delete cascade, + primary key (keyring_type, token_name) +); + +-- target contains cached boundary target resource for a specific user and with +-- specific fields extracted to facilitate searching over those fields +create table if not exists target ( + -- the boundary user id of the user who has was able to read/list this target + user_id text not null + references user(id) + on delete cascade, + -- the boundary id of the target + id text not null + check (length(id) > 0), + -- the following fields are used for searching and are set to the values + -- from the boundary resource + name text, + description text, + address text, + -- item is the json representation of this resource from the perspective of + -- the the requesting user. + item text, + primary key (user_id, id) +); + +-- session contains cached boundary session resource for a specific user and +-- with specific fields extracted to facilitate searching over those fields +create table if not exists session ( + -- the boundary user id of the user who has was able to read/list this resource + user_id text not null + references user(id) + on delete cascade, + -- the resource id from boundary of this session + id text not null + check (length(id) > 0), + -- the following fields are used for searching and are set to the values + -- from the boundary resource + endpoint text, + type text, + status text, + -- item is the json representation of this resource from the perspective of + -- of the user whose id is set in user_id + item text, + primary key (user_id, id) +); + +-- contains errors from the last attempt to sync data from boundary for a +-- specific resource type +create table if not exists api_error ( + user_id text not null + references user(id) + on delete cascade, + resource_type text not null, + error text not null, + create_time timestamp not null default current_timestamp, + primary key (user_id, resource_type) +); + +commit; diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index 6772d4ab9..d638bfc5a 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -102,7 +102,7 @@ type Command struct { FlagTokenName string FlagKeyringType string FlagRecoveryConfig string - flagOutputCurlString bool + FlagOutputCurlString bool FlagScopeId string FlagScopeName string @@ -204,8 +204,8 @@ func (c *Command) Client(opt ...Option) (*api.Client, error) { return nil, err } - if c.flagOutputCurlString { - config.OutputCurlString = c.flagOutputCurlString + if c.FlagOutputCurlString { + config.OutputCurlString = c.FlagOutputCurlString } c.client, err = api.NewClient(config) @@ -452,7 +452,7 @@ func (c *Command) FlagSet(bit FlagSetBit) *FlagSets { f.BoolVar(&BoolVar{ Name: "output-curl-string", - Target: &c.flagOutputCurlString, + Target: &c.FlagOutputCurlString, Usage: "Instead of executing the request, print an equivalent cURL command string and exit.", }) } diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 922f40c31..761ab92a5 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -4,6 +4,8 @@ package cmd import ( + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/hashicorp/boundary/internal/cmd/commands/accountscmd" "github.com/hashicorp/boundary/internal/cmd/commands/authenticate" @@ -30,6 +32,7 @@ import ( "github.com/hashicorp/boundary/internal/cmd/commands/sessionscmd" "github.com/hashicorp/boundary/internal/cmd/commands/storagebucketscmd" "github.com/hashicorp/boundary/internal/cmd/commands/targetscmd" + "github.com/hashicorp/boundary/internal/cmd/commands/unsupported" "github.com/hashicorp/boundary/internal/cmd/commands/userscmd" "github.com/hashicorp/boundary/internal/cmd/commands/version" "github.com/hashicorp/boundary/internal/cmd/commands/workerscmd" @@ -66,212 +69,180 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { }, nil }, - "authenticate": func() (cli.Command, error) { - return &authenticate.Command{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "authenticate password": func() (cli.Command, error) { - return &authenticate.PasswordCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "authenticate oidc": func() (cli.Command, error) { - return &authenticate.OidcCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "authenticate ldap": func() (cli.Command, error) { - return &authenticate.LdapCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, + "authenticate": commandFactoryWrapper(ui, + &authenticate.Command{ + Command: base.NewCommand(ui), + }), + "authenticate password": commandFactoryWrapper(ui, + &authenticate.PasswordCommand{ + Command: base.NewCommand(ui), + }), + "authenticate oidc": commandFactoryWrapper(ui, + &authenticate.OidcCommand{ + Command: base.NewCommand(ui), + }), + "authenticate ldap": commandFactoryWrapper(ui, + &authenticate.LdapCommand{ + Command: base.NewCommand(ui), + }), "accounts": func() (cli.Command, error) { return &accountscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "accounts read": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + "accounts read": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "accounts delete": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts delete": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "accounts list": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts list": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "accounts set-password": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts set-password": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "set-password", - }, nil - }, - "accounts change-password": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts change-password": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "change-password", - }, nil - }, - "accounts create": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts create": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "accounts create password": func() (cli.Command, error) { - return &accountscmd.PasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts create password": commandFactoryWrapper(ui, + &accountscmd.PasswordCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "accounts create oidc": func() (cli.Command, error) { - return &accountscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts create oidc": commandFactoryWrapper(ui, + &accountscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "accounts create ldap": func() (cli.Command, error) { - return &accountscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts create ldap": commandFactoryWrapper(ui, + &accountscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "accounts update": func() (cli.Command, error) { - return &accountscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "accounts update": commandFactoryWrapper(ui, + &accountscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "accounts update password": func() (cli.Command, error) { - return &accountscmd.PasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts update password": commandFactoryWrapper(ui, + &accountscmd.PasswordCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "accounts update oidc": func() (cli.Command, error) { - return &accountscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts update oidc": commandFactoryWrapper(ui, + &accountscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "accounts update ldap": func() (cli.Command, error) { - return &accountscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "accounts update ldap": commandFactoryWrapper(ui, + &accountscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "auth-methods": func() (cli.Command, error) { return &authmethodscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "auth-methods read": func() (cli.Command, error) { - return &authmethodscmd.Command{ - Command: base.NewCommand(ui, opts...), + "auth-methods read": commandFactoryWrapper(ui, + &authmethodscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "auth-methods delete": func() (cli.Command, error) { - return &authmethodscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods delete": commandFactoryWrapper(ui, + &authmethodscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "auth-methods list": func() (cli.Command, error) { - return &authmethodscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods list": commandFactoryWrapper(ui, + &authmethodscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "auth-methods create": func() (cli.Command, error) { - return &authmethodscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods create": commandFactoryWrapper(ui, + &authmethodscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "auth-methods create password": func() (cli.Command, error) { - return &authmethodscmd.PasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods create password": commandFactoryWrapper(ui, + &authmethodscmd.PasswordCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "auth-methods create oidc": func() (cli.Command, error) { - return &authmethodscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods create oidc": commandFactoryWrapper(ui, + &authmethodscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "auth-methods create ldap": func() (cli.Command, error) { - return &authmethodscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods create ldap": commandFactoryWrapper(ui, + &authmethodscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "auth-methods update": func() (cli.Command, error) { - return &authmethodscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods update": commandFactoryWrapper(ui, + &authmethodscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "auth-methods update password": func() (cli.Command, error) { - return &authmethodscmd.PasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods update password": commandFactoryWrapper(ui, + &authmethodscmd.PasswordCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "auth-methods update oidc": func() (cli.Command, error) { - return &authmethodscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods update oidc": commandFactoryWrapper(ui, + &authmethodscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "auth-methods update ldap": func() (cli.Command, error) { - return &authmethodscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods update ldap": commandFactoryWrapper(ui, + &authmethodscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "auth-methods change-state oidc": func() (cli.Command, error) { - return &authmethodscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "auth-methods change-state oidc": commandFactoryWrapper(ui, + &authmethodscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "change-state", - }, nil - }, + }), "auth-tokens": func() (cli.Command, error) { return &authtokenscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "auth-tokens read": func() (cli.Command, error) { - return &authtokenscmd.Command{ - Command: base.NewCommand(ui, opts...), + "auth-tokens read": commandFactoryWrapper(ui, + &authtokenscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "auth-tokens delete": func() (cli.Command, error) { - return &authtokenscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-tokens delete": commandFactoryWrapper(ui, + &authtokenscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "auth-tokens list": func() (cli.Command, error) { - return &authtokenscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "auth-tokens list": commandFactoryWrapper(ui, + &authtokenscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, + }), "config": func() (cli.Command, error) { return &config.Command{ @@ -315,42 +286,36 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { }, nil }, - "connect": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + "connect": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "connect", - }, nil - }, - "connect http": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + }), + "connect http": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "http", - }, nil - }, - "connect kube": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + }), + "connect kube": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "kube", - }, nil - }, - "connect postgres": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + }), + "connect postgres": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "postgres", - }, nil - }, - "connect rdp": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + }), + "connect rdp": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "rdp", - }, nil - }, - "connect ssh": func() (cli.Command, error) { - return &connect.Command{ - Command: base.NewCommand(ui, opts...), + }), + "connect ssh": commandFactoryWrapper(ui, + &connect.Command{ + Command: base.NewCommand(ui), Func: "ssh", - }, nil - }, + }), "database": func() (cli.Command, error) { return &database.Command{ @@ -373,190 +338,178 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "credential-libraries read": func() (cli.Command, error) { - return &credentiallibrariescmd.Command{ - Command: base.NewCommand(ui, opts...), + "credential-libraries read": commandFactoryWrapper(ui, + &credentiallibrariescmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "credential-libraries delete": func() (cli.Command, error) { - return &credentiallibrariescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries delete": commandFactoryWrapper(ui, + &credentiallibrariescmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "credential-libraries list": func() (cli.Command, error) { - return &credentiallibrariescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries list": commandFactoryWrapper(ui, + &credentiallibrariescmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "credential-libraries create": func() (cli.Command, error) { - return &credentiallibrariescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries create": commandFactoryWrapper(ui, + &credentiallibrariescmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-libraries create vault-generic": func() (cli.Command, error) { - return &credentiallibrariescmd.VaultGenericCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries create vault": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-libraries create vault-ssh-certificate": func() (cli.Command, error) { - return &credentiallibrariescmd.VaultSshCertificateCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries create vault-generic": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultGenericCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-libraries update": func() (cli.Command, error) { - return &credentiallibrariescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries create vault-ssh-certificate": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultSshCertificateCommand{ + Command: base.NewCommand(ui), + Func: "create", + }), + "credential-libraries update": commandFactoryWrapper(ui, + &credentiallibrariescmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credential-libraries update vault-generic": func() (cli.Command, error) { - return &credentiallibrariescmd.VaultGenericCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries update vault": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultGenericCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credential-libraries update vault-ssh-certificate": func() (cli.Command, error) { - return &credentiallibrariescmd.VaultSshCertificateCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-libraries update vault-generic": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultGenericCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), + "credential-libraries update vault-ssh-certificate": commandFactoryWrapper(ui, + &credentiallibrariescmd.VaultSshCertificateCommand{ + Command: base.NewCommand(ui), + Func: "update", + }), "credential-stores": func() (cli.Command, error) { return &credentialstorescmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "credential-stores read": func() (cli.Command, error) { - return &credentialstorescmd.Command{ - Command: base.NewCommand(ui, opts...), + "credential-stores read": commandFactoryWrapper(ui, + &credentialstorescmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "credential-stores delete": func() (cli.Command, error) { - return &credentialstorescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores delete": commandFactoryWrapper(ui, + &credentialstorescmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "credential-stores list": func() (cli.Command, error) { - return &credentialstorescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores list": commandFactoryWrapper(ui, + &credentialstorescmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "credential-stores create": func() (cli.Command, error) { - return &credentialstorescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores create": commandFactoryWrapper(ui, + &credentialstorescmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-stores create vault": func() (cli.Command, error) { - return &credentialstorescmd.VaultCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores create vault": commandFactoryWrapper(ui, + &credentialstorescmd.VaultCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-stores create static": func() (cli.Command, error) { - return &credentialstorescmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores create static": commandFactoryWrapper(ui, + &credentialstorescmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credential-stores update": func() (cli.Command, error) { - return &credentialstorescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores update": commandFactoryWrapper(ui, + &credentialstorescmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credential-stores update vault": func() (cli.Command, error) { - return &credentialstorescmd.VaultCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores update vault": commandFactoryWrapper(ui, + &credentialstorescmd.VaultCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credential-stores update static": func() (cli.Command, error) { - return &credentialstorescmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credential-stores update static": commandFactoryWrapper(ui, + &credentialstorescmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "credentials": func() (cli.Command, error) { return &credentialscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "credentials read": func() (cli.Command, error) { - return &credentialscmd.Command{ - Command: base.NewCommand(ui, opts...), + "credentials read": commandFactoryWrapper(ui, + &credentialscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "credentials delete": func() (cli.Command, error) { - return &credentialscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credentials delete": commandFactoryWrapper(ui, + &credentialscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "credentials list": func() (cli.Command, error) { - return &credentialscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credentials list": commandFactoryWrapper(ui, + &credentialscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "credentials create": func() (cli.Command, error) { - return &credentialscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credentials create": commandFactoryWrapper(ui, + &credentialscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credentials create username-password": func() (cli.Command, error) { - return &credentialscmd.UsernamePasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials create username-password": commandFactoryWrapper(ui, + &credentialscmd.UsernamePasswordCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credentials create ssh-private-key": func() (cli.Command, error) { - return &credentialscmd.SshPrivateKeyCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials create ssh-private-key": commandFactoryWrapper(ui, + &credentialscmd.SshPrivateKeyCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credentials create json": func() (cli.Command, error) { - return &credentialscmd.JsonCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials create json": commandFactoryWrapper(ui, + &credentialscmd.JsonCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "credentials update": func() (cli.Command, error) { - return &credentialscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "credentials update": commandFactoryWrapper(ui, + &credentialscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credentials update username-password": func() (cli.Command, error) { - return &credentialscmd.UsernamePasswordCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials update username-password": commandFactoryWrapper(ui, + &credentialscmd.UsernamePasswordCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credentials update ssh-private-key": func() (cli.Command, error) { - return &credentialscmd.SshPrivateKeyCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials update ssh-private-key": commandFactoryWrapper(ui, + &credentialscmd.SshPrivateKeyCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "credentials update json": func() (cli.Command, error) { - return &credentialscmd.JsonCommand{ - Command: base.NewCommand(ui, opts...), + }), + "credentials update json": commandFactoryWrapper(ui, + &credentialscmd.JsonCommand{ + Command: base.NewCommand(ui), Func: "update", + }), + + "daemon": func() (cli.Command, error) { + return &unsupported.UnsupportedCommand{ + Command: base.NewCommand(ui), + CommandName: "daemon", }, nil }, @@ -572,240 +525,204 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "groups create": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + "groups create": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "groups update": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups update": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "groups read": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups read": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "groups delete": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups delete": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "groups list": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups list": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "groups add-members": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups add-members": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "add-members", - }, nil - }, - "groups set-members": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups set-members": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "set-members", - }, nil - }, - "groups remove-members": func() (cli.Command, error) { - return &groupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "groups remove-members": commandFactoryWrapper(ui, + &groupscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-members", - }, nil - }, + }), "host-catalogs": func() (cli.Command, error) { return &hostcatalogscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "host-catalogs read": func() (cli.Command, error) { - return &hostcatalogscmd.Command{ - Command: base.NewCommand(ui, opts...), + "host-catalogs read": commandFactoryWrapper(ui, + &hostcatalogscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "host-catalogs delete": func() (cli.Command, error) { - return &hostcatalogscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs delete": commandFactoryWrapper(ui, + &hostcatalogscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "host-catalogs list": func() (cli.Command, error) { - return &hostcatalogscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs list": commandFactoryWrapper(ui, + &hostcatalogscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "host-catalogs create": func() (cli.Command, error) { - return &hostcatalogscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs create": commandFactoryWrapper(ui, + &hostcatalogscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-catalogs create static": func() (cli.Command, error) { - return &hostcatalogscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs create static": commandFactoryWrapper(ui, + &hostcatalogscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-catalogs create plugin": func() (cli.Command, error) { - return &hostcatalogscmd.PluginCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs create plugin": commandFactoryWrapper(ui, + &hostcatalogscmd.PluginCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-catalogs update": func() (cli.Command, error) { - return &hostcatalogscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs update": commandFactoryWrapper(ui, + &hostcatalogscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "host-catalogs update static": func() (cli.Command, error) { - return &hostcatalogscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs update static": commandFactoryWrapper(ui, + &hostcatalogscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "host-catalogs update plugin": func() (cli.Command, error) { - return &hostcatalogscmd.PluginCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-catalogs update plugin": commandFactoryWrapper(ui, + &hostcatalogscmd.PluginCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "host-sets": func() (cli.Command, error) { return &hostsetscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "host-sets read": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + "host-sets read": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "host-sets delete": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets delete": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "host-sets list": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets list": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "host-sets create": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets create": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-sets create static": func() (cli.Command, error) { - return &hostsetscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets create static": commandFactoryWrapper(ui, + &hostsetscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-sets create plugin": func() (cli.Command, error) { - return &hostsetscmd.PluginCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets create plugin": commandFactoryWrapper(ui, + &hostsetscmd.PluginCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "host-sets update": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets update": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "host-sets update static": func() (cli.Command, error) { - return &hostsetscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets update static": commandFactoryWrapper(ui, + &hostsetscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "host-sets update plugin": func() (cli.Command, error) { - return &hostsetscmd.PluginCommand{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets update plugin": commandFactoryWrapper(ui, + &hostsetscmd.PluginCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "host-sets add-hosts": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets add-hosts": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "add-hosts", - }, nil - }, - "host-sets remove-hosts": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets remove-hosts": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-hosts", - }, nil - }, - "host-sets set-hosts": func() (cli.Command, error) { - return &hostsetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "host-sets set-hosts": commandFactoryWrapper(ui, + &hostsetscmd.Command{ + Command: base.NewCommand(ui), Func: "set-hosts", - }, nil - }, + }), "hosts": func() (cli.Command, error) { return &hostscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "hosts read": func() (cli.Command, error) { - return &hostscmd.Command{ - Command: base.NewCommand(ui, opts...), + "hosts read": commandFactoryWrapper(ui, + &hostscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "hosts delete": func() (cli.Command, error) { - return &hostscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "hosts delete": commandFactoryWrapper(ui, + &hostscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "hosts list": func() (cli.Command, error) { - return &hostscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "hosts list": commandFactoryWrapper(ui, + &hostscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "hosts create": func() (cli.Command, error) { - return &hostscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "hosts create": commandFactoryWrapper(ui, + &hostscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "hosts create static": func() (cli.Command, error) { - return &hostscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "hosts create static": commandFactoryWrapper(ui, + &hostscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "hosts update": func() (cli.Command, error) { - return &hostscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "hosts update": commandFactoryWrapper(ui, + &hostscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "hosts update static": func() (cli.Command, error) { - return &hostscmd.StaticCommand{ - Command: base.NewCommand(ui, opts...), + }), + "hosts update static": commandFactoryWrapper(ui, + &hostscmd.StaticCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "logout": func() (cli.Command, error) { return &logout.LogoutCommand{ @@ -818,60 +735,51 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "managed-groups read": func() (cli.Command, error) { - return &managedgroupscmd.Command{ - Command: base.NewCommand(ui, opts...), + "managed-groups read": commandFactoryWrapper(ui, + &managedgroupscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "managed-groups delete": func() (cli.Command, error) { - return &managedgroupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups delete": commandFactoryWrapper(ui, + &managedgroupscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "managed-groups list": func() (cli.Command, error) { - return &managedgroupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups list": commandFactoryWrapper(ui, + &managedgroupscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "managed-groups create": func() (cli.Command, error) { - return &managedgroupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups create": commandFactoryWrapper(ui, + &managedgroupscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "managed-groups create oidc": func() (cli.Command, error) { - return &managedgroupscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups create oidc": commandFactoryWrapper(ui, + &managedgroupscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "managed-groups create ldap": func() (cli.Command, error) { - return &managedgroupscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups create ldap": commandFactoryWrapper(ui, + &managedgroupscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "managed-groups update": func() (cli.Command, error) { - return &managedgroupscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups update": commandFactoryWrapper(ui, + &managedgroupscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "managed-groups update oidc": func() (cli.Command, error) { - return &managedgroupscmd.OidcCommand{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups update oidc": commandFactoryWrapper(ui, + &managedgroupscmd.OidcCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "managed-groups update ldap": func() (cli.Command, error) { - return &managedgroupscmd.LdapCommand{ - Command: base.NewCommand(ui, opts...), + }), + "managed-groups update ldap": commandFactoryWrapper(ui, + &managedgroupscmd.LdapCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "read": func() (cli.Command, error) { return &genericcmd.Command{ @@ -885,126 +793,113 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "roles create": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + "roles create": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "roles update": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles update": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "roles read": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles read": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "roles delete": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles delete": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "roles list": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles list": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "roles add-principals": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles add-principals": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "add-principals", - }, nil - }, - "roles set-principals": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles set-principals": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "set-principals", - }, nil - }, - "roles remove-principals": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles remove-principals": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "remove-principals", - }, nil - }, - "roles add-grants": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles add-grants": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "add-grants", - }, nil - }, - "roles set-grants": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles set-grants": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "set-grants", - }, nil - }, - "roles remove-grants": func() (cli.Command, error) { - return &rolescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "roles remove-grants": commandFactoryWrapper(ui, + &rolescmd.Command{ + Command: base.NewCommand(ui), Func: "remove-grants", - }, nil - }, + }), "scopes": func() (cli.Command, error) { return &scopescmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "scopes create": func() (cli.Command, error) { - return &scopescmd.Command{ - Command: base.NewCommand(ui, opts...), + "scopes create": commandFactoryWrapper(ui, + &scopescmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "scopes read": func() (cli.Command, error) { - return &scopescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "scopes read": commandFactoryWrapper(ui, + &scopescmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "scopes update": func() (cli.Command, error) { - return &scopescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "scopes update": commandFactoryWrapper(ui, + &scopescmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "scopes delete": func() (cli.Command, error) { - return &scopescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "scopes delete": commandFactoryWrapper(ui, + &scopescmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "scopes list": func() (cli.Command, error) { - return &scopescmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "scopes list": commandFactoryWrapper(ui, + &scopescmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "scopes list-keys": func() (cli.Command, error) { - return &scopescmd.ListKeysCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "scopes rotate-keys": func() (cli.Command, error) { - return &scopescmd.RotateKeysCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "scopes list-key-version-destruction-jobs": func() (cli.Command, error) { - return &scopescmd.ListKeyVersionDestructionJobsCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "scopes destroy-key-version": func() (cli.Command, error) { - return &scopescmd.DestroyKeyVersionCommand{ - Command: base.NewCommand(ui, opts...), + }), + "scopes list-keys": commandFactoryWrapper(ui, + &scopescmd.ListKeysCommand{ + Command: base.NewCommand(ui), + }), + "scopes rotate-keys": commandFactoryWrapper(ui, + &scopescmd.RotateKeysCommand{ + Command: base.NewCommand(ui), + }), + "scopes list-key-version-destruction-jobs": commandFactoryWrapper(ui, + &scopescmd.ListKeyVersionDestructionJobsCommand{ + Command: base.NewCommand(ui), + }), + "scopes destroy-key-version": commandFactoryWrapper(ui, + &scopescmd.DestroyKeyVersionCommand{ + Command: base.NewCommand(ui), + }), + + "search": func() (cli.Command, error) { + return &unsupported.UnsupportedCommand{ + Command: base.NewCommand(ui), + CommandName: "search", }, nil }, @@ -1013,185 +908,158 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "sessions read": func() (cli.Command, error) { - return &sessionscmd.Command{ - Command: base.NewCommand(ui, opts...), + "sessions read": commandFactoryWrapper(ui, + &sessionscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "sessions list": func() (cli.Command, error) { - return &sessionscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "sessions list": commandFactoryWrapper(ui, + &sessionscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "sessions cancel": func() (cli.Command, error) { - return &sessionscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "sessions cancel": commandFactoryWrapper(ui, + &sessionscmd.Command{ + Command: base.NewCommand(ui), Func: "cancel", - }, nil - }, + }), "session-recordings": func() (cli.Command, error) { return &sessionrecordingscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "session-recordings read": func() (cli.Command, error) { - return &sessionrecordingscmd.Command{ - Command: base.NewCommand(ui, opts...), + "session-recordings read": commandFactoryWrapper(ui, + &sessionrecordingscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "session-recordings list": func() (cli.Command, error) { - return &sessionrecordingscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "session-recordings list": commandFactoryWrapper(ui, + &sessionrecordingscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "session-recordings download": func() (cli.Command, error) { - return &sessionrecordingscmd.DownloadCommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, + }), + "session-recordings download": commandFactoryWrapper(ui, + &sessionrecordingscmd.DownloadCommand{ + Command: base.NewCommand(ui), + }), "storage-buckets": func() (cli.Command, error) { return &storagebucketscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "storage-buckets read": func() (cli.Command, error) { - return &storagebucketscmd.Command{ - Command: base.NewCommand(ui, opts...), + "storage-buckets read": commandFactoryWrapper(ui, + &storagebucketscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "storage-buckets delete": func() (cli.Command, error) { - return &storagebucketscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "storage-buckets delete": commandFactoryWrapper(ui, + &storagebucketscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "storage-buckets list": func() (cli.Command, error) { - return &storagebucketscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "storage-buckets list": commandFactoryWrapper(ui, + &storagebucketscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "storage-buckets create": func() (cli.Command, error) { - return &storagebucketscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "storage-buckets create": commandFactoryWrapper(ui, + &storagebucketscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "storage-buckets update": func() (cli.Command, error) { - return &storagebucketscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "storage-buckets update": commandFactoryWrapper(ui, + &storagebucketscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, + }), "targets": func() (cli.Command, error) { return &targetscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "targets authorize-session": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + "targets authorize-session": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "authorize-session", - }, nil - }, - "targets read": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets read": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "targets delete": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets delete": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "targets list": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets list": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "targets create": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets create": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "targets create tcp": func() (cli.Command, error) { - return &targetscmd.TcpCommand{ - Command: base.NewCommand(ui, opts...), + }), + "targets create tcp": commandFactoryWrapper(ui, + &targetscmd.TcpCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "targets create ssh": func() (cli.Command, error) { - return &targetscmd.SshCommand{ - Command: base.NewCommand(ui, opts...), + }), + "targets create ssh": commandFactoryWrapper(ui, + &targetscmd.SshCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "targets update": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets update": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "targets update tcp": func() (cli.Command, error) { - return &targetscmd.TcpCommand{ - Command: base.NewCommand(ui, opts...), + }), + "targets update tcp": commandFactoryWrapper(ui, + &targetscmd.TcpCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "targets update ssh": func() (cli.Command, error) { - return &targetscmd.SshCommand{ - Command: base.NewCommand(ui, opts...), + }), + "targets update ssh": commandFactoryWrapper(ui, + &targetscmd.SshCommand{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "targets add-host-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets add-host-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "add-host-sources", - }, nil - }, - "targets remove-host-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets remove-host-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-host-sources", - }, nil - }, - "targets set-host-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets set-host-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "set-host-sources", - }, nil - }, - "targets add-credential-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets add-credential-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "add-credential-sources", - }, nil - }, - "targets remove-credential-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets remove-credential-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-credential-sources", - }, nil - }, - "targets set-credential-sources": func() (cli.Command, error) { - return &targetscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "targets set-credential-sources": commandFactoryWrapper(ui, + &targetscmd.Command{ + Command: base.NewCommand(ui), Func: "set-credential-sources", - }, nil - }, + }), "update": func() (cli.Command, error) { return &genericcmd.Command{ @@ -1205,144 +1073,142 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Command: base.NewCommand(ui, opts...), }, nil }, - "users create": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + "users create": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "users read": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users read": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "users update": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users update": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "users delete": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users delete": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "users list": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users list": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "users add-accounts": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users add-accounts": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "add-accounts", - }, nil - }, - "users set-accounts": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users set-accounts": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "set-accounts", - }, nil - }, - "users remove-accounts": func() (cli.Command, error) { - return &userscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "users remove-accounts": commandFactoryWrapper(ui, + &userscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-accounts", - }, nil - }, + }), "workers": func() (cli.Command, error) { return &workerscmd.Command{ Command: base.NewCommand(ui, opts...), }, nil }, - "workers create": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + "workers create": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "workers create worker-led": func() (cli.Command, error) { - return &workerscmd.WorkerLedCommand{ - Command: base.NewCommand(ui, opts...), + }), + "workers create worker-led": commandFactoryWrapper(ui, + &workerscmd.WorkerLedCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "workers create controller-led": func() (cli.Command, error) { - return &workerscmd.ControllerLedCommand{ - Command: base.NewCommand(ui, opts...), + }), + "workers create controller-led": commandFactoryWrapper(ui, + &workerscmd.ControllerLedCommand{ + Command: base.NewCommand(ui), Func: "create", - }, nil - }, - "workers read": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers read": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "workers update": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers update": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "update", - }, nil - }, - "workers delete": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers delete": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "delete", - }, nil - }, - "workers list": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers list": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "list", - }, nil - }, - "workers add-worker-tags": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers add-worker-tags": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "add-worker-tags", - }, nil - }, - "workers set-worker-tags": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers set-worker-tags": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "set-worker-tags", - }, nil - }, - "workers remove-worker-tags": func() (cli.Command, error) { - return &workerscmd.Command{ - Command: base.NewCommand(ui, opts...), + }), + "workers remove-worker-tags": commandFactoryWrapper(ui, + &workerscmd.Command{ + Command: base.NewCommand(ui), Func: "remove-worker-tags", - }, nil - }, - "workers certificate-authority": func() (cli.Command, error) { - return &workerscmd.WorkerCACommand{ - Command: base.NewCommand(ui, opts...), - }, nil - }, - "workers certificate-authority read": func() (cli.Command, error) { - return &workerscmd.WorkerCACommand{ - Command: base.NewCommand(ui, opts...), + }), + "workers certificate-authority": commandFactoryWrapper(ui, + &workerscmd.WorkerCACommand{ + Command: base.NewCommand(ui), + }), + "workers certificate-authority read": commandFactoryWrapper(ui, + &workerscmd.WorkerCACommand{ + Command: base.NewCommand(ui), Func: "read", - }, nil - }, - "workers certificate-authority reinitialize": func() (cli.Command, error) { - return &workerscmd.WorkerCACommand{ - Command: base.NewCommand(ui, opts...), + }), + "workers certificate-authority reinitialize": commandFactoryWrapper(ui, + &workerscmd.WorkerCACommand{ + Command: base.NewCommand(ui), Func: "reinitialize", - }, nil - }, + }), } for _, fn := range extraCommandsFuncs { if fn != nil { - fn() + fn(ui, serverCmdUi, runOpts) } } } -var extraCommandsFuncs []func() +var extraCommandsFuncs []func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) + +type clientAndTokenProvider interface { + Client(opt ...base.Option) (*api.Client, error) + DiscoverKeyringTokenInfo() (string, string, error) + ReadTokenFromKeyring(keyringType, tokenName string) *authtokens.AuthToken +} + +type wrappableCommand interface { + cli.Command + clientAndTokenProvider +} + +// commandFactoryWrapper wraps all short lived, non server, command factories. +// The default func is a noop. +var commandFactoryWrapper = func(ui cli.Ui, c wrappableCommand) cli.CommandFactory { + return func() (cli.Command, error) { + return c, nil + } +} diff --git a/internal/cmd/commands/authenticate/funcs.go b/internal/cmd/commands/authenticate/funcs.go index cb89678b4..f7b6b28ca 100644 --- a/internal/cmd/commands/authenticate/funcs.go +++ b/internal/cmd/commands/authenticate/funcs.go @@ -89,7 +89,7 @@ func saveAndOrPrintToken(c *base.Command, result *authmethods.AuthenticateResult } if !gotErr { - c.UI.Output("\nThe token was successfully stored in the chosen keyring and is not displayed here.") + c.UI.Output(fmt.Sprintf("\nThe token name %q was successfully stored in the chosen keyring and is not displayed here.", tokenName)) } } } diff --git a/internal/cmd/commands/unsupported/unsupported.go b/internal/cmd/commands/unsupported/unsupported.go new file mode 100644 index 000000000..b6c923090 --- /dev/null +++ b/internal/cmd/commands/unsupported/unsupported.go @@ -0,0 +1,38 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package unsupported + +import ( + "errors" + "fmt" + + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*UnsupportedCommand)(nil) + +// UnsupportedCommand is a command that simply prints out a message indicating +// the requested command is not supported on this platform. +type UnsupportedCommand struct { + *base.Command + CommandName string +} + +func (c *UnsupportedCommand) notice() string { + return fmt.Sprintf("'boundary %s' is not supported on this platform.", c.CommandName) +} + +func (c *UnsupportedCommand) Synopsis() string { + return c.notice() +} + +func (c *UnsupportedCommand) Help() string { + return c.notice() +} + +func (c *UnsupportedCommand) Run(args []string) int { + c.Command.PrintCliError(errors.New(c.notice())) + return base.CommandUserError +} diff --git a/internal/cmd/commands_windows.go b/internal/cmd/commands_windows.go index c0a38c054..e8be12ac8 100644 --- a/internal/cmd/commands_windows.go +++ b/internal/cmd/commands_windows.go @@ -1,9 +1,6 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -//go:build windows -// +build windows - package cmd // MakeSigUSR2Ch does nothing useful on Windows. diff --git a/internal/cmd/main.go b/internal/cmd/main.go index 6d0114281..b3c79d7bc 100644 --- a/internal/cmd/main.go +++ b/internal/cmd/main.go @@ -144,7 +144,7 @@ func Run(args []string) int { // RunCustom allows passing in a base command template to pass to other // commands. Currently, this is only used for setting a custom token helper. -func RunCustom(args []string, runOpts *RunOptions) int { +func RunCustom(args []string, runOpts *RunOptions) (exitCode int) { if runOpts == nil { runOpts = &RunOptions{} } @@ -256,7 +256,8 @@ func RunCustom(args []string, runOpts *RunOptions) int { AutocompleteNoDefaultFlags: true, } - exitCode, err := cli.Run() + var err error + exitCode, err = cli.Run() if outputCurlString { if exitCode == 0 { fmt.Fprint(runOpts.Stderr, "Could not generate cURL command\n") diff --git a/internal/cmd/search_cmd_darwin.go b/internal/cmd/search_cmd_darwin.go new file mode 100644 index 000000000..8ed0bd8bd --- /dev/null +++ b/internal/cmd/search_cmd_darwin.go @@ -0,0 +1,45 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon" + "github.com/hashicorp/boundary/internal/clientcache/cmd/search" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +func init() { + commandFactoryWrapper = daemonWrap + + extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { + delete(Commands, "daemon") + Commands["daemon start"] = func() (cli.Command, error) { + return &daemon.StartCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon stop"] = func() (cli.Command, error) { + return &daemon.StopCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon add-token"] = func() (cli.Command, error) { + return &daemon.AddTokenCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["search"] = func() (cli.Command, error) { + return &search.SearchCommand{ + Command: base.NewCommand(ui), + }, nil + } + }) +} + +// daemonWrap wraps the provided wrappableCommand with a daemon writer which +// conditionally starts the client side daemon after the command completes +func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory { + return daemon.Wrap(ui, c) +} diff --git a/internal/cmd/search_cmd_freebsd.go b/internal/cmd/search_cmd_freebsd.go new file mode 100644 index 000000000..32a85adf2 --- /dev/null +++ b/internal/cmd/search_cmd_freebsd.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build amd64 || arm64 + +package cmd + +import ( + "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon" + "github.com/hashicorp/boundary/internal/clientcache/cmd/search" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +func init() { + commandFactoryWrapper = daemonWrap + + extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { + delete(Commands, "daemon") + Commands["daemon start"] = func() (cli.Command, error) { + return &daemon.StartCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon stop"] = func() (cli.Command, error) { + return &daemon.StopCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon add-token"] = func() (cli.Command, error) { + return &daemon.AddTokenCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["search"] = func() (cli.Command, error) { + return &search.SearchCommand{ + Command: base.NewCommand(ui), + }, nil + } + }) +} + +// daemonWrap wraps the provided wrappableCommand with a daemon writer which +// conditionally starts the client side daemon after the command completes +func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory { + return daemon.Wrap(ui, c) +} diff --git a/internal/cmd/search_cmd_linux.go b/internal/cmd/search_cmd_linux.go new file mode 100644 index 000000000..8ed0bd8bd --- /dev/null +++ b/internal/cmd/search_cmd_linux.go @@ -0,0 +1,45 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon" + "github.com/hashicorp/boundary/internal/clientcache/cmd/search" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +func init() { + commandFactoryWrapper = daemonWrap + + extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { + delete(Commands, "daemon") + Commands["daemon start"] = func() (cli.Command, error) { + return &daemon.StartCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon stop"] = func() (cli.Command, error) { + return &daemon.StopCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon add-token"] = func() (cli.Command, error) { + return &daemon.AddTokenCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["search"] = func() (cli.Command, error) { + return &search.SearchCommand{ + Command: base.NewCommand(ui), + }, nil + } + }) +} + +// daemonWrap wraps the provided wrappableCommand with a daemon writer which +// conditionally starts the client side daemon after the command completes +func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory { + return daemon.Wrap(ui, c) +} diff --git a/internal/cmd/search_cmd_windows.go b/internal/cmd/search_cmd_windows.go new file mode 100644 index 000000000..32a85adf2 --- /dev/null +++ b/internal/cmd/search_cmd_windows.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build amd64 || arm64 + +package cmd + +import ( + "github.com/hashicorp/boundary/internal/clientcache/cmd/daemon" + "github.com/hashicorp/boundary/internal/clientcache/cmd/search" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" +) + +func init() { + commandFactoryWrapper = daemonWrap + + extraCommandsFuncs = append(extraCommandsFuncs, func(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { + delete(Commands, "daemon") + Commands["daemon start"] = func() (cli.Command, error) { + return &daemon.StartCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon stop"] = func() (cli.Command, error) { + return &daemon.StopCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["daemon add-token"] = func() (cli.Command, error) { + return &daemon.AddTokenCommand{ + Command: base.NewCommand(ui), + }, nil + } + Commands["search"] = func() (cli.Command, error) { + return &search.SearchCommand{ + Command: base.NewCommand(ui), + }, nil + } + }) +} + +// daemonWrap wraps the provided wrappableCommand with a daemon writer which +// conditionally starts the client side daemon after the command completes +func daemonWrap(ui cli.Ui, c wrappableCommand) cli.CommandFactory { + return daemon.Wrap(ui, c) +} diff --git a/internal/db/db.go b/internal/db/db.go index 3b8a0b085..571ecbacb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/boundary/internal/event" "github.com/hashicorp/go-dbw" _ "github.com/jackc/pgx/v5" + "gorm.io/driver/postgres" ) @@ -22,17 +23,25 @@ func init() { dbw.InitNonUpdatableFields([]string{"PublicId", "CreateTime", "UpdateTime"}) } +// sqliteOpen is a function used to get the dbw.Dialector for sqlite. It returns +// an error if there is any problem opening it. +var sqliteOpen = func(string) (dbw.Dialector, error) { + return nil, fmt.Errorf("sqlite is not supported on this platform") +} + type DbType int const ( UnknownDB DbType = 0 Postgres DbType = 1 + Sqlite DbType = 2 ) func (db DbType) String() string { return [...]string{ "unknown", "postgres", + "sqlite", }[db] } @@ -40,6 +49,8 @@ func StringToDbType(dialect string) (DbType, error) { switch dialect { case "postgres": return Postgres, nil + case "sqlite": + return Sqlite, nil default: return UnknownDB, fmt.Errorf("%s is an unknown dialect", dialect) } @@ -148,6 +159,12 @@ func Open(ctx context.Context, dbType DbType, connectionUrl string, opt ...Optio DSN: connectionUrl, }, ) + case Sqlite: + var err error + dialect, err = sqliteOpen(connectionUrl) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } default: return nil, fmt.Errorf("unable to open %s database type", dbType) } diff --git a/internal/db/sqlite_darwin.go b/internal/db/sqlite_darwin.go new file mode 100644 index 000000000..c32efd64c --- /dev/null +++ b/internal/db/sqlite_darwin.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package db + +import ( + "github.com/glebarez/sqlite" + "github.com/hashicorp/go-dbw" +) + +func init() { + sqliteOpen = supportedSqlite +} + +func supportedSqlite(s string) (dbw.Dialector, error) { + return sqlite.Open(s), nil +} diff --git a/internal/db/sqlite_freebsd.go b/internal/db/sqlite_freebsd.go new file mode 100644 index 000000000..739f16622 --- /dev/null +++ b/internal/db/sqlite_freebsd.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build amd64 || arm64 + +package db + +import ( + "github.com/glebarez/sqlite" + "github.com/hashicorp/go-dbw" +) + +func init() { + sqliteOpen = supportedSqlite +} + +func supportedSqlite(s string) (dbw.Dialector, error) { + return sqlite.Open(s), nil +} diff --git a/internal/db/sqlite_linux.go b/internal/db/sqlite_linux.go new file mode 100644 index 000000000..c32efd64c --- /dev/null +++ b/internal/db/sqlite_linux.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package db + +import ( + "github.com/glebarez/sqlite" + "github.com/hashicorp/go-dbw" +) + +func init() { + sqliteOpen = supportedSqlite +} + +func supportedSqlite(s string) (dbw.Dialector, error) { + return sqlite.Open(s), nil +} diff --git a/internal/db/sqlite_windows.go b/internal/db/sqlite_windows.go new file mode 100644 index 000000000..739f16622 --- /dev/null +++ b/internal/db/sqlite_windows.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build amd64 || arm64 + +package db + +import ( + "github.com/glebarez/sqlite" + "github.com/hashicorp/go-dbw" +) + +func init() { + sqliteOpen = supportedSqlite +} + +func supportedSqlite(s string) (dbw.Dialector, error) { + return sqlite.Open(s), nil +}