Skip to content

Commit

Permalink
Docker Config support
Browse files Browse the repository at this point in the history
  • Loading branch information
runabol committed Oct 25, 2023
1 parent ecb21fb commit 53eb9b1
Show file tree
Hide file tree
Showing 8 changed files with 625 additions and 4 deletions.
3 changes: 3 additions & 0 deletions configs/sample.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ type = "docker" # docker | shell
cmd = ["bash", "-c"] # the shell command used to execute the run script
uid = "" # set the uid for the the task process (recommended)
gid = "" # set the gid for the the task process (recommended)

[runtime.docker]
config = ""
5 changes: 4 additions & 1 deletion engine/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ func (e *Engine) initRuntime() (runtime.Runtime, error) {
mounter.RegisterMounter("volume", vm)
// register tmpfs mounter
mounter.RegisterMounter("tmpfs", docker.NewTmpfsMounter())
return docker.NewDockerRuntime(docker.WithMounter(mounter))
return docker.NewDockerRuntime(
docker.WithMounter(mounter),
docker.WithConfig(conf.String("runtime.docker.config")),
)
case runtime.Shell:
return shell.NewShellRuntime(shell.Config{
CMD: conf.Strings("runtime.shell.cmd"),
Expand Down
223 changes: 223 additions & 0 deletions runtime/docker/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package docker // import "https://github.com/cpuguy83/dockercfg"

import (
"encoding/base64"
"encoding/json"

"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)

// Errors from credential helpers
var (
errCredentialsNotFound = errors.New("credentials not found in native keychain")
errCredentialsMissingServerURL = errors.New("no credentials server URL")
)

// This is used by the docker CLI in casses where an oauth identity token is used.
// In that case the username is stored litterally as `<token>`
// When fetching the credentials we check for this value to determine if
const tokenUsername = "<token>"

// GetRegistryCredentials gets registry credentials for the passed in registry host.
//
// This will use `LoadDefaultConfig` to read registry auth details from the config.
// If the config doesn't exist, it will attempt to load registry credentials using the default credential helper for the platform.
func getRegistryCredentials(configFile string, hostname string) (string, string, error) {
cfg, err := loadConfig(configFile)
if err != nil {
if !os.IsNotExist(err) {
return "", "", err
}
return getCredentialsFromHelper("", hostname)
}
return cfg.getRegistryCredentials(hostname)
}

// GetRegistryCredentials gets credentials, if any, for the provided hostname
//
// Hostnames should already be resolved using `ResolveRegistryAuth`
//
// If the returned username string is empty, the password is an identity token.
func (c *config) getRegistryCredentials(hostname string) (string, string, error) {
h, ok := c.CredentialHelpers[hostname]
if ok {
return getCredentialsFromHelper(h, hostname)
}

if c.CredentialsStore != "" {
return getCredentialsFromHelper(c.CredentialsStore, hostname)
}

auth, ok := c.AuthConfigs[hostname]
if !ok {
return getCredentialsFromHelper("", hostname)
}

if auth.IdentityToken != "" {
return "", auth.IdentityToken, nil
}

if auth.Username != "" && auth.Password != "" {
return auth.Username, auth.Password, nil
}

return decodeBase64Auth(auth)
}

// DecodeBase64Auth decodes the legacy file-based auth storage from the docker CLI.
// It takes the "Auth" filed from AuthConfig and decodes that into a username and password.
//
// If "Auth" is empty, an empty user/pass will be returned, but not an error.
func decodeBase64Auth(auth authConfig) (string, string, error) {
if auth.Auth == "" {
return "", "", nil
}

decLen := base64.StdEncoding.DecodedLen(len(auth.Auth))
decoded := make([]byte, decLen)
n, err := base64.StdEncoding.Decode(decoded, []byte(auth.Auth))
if err != nil {
return "", "", fmt.Errorf("error decoding auth from file: %w", err)
}

if n > decLen {
return "", "", fmt.Errorf("decoded value is longer than expected length, expected: %d, actual: %d", decLen, n)
}

split := strings.SplitN(string(decoded), ":", 2)
if len(split) != 2 {
return "", "", errors.New("invalid auth string")
}

return split[0], strings.Trim(split[1], "\x00"), nil
}

// GetCredentialsFromHelper attempts to lookup credentials from the passed in docker credential helper.
//
// The credential helpoer should just be the suffix name (no "docker-credential-").
// If the passed in helper program is empty this will look up the default helper for the platform.
//
// If the credentials are not found, no error is returned, only empty credentials.
//
// Hostnames should already be resolved using `ResolveRegistryAuth`
//
// If the username string is empty, the password string is an identity token.
func getCredentialsFromHelper(helper, hostname string) (string, string, error) {
if helper == "" {
helper = getCredentialHelper()
}
if helper == "" {
return "", "", nil
}

p, err := exec.LookPath("docker-credential-" + helper)
if err != nil {
return "", "", nil
}

cmd := exec.Command(p, "get")
cmd.Stdin = strings.NewReader(hostname)

b, err := cmd.Output()
if err != nil {
s := strings.TrimSpace(string(b))

switch s {
case errCredentialsNotFound.Error():
return "", "", nil
case errCredentialsMissingServerURL.Error():
return "", "", errors.New(s)
default:
}

return "", "", err
}

var creds struct {
Username string
Secret string
}

if err := json.Unmarshal(b, &creds); err != nil {
return "", "", err
}

// When tokenUsername is used, the output is an identity token and the username is garbage
if creds.Username == tokenUsername {
creds.Username = ""
}

return creds.Username, creds.Secret, nil
}

// getCredentialHelper gets the default credential helper name for the current platform.
func getCredentialHelper() string {
switch runtime.GOOS {
case "linux":
if _, err := exec.LookPath("pass"); err == nil {
return "pass"
}
return "secretservice"
case "darwin":
return "osxkeychain"
case "windows":
return "wincred"
default:
return ""
}
}

// UserHomeConfigPath returns the path to the docker config in the current user's home dir.
func userHomeConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("error looking up user home dir: %w", err)
}

return filepath.Join(home, ".docker", "config.json"), nil
}

// ConfigPath returns the path to the docker cli config.
//
// It will either use the DOCKER_CONFIG env var if set, or the value from `UserHomeConfigPath`
// DOCKER_CONFIG would be the dir path where `config.json` is stored, this returns the path to config.json.
func configPath(configFile string) (string, error) {
if configFile != "" {
return configFile, nil
}
return userHomeConfigPath()
}

// LoadDefaultConfig loads the docker cli config from the path returned from `ConfigPath`
func loadConfig(configFile string) (config, error) {
var cfg config
p, err := configPath(configFile)
if err != nil {
return cfg, err
}
return cfg, fromFile(p, &cfg)
}

// FromFile loads config from the specified path into cfg
func fromFile(configPath string, cfg *config) error {
log.Debug().Msgf("Loading docker config: %s", configPath)
f, err := os.Open(configPath)
if err != nil {
return err
}
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return errors.Wrapf(err, "error decoding docker config")
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "error closing docker config file")
}
return nil
}
69 changes: 69 additions & 0 deletions runtime/docker/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package docker

import (
"encoding/base64"
"testing"
)

func TestDecodeBase64Auth(t *testing.T) {
for _, tc := range base64TestCases() {
t.Run(tc.name, testBase64Case(tc, func() (string, string, error) {
return decodeBase64Auth(tc.config)
}))
}
}

func TestGetRegistryCredentials(t *testing.T) {
t.Run("from base64 auth", func(t *testing.T) {
for _, tc := range base64TestCases() {
t.Run(tc.name, func(T *testing.T) {
config := config{
AuthConfigs: map[string]authConfig{
"some.domain": tc.config,
},
}
testBase64Case(tc, func() (string, string, error) {
return config.getRegistryCredentials("some.domain")
})
})
}
})
}

type base64TestCase struct {
name string
config authConfig
expUser string
expPass string
expErr bool
}

func base64TestCases() []base64TestCase {
cases := []base64TestCase{
{name: "empty"},
{name: "not base64", expErr: true, config: authConfig{Auth: "not base64"}},
{name: "invalid format", expErr: true, config: authConfig{
Auth: base64.StdEncoding.EncodeToString([]byte("invalid format")),
}},
{name: "happy case", expUser: "user", expPass: "pass", config: authConfig{
Auth: base64.StdEncoding.EncodeToString([]byte("user:pass")),
}},
}

return cases
}

type testAuthFn func() (string, string, error)

func testBase64Case(tc base64TestCase, authFn testAuthFn) func(t *testing.T) {
return func(t *testing.T) {
u, p, err := authFn()
if tc.expErr && err == nil {
t.Fatal("expected error")
}

if u != tc.expUser || p != tc.expPass {
t.Errorf("decoded username and password do not match, expected user: %s, password: %s, got user: %s, password: %s", tc.expUser, tc.expPass, u, p)
}
}
}
65 changes: 65 additions & 0 deletions runtime/docker/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package docker // import "https://github.com/cpuguy83/dockercfg"

// Config represents the on disk format of the docker CLI's config file.
type config struct {
AuthConfigs map[string]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"`
StackOrchestrator string `json:"stackOrchestrator,omitempty"`
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
CurrentContext string `json:"currentContext,omitempty"`
CLIPluginsExtraDirs []string `json:"cliPluginsExtraDirs,omitempty"`
Aliases map[string]string `json:"aliases,omitempty"`
}

// ProxyConfig contains proxy configuration settings
type proxyConfig struct {
HTTPProxy string `json:"httpProxy,omitempty"`
HTTPSProxy string `json:"httpsProxy,omitempty"`
NoProxy string `json:"noProxy,omitempty"`
FTPProxy string `json:"ftpProxy,omitempty"`
}

// AuthConfig contains authorization information for connecting to a Registry
type authConfig struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Auth string `json:"auth,omitempty"`

// Email is an optional value associated with the username.
// This field is deprecated and will be removed in a later
// version of docker.
Email string `json:"email,omitempty"`

ServerAddress string `json:"serveraddress,omitempty"`

// IdentityToken is used to authenticate the user and get
// an access token for the registry.
IdentityToken string `json:"identitytoken,omitempty"`

// RegistryToken is a bearer token to be sent to a registry
RegistryToken string `json:"registrytoken,omitempty"`
}

// KubernetesConfig contains Kubernetes orchestrator settings
type KubernetesConfig struct {
AllNamespaces string `json:"allNamespaces,omitempty"`
}
Loading

0 comments on commit 53eb9b1

Please sign in to comment.