From eed9400d263f7a4b489e2fe607ec0f2761e3c5d0 Mon Sep 17 00:00:00 2001 From: Chun-Hung Hsiao Date: Tue, 13 Aug 2024 13:03:36 -0700 Subject: [PATCH] feat: support custom exec-based credential helper in proxy mode This change allows users to run the registry as a pull-through cache that can use a credential helper to authenticate against the upstream registry. Signed-off-by: Chun-Hung Hsiao --- configuration/configuration.go | 16 ++ docs/content/about/configuration.md | 28 ++- go.mod | 1 + go.sum | 2 + registry/proxy/proxyauth_exec.go | 58 +++++ registry/proxy/proxyauth_exec_test.go | 175 +++++++++++++++ registry/proxy/proxyregistry.go | 10 +- .../docker/docker-credential-helpers/LICENSE | 20 ++ .../client/client.go | 114 ++++++++++ .../client/command.go | 54 +++++ .../credentials/credentials.go | 209 ++++++++++++++++++ .../credentials/error.go | 124 +++++++++++ .../credentials/helper.go | 14 ++ .../credentials/version.go | 16 ++ vendor/modules.txt | 4 + 15 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 registry/proxy/proxyauth_exec.go create mode 100644 registry/proxy/proxyauth_exec_test.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/LICENSE create mode 100644 vendor/github.com/docker/docker-credential-helpers/client/client.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/client/command.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/credentials/credentials.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/credentials/error.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/credentials/helper.go create mode 100644 vendor/github.com/docker/docker-credential-helpers/credentials/version.go diff --git a/configuration/configuration.go b/configuration/configuration.go index 724e39a11..f686c6bff 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -600,12 +600,28 @@ type Proxy struct { // Password of the hub user Password string `yaml:"password"` + // Exec specifies a custom exec-based command to retrieve credentials. + // If set, Username and Password are ignored. + Exec *ExecConfig `yaml:"exec,omitempty"` + // TTL is the expiry time of the content and will be cleaned up when it expires // if not set, defaults to 7 * 24 hours // If set to zero, will never expire cache TTL *time.Duration `yaml:"ttl,omitempty"` } +type ExecConfig struct { + // Command is the command to execute. + Command string `yaml:"command"` + + // Lifetime is the expiry period of the credentials. The credentials + // returned by the command is reused through the configured lifetime, then + // the command will be re-executed to retrieve new credentials. + // If set to zero, the command will be executed for every request. + // If not set, the command will only be executed once. + Lifetime *time.Duration `yaml:"lifetime,omitempty"` +} + type Validation struct { // Enabled enables the other options in this section. This field is // deprecated in favor of Disabled. diff --git a/docs/content/about/configuration.md b/docs/content/about/configuration.md index 88c4e8589..60f7b5759 100644 --- a/docs/content/about/configuration.md +++ b/docs/content/about/configuration.md @@ -288,6 +288,9 @@ proxy: remoteurl: https://registry-1.docker.io username: [username] password: [password] + exec: + command: docker-credential-helper + lifetime: 1h ttl: 168h validation: manifests: @@ -1160,7 +1163,7 @@ proxy: ``` The `proxy` structure allows a registry to be configured as a pull-through cache -to Docker Hub. See +to an upstream registry such as Docker Hub. See [mirror](../recipes/mirror.md) for more information. Pushing to a registry configured as a pull-through cache is unsupported. @@ -1168,13 +1171,28 @@ is unsupported. | Parameter | Required | Description | |-----------|----------|-------------------------------------------------------| | `remoteurl`| yes | The URL for the repository on Docker Hub. | -| `username` | no | The username registered with Docker Hub which has access to the repository. | -| `password` | no | The password used to authenticate to Docker Hub using the username specified in `username`. | | `ttl` | no | Expire proxy cache configured in "storage" after this time. Cache 168h(7 days) by default, set to 0 to disable cache expiration, The suffix is one of `ns`, `us`, `ms`, `s`, `m`, or `h`. If you specify a value but omit the suffix, the value is interpreted as a number of nanoseconds. | +To enable pulling private repositories (e.g. `batman/robin`), specify one of the +following authentication methods for the pull-through cache to authenticate with +the upstream registry via the [v2 Distribution registry authentication +scheme](https://distribution.github.io/distribution/spec/auth/token/).] + +### `username` and `password` + +The username and password used to authenticate with the upstream registry to +access the private repositories. + +### `exec` + +Run a custom exec-based [Docker credential helper](https://github.com/docker/docker-credential-helpers) +to retrieve the credentials to authenticate with the upstream registry. + +| Parameter | Required | Description | +|-----------|----------|-------------------------------------------------------| +| `command` | yes | The command to execute. | +| `lifetime`| no | The expiry period of the credentials. The credentials returned by the command is reused through the configured lifetime, then the command will be re-executed to retrieve new credentials. If set to zero, the command will be executed for every request. If not set, the command will only be executed once. | -To enable pulling private repositories (e.g. `batman/robin`) specify the -username (such as `batman`) and the password for that username. > **Note**: These private repositories are stored in the proxy cache's storage. > Take appropriate measures to protect access to the proxy cache. diff --git a/go.mod b/go.mod index 576628968..aff10611a 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/bshuster-repo/logrus-logstash-hook v1.0.0 github.com/coreos/go-systemd/v22 v22.5.0 github.com/distribution/reference v0.6.0 + github.com/docker/docker-credential-helpers v0.8.2 github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c github.com/docker/go-metrics v0.0.1 github.com/go-jose/go-jose/v4 v4.0.2 diff --git a/go.sum b/go.sum index 909d2f813..9c87dac70 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= diff --git a/registry/proxy/proxyauth_exec.go b/registry/proxy/proxyauth_exec.go new file mode 100644 index 000000000..a23a9967f --- /dev/null +++ b/registry/proxy/proxyauth_exec.go @@ -0,0 +1,58 @@ +package proxy + +import ( + "net/url" + "sync" + "time" + + "github.com/docker/docker-credential-helpers/client" + credspkg "github.com/docker/docker-credential-helpers/credentials" + "github.com/sirupsen/logrus" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/internal/client/auth" +) + +type execCredentials struct { + m sync.Mutex + helper client.ProgramFunc + lifetime *time.Duration + creds *credspkg.Credentials + expiry time.Time +} + +func (c *execCredentials) Basic(url *url.URL) (string, string) { + c.m.Lock() + defer c.m.Unlock() + + now := time.Now() + if c.creds != nil && (c.lifetime == nil || now.Before(c.expiry)) { + return c.creds.Username, c.creds.Secret + } + + creds, err := client.Get(c.helper, url.Host) + if err != nil { + logrus.Errorf("failed to run command: %v", err) + return "", "" + } + c.creds = creds + if c.lifetime != nil && *c.lifetime > 0 { + c.expiry = now.Add(*c.lifetime) + } + + return c.creds.Username, c.creds.Secret +} + +func (c *execCredentials) RefreshToken(_ *url.URL, _ string) string { + return "" +} + +func (c *execCredentials) SetRefreshToken(_ *url.URL, _, _ string) { +} + +func configureExecAuth(cfg configuration.ExecConfig) (auth.CredentialStore, error) { + return &execCredentials{ + helper: client.NewShellProgramFunc(cfg.Command), + lifetime: cfg.Lifetime, + }, nil +} diff --git a/registry/proxy/proxyauth_exec_test.go b/registry/proxy/proxyauth_exec_test.go new file mode 100644 index 000000000..50fa942b9 --- /dev/null +++ b/registry/proxy/proxyauth_exec_test.go @@ -0,0 +1,175 @@ +package proxy + +import ( + "fmt" + "io" + "net/url" + "testing" + "time" + + "github.com/docker/docker-credential-helpers/client" + credspkg "github.com/docker/docker-credential-helpers/credentials" +) + +type testHelper struct { + username string + secret string + err error +} + +func (h *testHelper) Output() ([]byte, error) { + return []byte(fmt.Sprintf(`{"Username":%q,"Secret":%q}`, h.username, h.secret)), h.err +} + +func (h *testHelper) Input(in io.Reader) { +} + +var _ client.Program = (*testHelper)(nil) + +func TestExecAuth(t *testing.T) { + ptrDuration := func(t time.Duration) *time.Duration { return &t } + + for _, tc := range []struct { + name string + helper client.ProgramFunc + lifetime *time.Duration + currCreds *credspkg.Credentials + currExpiry time.Time + wantUsername string + wantPassword string + wantExpiry time.Time + }{{ + name: "first auth without lifetime", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + wantUsername: "user", + wantPassword: "nextpass", + }, { + name: "first auth with zero lifetime", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + lifetime: ptrDuration(0), + wantUsername: "user", + wantPassword: "nextpass", + }, { + name: "first auth with lifetime", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + lifetime: ptrDuration(time.Hour), + wantUsername: "user", + wantPassword: "nextpass", + wantExpiry: time.Now().Add(time.Hour), + }, { + name: "re-auth without lifetime", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + currCreds: &credspkg.Credentials{ + Username: "user", + Secret: "currpass", + }, + wantUsername: "user", + wantPassword: "currpass", + }, { + name: "re-auth with zero lifetime", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + lifetime: ptrDuration(0), + currCreds: &credspkg.Credentials{ + Username: "user", + Secret: "currpass", + }, + wantUsername: "user", + wantPassword: "nextpass", + }, { + name: "re-auth when not expired", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + lifetime: ptrDuration(time.Hour), + currCreds: &credspkg.Credentials{ + Username: "user", + Secret: "currpass", + }, + currExpiry: time.Now().Add(time.Minute), + wantUsername: "user", + wantPassword: "currpass", + wantExpiry: time.Now().Add(time.Minute), + }, { + name: "re-auth when expired", + helper: func(...string) client.Program { + return &testHelper{ + username: "user", + secret: "nextpass", + } + }, + lifetime: ptrDuration(time.Hour), + currCreds: &credspkg.Credentials{ + Username: "user", + Secret: "currpass", + }, + currExpiry: time.Now().Add(-1), + wantUsername: "user", + wantPassword: "nextpass", + wantExpiry: time.Now().Add(time.Hour), + }, { + name: "exec error", + helper: func(...string) client.Program { + return &testHelper{ + err: fmt.Errorf("exec error"), + } + }, + lifetime: ptrDuration(time.Hour), + currCreds: &credspkg.Credentials{ + Username: "user", + Secret: "currpass", + }, + currExpiry: time.Now().Add(-1), + wantUsername: "", + wantPassword: "", + wantExpiry: time.Now().Add(-1), + }} { + t.Run(tc.name, func(t *testing.T) { + cs := &execCredentials{ + helper: tc.helper, + lifetime: tc.lifetime, + creds: tc.currCreds, + expiry: tc.currExpiry, + } + url := &url.URL{ + Scheme: "https", + Host: "example.com", + } + user, pass := cs.Basic(url) + if user != tc.wantUsername || pass != tc.wantPassword { + t.Errorf("execCredentials.Basic(%q) = (%q, %q), want (%q, %q)", url, user, pass, tc.wantUsername, tc.wantPassword) + } + // All tests should finish within seconds, so the time error should be less than a minute. + if cs.expiry.Sub(tc.wantExpiry).Abs() > time.Minute { + t.Errorf("execCredentials.expiry = %v, want %v", cs.expiry, tc.wantExpiry) + } + }) + } +} diff --git a/registry/proxy/proxyregistry.go b/registry/proxy/proxyregistry.go index e8bbe6bdf..d353af3ff 100644 --- a/registry/proxy/proxyregistry.go +++ b/registry/proxy/proxyregistry.go @@ -114,7 +114,15 @@ func NewRegistryPullThroughCache(ctx context.Context, registry distribution.Name } } - cs, b, err := configureAuth(config.Username, config.Password, config.RemoteURL) + cs, b, err := func() (auth.CredentialStore, auth.CredentialStore, error) { + switch { + case config.Exec != nil: + cs, err := configureExecAuth(*config.Exec) + return cs, cs, err + default: + return configureAuth(config.Username, config.Password, config.RemoteURL) + } + }() if err != nil { return nil, err } diff --git a/vendor/github.com/docker/docker-credential-helpers/LICENSE b/vendor/github.com/docker/docker-credential-helpers/LICENSE new file mode 100644 index 000000000..1ea555e2a --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 David Calavera + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/docker/docker-credential-helpers/client/client.go b/vendor/github.com/docker/docker-credential-helpers/client/client.go new file mode 100644 index 000000000..7ca5ab722 --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/client/client.go @@ -0,0 +1,114 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/docker/docker-credential-helpers/credentials" +) + +// isValidCredsMessage checks if 'msg' contains invalid credentials error message. +// It returns whether the logs are free of invalid credentials errors and the error if it isn't. +// error values can be errCredentialsMissingServerURL or errCredentialsMissingUsername. +func isValidCredsMessage(msg string) error { + if credentials.IsCredentialsMissingServerURLMessage(msg) { + return credentials.NewErrCredentialsMissingServerURL() + } + if credentials.IsCredentialsMissingUsernameMessage(msg) { + return credentials.NewErrCredentialsMissingUsername() + } + return nil +} + +// Store uses an external program to save credentials. +func Store(program ProgramFunc, creds *credentials.Credentials) error { + cmd := program(credentials.ActionStore) + + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(creds); err != nil { + return err + } + cmd.Input(buffer) + + out, err := cmd.Output() + if err != nil { + if isValidErr := isValidCredsMessage(string(out)); isValidErr != nil { + err = isValidErr + } + return fmt.Errorf("error storing credentials - err: %v, out: `%s`", err, strings.TrimSpace(string(out))) + } + + return nil +} + +// Get executes an external program to get the credentials from a native store. +func Get(program ProgramFunc, serverURL string) (*credentials.Credentials, error) { + cmd := program(credentials.ActionGet) + cmd.Input(strings.NewReader(serverURL)) + + out, err := cmd.Output() + if err != nil { + if credentials.IsErrCredentialsNotFoundMessage(string(out)) { + return nil, credentials.NewErrCredentialsNotFound() + } + + if isValidErr := isValidCredsMessage(string(out)); isValidErr != nil { + err = isValidErr + } + + return nil, fmt.Errorf("error getting credentials - err: %v, out: `%s`", err, strings.TrimSpace(string(out))) + } + + resp := &credentials.Credentials{ + ServerURL: serverURL, + } + + if err := json.NewDecoder(bytes.NewReader(out)).Decode(resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Erase executes a program to remove the server credentials from the native store. +func Erase(program ProgramFunc, serverURL string) error { + cmd := program(credentials.ActionErase) + cmd.Input(strings.NewReader(serverURL)) + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + + if isValidErr := isValidCredsMessage(t); isValidErr != nil { + err = isValidErr + } + + return fmt.Errorf("error erasing credentials - err: %v, out: `%s`", err, t) + } + + return nil +} + +// List executes a program to list server credentials in the native store. +func List(program ProgramFunc) (map[string]string, error) { + cmd := program(credentials.ActionList) + cmd.Input(strings.NewReader("unused")) + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + + if isValidErr := isValidCredsMessage(t); isValidErr != nil { + err = isValidErr + } + + return nil, fmt.Errorf("error listing credentials - err: %v, out: `%s`", err, t) + } + + var resp map[string]string + if err = json.NewDecoder(bytes.NewReader(out)).Decode(&resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/vendor/github.com/docker/docker-credential-helpers/client/command.go b/vendor/github.com/docker/docker-credential-helpers/client/command.go new file mode 100644 index 000000000..1936234be --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/client/command.go @@ -0,0 +1,54 @@ +package client + +import ( + "io" + "os" + "os/exec" +) + +// Program is an interface to execute external programs. +type Program interface { + Output() ([]byte, error) + Input(in io.Reader) +} + +// ProgramFunc is a type of function that initializes programs based on arguments. +type ProgramFunc func(args ...string) Program + +// NewShellProgramFunc creates programs that are executed in a Shell. +func NewShellProgramFunc(name string) ProgramFunc { + return NewShellProgramFuncWithEnv(name, nil) +} + +// NewShellProgramFuncWithEnv creates programs that are executed in a Shell with environment variables +func NewShellProgramFuncWithEnv(name string, env *map[string]string) ProgramFunc { + return func(args ...string) Program { + return &Shell{cmd: createProgramCmdRedirectErr(name, args, env)} + } +} + +func createProgramCmdRedirectErr(commandName string, args []string, env *map[string]string) *exec.Cmd { + programCmd := exec.Command(commandName, args...) + if env != nil { + for k, v := range *env { + programCmd.Env = append(programCmd.Environ(), k+"="+v) + } + } + programCmd.Stderr = os.Stderr + return programCmd +} + +// Shell invokes shell commands to talk with a remote credentials-helper. +type Shell struct { + cmd *exec.Cmd +} + +// Output returns responses from the remote credentials-helper. +func (s *Shell) Output() ([]byte, error) { + return s.cmd.Output() +} + +// Input sets the input to send to a remote credentials-helper. +func (s *Shell) Input(in io.Reader) { + s.cmd.Stdin = in +} diff --git a/vendor/github.com/docker/docker-credential-helpers/credentials/credentials.go b/vendor/github.com/docker/docker-credential-helpers/credentials/credentials.go new file mode 100644 index 000000000..eac551884 --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/credentials/credentials.go @@ -0,0 +1,209 @@ +package credentials + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// Action defines the name of an action (sub-command) supported by a +// credential-helper binary. It is an alias for "string", and mostly +// for convenience. +type Action = string + +// List of actions (sub-commands) supported by credential-helper binaries. +const ( + ActionStore Action = "store" + ActionGet Action = "get" + ActionErase Action = "erase" + ActionList Action = "list" + ActionVersion Action = "version" +) + +// Credentials holds the information shared between docker and the credentials store. +type Credentials struct { + ServerURL string + Username string + Secret string +} + +// isValid checks the integrity of Credentials object such that no credentials lack +// a server URL or a username. +// It returns whether the credentials are valid and the error if it isn't. +// error values can be errCredentialsMissingServerURL or errCredentialsMissingUsername +func (c *Credentials) isValid() (bool, error) { + if len(c.ServerURL) == 0 { + return false, NewErrCredentialsMissingServerURL() + } + + if len(c.Username) == 0 { + return false, NewErrCredentialsMissingUsername() + } + + return true, nil +} + +// CredsLabel holds the way Docker credentials should be labeled as such in credentials stores that allow labelling. +// That label allows to filter out non-Docker credentials too at lookup/search in macOS keychain, +// Windows credentials manager and Linux libsecret. Default value is "Docker Credentials" +var CredsLabel = "Docker Credentials" + +// SetCredsLabel is a simple setter for CredsLabel +func SetCredsLabel(label string) { + CredsLabel = label +} + +// Serve initializes the credentials-helper and parses the action argument. +// This function is designed to be called from a command line interface. +// It uses os.Args[1] as the key for the action. +// It uses os.Stdin as input and os.Stdout as output. +// This function terminates the program with os.Exit(1) if there is an error. +func Serve(helper Helper) { + if len(os.Args) != 2 { + _, _ = fmt.Fprintln(os.Stdout, usage()) + os.Exit(1) + } + + switch os.Args[1] { + case "--version", "-v": + _ = PrintVersion(os.Stdout) + os.Exit(0) + case "--help", "-h": + _, _ = fmt.Fprintln(os.Stdout, usage()) + os.Exit(0) + } + + if err := HandleCommand(helper, os.Args[1], os.Stdin, os.Stdout); err != nil { + _, _ = fmt.Fprintln(os.Stdout, err) + os.Exit(1) + } +} + +func usage() string { + return fmt.Sprintf("Usage: %s ", Name) +} + +// HandleCommand runs a helper to execute a credential action. +func HandleCommand(helper Helper, action Action, in io.Reader, out io.Writer) error { + switch action { + case ActionStore: + return Store(helper, in) + case ActionGet: + return Get(helper, in, out) + case ActionErase: + return Erase(helper, in) + case ActionList: + return List(helper, out) + case ActionVersion: + return PrintVersion(out) + default: + return fmt.Errorf("%s: unknown action: %s", Name, action) + } +} + +// Store uses a helper and an input reader to save credentials. +// The reader must contain the JSON serialization of a Credentials struct. +func Store(helper Helper, reader io.Reader) error { + scanner := bufio.NewScanner(reader) + + buffer := new(bytes.Buffer) + for scanner.Scan() { + buffer.Write(scanner.Bytes()) + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return err + } + + var creds Credentials + if err := json.NewDecoder(buffer).Decode(&creds); err != nil { + return err + } + + if ok, err := creds.isValid(); !ok { + return err + } + + return helper.Add(&creds) +} + +// Get retrieves the credentials for a given server url. +// The reader must contain the server URL to search. +// The writer is used to write the JSON serialization of the credentials. +func Get(helper Helper, reader io.Reader, writer io.Writer) error { + scanner := bufio.NewScanner(reader) + + buffer := new(bytes.Buffer) + for scanner.Scan() { + buffer.Write(scanner.Bytes()) + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return err + } + + serverURL := strings.TrimSpace(buffer.String()) + if len(serverURL) == 0 { + return NewErrCredentialsMissingServerURL() + } + + username, secret, err := helper.Get(serverURL) + if err != nil { + return err + } + + buffer.Reset() + err = json.NewEncoder(buffer).Encode(Credentials{ + ServerURL: serverURL, + Username: username, + Secret: secret, + }) + if err != nil { + return err + } + + _, _ = fmt.Fprint(writer, buffer.String()) + return nil +} + +// Erase removes credentials from the store. +// The reader must contain the server URL to remove. +func Erase(helper Helper, reader io.Reader) error { + scanner := bufio.NewScanner(reader) + + buffer := new(bytes.Buffer) + for scanner.Scan() { + buffer.Write(scanner.Bytes()) + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return err + } + + serverURL := strings.TrimSpace(buffer.String()) + if len(serverURL) == 0 { + return NewErrCredentialsMissingServerURL() + } + + return helper.Delete(serverURL) +} + +// List returns all the serverURLs of keys in +// the OS store as a list of strings +func List(helper Helper, writer io.Writer) error { + accts, err := helper.List() + if err != nil { + return err + } + return json.NewEncoder(writer).Encode(accts) +} + +// PrintVersion outputs the current version. +func PrintVersion(writer io.Writer) error { + _, _ = fmt.Fprintf(writer, "%s (%s) %s\n", Name, Package, Version) + return nil +} diff --git a/vendor/github.com/docker/docker-credential-helpers/credentials/error.go b/vendor/github.com/docker/docker-credential-helpers/credentials/error.go new file mode 100644 index 000000000..2283d5a44 --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/credentials/error.go @@ -0,0 +1,124 @@ +package credentials + +import ( + "errors" + "strings" +) + +const ( + // ErrCredentialsNotFound standardizes the not found error, so every helper returns + // the same message and docker can handle it properly. + errCredentialsNotFoundMessage = "credentials not found in native keychain" + + // ErrCredentialsMissingServerURL and ErrCredentialsMissingUsername standardize + // invalid credentials or credentials management operations + errCredentialsMissingServerURLMessage = "no credentials server URL" + errCredentialsMissingUsernameMessage = "no credentials username" +) + +// errCredentialsNotFound represents an error +// raised when credentials are not in the store. +type errCredentialsNotFound struct{} + +// Error returns the standard error message +// for when the credentials are not in the store. +func (errCredentialsNotFound) Error() string { + return errCredentialsNotFoundMessage +} + +// NotFound implements the [ErrNotFound][errdefs.ErrNotFound] interface. +// +// [errdefs.ErrNotFound]: https://pkg.go.dev/github.com/docker/docker@v24.0.1+incompatible/errdefs#ErrNotFound +func (errCredentialsNotFound) NotFound() {} + +// NewErrCredentialsNotFound creates a new error +// for when the credentials are not in the store. +func NewErrCredentialsNotFound() error { + return errCredentialsNotFound{} +} + +// IsErrCredentialsNotFound returns true if the error +// was caused by not having a set of credentials in a store. +func IsErrCredentialsNotFound(err error) bool { + var target errCredentialsNotFound + return errors.As(err, &target) +} + +// IsErrCredentialsNotFoundMessage returns true if the error +// was caused by not having a set of credentials in a store. +// +// This function helps to check messages returned by an +// external program via its standard output. +func IsErrCredentialsNotFoundMessage(err string) bool { + return strings.TrimSpace(err) == errCredentialsNotFoundMessage +} + +// errCredentialsMissingServerURL represents an error raised +// when the credentials object has no server URL or when no +// server URL is provided to a credentials operation requiring +// one. +type errCredentialsMissingServerURL struct{} + +func (errCredentialsMissingServerURL) Error() string { + return errCredentialsMissingServerURLMessage +} + +// InvalidParameter implements the [ErrInvalidParameter][errdefs.ErrInvalidParameter] +// interface. +// +// [errdefs.ErrInvalidParameter]: https://pkg.go.dev/github.com/docker/docker@v24.0.1+incompatible/errdefs#ErrInvalidParameter +func (errCredentialsMissingServerURL) InvalidParameter() {} + +// errCredentialsMissingUsername represents an error raised +// when the credentials object has no username or when no +// username is provided to a credentials operation requiring +// one. +type errCredentialsMissingUsername struct{} + +func (errCredentialsMissingUsername) Error() string { + return errCredentialsMissingUsernameMessage +} + +// InvalidParameter implements the [ErrInvalidParameter][errdefs.ErrInvalidParameter] +// interface. +// +// [errdefs.ErrInvalidParameter]: https://pkg.go.dev/github.com/docker/docker@v24.0.1+incompatible/errdefs#ErrInvalidParameter +func (errCredentialsMissingUsername) InvalidParameter() {} + +// NewErrCredentialsMissingServerURL creates a new error for +// errCredentialsMissingServerURL. +func NewErrCredentialsMissingServerURL() error { + return errCredentialsMissingServerURL{} +} + +// NewErrCredentialsMissingUsername creates a new error for +// errCredentialsMissingUsername. +func NewErrCredentialsMissingUsername() error { + return errCredentialsMissingUsername{} +} + +// IsCredentialsMissingServerURL returns true if the error +// was an errCredentialsMissingServerURL. +func IsCredentialsMissingServerURL(err error) bool { + var target errCredentialsMissingServerURL + return errors.As(err, &target) +} + +// IsCredentialsMissingServerURLMessage checks for an +// errCredentialsMissingServerURL in the error message. +func IsCredentialsMissingServerURLMessage(err string) bool { + return strings.TrimSpace(err) == errCredentialsMissingServerURLMessage +} + +// IsCredentialsMissingUsername returns true if the error +// was an errCredentialsMissingUsername. +func IsCredentialsMissingUsername(err error) bool { + var target errCredentialsMissingUsername + return errors.As(err, &target) +} + +// IsCredentialsMissingUsernameMessage checks for an +// errCredentialsMissingUsername in the error message. +func IsCredentialsMissingUsernameMessage(err string) bool { + return strings.TrimSpace(err) == errCredentialsMissingUsernameMessage +} diff --git a/vendor/github.com/docker/docker-credential-helpers/credentials/helper.go b/vendor/github.com/docker/docker-credential-helpers/credentials/helper.go new file mode 100644 index 000000000..135acd254 --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/credentials/helper.go @@ -0,0 +1,14 @@ +package credentials + +// Helper is the interface a credentials store helper must implement. +type Helper interface { + // Add appends credentials to the store. + Add(*Credentials) error + // Delete removes credentials from the store. + Delete(serverURL string) error + // Get retrieves credentials from the store. + // It returns username and secret as strings. + Get(serverURL string) (string, string, error) + // List returns the stored serverURLs and their associated usernames. + List() (map[string]string, error) +} diff --git a/vendor/github.com/docker/docker-credential-helpers/credentials/version.go b/vendor/github.com/docker/docker-credential-helpers/credentials/version.go new file mode 100644 index 000000000..84377c263 --- /dev/null +++ b/vendor/github.com/docker/docker-credential-helpers/credentials/version.go @@ -0,0 +1,16 @@ +package credentials + +var ( + // Name is filled at linking time + Name = "" + + // Package is filled at linking time + Package = "github.com/docker/docker-credential-helpers" + + // Version holds the complete version number. Filled in at linking time. + Version = "v0.0.0+unknown" + + // Revision is filled with the VCS (e.g. git) revision being used to build + // the program at linking time. + Revision = "" +) diff --git a/vendor/modules.txt b/vendor/modules.txt index cc3404982..f825b0144 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -177,6 +177,10 @@ github.com/dgryski/go-rendezvous # github.com/distribution/reference v0.6.0 ## explicit; go 1.20 github.com/distribution/reference +# github.com/docker/docker-credential-helpers v0.8.2 +## explicit; go 1.19 +github.com/docker/docker-credential-helpers/client +github.com/docker/docker-credential-helpers/credentials # github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c ## explicit github.com/docker/go-events