Skip to content

Commit 653818b

Browse files
committed
Share CLI credentials over a unix socket
Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
1 parent 48741f7 commit 653818b

File tree

6 files changed

+304
-26
lines changed

6 files changed

+304
-26
lines changed

cli/command/auth/auth.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/docker/cli/cli/config"
7+
"github.com/docker/cli/cli/config/server"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewAuthCommand() *cobra.Command {
12+
authCmd := &cobra.Command{
13+
Use: "auth",
14+
}
15+
16+
proxyServerCmd := &cobra.Command{
17+
Use: "credential-server",
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
file := config.LoadDefaultConfigFile(cmd.ErrOrStderr())
20+
fmt.Fprint(cmd.OutOrStdout(), "Starting credential server...\n")
21+
err := server.StartCredentialsServer(cmd.Context(), config.Dir(), file)
22+
if err != nil {
23+
return err
24+
}
25+
return nil
26+
},
27+
}
28+
authCmd.AddCommand(proxyServerCmd)
29+
30+
return authCmd
31+
}

cli/command/commands/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55

66
"github.com/docker/cli/cli/command"
7+
"github.com/docker/cli/cli/command/auth"
78
"github.com/docker/cli/cli/command/builder"
89
"github.com/docker/cli/cli/command/checkpoint"
910
"github.com/docker/cli/cli/command/config"
@@ -55,6 +56,8 @@ func AddCommands(cmd *cobra.Command, dockerCli command.Cli) {
5556
trust.NewTrustCommand(dockerCli),
5657
volume.NewVolumeCommand(dockerCli),
5758

59+
auth.NewAuthCommand(),
60+
5861
// orchestration (swarm) commands
5962
config.NewConfigCommand(dockerCli),
6063
node.NewNodeCommand(dockerCli),

cli/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/docker/cli/cli/config/configfile"
1414
"github.com/docker/cli/cli/config/credentials"
15+
"github.com/docker/cli/cli/config/server"
1516
"github.com/docker/cli/cli/config/types"
1617
"github.com/pkg/errors"
1718
)
@@ -135,6 +136,10 @@ func load(configDir string) (*configfile.ConfigFile, error) {
135136
filename := filepath.Join(configDir, ConfigFileName)
136137
configFile := configfile.New(filename)
137138

139+
if addr, err := server.CheckCredentialServer(configDir); err == nil {
140+
configFile.SocketCredentialStoreAddr = addr
141+
}
142+
138143
file, err := os.Open(filename)
139144
if err != nil {
140145
if os.IsNotExist(err) {

cli/config/configfile/file.go

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,33 @@ import (
1616

1717
// ConfigFile ~/.docker/config.json file info
1818
type ConfigFile struct {
19-
AuthConfigs map[string]types.AuthConfig `json:"auths"`
20-
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
21-
PsFormat string `json:"psFormat,omitempty"`
22-
ImagesFormat string `json:"imagesFormat,omitempty"`
23-
NetworksFormat string `json:"networksFormat,omitempty"`
24-
PluginsFormat string `json:"pluginsFormat,omitempty"`
25-
VolumesFormat string `json:"volumesFormat,omitempty"`
26-
StatsFormat string `json:"statsFormat,omitempty"`
27-
DetachKeys string `json:"detachKeys,omitempty"`
28-
CredentialsStore string `json:"credsStore,omitempty"`
29-
CredentialHelpers map[string]string `json:"credHelpers,omitempty"`
30-
Filename string `json:"-"` // Note: for internal use only
31-
ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"`
32-
ServicesFormat string `json:"servicesFormat,omitempty"`
33-
TasksFormat string `json:"tasksFormat,omitempty"`
34-
SecretFormat string `json:"secretFormat,omitempty"`
35-
ConfigFormat string `json:"configFormat,omitempty"`
36-
NodesFormat string `json:"nodesFormat,omitempty"`
37-
PruneFilters []string `json:"pruneFilters,omitempty"`
38-
Proxies map[string]ProxyConfig `json:"proxies,omitempty"`
39-
Experimental string `json:"experimental,omitempty"`
40-
CurrentContext string `json:"currentContext,omitempty"`
41-
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
42-
Plugins map[string]map[string]string `json:"plugins,omitempty"`
43-
Aliases map[string]string `json:"aliases,omitempty"`
44-
Features map[string]string `json:"features,omitempty"`
19+
AuthConfigs map[string]types.AuthConfig `json:"auths"`
20+
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
21+
PsFormat string `json:"psFormat,omitempty"`
22+
ImagesFormat string `json:"imagesFormat,omitempty"`
23+
NetworksFormat string `json:"networksFormat,omitempty"`
24+
PluginsFormat string `json:"pluginsFormat,omitempty"`
25+
VolumesFormat string `json:"volumesFormat,omitempty"`
26+
StatsFormat string `json:"statsFormat,omitempty"`
27+
DetachKeys string `json:"detachKeys,omitempty"`
28+
CredentialsStore string `json:"credsStore,omitempty"`
29+
CredentialHelpers map[string]string `json:"credHelpers,omitempty"`
30+
Filename string `json:"-"` // Note: for internal use only
31+
ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"`
32+
ServicesFormat string `json:"servicesFormat,omitempty"`
33+
TasksFormat string `json:"tasksFormat,omitempty"`
34+
SecretFormat string `json:"secretFormat,omitempty"`
35+
ConfigFormat string `json:"configFormat,omitempty"`
36+
NodesFormat string `json:"nodesFormat,omitempty"`
37+
PruneFilters []string `json:"pruneFilters,omitempty"`
38+
Proxies map[string]ProxyConfig `json:"proxies,omitempty"`
39+
Experimental string `json:"experimental,omitempty"`
40+
CurrentContext string `json:"currentContext,omitempty"`
41+
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
42+
Plugins map[string]map[string]string `json:"plugins,omitempty"`
43+
Aliases map[string]string `json:"aliases,omitempty"`
44+
Features map[string]string `json:"features,omitempty"`
45+
SocketCredentialStoreAddr string `json:"-"`
4546
}
4647

4748
// ProxyConfig contains proxy configuration settings
@@ -254,6 +255,9 @@ func decodeAuth(authStr string) (string, string, error) {
254255
// GetCredentialsStore returns a new credentials store from the settings in the
255256
// configuration file
256257
func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store {
258+
if configFile.SocketCredentialStoreAddr != "" {
259+
return credentials.NewSocketStore(configFile.SocketCredentialStoreAddr)
260+
}
257261
if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" {
258262
return newNativeStore(configFile, helper)
259263
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package credentials
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"net"
9+
"net/http"
10+
"net/url"
11+
12+
"github.com/docker/cli/cli/config/types"
13+
)
14+
15+
type socketStore struct {
16+
socketPath string
17+
client http.Client
18+
}
19+
20+
// Erase implements Store.
21+
func (s *socketStore) Erase(serverAddress string) error {
22+
q := url.Values{"key": {serverAddress}}
23+
req, err := http.NewRequest(http.MethodDelete, "http://localhost/credentials?"+q.Encode(), nil)
24+
if err != nil {
25+
return err
26+
}
27+
resp, err := s.client.Do(req)
28+
if err != nil {
29+
return err
30+
}
31+
if resp.StatusCode != http.StatusOK {
32+
return errors.New("failed to erase credentials")
33+
}
34+
return nil
35+
}
36+
37+
// Get implements Store.
38+
func (s *socketStore) Get(serverAddress string) (types.AuthConfig, error) {
39+
q := url.Values{"key": {serverAddress}}
40+
req, err := http.NewRequest(http.MethodGet, "http://localhost/credentials?"+q.Encode(), nil)
41+
if err != nil {
42+
return types.AuthConfig{}, err
43+
}
44+
resp, err := s.client.Do(req)
45+
if err != nil {
46+
return types.AuthConfig{}, err
47+
}
48+
defer resp.Body.Close()
49+
50+
var authConfig types.AuthConfig
51+
if err := json.NewDecoder(resp.Body).Decode(&authConfig); err != nil {
52+
return types.AuthConfig{}, err
53+
}
54+
return authConfig, nil
55+
}
56+
57+
// GetAll implements Store.
58+
func (s *socketStore) GetAll() (map[string]types.AuthConfig, error) {
59+
req, err := http.NewRequest(http.MethodGet, "http://localhost/credentials", nil)
60+
if err != nil {
61+
return nil, err
62+
}
63+
resp, err := s.client.Do(req)
64+
if err != nil {
65+
return nil, err
66+
}
67+
defer resp.Body.Close()
68+
69+
var authConfigs map[string]types.AuthConfig
70+
if err := json.NewDecoder(resp.Body).Decode(&authConfigs); err != nil {
71+
return nil, err
72+
}
73+
return authConfigs, nil
74+
}
75+
76+
// Store implements Store.
77+
func (s *socketStore) Store(authConfig types.AuthConfig) error {
78+
var buf bytes.Buffer
79+
if err := json.NewEncoder(&buf).Encode(authConfig); err != nil {
80+
return err
81+
}
82+
req, err := http.NewRequest(http.MethodPost, "http://localhost/credentials", &buf)
83+
if err != nil {
84+
return err
85+
}
86+
resp, err := s.client.Do(req)
87+
if err != nil {
88+
return err
89+
}
90+
defer resp.Body.Close()
91+
if resp.StatusCode != http.StatusOK {
92+
return errors.New("failed to store credentials")
93+
}
94+
return nil
95+
}
96+
97+
func NewSocketStore(socketPath string) Store {
98+
return &socketStore{
99+
socketPath: socketPath,
100+
client: http.Client{
101+
Transport: &http.Transport{
102+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
103+
return net.Dial("unix", socketPath)
104+
},
105+
},
106+
},
107+
}
108+
}

cli/config/server/server.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log"
7+
"net"
8+
"net/http"
9+
"path/filepath"
10+
"sync/atomic"
11+
"time"
12+
13+
"github.com/docker/cli/cli/config/types"
14+
)
15+
16+
const CredentialServerSocket = "docker_cli_credential_server.sock"
17+
18+
// GetCredentialServerSocket returns the path to the Unix socket
19+
// configDir is the directory where the docker configuration file is stored
20+
func GetCredentialServerSocket(configDir string) string {
21+
return filepath.Join(configDir, "run", CredentialServerSocket)
22+
}
23+
24+
type CredentialConfig interface {
25+
GetAuthConfig(serverAddress string) (types.AuthConfig, error)
26+
GetAllCredentials() (map[string]types.AuthConfig, error)
27+
}
28+
29+
// CheckCredentialServer checks if the credential server is running
30+
// in the configDir directory by attempting to connect to the Unix socket.
31+
// It returns the absolute path of the Unix socket if the server is running.
32+
func CheckCredentialServer(configDir string) (string, error) {
33+
addr, err := net.ResolveUnixAddr("unix", GetCredentialServerSocket(configDir))
34+
if err != nil {
35+
return "", err
36+
}
37+
_, err = net.Dial(addr.Network(), addr.String())
38+
return addr.String(), err
39+
}
40+
41+
// StartCredentialsServer hosts a Unix socket server that exposes
42+
// the credentials store to the Docker CLI running in a container.
43+
func StartCredentialsServer(ctx context.Context, configDir string, config CredentialConfig) error {
44+
ctx, cancel := context.WithCancel(ctx)
45+
defer cancel()
46+
47+
l, err := net.ListenUnix("unix", &net.UnixAddr{
48+
Name: GetCredentialServerSocket(configDir),
49+
Net: "unix",
50+
})
51+
if err != nil {
52+
return err
53+
}
54+
55+
mux := http.NewServeMux()
56+
mux.HandleFunc("/credentials", func(w http.ResponseWriter, r *http.Request) {
57+
switch r.Method {
58+
case http.MethodGet:
59+
log.Println("GET /credentials")
60+
if key := r.URL.Query().Get("key"); key != "" {
61+
log.Printf("GET /credentials?key=%s", key)
62+
credential, err := config.GetAuthConfig(key)
63+
if err != nil {
64+
http.Error(w, err.Error(), http.StatusInternalServerError)
65+
return
66+
}
67+
if err := json.NewEncoder(w).Encode(credential); err != nil {
68+
http.Error(w, err.Error(), http.StatusInternalServerError)
69+
return
70+
}
71+
return
72+
}
73+
// Get credentials
74+
credentials, err := config.GetAllCredentials()
75+
if err != nil {
76+
http.Error(w, err.Error(), http.StatusInternalServerError)
77+
return
78+
}
79+
// Write credentials
80+
err = json.NewEncoder(w).Encode(credentials)
81+
if err != nil {
82+
http.Error(w, err.Error(), http.StatusInternalServerError)
83+
return
84+
}
85+
case http.MethodPost:
86+
// Store credentials
87+
case http.MethodDelete:
88+
// Erase credentials
89+
default:
90+
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
91+
}
92+
})
93+
94+
timer := time.NewTimer(1000 * time.Second)
95+
activeConnections := atomic.Int32{}
96+
s := http.Server{
97+
BaseContext: func(l net.Listener) context.Context { return ctx },
98+
ReadTimeout: 5 * time.Second,
99+
WriteTimeout: 5 * time.Second,
100+
IdleTimeout: 5 * time.Second,
101+
ConnState: func(c net.Conn, cs http.ConnState) {
102+
switch cs {
103+
case http.StateActive, http.StateNew, http.StateHijacked:
104+
if activeConnections.Load() == 0 {
105+
timer.Stop()
106+
}
107+
activeConnections.Add(1)
108+
case http.StateClosed, http.StateIdle:
109+
if activeConnections.Load() == 0 {
110+
timer.Reset(10 * time.Second)
111+
}
112+
activeConnections.Add(-1)
113+
}
114+
},
115+
Handler: mux,
116+
}
117+
118+
go func() {
119+
select {
120+
case <-ctx.Done():
121+
case <-timer.C:
122+
}
123+
s.Shutdown(ctx)
124+
}()
125+
126+
return s.Serve(l)
127+
}

0 commit comments

Comments
 (0)