From e557d2583c8a52ec051a51a15aaecddc368f0f76 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 21 Oct 2024 16:53:09 +0100 Subject: [PATCH] fix: parse image auth config correctly (#103) --- cli/docker.go | 10 +-- cli/docker_test.go | 96 +++++++++++++++++++++- dockerutil/client.go | 38 ++++----- dockerutil/client_test.go | 41 ++++++++++ integration/docker_test.go | 22 +++++ integration/integrationtest/docker.go | 113 +++++++++++++++++++++----- 6 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 dockerutil/client_test.go diff --git a/cli/docker.go b/cli/docker.go index a798ee4..8ffbb48 100644 --- a/cli/docker.go +++ b/cli/docker.go @@ -397,10 +397,14 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Docker if err != nil { return xerrors.Errorf("set oom score: %w", err) } + ref, err := name.NewTag(flags.innerImage) + if err != nil { + return xerrors.Errorf("parse ref: %w", err) + } var dockerAuth dockerutil.AuthConfig if flags.imagePullSecret != "" { - dockerAuth, err = dockerutil.ParseAuthConfig(flags.imagePullSecret) + dockerAuth, err = dockerutil.AuthConfigFromString(flags.imagePullSecret, ref.RegistryStr()) if err != nil { return xerrors.Errorf("parse auth config: %w", err) } @@ -409,10 +413,6 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Docker log.Info(ctx, "checking for docker config file", slog.F("path", flags.dockerConfig)) if _, err := fs.Stat(flags.dockerConfig); err == nil { log.Info(ctx, "detected file", slog.F("image", flags.innerImage)) - ref, err := name.NewTag(flags.innerImage) - if err != nil { - return xerrors.Errorf("parse ref: %w", err) - } dockerAuth, err = dockerutil.AuthConfigFromPath(flags.dockerConfig, ref.RegistryStr()) if err != nil && !xerrors.Is(err, os.ErrNotExist) { return xerrors.Errorf("auth config from file: %w", err) diff --git a/cli/docker_test.go b/cli/docker_test.go index 5f56f2e..3c077e8 100644 --- a/cli/docker_test.go +++ b/cli/docker_test.go @@ -63,6 +63,98 @@ func TestDocker(t *testing.T) { execer.AssertCommandsCalled(t) }) + t.Run("Images", func(t *testing.T) { + t.Parallel() + + type testcase struct { + name string + image string + success bool + } + + testcases := []testcase{ + { + name: "Repository", + image: "ubuntu", + success: true, + }, + { + name: "RepositoryPath", + image: "ubuntu/ubuntu", + success: true, + }, + + { + name: "RepositoryLatest", + image: "ubuntu:latest", + success: true, + }, + { + name: "RepositoryTag", + image: "ubuntu:24.04", + success: true, + }, + { + name: "RepositoryPathTag", + image: "ubuntu/ubuntu:18.04", + success: true, + }, + { + name: "RegistryRepository", + image: "gcr.io/ubuntu", + success: true, + }, + { + name: "RegistryRepositoryTag", + image: "gcr.io/ubuntu:24.04", + success: true, + }, + } + + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cmd := clitest.New(t, "docker", + "--image="+tc.image, + "--username=root", + "--agent-token=hi", + ) + + called := make(chan struct{}) + execer := clitest.Execer(ctx) + client := clitest.DockerClient(t, ctx) + execer.AddCommands(&xunixfake.FakeCmd{ + FakeCmd: &testingexec.FakeCmd{ + Argv: []string{ + "sysbox-mgr", + }, + }, + WaitFn: func() error { close(called); select {} }, //nolint:revive + }) + + var created bool + client.ContainerCreateFn = func(_ context.Context, conf *container.Config, _ *container.HostConfig, _ *network.NetworkingConfig, _ *v1.Platform, _ string) (container.CreateResponse, error) { + created = true + require.Equal(t, tc.image, conf.Image) + return container.CreateResponse{}, nil + } + + err := cmd.ExecuteContext(ctx) + if !tc.success { + require.Error(t, err) + return + } + + <-called + require.NoError(t, err) + require.True(t, created, "container create fn not called") + execer.AssertCommandsCalled(t) + }) + } + }) + // Test that dockerd is configured correctly. t.Run("DockerdConfigured", func(t *testing.T) { t.Parallel() @@ -384,13 +476,13 @@ func TestDocker(t *testing.T) { t.Parallel() ctx, cmd := clitest.New(t, "docker", - "--image=ubuntu", + "--image=us.gcr.io/ubuntu", "--username=root", "--agent-token=hi", fmt.Sprintf("--image-secret=%s", rawDockerAuth), ) - raw := []byte(`{"username":"_json_key","password":"{\"type\": \"service_account\", \"project_id\": \"some-test\", \"private_key_id\": \"blahblah\", \"private_key\": \"-----BEGIN PRIVATE KEY-----mykey-----END PRIVATE KEY-----\", \"client_email\": \"test@test.iam.gserviceaccount.com\", \"client_id\": \"123\", \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\", \"token_uri\": \"https://oauth2.googleapis.com/token\", \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\", \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/test.iam.gserviceaccount.com\" }","auth":"X2pzb25fa2V5OnsKCgkidHlwZSI6ICJzZXJ2aWNlX2FjY291bnQiLAoJInByb2plY3RfaWQiOiAic29tZS10ZXN0IiwKCSJwcml2YXRlX2tleV9pZCI6ICJibGFoYmxhaCIsCgkicHJpdmF0ZV9rZXkiOiAiLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCm15a2V5LS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQoiLAoJImNsaWVudF9lbWFpbCI6ICJ0ZXN0QHRlc3QuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAoJImNsaWVudF9pZCI6ICIxMjMiLAoJImF1dGhfdXJpIjogImh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL29hdXRoMi9hdXRoIiwKCSJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAoJImF1dGhfcHJvdmlkZXJfeDUwOV9jZXJ0X3VybCI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9vYXV0aDIvdjEvY2VydHMiLAoJImNsaWVudF94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL3JvYm90L3YxL21ldGFkYXRhL3g1MDkvdGVzdC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIKfQo=","email":"test@test.iam.gserviceaccount.com"}`) + raw := []byte(`{"username":"_json_key","password":"{\"type\": \"service_account\", \"project_id\": \"some-test\", \"private_key_id\": \"blahblah\", \"private_key\": \"-----BEGIN PRIVATE KEY-----mykey-----END PRIVATE KEY-----\", \"client_email\": \"test@test.iam.gserviceaccount.com\", \"client_id\": \"123\", \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\", \"token_uri\": \"https://oauth2.googleapis.com/token\", \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\", \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/test.iam.gserviceaccount.com\" }"}`) authB64 := base64.URLEncoding.EncodeToString(raw) client := clitest.DockerClient(t, ctx) diff --git a/dockerutil/client.go b/dockerutil/client.go index 7e97f71..d206219 100644 --- a/dockerutil/client.go +++ b/dockerutil/client.go @@ -48,20 +48,29 @@ func (a AuthConfig) Base64() (string, error) { return base64.URLEncoding.EncodeToString(authStr), nil } -func AuthConfigFromPath(path string, registry string) (AuthConfig, error) { +func AuthConfigFromPath(path string, reg string) (AuthConfig, error) { var config dockercfg.Config err := dockercfg.FromFile(path, &config) if err != nil { return AuthConfig{}, xerrors.Errorf("load config: %w", err) } - hostname := dockercfg.ResolveRegistryHost(registry) + return parseConfig(config, reg) +} - if config, ok := config.AuthConfigs[registry]; ok { - return AuthConfig(config), nil +func AuthConfigFromString(raw string, reg string) (AuthConfig, error) { + var cfg dockercfg.Config + err := json.Unmarshal([]byte(raw), &cfg) + if err != nil { + return AuthConfig{}, xerrors.Errorf("parse config: %w", err) } + return parseConfig(cfg, reg) +} - username, secret, err := config.GetRegistryCredentials(hostname) +func parseConfig(cfg dockercfg.Config, registry string) (AuthConfig, error) { + hostname := dockercfg.ResolveRegistryHost(registry) + + username, secret, err := cfg.GetRegistryCredentials(hostname) if err != nil { return AuthConfig{}, xerrors.Errorf("get credentials from helper: %w", err) } @@ -80,22 +89,3 @@ func AuthConfigFromPath(path string, registry string) (AuthConfig, error) { return AuthConfig{}, xerrors.Errorf("no auth config found for registry %s: %w", registry, os.ErrNotExist) } - -func ParseAuthConfig(raw string) (AuthConfig, error) { - type dockerConfig struct { - AuthConfigs map[string]dockertypes.AuthConfig `json:"auths"` - } - - var conf dockerConfig - if err := json.Unmarshal([]byte(raw), &conf); err != nil { - return AuthConfig{}, xerrors.Errorf("parse docker auth config secret: %w", err) - } - if len(conf.AuthConfigs) != 1 { - return AuthConfig{}, xerrors.Errorf("number of image pull auth configs not equal to 1 (%d)", len(conf.AuthConfigs)) - } - for _, regConfig := range conf.AuthConfigs { - return AuthConfig(regConfig), nil - } - - return AuthConfig{}, xerrors.New("no auth configs parsed.") -} diff --git a/dockerutil/client_test.go b/dockerutil/client_test.go new file mode 100644 index 0000000..b515632 --- /dev/null +++ b/dockerutil/client_test.go @@ -0,0 +1,41 @@ +package dockerutil_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/envbox/dockerutil" +) + +func TestAuthConfigFromString(t *testing.T) { + t.Parallel() + + t.Run("Auth", func(t *testing.T) { + t.Parallel() + + //nolint:gosec // this is a test + creds := `{ "auths": { "docker.registry.test": { "auth": "Zm9vQGJhci5jb206YWJjMTIz" } } }` + expectedUsername := "foo@bar.com" + expectedPassword := "abc123" + + cfg, err := dockerutil.AuthConfigFromString(creds, "docker.registry.test") + require.NoError(t, err) + require.Equal(t, expectedUsername, cfg.Username) + require.Equal(t, expectedPassword, cfg.Password) + }) + + t.Run("UsernamePassword", func(t *testing.T) { + t.Parallel() + + //nolint:gosec // this is a test + creds := `{ "auths": { "docker.registry.test": { "username": "foobarbaz", "password": "123abc" } } }` + expectedUsername := "foobarbaz" + expectedPassword := "123abc" + + cfg, err := dockerutil.AuthConfigFromString(creds, "docker.registry.test") + require.NoError(t, err) + require.Equal(t, expectedUsername, cfg.Username) + require.Equal(t, expectedPassword, cfg.Password) + }) +} diff --git a/integration/docker_test.go b/integration/docker_test.go index 54856bd..1419ff5 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "encoding/json" "fmt" "net" "os" @@ -17,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/envbox/cli" + "github.com/coder/envbox/dockerutil" "github.com/coder/envbox/integration/integrationtest" ) @@ -318,6 +320,9 @@ func TestDocker(t *testing.T) { regKeyPath := filepath.Join(certDir, "registry_key.pem") integrationtest.WriteCertificate(t, dockerCert, regCertPath, regKeyPath) + username := "coder" + password := "helloworld" + // Start up the docker registry and push an image // to it that we can reference. image := integrationtest.RunLocalDockerRegistry(t, pool, integrationtest.RegistryConfig{ @@ -325,12 +330,29 @@ func TestDocker(t *testing.T) { HostKeyPath: regKeyPath, Image: integrationtest.UbuntuImage, TLSPort: strconv.Itoa(registryAddr.Port), + PasswordDir: dir, + Username: username, + Password: password, }) + type authConfigs struct { + Auths map[string]dockerutil.AuthConfig `json:"auths"` + } + + auths := authConfigs{ + Auths: map[string]dockerutil.AuthConfig{ + image.Registry(): {Username: username, Password: password}, + }, + } + + authStr, err := json.Marshal(auths) + require.NoError(t, err) + envs := []string{ integrationtest.EnvVar(cli.EnvAgentToken, "faketoken"), integrationtest.EnvVar(cli.EnvAgentURL, fmt.Sprintf("https://%s:%d", "host.docker.internal", coderAddr.Port)), integrationtest.EnvVar(cli.EnvExtraCertsPath, "/tmp/certs"), + integrationtest.EnvVar(cli.EnvBoxPullImageSecretEnvVar, string(authStr)), } // Run the envbox container. diff --git a/integration/integrationtest/docker.go b/integration/integrationtest/docker.go index f24393c..6b5e07c 100644 --- a/integration/integrationtest/docker.go +++ b/integration/integrationtest/docker.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "encoding/json" "fmt" "io" @@ -20,6 +21,7 @@ import ( "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "golang.org/x/xerrors" "github.com/coder/envbox/buildlog" @@ -361,6 +363,10 @@ type RegistryConfig struct { HostKeyPath string TLSPort string Image string + Username string + Password string + // PasswordDir is the directory under which the htpasswd file is written. + PasswordDir string } type RegistryImage string @@ -373,28 +379,50 @@ func (r RegistryImage) String() string { return string(r) } -func RunLocalDockerRegistry(t testing.TB, pool *dockertest.Pool, conf RegistryConfig) RegistryImage { +func RunLocalDockerRegistry(t *testing.T, pool *dockertest.Pool, conf RegistryConfig) RegistryImage { t.Helper() const ( certPath = "/certs/cert.pem" keyPath = "/certs/key.pem" + authPath = "/auth/htpasswd" ) - resource, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: registryImage, - Tag: registryTag, - Env: []string{ + var ( + envs = []string{ + EnvVar("REGISTRY_HTTP_ADDR", "0.0.0.0:443"), + } + binds []string + ) + + if conf.HostCertPath != "" && conf.HostKeyPath != "" { + envs = append(envs, EnvVar("REGISTRY_HTTP_TLS_CERTIFICATE", certPath), EnvVar("REGISTRY_HTTP_TLS_KEY", keyPath), - EnvVar("REGISTRY_HTTP_ADDR", "0.0.0.0:443"), - }, - ExposedPorts: []string{"443/tcp"}, - }, func(host *docker.HostConfig) { - host.Binds = []string{ + ) + binds = append(binds, mountBinding(conf.HostCertPath, certPath), mountBinding(conf.HostKeyPath, keyPath), - } + ) + } + + if conf.PasswordDir != "" { + authFile := GenerateRegistryAuth(t, conf.PasswordDir, conf.Username, conf.Password) + envs = append(envs, + EnvVar("REGISTRY_AUTH", "htpasswd"), + EnvVar("REGISTRY_AUTH_HTPASSWD_REALM", "Test Registry"), + EnvVar("REGISTRY_AUTH_HTPASSWD_PATH", authPath), + ) + binds = append(binds, mountBinding(authFile, authPath)) + } + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: registryImage, + Tag: registryTag, + Env: envs, + ExposedPorts: []string{"443/tcp"}, + }, func(host *docker.HostConfig) { + host.Binds = binds host.ExtraHosts = []string{"host.docker.internal:host-gateway"} host.PortBindings = map[docker.Port][]docker.PortBinding{ "443/tcp": {{ @@ -415,7 +443,13 @@ func RunLocalDockerRegistry(t testing.TB, pool *dockertest.Pool, conf RegistryCo url := fmt.Sprintf("https://%s/v2/_catalog", host) waitForRegistry(t, pool, resource, url) - return pushLocalImage(t, pool, host, conf.Image) + return pushLocalImage(t, pool, pushOptions{ + Host: host, + RemoteImage: conf.Image, + Username: conf.Username, + Password: conf.Password, + ConfigDir: conf.PasswordDir, + }) } func waitForRegistry(t testing.TB, pool *dockertest.Pool, resource *dockertest.Resource, url string) { @@ -447,18 +481,26 @@ func waitForRegistry(t testing.TB, pool *dockertest.Pool, resource *dockertest.R continue } _ = res.Body.Close() - if res.StatusCode == http.StatusOK { + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusUnauthorized { return } } require.NoError(t, ctx.Err()) } -func pushLocalImage(t testing.TB, pool *dockertest.Pool, host, remoteImage string) RegistryImage { +type pushOptions struct { + Host string + RemoteImage string + Username string + Password string + ConfigDir string +} + +func pushLocalImage(t *testing.T, pool *dockertest.Pool, opts pushOptions) RegistryImage { t.Helper() const registryHost = "127.0.0.1" - name := filepath.Base(remoteImage) + name := filepath.Base(opts.RemoteImage) repoTag := strings.Split(name, ":") tag := "latest" if len(repoTag) == 2 { @@ -469,25 +511,46 @@ func pushLocalImage(t testing.TB, pool *dockertest.Pool, host, remoteImage strin t: t, } err := pool.Client.PullImage(docker.PullImageOptions{ - Repository: strings.Split(remoteImage, ":")[0], + Repository: strings.Split(opts.RemoteImage, ":")[0], Tag: tag, OutputStream: tw, }, docker.AuthConfiguration{}) require.NoError(t, err) - _, port, err := net.SplitHostPort(host) + _, port, err := net.SplitHostPort(opts.Host) require.NoError(t, err) - err = pool.Client.TagImage(remoteImage, docker.TagImageOptions{ + err = pool.Client.TagImage(opts.RemoteImage, docker.TagImageOptions{ Repo: fmt.Sprintf("%s:%s/%s", registryHost, port, name), Tag: tag, }) require.NoError(t, err) + type config struct { + Auths map[string]dockerutil.AuthConfig `json:"auths"` + } + + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", opts.Username, opts.Password))) + + cfg := config{ + Auths: map[string]dockerutil.AuthConfig{ + net.JoinHostPort(registryHost, port): { + Username: opts.Username, + Password: opts.Password, + Auth: auth, + }, + }, + } + b, err := json.Marshal(cfg) + require.NoError(t, err) + configPath := filepath.Join(opts.ConfigDir, "config.json") + WriteFile(t, configPath, string(b)) + // Idk what to tell you but the pool.Client.PushImage // function is bugged or I'm just dumb... image := fmt.Sprintf("%s:%s/%s:%s", registryHost, port, name, tag) - cmd := exec.Command("docker", "push", image) + //nolint:gosec + cmd := exec.Command("docker", "--config", opts.ConfigDir, "push", image) cmd.Stderr = tw cmd.Stdout = tw err = cmd.Run() @@ -516,3 +579,15 @@ func BindMount(src, dst string, ro bool) docker.HostMount { Type: "bind", } } + +func GenerateRegistryAuth(t *testing.T, directory, username, password string) string { + t.Helper() + + p, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + require.NoError(t, err) + + authFile := filepath.Join(directory, "credentials") + WriteFile(t, authFile, fmt.Sprintf("%s:%s", username, string(p))) + + return authFile +}