diff --git a/configs/sample.config.toml b/configs/sample.config.toml index b48a8426..12d4f135 100644 --- a/configs/sample.config.toml +++ b/configs/sample.config.toml @@ -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 = "" diff --git a/engine/worker.go b/engine/worker.go index 7f636558..0d7c5e1c 100644 --- a/engine/worker.go +++ b/engine/worker.go @@ -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"), diff --git a/runtime/docker/auth.go b/runtime/docker/auth.go new file mode 100644 index 00000000..13d1651a --- /dev/null +++ b/runtime/docker/auth.go @@ -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 `` +// When fetching the credentials we check for this value to determine if +const tokenUsername = "" + +// 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 +} diff --git a/runtime/docker/auth_test.go b/runtime/docker/auth_test.go new file mode 100644 index 00000000..a408df7c --- /dev/null +++ b/runtime/docker/auth_test.go @@ -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) + } + } +} diff --git a/runtime/docker/config.go b/runtime/docker/config.go new file mode 100644 index 00000000..b099ea47 --- /dev/null +++ b/runtime/docker/config.go @@ -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"` +} diff --git a/runtime/docker/docker.go b/runtime/docker/docker.go index 0b38b9bf..965574d2 100644 --- a/runtime/docker/docker.go +++ b/runtime/docker/docker.go @@ -35,6 +35,7 @@ type DockerRuntime struct { images *syncx.Map[string, bool] pullq chan *pullRequest mounter runtime.Mounter + config string } type printableReader struct { @@ -60,6 +61,12 @@ func WithMounter(mounter runtime.Mounter) Option { } } +func WithConfig(config string) Option { + return func(rt *DockerRuntime) { + rt.config = config + } +} + func NewDockerRuntime(opts ...Option) (*DockerRuntime, error) { dc, err := client.NewClientWithOpts(client.FromEnv) if err != nil { @@ -562,10 +569,31 @@ func (d *DockerRuntime) imagePull(ctx context.Context, t *tork.Task) error { // to pull images from the docker repo func (d *DockerRuntime) puller(ctx context.Context) { for pr := range d.pullq { - authConfig := regtypes.AuthConfig{ - Username: pr.registry.username, - Password: pr.registry.password, + var authConfig regtypes.AuthConfig + if pr.registry.username != "" { + authConfig = regtypes.AuthConfig{ + Username: pr.registry.username, + Password: pr.registry.password, + } + } else { + ref, err := parseRef(pr.image) + if err != nil { + pr.done <- err + continue + } + if ref.domain != "" { + username, password, err := getRegistryCredentials(d.config, ref.domain) + if err != nil { + pr.done <- err + continue + } + authConfig = regtypes.AuthConfig{ + Username: username, + Password: password, + } + } } + encodedJSON, err := json.Marshal(authConfig) if err != nil { pr.done <- err diff --git a/runtime/docker/reference.go b/runtime/docker/reference.go new file mode 100644 index 00000000..6eb319d8 --- /dev/null +++ b/runtime/docker/reference.go @@ -0,0 +1,179 @@ +package docker + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +const ( + // NameTotalLengthMax is the maximum total number of characters in a repository name. + nameTotalLengthMax = 255 +) + +var ( + // alphaNumericRegexp defines the alpha numeric atom, typically a + // component of names. This only allows lower case characters and digits. + alphaNumericRegexp = match(`[a-z0-9]+`) + + // separatorRegexp defines the separators allowed to be embedded in name + // components. This allow one period, one or two underscore and multiple + // dashes. + separatorRegexp = match(`(?:[._]|__|[-]*)`) + + // nameComponentRegexp restricts registry path component names to start + // with at least one letter or number, with following parts able to be + // separated by one period, one or two underscore and multiple dashes. + nameComponentRegexp = expression( + alphaNumericRegexp, + optional(repeated(separatorRegexp, alphaNumericRegexp))) + + // domainComponentRegexp restricts the registry domain component of a + // repository name to start with a component as defined by DomainRegexp + // and followed by an optional port. + domainComponentRegexp = match(`(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])`) + + // DomainRegexp defines the structure of potential domain components + // that may be part of image names. This is purposely a subset of what is + // allowed by DNS to ensure backwards compatibility with Docker image + // names. + domainRegexp = expression( + domainComponentRegexp, + optional(repeated(literal(`.`), domainComponentRegexp)), + optional(literal(`:`), match(`[0-9]+`))) + + // TagRegexp matches valid tag names. From docker/docker:graph/tags.go. + tagRegexp = match(`[\w][\w.-]{0,127}`) + + // DigestRegexp matches valid digests. + digestRegexp = match(`[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}`) + + // NameRegexp is the format for the name component of references. The + // regexp has capturing groups for the domain and name part omitting + // the separating forward slash from either. + nameRegexp = expression( + optional(domainRegexp, literal(`/`)), + nameComponentRegexp, + optional(repeated(literal(`/`), nameComponentRegexp))) + + // anchoredNameRegexp is used to parse a name value, capturing the + // domain and trailing components. + anchoredNameRegexp = anchored( + optional(capture(domainRegexp), literal(`/`)), + capture(nameComponentRegexp, + optional(repeated(literal(`/`), nameComponentRegexp)))) + + // ReferenceRegexp is the full supported format of a reference. The regexp + // is anchored and has capturing groups for name, tag, and digest + // components. + referenceRegexp = anchored(capture(nameRegexp), + optional(literal(":"), capture(tagRegexp)), + optional(literal("@"), capture(digestRegexp))) +) + +var ( + // ErrReferenceInvalidFormat represents an error while trying to parse a string as a reference. + errReferenceInvalidFormat = errors.New("invalid reference format") + + // ErrNameContainsUppercase is returned for invalid repository names that contain uppercase characters. + errNameContainsUppercase = errors.New("repository name must be lowercase") + + // ErrNameEmpty is returned for empty, invalid repository names. + errNameEmpty = errors.New("repository name must have at least one component") + + // ErrNameTooLong is returned when a repository name is longer than NameTotalLengthMax. + errNameTooLong = fmt.Errorf("repository name must not be more than %v characters", nameTotalLengthMax) +) + +// match compiles the string to a regular expression. +var match = regexp.MustCompile + +type reference struct { + domain string + path string + tag string +} + +// Parse parses s and returns a syntactically valid Reference. +func parseRef(s string) (reference, error) { + matches := referenceRegexp.FindStringSubmatch(s) + if matches == nil { + if s == "" { + return reference{}, errNameEmpty + } + if referenceRegexp.FindStringSubmatch(strings.ToLower(s)) != nil { + return reference{}, errNameContainsUppercase + } + return reference{}, errReferenceInvalidFormat + } + + if len(matches[1]) > nameTotalLengthMax { + return reference{}, errNameTooLong + } + + ref := reference{ + tag: matches[2], + } + + nameMatch := anchoredNameRegexp.FindStringSubmatch(matches[1]) + if len(nameMatch) == 3 { + ref.domain = nameMatch[1] + ref.path = nameMatch[2] + } else { + ref.domain = "" + ref.path = matches[1] + } + + return ref, nil +} + +// literal compiles s into a literal regular expression, escaping any regexp +// reserved characters. +func literal(s string) *regexp.Regexp { + re := match(regexp.QuoteMeta(s)) + + if _, complete := re.LiteralPrefix(); !complete { + panic("must be a literal") + } + + return re +} + +// expression defines a full expression, where each regular expression must +// follow the previous. +func expression(res ...*regexp.Regexp) *regexp.Regexp { + var s string + for _, re := range res { + s += re.String() + } + + return match(s) +} + +// optional wraps the expression in a non-capturing group and makes the +// production optional. +func optional(res ...*regexp.Regexp) *regexp.Regexp { + return match(group(expression(res...)).String() + `?`) +} + +// repeated wraps the regexp in a non-capturing group to get one or more +// matches. +func repeated(res ...*regexp.Regexp) *regexp.Regexp { + return match(group(expression(res...)).String() + `+`) +} + +// group wraps the regexp in a non-capturing group. +func group(res ...*regexp.Regexp) *regexp.Regexp { + return match(`(?:` + expression(res...).String() + `)`) +} + +// capture wraps the expression in a capturing group. +func capture(res ...*regexp.Regexp) *regexp.Regexp { + return match(`(` + expression(res...).String() + `)`) +} + +// anchored anchors the regular expression by adding start and end delimiters. +func anchored(res ...*regexp.Regexp) *regexp.Regexp { + return match(`^` + expression(res...).String() + `$`) +} diff --git a/runtime/docker/reference_test.go b/runtime/docker/reference_test.go new file mode 100644 index 00000000..ac3d94ae --- /dev/null +++ b/runtime/docker/reference_test.go @@ -0,0 +1,51 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + ref, err := parseRef("ubuntu:mantic") + assert.NoError(t, err) + assert.Equal(t, "", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "mantic", ref.tag) + + ref, err = parseRef("localhost:9090/ubuntu:mantic") + assert.NoError(t, err) + assert.Equal(t, "localhost:9090", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "mantic", ref.tag) + + ref, err = parseRef("localhost:9090/ubuntu:mantic-2.7") + assert.NoError(t, err) + assert.Equal(t, "localhost:9090", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "mantic-2.7", ref.tag) + + ref, err = parseRef("my-registry/ubuntu:mantic-2.7") + assert.NoError(t, err) + assert.Equal(t, "my-registry", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "mantic-2.7", ref.tag) + + ref, err = parseRef("my-registry/ubuntu") + assert.NoError(t, err) + assert.Equal(t, "my-registry", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "", ref.tag) + + ref, err = parseRef("ubuntu") + assert.NoError(t, err) + assert.Equal(t, "", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "", ref.tag) + + ref, err = parseRef("ubuntu:latest") + assert.NoError(t, err) + assert.Equal(t, "", ref.domain) + assert.Equal(t, "ubuntu", ref.path) + assert.Equal(t, "latest", ref.tag) +}