From 653818be700330a64ed6b522b42236864f84b8b0 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:56:30 +0100 Subject: [PATCH] Share CLI credentials over a unix socket Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- cli/command/auth/auth.go | 31 ++++++ cli/command/commands/commands.go | 3 + cli/config/config.go | 5 + cli/config/configfile/file.go | 56 ++++++----- cli/config/credentials/socket_store.go | 108 +++++++++++++++++++++ cli/config/server/server.go | 127 +++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 26 deletions(-) create mode 100644 cli/command/auth/auth.go create mode 100644 cli/config/credentials/socket_store.go create mode 100644 cli/config/server/server.go diff --git a/cli/command/auth/auth.go b/cli/command/auth/auth.go new file mode 100644 index 000000000000..4ec44a50b881 --- /dev/null +++ b/cli/command/auth/auth.go @@ -0,0 +1,31 @@ +package auth + +import ( + "fmt" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/server" + "github.com/spf13/cobra" +) + +func NewAuthCommand() *cobra.Command { + authCmd := &cobra.Command{ + Use: "auth", + } + + proxyServerCmd := &cobra.Command{ + Use: "credential-server", + RunE: func(cmd *cobra.Command, args []string) error { + file := config.LoadDefaultConfigFile(cmd.ErrOrStderr()) + fmt.Fprint(cmd.OutOrStdout(), "Starting credential server...\n") + err := server.StartCredentialsServer(cmd.Context(), config.Dir(), file) + if err != nil { + return err + } + return nil + }, + } + authCmd.AddCommand(proxyServerCmd) + + return authCmd +} diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index 23a43568b51f..cf5cedf32f89 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -4,6 +4,7 @@ import ( "os" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/auth" "github.com/docker/cli/cli/command/builder" "github.com/docker/cli/cli/command/checkpoint" "github.com/docker/cli/cli/command/config" @@ -55,6 +56,8 @@ func AddCommands(cmd *cobra.Command, dockerCli command.Cli) { trust.NewTrustCommand(dockerCli), volume.NewVolumeCommand(dockerCli), + auth.NewAuthCommand(), + // orchestration (swarm) commands config.NewConfigCommand(dockerCli), node.NewNodeCommand(dockerCli), diff --git a/cli/config/config.go b/cli/config/config.go index daf504333cda..9a597d62c10e 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -12,6 +12,7 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/credentials" + "github.com/docker/cli/cli/config/server" "github.com/docker/cli/cli/config/types" "github.com/pkg/errors" ) @@ -135,6 +136,10 @@ func load(configDir string) (*configfile.ConfigFile, error) { filename := filepath.Join(configDir, ConfigFileName) configFile := configfile.New(filename) + if addr, err := server.CheckCredentialServer(configDir); err == nil { + configFile.SocketCredentialStoreAddr = addr + } + file, err := os.Open(filename) if err != nil { if os.IsNotExist(err) { diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index ae9dcb3370c7..a2ffee49805d 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -16,32 +16,33 @@ import ( // ConfigFile ~/.docker/config.json file info type ConfigFile struct { - AuthConfigs map[string]types.AuthConfig `json:"auths"` - HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` - PsFormat string `json:"psFormat,omitempty"` - ImagesFormat string `json:"imagesFormat,omitempty"` - NetworksFormat string `json:"networksFormat,omitempty"` - PluginsFormat string `json:"pluginsFormat,omitempty"` - VolumesFormat string `json:"volumesFormat,omitempty"` - StatsFormat string `json:"statsFormat,omitempty"` - DetachKeys string `json:"detachKeys,omitempty"` - CredentialsStore string `json:"credsStore,omitempty"` - CredentialHelpers map[string]string `json:"credHelpers,omitempty"` - Filename string `json:"-"` // Note: for internal use only - ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"` - ServicesFormat string `json:"servicesFormat,omitempty"` - TasksFormat string `json:"tasksFormat,omitempty"` - SecretFormat string `json:"secretFormat,omitempty"` - ConfigFormat string `json:"configFormat,omitempty"` - NodesFormat string `json:"nodesFormat,omitempty"` - PruneFilters []string `json:"pruneFilters,omitempty"` - Proxies map[string]ProxyConfig `json:"proxies,omitempty"` - Experimental string `json:"experimental,omitempty"` - CurrentContext string `json:"currentContext,omitempty"` - CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` - Plugins map[string]map[string]string `json:"plugins,omitempty"` - Aliases map[string]string `json:"aliases,omitempty"` - Features map[string]string `json:"features,omitempty"` + AuthConfigs map[string]types.AuthConfig `json:"auths"` + HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` + PsFormat string `json:"psFormat,omitempty"` + ImagesFormat string `json:"imagesFormat,omitempty"` + NetworksFormat string `json:"networksFormat,omitempty"` + PluginsFormat string `json:"pluginsFormat,omitempty"` + VolumesFormat string `json:"volumesFormat,omitempty"` + StatsFormat string `json:"statsFormat,omitempty"` + DetachKeys string `json:"detachKeys,omitempty"` + CredentialsStore string `json:"credsStore,omitempty"` + CredentialHelpers map[string]string `json:"credHelpers,omitempty"` + Filename string `json:"-"` // Note: for internal use only + ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"` + ServicesFormat string `json:"servicesFormat,omitempty"` + TasksFormat string `json:"tasksFormat,omitempty"` + SecretFormat string `json:"secretFormat,omitempty"` + ConfigFormat string `json:"configFormat,omitempty"` + NodesFormat string `json:"nodesFormat,omitempty"` + PruneFilters []string `json:"pruneFilters,omitempty"` + Proxies map[string]ProxyConfig `json:"proxies,omitempty"` + Experimental string `json:"experimental,omitempty"` + CurrentContext string `json:"currentContext,omitempty"` + CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"` + Plugins map[string]map[string]string `json:"plugins,omitempty"` + Aliases map[string]string `json:"aliases,omitempty"` + Features map[string]string `json:"features,omitempty"` + SocketCredentialStoreAddr string `json:"-"` } // ProxyConfig contains proxy configuration settings @@ -254,6 +255,9 @@ func decodeAuth(authStr string) (string, string, error) { // GetCredentialsStore returns a new credentials store from the settings in the // configuration file func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store { + if configFile.SocketCredentialStoreAddr != "" { + return credentials.NewSocketStore(configFile.SocketCredentialStoreAddr) + } if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" { return newNativeStore(configFile, helper) } diff --git a/cli/config/credentials/socket_store.go b/cli/config/credentials/socket_store.go new file mode 100644 index 000000000000..20f5c40b8e61 --- /dev/null +++ b/cli/config/credentials/socket_store.go @@ -0,0 +1,108 @@ +package credentials + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/url" + + "github.com/docker/cli/cli/config/types" +) + +type socketStore struct { + socketPath string + client http.Client +} + +// Erase implements Store. +func (s *socketStore) Erase(serverAddress string) error { + q := url.Values{"key": {serverAddress}} + req, err := http.NewRequest(http.MethodDelete, "http://localhost/credentials?"+q.Encode(), nil) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return errors.New("failed to erase credentials") + } + return nil +} + +// Get implements Store. +func (s *socketStore) Get(serverAddress string) (types.AuthConfig, error) { + q := url.Values{"key": {serverAddress}} + req, err := http.NewRequest(http.MethodGet, "http://localhost/credentials?"+q.Encode(), nil) + if err != nil { + return types.AuthConfig{}, err + } + resp, err := s.client.Do(req) + if err != nil { + return types.AuthConfig{}, err + } + defer resp.Body.Close() + + var authConfig types.AuthConfig + if err := json.NewDecoder(resp.Body).Decode(&authConfig); err != nil { + return types.AuthConfig{}, err + } + return authConfig, nil +} + +// GetAll implements Store. +func (s *socketStore) GetAll() (map[string]types.AuthConfig, error) { + req, err := http.NewRequest(http.MethodGet, "http://localhost/credentials", nil) + if err != nil { + return nil, err + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var authConfigs map[string]types.AuthConfig + if err := json.NewDecoder(resp.Body).Decode(&authConfigs); err != nil { + return nil, err + } + return authConfigs, nil +} + +// Store implements Store. +func (s *socketStore) Store(authConfig types.AuthConfig) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(authConfig); err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://localhost/credentials", &buf) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New("failed to store credentials") + } + return nil +} + +func NewSocketStore(socketPath string) Store { + return &socketStore{ + socketPath: socketPath, + client: http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + }, + } +} diff --git a/cli/config/server/server.go b/cli/config/server/server.go new file mode 100644 index 000000000000..14db23e9c49d --- /dev/null +++ b/cli/config/server/server.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "encoding/json" + "log" + "net" + "net/http" + "path/filepath" + "sync/atomic" + "time" + + "github.com/docker/cli/cli/config/types" +) + +const CredentialServerSocket = "docker_cli_credential_server.sock" + +// GetCredentialServerSocket returns the path to the Unix socket +// configDir is the directory where the docker configuration file is stored +func GetCredentialServerSocket(configDir string) string { + return filepath.Join(configDir, "run", CredentialServerSocket) +} + +type CredentialConfig interface { + GetAuthConfig(serverAddress string) (types.AuthConfig, error) + GetAllCredentials() (map[string]types.AuthConfig, error) +} + +// CheckCredentialServer checks if the credential server is running +// in the configDir directory by attempting to connect to the Unix socket. +// It returns the absolute path of the Unix socket if the server is running. +func CheckCredentialServer(configDir string) (string, error) { + addr, err := net.ResolveUnixAddr("unix", GetCredentialServerSocket(configDir)) + if err != nil { + return "", err + } + _, err = net.Dial(addr.Network(), addr.String()) + return addr.String(), err +} + +// StartCredentialsServer hosts a Unix socket server that exposes +// the credentials store to the Docker CLI running in a container. +func StartCredentialsServer(ctx context.Context, configDir string, config CredentialConfig) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + l, err := net.ListenUnix("unix", &net.UnixAddr{ + Name: GetCredentialServerSocket(configDir), + Net: "unix", + }) + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.HandleFunc("/credentials", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + log.Println("GET /credentials") + if key := r.URL.Query().Get("key"); key != "" { + log.Printf("GET /credentials?key=%s", key) + credential, err := config.GetAuthConfig(key) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(credential); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } + // Get credentials + credentials, err := config.GetAllCredentials() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Write credentials + err = json.NewEncoder(w).Encode(credentials) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + case http.MethodPost: + // Store credentials + case http.MethodDelete: + // Erase credentials + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + }) + + timer := time.NewTimer(1000 * time.Second) + activeConnections := atomic.Int32{} + s := http.Server{ + BaseContext: func(l net.Listener) context.Context { return ctx }, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 5 * time.Second, + ConnState: func(c net.Conn, cs http.ConnState) { + switch cs { + case http.StateActive, http.StateNew, http.StateHijacked: + if activeConnections.Load() == 0 { + timer.Stop() + } + activeConnections.Add(1) + case http.StateClosed, http.StateIdle: + if activeConnections.Load() == 0 { + timer.Reset(10 * time.Second) + } + activeConnections.Add(-1) + } + }, + Handler: mux, + } + + go func() { + select { + case <-ctx.Done(): + case <-timer.C: + } + s.Shutdown(ctx) + }() + + return s.Serve(l) +}