diff --git a/cmd/nerdctl/compose_run_linux_test.go b/cmd/nerdctl/compose_run_linux_test.go index f2e06fcea35..a5e6173ea29 100644 --- a/cmd/nerdctl/compose_run_linux_test.go +++ b/cmd/nerdctl/compose_run_linux_test.go @@ -437,8 +437,9 @@ func TestComposePushAndPullWithCosignVerify(t *testing.T) { reg.Cleanup(nil) }) + localhostIP := "127.0.0.1" tID := testutil.Identifier(t) - testImageRefPrefix := fmt.Sprintf("127.0.0.1:%d/%s/", reg.Port, tID) + testImageRefPrefix := fmt.Sprintf("%s:%d/%s/", localhostIP, reg.Port, tID) var ( imageSvc0 = testImageRefPrefix + "composebuild_svc0" diff --git a/cmd/nerdctl/container_run_verify_linux_test.go b/cmd/nerdctl/container_run_verify_linux_test.go index 9ef955dc986..963ac58c9ba 100644 --- a/cmd/nerdctl/container_run_verify_linux_test.go +++ b/cmd/nerdctl/container_run_verify_linux_test.go @@ -42,7 +42,10 @@ func TestRunVerifyCosign(t *testing.T) { }) tID := testutil.Identifier(t) - testImageRef := fmt.Sprintf("127.0.0.1:%d/%s", reg.Port, tID) + localhostIP := "127.0.0.1" + testImageRef := fmt.Sprintf("%s:%d/%s", + localhostIP, reg.Port, tID) + dockerfile := fmt.Sprintf(`FROM %s CMD ["echo", "nerdctl-build-test-string"] `, testutil.CommonImage) diff --git a/cmd/nerdctl/flagutil.go b/cmd/nerdctl/flagutil.go index 795d9d2a0cd..5488366ba0d 100644 --- a/cmd/nerdctl/flagutil.go +++ b/cmd/nerdctl/flagutil.go @@ -104,6 +104,7 @@ func processRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) return types.GlobalCommandOptions{}, err } insecureRegistry, err := cmd.Flags().GetBool("insecure-registry") + explicitInsecureRegistry := cmd.Flags().Changed("insecure-registry") if err != nil { return types.GlobalCommandOptions{}, err } @@ -120,18 +121,19 @@ func processRootCmdFlags(cmd *cobra.Command) (types.GlobalCommandOptions, error) return types.GlobalCommandOptions{}, err } return types.GlobalCommandOptions{ - Debug: debug, - DebugFull: debugFull, - Address: address, - Namespace: namespace, - Snapshotter: snapshotter, - CNIPath: cniPath, - CNINetConfPath: cniConfigPath, - DataRoot: dataRoot, - CgroupManager: cgroupManager, - InsecureRegistry: insecureRegistry, - HostsDir: hostsDir, - Experimental: experimental, - HostGatewayIP: hostGatewayIP, + Debug: debug, + DebugFull: debugFull, + Address: address, + Namespace: namespace, + Snapshotter: snapshotter, + CNIPath: cniPath, + CNINetConfPath: cniConfigPath, + DataRoot: dataRoot, + CgroupManager: cgroupManager, + InsecureRegistry: insecureRegistry, + ExplicitInsecureRegistry: explicitInsecureRegistry, + HostsDir: hostsDir, + Experimental: experimental, + HostGatewayIP: hostGatewayIP, }, nil } diff --git a/cmd/nerdctl/login.go b/cmd/nerdctl/login.go index d8d27415167..591585bfe38 100644 --- a/cmd/nerdctl/login.go +++ b/cmd/nerdctl/login.go @@ -18,6 +18,7 @@ package main import ( "errors" + "fmt" "io" "strings" @@ -26,6 +27,7 @@ import ( "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/cmd/login" + "github.com/containerd/nerdctl/v2/pkg/nerderr" ) func newLoginCommand() *cobra.Command { @@ -43,50 +45,50 @@ func newLoginCommand() *cobra.Command { return loginCommand } -func processLoginOptions(cmd *cobra.Command) (types.LoginCommandOptions, error) { +func processLoginOptions(cmd *cobra.Command) (*types.LoginCommandOptions, error) { globalOptions, err := processRootCmdFlags(cmd) if err != nil { - return types.LoginCommandOptions{}, err + return nil, err } username, err := cmd.Flags().GetString("username") if err != nil { - return types.LoginCommandOptions{}, err + return nil, err } password, err := cmd.Flags().GetString("password") if err != nil { - return types.LoginCommandOptions{}, err + return nil, err } passwordStdin, err := cmd.Flags().GetBool("password-stdin") if err != nil { - return types.LoginCommandOptions{}, err + return nil, err } if strings.Contains(username, ":") { - return types.LoginCommandOptions{}, errors.New("username cannot contain colons") + return nil, errors.New("username cannot contain colons") } if password != "" { log.L.Warn("WARNING! Using --password via the CLI is insecure. Use --password-stdin.") if passwordStdin { - return types.LoginCommandOptions{}, errors.New("--password and --password-stdin are mutually exclusive") + return nil, errors.New("--password and --password-stdin are mutually exclusive") } } if passwordStdin { if username == "" { - return types.LoginCommandOptions{}, errors.New("must provide --username with --password-stdin") + return nil, errors.New("must provide --username with --password-stdin") } contents, err := io.ReadAll(cmd.InOrStdin()) if err != nil { - return types.LoginCommandOptions{}, err + return nil, err } - password = strings.TrimSuffix(string(contents), "\n") - password = strings.TrimSuffix(password, "\r") + password = strings.TrimSpace(string(contents)) } - return types.LoginCommandOptions{ + + return &types.LoginCommandOptions{ GOptions: globalOptions, Username: username, Password: password, @@ -103,5 +105,98 @@ func loginAction(cmd *cobra.Command, args []string) error { options.ServerAddress = args[0] } - return login.Login(cmd.Context(), options, cmd.OutOrStdout()) + stdo := cmd.OutOrStdout() + + // Warnings may be returned with or without an error + warnings, err := login.Login(cmd.Context(), options, stdo) + + // Note that we are ignoring Fprintln errors here, as we do not want to return BEFORE the main error is handled + for _, warning := range warnings { + _, _ = fmt.Fprintln(stdo, warning) + } + + switch err { + case nil: + _, fErr := fmt.Fprintln(cmd.OutOrStdout(), "Login Succeeded") + return fErr + case nerderr.ErrSystemIsBroken: + log.L.Error("Your system is misconfigured or in a broken state. Probably your hosts.toml files have error, or your ~/.docker/config.json file is hosed") + case nerderr.ErrInvalidArgument: + log.L.Error("Invalid arguments provided") + case nerderr.ErrServerIsMisbehaving: + log.L.Error("The registry server you are trying to log into is possibly misconfigured, or otherwise misbehaving") + case login.ErrCredentialsCannotBeRead: + log.L.Error("Nerdctl cannot login without a username and password") + case login.ErrConnectionFailed: + log.L.Error("Failed establishing a connection. There was a DNS, TCP, or TLS issue preventing nerdctl from talking to the registry") + case login.ErrAuthenticationFailure: + log.L.Error("Authentication failed. Provided credentials were refused by the registry") + } + /* + var dnsErr *net.DNSError + var sysCallErr *os.SyscallError + var opErr *net.OpError + var urlErr *url.Error + var tlsErr *tls.CertificateVerificationError + // Providing understandable feedback to user for specific errors + if errors.Is(err, login.ErrLoginCredentialsCannotBeRead) { + log.L.Errorf("Unable to read credentials from docker store (~/.docker/config.json or credentials helper). Please manually inspect.") + } else if errors.Is(err, login.ErrLoginCredentialsCannotBeWritten) { + log.L.Errorf("Though the login was succesfull, credentials could not be saved to the docker store (~/.docker/config.json or credentials helper). Please manually inspect.") + } else if errors.Is(err, login.ErrLoginCredentialsRefused) { + log.L.Errorf("Unable to login with the provided credentials") + } else if errors.As(err, &dnsErr) { + if dnsErr.IsNotFound && !dnsErr.IsTemporary { + // donotresolveeverever.foo + log.L.Errorf("domain name %q is unknown to your DNS server %q (hint: is the domain name spelled correctly?)", dnsErr.Name, dnsErr.Server) + } else if dnsErr.Timeout() { + // resolve using an unreachable DNS server + log.L.Errorf("unable to get a timely response from your DNS server %q (hint: is your DNS configuration ok?)", dnsErr.Server) + } else { + debErr, _ := json.Marshal(dnsErr) + log.L.Errorf("non-specific DNS resolution error (timeout: %t):\n%s", dnsErr.Timeout(), string(debErr)) + } + } else if errors.Is(err, http.ErrSchemeMismatch) { + log.L.Errorf("the server does not speak https") + } else if errors.As(err, &sysCallErr) { + if sysCallErr.Syscall == "connect" { + // Connect error - no way to reach that server, or server dropped us immediately + log.L.Error("failed connecting to server") + } else { + debErr, _ := json.Marshal(sysCallErr) + log.L.Errorf("non-specific syscall error (timeout: %t):\n%s", sysCallErr.Timeout(), string(debErr)) + } + } else if errors.As(err, &opErr) { + // Typically a tcp timeout + if opErr.Timeout() { + log.L.Errorf("timeout trying to connect to server)") + } else { + debErr, _ := json.Marshal(opErr) + log.L.Errorf("non-specific tcp error:\n%s", string(debErr)) + } + } else if errors.As(err, &tlsErr) { + log.L.Debugf("server certificate verification error") + } else if errors.As(err, &urlErr) { + // Typically a TLS handshake timeout + if urlErr.Timeout() { + log.L.Debugf("server timeout while awaiting response") + } else { + debErr, _ := json.Marshal(urlErr) + log.L.Errorf("non-specific server error:\n%s", string(debErr)) + } + } else if errors.Is(err, login.ErrTooManyRedirects) { + log.L.Errorf("too many redirects sent back by server - it is likely misconfigured") + } else if errors.Is(err, login.ErrRedirectAuthorizerError) { + log.L.Errorf("server is redirecting to a different location and credentials are not going to be sent there - " + + "server is either misconfigured, or there is a security problem") + } else if errors.Is(err, login.ErrAuthorizerError) { + log.L.Errorf("credentials cannot be sent to that server - " + + "server is possibly misconfigured, or there is a security problem") + //} else { + // log.L.Error("non-specific error") + } + + */ + + return err } diff --git a/cmd/nerdctl/login_linux_test.go b/cmd/nerdctl/login_linux_test.go index 4429b4480ea..9512c19fc65 100644 --- a/cmd/nerdctl/login_linux_test.go +++ b/cmd/nerdctl/login_linux_test.go @@ -14,32 +14,32 @@ limitations under the License. */ +// https://docs.docker.com/reference/cli/dockerd/#insecure-registries +// Local registries, whose IP address falls in the 127.0.0.0/8 range, are automatically marked as insecure as of Docker 1.3.2. +// It isn't recommended to rely on this, as it may change in the future. +// "--insecure" means that either the certificates are untrusted, or that the protocol is plain http + package main import ( - "crypto/rand" - "encoding/base64" "fmt" "net" + "net/http" "os" + "path/filepath" "strconv" + "strings" "testing" + "github.com/containerd/nerdctl/v2/pkg/cmd/login" + "github.com/containerd/nerdctl/v2/pkg/dockerutil" "github.com/containerd/nerdctl/v2/pkg/testutil" "github.com/containerd/nerdctl/v2/pkg/testutil/testca" "github.com/containerd/nerdctl/v2/pkg/testutil/testregistry" + "gotest.tools/v3/assert" "gotest.tools/v3/icmd" ) -func safeRandomString(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - // XXX WARNING there is something in the registry (or more likely in the way we generate htpasswd files) - // that is broken and does not resist truly random strings - // return string(b) - return base64.URLEncoding.EncodeToString(b) -} - type Client struct { args []string configPath string @@ -56,15 +56,29 @@ func (ag *Client) WithHostsDir(hostDirs string) *Client { } func (ag *Client) WithCredentials(username, password string) *Client { - ag.args = append(ag.args, "--username", username, "--password", password) + if username != "" { + ag.args = append(ag.args, "--username", username) + } + if password != "" { + ag.args = append(ag.args, "--password", password) + } return ag } +func (ag *Client) WithConfigPath(value string) *Client { + ag.configPath = value + return ag +} + +func (ag *Client) GetConfigPath() string { + return ag.configPath +} + func (ag *Client) Run(base *testutil.Base, host string) *testutil.Cmd { if ag.configPath == "" { ag.configPath, _ = os.MkdirTemp(base.T.TempDir(), "docker-config") } - args := append([]string{"--debug-full", "login"}, ag.args...) + args := append([]string{"login", "--debug-full"}, ag.args...) icmdCmd := icmd.Command(base.Binary, append(base.Args, append(args, host)...)...) icmdCmd.Env = append(base.Env, "HOME="+os.Getenv("HOME"), "DOCKER_CONFIG="+ag.configPath) return &testutil.Cmd{ @@ -73,200 +87,491 @@ func (ag *Client) Run(base *testutil.Base, host string) *testutil.Cmd { } } -func TestLogin(t *testing.T) { +func Match(thing string) func(stdout string) { + return func(stdout string) { + strings.Contains(stdout, thing) + } +} + +type TestCase struct { + Description string + Exp *Expected + Data Meta + + TearUp func(tID string) + TearDown func(tID string) + Command func(tID string) *testutil.Cmd + Expected func(tID string) icmd.Expected + Inspect func(t *testing.T, stdout string, stderr string) + DockerIncompatible bool + + SubTests []*TestCase +} + +func TestBrokenServers(t *testing.T) { + base := testutil.NewBase(t) + t.Parallel() + + testCases := []*TestCase{ + // TODO: failing to reach the DNS resolver + /* + { + Description: "willneverresolve.whatever: ErrConnectionFailed (DNS resolution fail)", + Command: func(tID string) *testutil.Cmd { + return (&Client{}). + WithCredentials("bla", "foo"). + Run(base, fmt.Sprintf("%s:%d", "willneverresolve.whatever", 0)) + }, + Expected: func(tID string) icmd.Expected { + return icmd.Expected{ + ExitCode: 1, + Out: "", + Err: login.ErrConnectionFailed.Error(), + } + }, + }, + { + Description: "ghcr.io:12345: ErrConnectionFailed (tcp timeout)", + Command: func(tID string) *testutil.Cmd { + return (&Client{}). + WithCredentials("bla", "foo"). + Run(base, fmt.Sprintf("%s:%d", "ghcr.io", 12345)) + }, + Expected: func(tID string) icmd.Expected { + return icmd.Expected{ + ExitCode: 1, + Out: "", + Err: login.ErrConnectionFailed.Error(), + } + }, + }, + { + Description: "ghcr.io:80: ErrConnectionFailed (ErrSchemeMismatch)", + Command: func(tID string) *testutil.Cmd { + return (&Client{}). + WithCredentials("bla", "foo"). + Run(base, fmt.Sprintf("%s:%d", "ghcr.io", 80)) + }, + Expected: func(tID string) icmd.Expected { + return icmd.Expected{ + ExitCode: 1, + Out: "", + Err: login.ErrConnectionFailed.Error(), + } + }, + Inspect: func(t *testing.T, stdout string, stderr string) { + assert.Assert(t, + strings.Contains(stderr, http.ErrSchemeMismatch.Error()), + "stderr should have matched http.ErrSchemeMismatch.Error()") + }, + }, + */ + + // + { + Description: "140.82.116.34:443: invalid TLS certs", + Command: func(tID string) *testutil.Cmd { + return (&Client{}). + WithCredentials("bla", "foo"). + Run(base, fmt.Sprintf("%s:%d", "140.82.116.34", 443)) + }, + Exp: &Expected{ + ExitCode: 1, + Errors: []error{ + login.ErrConnectionFailed, + http.ErrSchemeMismatch, + }, + Output: func(stdout string) { + }, + }, + Expected: func(tID string) icmd.Expected { + return icmd.Expected{ + ExitCode: 1, + Error: login.ErrConnectionFailed.Error(), + Out: "foo", + Err: "", // login.ErrConnectionFailed.Error(), + } + }, + Inspect: func(t *testing.T, stdout string, stderr string) { + assert.Assert(t, + strings.Contains(stderr, http.ErrSchemeMismatch.Error()), + "stderr should have matched http.ErrSchemeMismatch.Error()") + }, + }, + } + + for _, tc := range testCases { + currentTest := tc + t.Run(currentTest.Description, func(tt *testing.T) { + if currentTest.DockerIncompatible { + testutil.DockerIncompatible(tt) + } + + tt.Parallel() + + tID := testutil.Identifier(tt) + + if currentTest.TearDown != nil { + currentTest.TearDown(tID) + tt.Cleanup(func() { + currentTest.TearDown(tID) + }) + } + if currentTest.TearUp != nil { + currentTest.TearUp(tID) + } + + res := currentTest.Command(tID).Run() + // assert.Assert(t, res.Compare(currentTest.Expected(tID))) //().Success() + assert.Assert(t, res.Equal(currentTest.Expected(tID))) //().Success() + + // res.Assert(t, currentTest.Expected(tID)) + /* + res.Assert(t, currentTest.Expected(tID)) + if currentTest.Inspect != nil { + currentTest.Inspect(tt, res.Stdout(), res.Stderr()) + } + + */ + }) + } +} + +func TestLoginPersistence(t *testing.T) { + base := testutil.NewBase(t) + t.Parallel() + + // Retrieve from the store + testCases := []struct { + auth string + }{ + { + "basic", + }, + { + "token", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("Server %s", tc.auth), func(t *testing.T) { + t.Parallel() + + username := testregistry.SafeRandomString(30) + "∞" + password := testregistry.SafeRandomString(30) + ":∞" + + // Add the requested authentication + var auth testregistry.Auth + var dependentCleanup func(error) + + auth = &testregistry.NoAuth{} + if tc.auth == "basic" { + auth = &testregistry.BasicAuth{ + Username: username, + Password: password, + } + } else if tc.auth == "token" { + authCa := testca.New(base.T) + as := testregistry.NewAuthServer(base, authCa, 0, username, password, false) + auth = &testregistry.TokenAuth{ + Address: as.Scheme + "://" + net.JoinHostPort(as.IP.String(), strconv.Itoa(as.Port)), + CertPath: as.CertPath, + } + dependentCleanup = as.Cleanup + } + + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, nil, 0, auth, dependentCleanup) + + // Register registry cleanup + t.Cleanup(func() { + reg.Cleanup(nil) + }) + + t.Run("Login repeats", func(t *testing.T) { + t.Parallel() + + // First, login successfully + c := (&Client{}). + WithCredentials(username, password) + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + // Now, log in successfully without passing any explicit credentials + nc := (&Client{}). + WithConfigPath(c.GetConfigPath()) + + nc.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + // Now fail while using invalid credentials + nc.WithCredentials("invalid", "invalid"). + Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertFail() + + // And login again without, reverting to the last saved good state + nc = (&Client{}). + WithConfigPath(c.GetConfigPath()) + + nc.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + }) + }) + } +} + +func TestAgainstNoAuth(t *testing.T) { + base := testutil.NewBase(t) + t.Parallel() + + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, nil, 0, &testregistry.NoAuth{}, nil) + + // Register registry cleanup + t.Cleanup(func() { + reg.Cleanup(nil) + }) + + c := (&Client{}). + WithCredentials("invalid", "invalid") + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertOK() + + content, _ := os.ReadFile(filepath.Join(c.configPath, "config.json")) + fmt.Println(string(content)) + + c.Run(base, fmt.Sprintf("localhost:%d", reg.Port)). + AssertFail() + +} + +func TestLoginAgainstVariants(t *testing.T) { // Skip docker, because Docker doesn't have `--hosts-dir` nor `insecure-registry` option + // This will test access to a wide variety of servers, with or without TLS, with basic or token authentication testutil.DockerIncompatible(t) base := testutil.NewBase(t) t.Parallel() - testregistry.EnsureImages(base) - testCases := []struct { - port int - tls bool - auth string - insecure bool + port int + tls bool + auth string }{ + // Basic auth, no TLS { 80, false, "basic", - true, }, { 443, false, "basic", - true, }, { 0, false, "basic", - true, }, + // Basic auth, with TLS { 80, true, "basic", - false, }, { 443, true, "basic", - false, }, { 0, true, "basic", - false, }, + // Token auth, no TLS { 80, false, "token", - true, }, { 443, false, "token", - true, }, { 0, false, "token", - true, }, + // Token auth, with TLS { 80, true, "token", - false, }, { 443, true, "token", - false, }, { 0, true, "token", - false, }, } for _, tc := range testCases { - // Since we have a lock mechanism for acquiring ports, we can just parallelize everything - t.Run(fmt.Sprintf("Login against registry with tls: %t port: %d auth: %s", tc.tls, tc.port, tc.auth), func(t *testing.T) { + port := tc.port + tls := tc.tls + auth := tc.auth + + // Iterate through all cases, that will present a variety of port (80, 443, random), TLS (yes or no), and authentication (basic, token) type combinations + t.Run(fmt.Sprintf("Login against `tls: %t port: %d auth: %s`", tls, port, auth), func(t *testing.T) { // Tests with fixed ports should not be parallelized (although the port locking mechanism will prevent conflicts) - // as their children are, and this might deadlock given how Parallel works - if tc.port == 0 { + // as their children tests are, and this might deadlock given the way `Parallel` works + if port == 0 { t.Parallel() } - // Generate credentials so that we never cross hit another test registry (spiced up with unicode) - // Note that the grammar for basic auth does not allow colons in usernames, while token auth allows it - username := safeRandomString(30) + "∞" - password := safeRandomString(30) + ":∞" + // Generate credentials that are specific to a single registry, so that we never cross hit another one + // Note that the grammar for basic auth does not allow colons in usernames, while token auth would allow it + username := testregistry.SafeRandomString(30) + "∞" + password := testregistry.SafeRandomString(30) + ":∞" // Get a CA if we want TLS var ca *testca.CA - if tc.tls { + if tls { ca = testca.New(base.T) } // Add the requested authentication - var auth testregistry.Auth - auth = &testregistry.NoAuth{} + var authenticator testregistry.Auth var dependentCleanup func(error) - if tc.auth == "basic" { - auth = &testregistry.BasicAuth{ + + authenticator = &testregistry.NoAuth{} + if auth == "basic" { + authenticator = &testregistry.BasicAuth{ Username: username, Password: password, } - } else if tc.auth == "token" { + } else if auth == "token" { authCa := ca - // We could be on !tls - still need a ca to sign jwt + // We could be on !tls, meaning no ca - but we still need one to sign jwt tokens if authCa == nil { authCa = testca.New(base.T) } - as := testregistry.NewAuthServer(base, authCa, 0, username, password, tc.tls) - auth = &testregistry.TokenAuth{ + as := testregistry.NewAuthServer(base, authCa, 0, username, password, tls) + authenticator = &testregistry.TokenAuth{ Address: as.Scheme + "://" + net.JoinHostPort(as.IP.String(), strconv.Itoa(as.Port)), CertPath: as.CertPath, } dependentCleanup = as.Cleanup } - // Start the registry - reg := testregistry.NewRegistry(base, ca, tc.port, auth, dependentCleanup) + // Start the registry with the requested options + reg := testregistry.NewRegistry(base, ca, port, authenticator, dependentCleanup) - // Attach our cleanup function + // Register registry cleanup t.Cleanup(func() { reg.Cleanup(nil) }) + // Any registry is reachable through its ip+port, and localhost variants regHosts := []string{ net.JoinHostPort(reg.IP.String(), strconv.Itoa(reg.Port)), + net.JoinHostPort("localhost", strconv.Itoa(reg.Port)), + net.JoinHostPort("127.0.0.1", strconv.Itoa(reg.Port)), + // net.JoinHostPort("::1", strconv.Itoa(reg.Port)), } - // XXX seems like omitting ports is broken on main currently - // (plus the hosts.toml resolution is not good either) - // XXX we should also add hostname here (maybe use the container name?) - // Obviously also need to add localhost to the mix once we fix behavior - /* - if reg.Port == 443 || reg.Port == 80 { - regHosts = append(regHosts, reg.IP.String()) - } - */ + // Registries that use port 443 also allow access without specifying a port + if reg.Port == 443 { + regHosts = append(regHosts, reg.IP.String()) + regHosts = append(regHosts, "localhost") + regHosts = append(regHosts, "127.0.0.1") + // regHosts = append(regHosts, "::1") + } + // Iterate through these hosts access points for _, value := range regHosts { regHost := value t.Run(regHost, func(t *testing.T) { t.Parallel() - t.Run("Valid credentials (no certs) ", func(t *testing.T) { + t.Run("Valid credentials (no CA) ", func(t *testing.T) { t.Parallel() c := (&Client{}). WithCredentials(username, password) - // Fail without insecure - c.Run(base, regHost).AssertFail() + // Insecure flag not being set, localhost defaults to `insecure=true` and will succeed + rl, _ := dockerutil.Parse(regHost) + if rl.IsLocalhost() { + c.Run(base, regHost). + AssertOK() + } else { + c.Run(base, regHost). + AssertFail() + } - // Succeed with insecure - c.WithInsecure(true). - Run(base, regHost).AssertOK() + // Explicit "no insecure" flag should always fail here since we do not have access to the CA + (&Client{}). + WithCredentials(username, password). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + // Always succeed with insecure + (&Client{}). + WithCredentials(username, password). + WithInsecure(true). + Run(base, regHost). + AssertOK() }) - t.Run("Valid credentials (with certs)", func(t *testing.T) { + t.Run("Valid credentials (with access to server CA)", func(t *testing.T) { t.Parallel() c := (&Client{}). WithCredentials(username, password). WithHostsDir(reg.HostsDir) - if tc.insecure { - c.Run(base, regHost).AssertFail() + rl, _ := dockerutil.Parse(regHost) + if rl.IsLocalhost() || tls { + c.Run(base, regHost). + AssertOK() } else { - c.Run(base, regHost).AssertOK() + c.Run(base, regHost). + AssertFail() + } + + // Succeeds only if the server uses TLS + if tls { + c.WithInsecure(false). + Run(base, regHost). + AssertOK() + } else { + c.WithInsecure(false). + Run(base, regHost). + AssertFail() } c.WithInsecure(true). - Run(base, regHost).AssertOK() + Run(base, regHost). + AssertOK() }) - t.Run("Valid credentials (with certs), any variant", func(t *testing.T) { + t.Run("Valid credentials, any url variant, should always succeed", func(t *testing.T) { t.Parallel() c := (&Client{}). WithCredentials(username, password). WithHostsDir(reg.HostsDir). // Just use insecure here for all servers - it does not matter for what we are testing here + // in this case, which is whether we can successfully log in against any of these variants WithInsecure(true) c.Run(base, "http://"+regHost).AssertOK() @@ -275,50 +580,86 @@ func TestLogin(t *testing.T) { c.Run(base, "https://"+regHost+"/whatever?foo=bar&bar=foo;foo=foo+bar:bar#foo=bar").AssertOK() }) - t.Run("Wrong pass (no certs)", func(t *testing.T) { + t.Run("Wrong password should always fail", func(t *testing.T) { t.Parallel() - c := (&Client{}). - WithCredentials(username, "invalid") - c.Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + Run(base, regHost). + AssertFail() - c.WithInsecure(true). - Run(base, regHost).AssertFail() - }) + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + WithInsecure(false). + Run(base, regHost). + AssertFail() - t.Run("Wrong user (no certs)", func(t *testing.T) { - t.Parallel() - c := (&Client{}). - WithCredentials("invalid", password) + (&Client{}). + WithCredentials(username, "invalid"). + WithHostsDir(reg.HostsDir). + WithInsecure(true). + Run(base, regHost). + AssertFail() - c.Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials(username, "invalid"). + Run(base, regHost). + AssertFail() - c.WithInsecure(true). - Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials(username, "invalid"). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials(username, "invalid"). + WithInsecure(true). + Run(base, regHost). + AssertFail() }) - t.Run("Wrong pass (with certs)", func(t *testing.T) { + t.Run("Wrong username should always fail", func(t *testing.T) { t.Parallel() - c := (&Client{}). - WithCredentials(username, "invalid"). - WithHostsDir(reg.HostsDir) - c.Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials("invalid", password). + WithHostsDir(reg.HostsDir). + Run(base, regHost). + AssertFail() - c.WithInsecure(true). - Run(base, regHost).AssertFail() - }) + (&Client{}). + WithCredentials("invalid", password). + WithHostsDir(reg.HostsDir). + WithInsecure(false). + Run(base, regHost). + AssertFail() - t.Run("Wrong user (with certs)", func(t *testing.T) { - t.Parallel() - c := (&Client{}). + (&Client{}). WithCredentials("invalid", password). - WithHostsDir(reg.HostsDir) + WithHostsDir(reg.HostsDir). + WithInsecure(true). + Run(base, regHost). + AssertFail() - c.Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials("invalid", password). + Run(base, regHost). + AssertFail() - c.WithInsecure(true). - Run(base, regHost).AssertFail() + (&Client{}). + WithCredentials("invalid", password). + WithInsecure(false). + Run(base, regHost). + AssertFail() + + (&Client{}). + WithCredentials("invalid", password). + WithInsecure(true). + Run(base, regHost). + AssertFail() }) }) } diff --git a/cmd/nerdctl/logout.go b/cmd/nerdctl/logout.go index a08c2bdd0a8..e88953ff580 100644 --- a/cmd/nerdctl/logout.go +++ b/cmd/nerdctl/logout.go @@ -17,15 +17,14 @@ package main import ( - "fmt" - - "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" - dockercliconfig "github.com/docker/cli/cli/config" "github.com/spf13/cobra" + + "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/cmd/logout" ) func newLogoutCommand() *cobra.Command { - var logoutCommand = &cobra.Command{ + return &cobra.Command{ Use: "logout [flags] [SERVER]", Args: cobra.MaximumNArgs(1), Short: "Log out from a container registry", @@ -34,62 +33,33 @@ func newLogoutCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - return logoutCommand } -// code inspired from XXX func logoutAction(cmd *cobra.Command, args []string) error { - serverAddress := dockerconfigresolver.IndexServer - isDefaultRegistry := true - if len(args) >= 1 { - serverAddress = args[0] - isDefaultRegistry = false - } - - var ( - regsToLogout = []string{serverAddress} - hostnameAddress = serverAddress - ) - - if !isDefaultRegistry { - hostnameAddress = dockerconfigresolver.ConvertToHostname(serverAddress) - // the tries below are kept for backward compatibility where a user could have - // saved the registry in one of the following format. - regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress) + logoutServer := "" + if len(args) > 0 { + logoutServer = args[0] } - fmt.Fprintf(cmd.OutOrStdout(), "Removing login credentials for %s\n", hostnameAddress) - - dockerConfigFile, err := dockercliconfig.Load("") + errGroup, err := logout.Logout(cmd.Context(), logoutServer) if err != nil { - return err + log.L.WithError(err).Errorf("Failed to erase credentials for: %s", logoutServer) } - errs := make(map[string]error) - for _, r := range regsToLogout { - if err := dockerConfigFile.GetCredentialsStore(r).Erase(r); err != nil { - errs[r] = err + if errGroup != nil { + log.L.Error("None of the following entries could be found") + for _, v := range errGroup { + log.L.Errorf("%s", v) } } - // if at least one removal succeeded, report success. Otherwise report errors - if len(errs) == len(regsToLogout) { - fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: could not erase credentials:") - for k, v := range errs { - fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", k, v) - } - } - - return nil + return err } func logoutShellComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - dockerConfigFile, err := dockercliconfig.Load("") + candidates, err := logout.ShellCompletion() if err != nil { return nil, cobra.ShellCompDirectiveError } - candidates := []string{} - for key := range dockerConfigFile.AuthConfigs { - candidates = append(candidates, key) - } + return candidates, cobra.ShellCompDirectiveNoFileComp } diff --git a/docs/faq.md b/docs/faq.md index 7280c503dba..197cdd78079 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -336,7 +336,7 @@ See https://rootlesscontaine.rs/getting-started/common/login/ . Running a rootless container with `systemd` cgroup driver requires dbus to be running as a user session service. -Otherwise runc may fail with an error like below: +Otherwise, runc may fail with an error like below: ``` FATA[0000] failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: unable to apply cgroup configuration: unable to start unit "nerdctl-7bda4abaa1f006ab9feeb98c06953db43f212f1c0aaf658fb8a88d6f63dff9f9.scope" (properties [{Name:Description Value:"libcontainer container 7bda4abaa1f006ab9feeb98c06953db43f212f1c0aaf658fb8a88d6f63dff9f9"} {Name:Slice Value:"user.slice"} {Name:Delegate Value:true} {Name:PIDs Value:@au [1154]} {Name:MemoryAccounting Value:true} {Name:CPUAccounting Value:true} {Name:IOAccounting Value:true} {Name:TasksAccounting Value:true} {Name:DefaultDependencies Value:false}]): Permission denied: unknown ``` diff --git a/go.mod b/go.mod index cf21bd06d17..b1403eff029 100644 --- a/go.mod +++ b/go.mod @@ -65,7 +65,6 @@ require ( github.com/yuchanns/srslog v1.1.0 go.uber.org/mock v0.4.0 golang.org/x/crypto v0.26.0 - golang.org/x/net v0.28.0 golang.org/x/sync v0.8.0 golang.org/x/sys v0.24.0 golang.org/x/term v0.23.0 @@ -134,9 +133,6 @@ require ( go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.20.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240805194559-2c9e96a0b5d4 // indirect google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect lukechampine.com/blake3 v1.3.0 // indirect diff --git a/go.sum b/go.sum index fb349f4d297..afbca7e6306 100644 --- a/go.sum +++ b/go.sum @@ -360,8 +360,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/cmd/image/push.go b/pkg/cmd/image/push.go index 3c28cd91d40..69c9185b649 100644 --- a/pkg/cmd/image/push.go +++ b/pkg/cmd/image/push.go @@ -29,7 +29,6 @@ import ( "github.com/containerd/containerd/v2/core/images/converter" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" - dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" "github.com/containerd/containerd/v2/pkg/reference" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" @@ -45,7 +44,6 @@ import ( "github.com/containerd/stargz-snapshotter/estargz/zstdchunked" estargzconvert "github.com/containerd/stargz-snapshotter/nativeconverter/estargz" distributionref "github.com/distribution/reference" - "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) @@ -131,24 +129,15 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return push.Push(ctx, client, r, pushTracker, options.Stdout, pushRef, ref, platMC, options.AllowNondistributableArtifacts, options.Quiet) } - var dOpts []dockerconfigresolver.Opt - if options.GOptions.InsecureRegistry { - log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) - } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) - - ho, err := dockerconfigresolver.NewHostOptions(ctx, refDomain, dOpts...) + resolver, err := dockerconfigresolver.New(ctx, refDomain, &dockerconfigresolver.ResolverOptions{ + HostsDirs: options.GOptions.HostsDir, + Insecure: options.GOptions.InsecureRegistry, + ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + }) if err != nil { return err } - resolverOpts := docker.ResolverOptions{ - Tracker: pushTracker, - Hosts: dockerconfig.ConfigureHosts(ctx, *ho), - } - - resolver := docker.NewResolver(resolverOpts) if err = pushFunc(resolver); err != nil { // In some circumstance (e.g. people just use 80 port to support pure http), the error will contain message like "dial tcp : connection refused" if !errutil.IsErrHTTPResponseToHTTPSClient(err) && !errutil.IsErrConnectionRefused(err) { @@ -156,11 +145,16 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options } if options.GOptions.InsecureRegistry { log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) - resolver, err = dockerconfigresolver.New(ctx, refDomain, dOpts...) + + resolver, err := dockerconfigresolver.New(ctx, refDomain, &dockerconfigresolver.ResolverOptions{ + HostsDirs: options.GOptions.HostsDir, + Insecure: options.GOptions.InsecureRegistry, + ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + }) if err != nil { return err } + return pushFunc(resolver) } log.G(ctx).WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) diff --git a/pkg/cmd/login/login.go b/pkg/cmd/login/login.go index 744a7b5a256..d6cb8868dfa 100644 --- a/pkg/cmd/login/login.go +++ b/pkg/cmd/login/login.go @@ -17,308 +17,451 @@ package login import ( - "bufio" "context" "errors" "fmt" "io" + "net" "net/http" - "net/url" - "os" + "runtime" + "strconv" "strings" - dockercliconfig "github.com/docker/cli/cli/config" - dockercliconfigtypes "github.com/docker/cli/cli/config/types" - "github.com/docker/docker/api/types/registry" - "github.com/docker/docker/errdefs" - "golang.org/x/net/context/ctxhttp" - "golang.org/x/term" - "github.com/containerd/containerd/v2/core/remotes/docker" - "github.com/containerd/containerd/v2/core/remotes/docker/config" + "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/errutil" - "github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver" ) -const unencryptedPasswordWarning = `WARNING: Your password will be stored unencrypted in %s. +const ( + redirectLimit = 10 + maxResponsesRetries = 5 + unencryptedPasswordWarning = `WARNING: Your password will be stored unencrypted in %s. Configure a credential helper to remove this warning. See https://docs.docker.com/engine/reference/commandline/login/#credentials-store ` +) -type isFileStore interface { - IsFileStore() bool - GetFilename() string -} +var ( + defaultNerdUserAgent = fmt.Sprintf("nerdctl/%s (os: %s)", version.GetVersion(), runtime.GOOS) + + // ErrSystemIsBroken wraps all system-level errors (credentials store inability to open or store credentials or broken hosts.toml) + // ErrUnableToInstantiate + // ErrUnableToStore + + // ErrInvalidArgument wraps all cases related to a wrong argument + // ErrUnparsableURL + // ErrUnsupportedScheme + // ErrNoHostsForNamespace + // it is also returned when a namespace declares a server which Host differs from the namespace Host + + // ErrCredentialsCannotBeRead wraps all prompting errors + ErrCredentialsCannotBeRead = errors.New("failed prompting for credentials") + // ErrUsernameIsRequired + // ErrPasswordIsRequired + // ErrReadingUsername + // ErrReadingPassword + // ErrNotATerminal + // ErrCannotAllocateTerminal + + // ErrServerIsMisbehaving wraps all server errors + // ErrServerUnspecified + // ErrServerBlacklist + // ErrServerUnavailable + // ErrServerTimeout + // ErrServerTooManyRetries + // ErrServerTooManyRedirects + + // ErrAuthenticationFailure wraps using wrong credentials, and authorizer erroring + ErrAuthenticationFailure = errors.New("authentication failed") + // ErrCredentialsRefused + // ErrUnsupportedAuthenticationMethod + // ErrAuthorizerError + // ErrAuthorizerRedirectError + + // Finally, ErrConnectionFailed is passed through from do, and wraps all dns, tcp and cert validation errors + // ErrConnectionFailed +) -func Login(ctx context.Context, options types.LoginCommandOptions, stdout io.Writer) error { - var serverAddress string - if options.ServerAddress == "" { - serverAddress = dockerconfigresolver.IndexServer - } else { - serverAddress = options.ServerAddress +// Login will try to authenticate with the provided LoginCommandOptions, retrieving credentials and hosts.toml configuration +// for the provided registry namespace, possibly prompting the user for credentials. +// It may return the following errors: +// - ErrSystemIsBroken: this should rarely happen, and is a symptom of a borked docker credentials store or broken hosts.toml configuration +// - ErrInvalidArgument: provided namespace cannot be parsed, uses an invalid scheme, or is impossible to login because of hosts.toml configuration +// - ErrCredentialsCannotBeRead: terminal error, or user did not provide credentials when prompted +// - ErrConnectionFailed: dns, tcp or tls class of errors +// - ErrServerIsMisbehaving: any server side error, 50x status code, redirect misconfiguration, etc +// - ErrAuthenticationFailure: wrong credentials +// See details about these errors for more fine-grained wrapped errors +// Additionally, Login will return a slice of strings containing warnings that should be displayed to the user +func Login(ctx context.Context, options *types.LoginCommandOptions, stdout io.Writer) ([]string, error) { + warnings := []string{} + + // Get a credentialStore (does not error on ENOENT). + // If it errors, it is a hard filesystem error or a JSON parsing error for an existing credentials file, + // and login in that context does not make sense as we will not be able to save anything, so, just stop here. + credentialsStore, err := dockerutil.New("") + if err != nil { + return warnings, errors.Join(nerderr.ErrSystemIsBroken, err) } - var responseIdentityToken string - isDefaultRegistry := serverAddress == dockerconfigresolver.IndexServer - - authConfig, err := GetDefaultAuthConfig(options.Username == "" && options.Password == "", serverAddress, isDefaultRegistry) - if authConfig == nil { - authConfig = ®istry.AuthConfig{ServerAddress: serverAddress} + // Get a resolver, with the requested options + resolver, err := dockerutil.NewResolver(options.ServerAddress, credentialsStore, &dockerutil.ResolveOptions{ + Insecure: options.GOptions.InsecureRegistry, + ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + HostsDirs: options.GOptions.HostsDir, + Username: options.Username, + Password: options.Password, + }) + + // Handle possible errors + if errors.Is(err, dockerutil.ErrNoHostsForNamespace) { + return warnings, errors.Join(nerderr.ErrInvalidArgument, err) + } else if errors.Is(err, dockerutil.ErrNoSuchHostForNamespace) { + return warnings, errors.Join(nerderr.ErrInvalidArgument, err) + } else if err != nil { + return warnings, errors.Join(nerderr.ErrSystemIsBroken, err) } - if err == nil && authConfig.Username != "" && authConfig.Password != "" { - //login With StoreCreds - responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig) - } - - if err != nil || authConfig.Username == "" || authConfig.Password == "" { - err = ConfigureAuthentication(authConfig, options.Username, options.Password) - if err != nil { - return err - } - responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig) - if err != nil { - return err - } + // Warn the user that schemes are meaningless, especially http://, if they used it + if strings.HasPrefix(options.ServerAddress, "http://") { + log.L.Warnf("Login to the server hosted at %q will ignore the provided scheme (http) and will connect using https, "+ + "unless you explicitly request to use it in insecure mode with the --insecure-registry flag", resolver.RegistryNamespace.Host) } - if responseIdentityToken != "" { - authConfig.Password = "" - authConfig.IdentityToken = responseIdentityToken - } + // Get the resolved server and hosts + registryHosts := resolver.GetHosts() + registryServer := resolver.GetServer() - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return err + // Ensure we have a port for it + if _, _, err = net.SplitHostPort(registryServer.Host); err != nil { + registryServer.Host = net.JoinHostPort(registryServer.Host, "443") } - creds := dockerConfigFile.GetCredentialsStore(serverAddress) - - store, isFile := creds.(isFileStore) - // Display a warning if we're storing the users password (not a token) and credentials store type is file. - if isFile && authConfig.Password != "" { - _, err = fmt.Fprintln(stdout, fmt.Sprintf(unencryptedPasswordWarning, store.GetFilename())) - if err != nil { - return err + // If the passed-in ServerAddress is the namespace, and the server does not resolve to that, we are stopping now + // If it is not the namespace, we already know it exists and is valid, we just don't know which registryHost object it is + // ... if the server (which is either the explicit `server` section, or the implied host) does + // NOT match the namespace we are asked to log into, we are stopping here. + if registryServer.Host != resolver.RegistryNamespace.Host { + warnings = append( + warnings, + fmt.Sprintf("The registry namespace (%q) has a hosts.toml configuration that resolves to a different server host (%q).\n"+ + "We cannot login to that registry namespace directly. If you are expecting the configured endpoints to be authenticated, please login to them individually with:", + resolver.RegistryNamespace.Host, + registryServer.Host, + )) + for _, regHost := range registryHosts { + warnings = append( + warnings, + fmt.Sprintf(" nerdctl login %s%s?ns=%s", regHost.Host, regHost.Path, resolver.RegistryNamespace.Host), + ) } + warnings = append( + warnings, + fmt.Sprintf(" nerdctl login %s%s?ns=%s", registryServer.Host, registryServer.Path, resolver.RegistryNamespace.Host), + ) + return warnings, nerderr.ErrInvalidArgument } - if err := creds.Store(dockercliconfigtypes.AuthConfig(*(authConfig))); err != nil { - return fmt.Errorf("error saving credentials: %w", err) - } + // var responseIdentityHost string + // var responseIdentityToken string - fmt.Fprintln(stdout, "Login Succeeded") + // Query the credentialStore, but only force a lookup if both username and password have not been provided explicitly + // fmt.Println("DUUUF", resolver.RegistryNamespace.CanonicalIdentifier()) + credentials, credStoreErr := credentialsStore.Retrieve(resolver.RegistryNamespace, options.Username == "" && options.Password == "") - return nil -} + // We should downgrade to http IF + // we have --insecure-registry (or --insecure-registry=true) + // OR + // we are on localhost AND we do NOT have --insecure-registry=false + insecureLogin := options.GOptions.InsecureRegistry || (resolver.RegistryNamespace.IsLocalhost() && !options.GOptions.ExplicitInsecureRegistry) -// GetDefaultAuthConfig gets the default auth config given a serverAddress. -// If credentials for given serverAddress exists in the credential store, the configuration will be populated with values in it. -// Code from github.com/docker/cli/cli/command (v20.10.3). -func GetDefaultAuthConfig(checkCredStore bool, serverAddress string, isDefaultRegistry bool) (*registry.AuthConfig, error) { - if !isDefaultRegistry { - var err error - serverAddress, err = convertToHostname(serverAddress) - if err != nil { - return nil, err - } + var queryErr error + // If `Retrieve` did not error and there is a username and password from the store, then try to log in with that + if credStoreErr == nil && credentials.Username != "" && credentials.Password != "" { + queryErr = login(ctx, registryServer, insecureLogin) + // Note: failing to authenticate here with invalid (stored) credentials will NOT delete said saved credentials } - var authconfig = dockercliconfigtypes.AuthConfig{} - if checkCredStore { - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return nil, err - } - authconfig, err = dockerConfigFile.GetAuthConfig(serverAddress) + + // If the above failed, or if we had an error from `Retrieve`, or we did not have a username and password, + // ask the user for what's missing and try (again) + if queryErr != nil || (credStoreErr != nil || credentials.Username == "" || credentials.Password == "") { + err = promptUserForAuthentication(credentials, options.Username, options.Password, stdout) if err != nil { - return nil, err + return warnings, errors.Join(ErrCredentialsCannotBeRead, err) } - } - authconfig.ServerAddress = serverAddress - authconfig.IdentityToken = "" - res := registry.AuthConfig(authconfig) - return &res, nil -} -func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptions, auth registry.AuthConfig) (string, error) { - host, err := convertToHostname(auth.ServerAddress) - if err != nil { - return "", err - } - var dOpts []dockerconfigresolver.Opt - if globalOptions.InsecureRegistry { - log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", host) - dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) - } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(globalOptions.HostsDir)) - - authCreds := func(acArg string) (string, string, error) { - if acArg == host { - if auth.RegistryToken != "" { - // Even containerd/CRI does not support RegistryToken as of v1.4.3, - // so, nobody is actually using RegistryToken? - log.G(ctx).Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host) + // We have credentials, let's try to login + err = login(ctx, registryServer, insecureLogin) + + if err != nil { + if errors.Is(err, ErrServerUnspecified) || + errors.Is(err, ErrServerBlacklist) || + errors.Is(err, ErrServerUnavailable) || + errors.Is(err, ErrServerTimeout) || + errors.Is(err, ErrServerTooManyRedirects) || + errors.Is(err, ErrServerTooManyRetries) { + // Wrap all server related issues + err = errors.Join(nerderr.ErrServerIsMisbehaving, err) + } else if errors.Is(err, ErrAuthorizerError) || + errors.Is(err, ErrAuthorizerRedirectError) || + errors.Is(err, ErrCredentialsRefused) || + errors.Is(err, ErrUnsupportedAuthenticationMethod) { + // Wrap all authentication-proper related issues + err = errors.Join(ErrAuthenticationFailure, err) + } else { + log.L.Error("non-specific error condition - please report this as a bug") + // } else if errors.Is(err, ErrConnectionFailed) { } - return auth.Username, auth.Password, nil + return warnings, err } - return "", "", fmt.Errorf("expected acArg to be %q, got %q", host, acArg) } - dOpts = append(dOpts, dockerconfigresolver.WithAuthCreds(authCreds)) - ho, err := dockerconfigresolver.NewHostOptions(ctx, host, dOpts...) - if err != nil { - return "", err - } - fetchedRefreshTokens := make(map[string]string) // key: req.URL.Host - // onFetchRefreshToken is called when tryLoginWithRegHost calls rh.Authorizer.Authorize() - onFetchRefreshToken := func(ctx context.Context, s string, req *http.Request) { - fetchedRefreshTokens[req.URL.Host] = s + // If we got an identity token back, this is what we are going to store instead of the password + responseIdentityToken := resolver.IdentityTokenForHost(resolver.RegistryNamespace.Host) + if responseIdentityToken != "" { + credentials.Password = "" + credentials.IdentityToken = responseIdentityToken } - ho.AuthorizerOpts = append(ho.AuthorizerOpts, docker.WithFetchRefreshToken(onFetchRefreshToken)) - regHosts, err := config.ConfigureHosts(ctx, *ho)(host) - if err != nil { - return "", err + + // Add a warning if we're storing the users password (not a token) and credentials store type is file. + if filename := credentialsStore.FileStorageLocation(resolver.RegistryNamespace); credentials.Password != "" && filename != "" { + warnings = append(warnings, fmt.Sprintf(unencryptedPasswordWarning, filename)) } - log.G(ctx).Debugf("len(regHosts)=%d", len(regHosts)) - if len(regHosts) == 0 { - return "", fmt.Errorf("got empty []docker.RegistryHost for %q", host) + + // fmt.Println("AGAIN", resolver.RegistryNamespace.CanonicalIdentifier()) + if err = credentialsStore.Store(resolver.RegistryNamespace, credentials); err != nil { + return warnings, errors.Join(nerderr.ErrSystemIsBroken, err) } - for i, rh := range regHosts { - err = tryLoginWithRegHost(ctx, rh) - if err != nil && globalOptions.InsecureRegistry && (errutil.IsErrHTTPResponseToHTTPSClient(err) || errutil.IsErrConnectionRefused(err)) { - rh.Scheme = "http" - err = tryLoginWithRegHost(ctx, rh) - } - identityToken := fetchedRefreshTokens[rh.Host] // can be empty - if err == nil { - return identityToken, nil + + if len(registryHosts) > 1 { + warnings = append(warnings, fmt.Sprintf("The registry namespace %q has a hosts.toml configuration that "+ + "resolves to other hosts.\n"+ + "If you are expecting these endpoints to be authenticated as well, please login to them individually with:", + resolver.RegistryNamespace.Host)) + for _, regHost := range registryHosts { + warnings = append( + warnings, + fmt.Sprintf(" nerdctl login %s%s/?ns=%s", regHost.Host, regHost.Path, resolver.RegistryNamespace.Host), + ) } - log.G(ctx).WithError(err).WithField("i", i).Error("failed to call tryLoginWithRegHost") } - return "", err + + return warnings, nil } -func tryLoginWithRegHost(ctx context.Context, rh docker.RegistryHost) error { - if rh.Authorizer == nil { - return errors.New("got nil Authorizer") - } - if rh.Path == "/v2" { - // If the path is using /v2 endpoint but lacks trailing slash add it - // https://docs.docker.com/registry/spec/api/#detail. Acts as a workaround - // for containerd issue https://github.com/containerd/containerd/blob/2986d5b077feb8252d5d2060277a9c98ff8e009b/remotes/docker/config/hosts.go#L110 - rh.Path = "/v2/" - } - u := url.URL{ - Scheme: rh.Scheme, - Host: rh.Host, - Path: rh.Path, - } - var ress []*http.Response - for i := 0; i < 10; i++ { - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return err - } - for k, v := range rh.Header.Clone() { - for _, vv := range v { - req.Header.Add(k, vv) - } - } - if err := rh.Authorizer.Authorize(ctx, req); err != nil { - return fmt.Errorf("failed to call rh.Authorizer.Authorize: %w", err) +// login will try a registry server and possibly downgrade to http if insecure. +func login(ctx context.Context, registryHost docker.RegistryHost, insecure bool) error { + err := registryLogin(ctx, registryHost) + if err != nil { + // If we have been asked to do insecure, downgrade to plain http and try again + // if the server gave us a http answer, or refused to connect + // TODO: replace IsErrConnectionRefused with the actual conditions we want to consider + // Or just retry anyhow? + // XXX investigate the usefulness of IsErrConnectionRefused + // errs := err.(interface{ Unwrap() []error }) //.Unwrap() + // var t *os.PathError + if insecure && (errors.As(err, &http.ErrSchemeMismatch) || errutil.IsErrConnectionRefused(err)) { + registryHost.Scheme = "http" + err = registryLogin(ctx, registryHost) } - res, err := ctxhttp.Do(ctx, rh.Client, req) if err != nil { - return fmt.Errorf("failed to call rh.Client.Do: %w", err) - } - ress = append(ress, res) - if res.StatusCode == 401 { - if err := rh.Authorizer.AddResponses(ctx, ress); err != nil && !errdefs.IsNotImplemented(err) { - return fmt.Errorf("failed to call rh.Authorizer.AddResponses: %w", err) - } - continue - } - if res.StatusCode/100 != 2 { - return fmt.Errorf("unexpected status code %d", res.StatusCode) + return err } - - return nil } - return errors.New("too many 401 (probably)") + return nil } -func ConfigureAuthentication(authConfig *registry.AuthConfig, username, password string) error { - authConfig.Username = strings.TrimSpace(authConfig.Username) - if username = strings.TrimSpace(username); username == "" { - username = authConfig.Username - } - if username == "" { - fmt.Print("Enter Username: ") - usr, err := readUsername() +var ( + ErrServerUnspecified = errors.New("unspecified server error") + ErrServerBlacklist = errors.New("server blacklisted us") + ErrServerUnavailable = errors.New("server responded but did 500") + ErrServerTimeout = errors.New("server response timeout") + ErrServerTooManyRetries = errors.New("too many retries") + + ErrCredentialsRefused = errors.New("failed login with provided credentials") + ErrUnsupportedAuthenticationMethod = errors.New("unsupported authentication") +) + +// registryLogin will try to log into the provided registryHost with a maximum of maxResponsesRetries +// It does workaround some registries idiosyncrasies, and return expressive enough errors to provide meaningful +// feedback to the user. +// This method does not try to downgrade protocol, or bypass certificate validation, etc +// (downstream consumer should take care of that) +// In addition to the errors returned from "do" (which are going to be passed through as-is), it may error with: +// - any of the ErrServer* errors - including ErrServerTooManyRetries in case we hit maxResponsesRetries with no error except 401 +// - ErrCredentialsRefused when authentication has been refused by the server +// - ErrUnsupportedAuthenticationMethod if the server is requesting an authentication method we do not know about +// currently supported: basic and token auth +// currently NOT supported: registry bearer token auth +func registryLogin(ctx context.Context, registryHost docker.RegistryHost) error { + var resp *http.Response + var err error + responses := []*http.Response{} + for x := 0; x < maxResponsesRetries; x++ { + // Do the request - exit on error + resp, err = do(ctx, registryHost) if err != nil { return err } - username = usr - } - if username == "" { - return fmt.Errorf("error: Username is Required") - } - if password == "" { - fmt.Print("Enter Password: ") - pwd, err := readPassword() - fmt.Println() - if err != nil { - return err + // Make sure the body gets closed when we return, but leave it open for now as the authorizer might want to inspect it + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + // Attach the last response for the authorizer to inspect + responses = append(responses, resp) + + log.L.Debugf("received response with status code %d", resp.StatusCode) + + // Decide if we should try again + switch resp.StatusCode { + case http.StatusUnauthorized: + if registryHost.Authorizer == nil { + panic("unable to login without an authorizer - please report this as a bug") + } + + // Add the last response + err = registryHost.Authorizer.AddResponses(ctx, responses) + // Not implemented is the only AddResponses documented error condition + if err != nil { + if errdefs.IsNotImplemented(err) { + return ErrUnsupportedAuthenticationMethod + } + panic("unhandled condition from Authorizer.AddResponses - report this as a bug") + } + + // Handle bugs and bizarro-registry-behaviors + if len(responses) >= 2 { + // Fix https://github.com/containerd/nerdctl/issues/3068 + // This is really a (dirty) workaround and should probably be fixed in containerd remote/docker basic auth instead + // With basic authentication, do not retry the same thing over and over again... + last := responses[len(responses)-1] + prior := responses[len(responses)-2] + // Note: Get returns case-insensitive + wwwAuth := last.Header.Get("www-authenticate") + wwwAuthPrior := prior.Header.Get("www-authenticate") + + if prior.Request.URL == last.Request.URL { + // If we received the same challenge twice in a "basic" context for the same URL, that's it. + if strings.HasPrefix(wwwAuth, "basic") && wwwAuth == wwwAuthPrior { + return ErrCredentialsRefused + } + + // See https://github.com/containerd/nerdctl/issues/1675 + // Some misbehaving registries may (buggily) switch to a different authentication type on auth failure + // In that case, just reset the responses and retry from scratch + // TODO: the ticket issue happens on push, as the scope is not enough. This logic needs to go there as well. + if wwwAuth[0:6] != wwwAuthPrior[0:6] { + log.L.Warn("Misbehaving server! We received different authentication types for the same URL. Resetting responses.") + responses = []*http.Response{} + } + } + } + case http.StatusRequestTimeout: + // It is worth assuming this is a fluke - retry if possible + err = ErrServerTimeout + case http.StatusServiceUnavailable: + // This is assumed to be a fluke (docker hub has a lot of these) - retry if possible + err = ErrServerUnavailable + case http.StatusTooManyRequests: + // We got blacklisted. Drop off. + return ErrServerBlacklist + case http.StatusOK: + // Authentication successful + return nil + default: + // Non-specific error condition. Drop off. + return ErrServerUnspecified } - password = pwd - } - if password == "" { - return fmt.Errorf("error: Password is Required") + + // Retry - make sure we close first + _ = resp.Body.Close() } - authConfig.Username = username - authConfig.Password = password + // If we are here and the error is nil, we have exhausted our attempts + if err == nil { + err = ErrServerTooManyRetries + } - return nil + return err } -func readUsername() (string, error) { - var fd *os.File - if term.IsTerminal(int(os.Stdin.Fd())) { - fd = os.Stdin - } else { - return "", fmt.Errorf("stdin is not a terminal (Hint: use `nerdctl login --username=USERNAME --password-stdin`)") +var ( + ErrConnectionFailed = errors.New("http client connection error") + ErrServerTooManyRedirects = errors.New("too many redirects: " + strconv.Itoa(redirectLimit)) + ErrAuthorizerError = errors.New("authorizer fail") + ErrAuthorizerRedirectError = errors.New("authorizer fail on redirect") +) + +// do is a private function performing the actual http requests +// It might error with: +// - ErrConnectionFailed which will wrap any http connection error, like: +// tcp timeouts, certificate validation errors, DNS resolution errors, etc +// - ErrServerTooManyRedirects in case there are too many redirects: +// this is indicative of a server misconfiguration (or malicious) +// - ErrAuthorizerError and ErrAuthorizerRedirectError errors, wrapping the underlying authorizer error +// TODO clarify what these are +func do(ctx context.Context, registryHost docker.RegistryHost) (*http.Response, error) { + if registryHost.Path == "/v2" { + // Containerd usually return the path without the trailing slash + // https://github.com/containerd/containerd/blob/2986d5b077feb8252d5d2060277a9c98ff8e009b/remotes/docker/config/hosts.go#L110 + // This may cause issues with certain registries, or a (useless) extra redirect for most + // See spec for details https://docs.docker.com/registry/spec/api/#detail + registryHost.Path = "/v2/" } - reader := bufio.NewReader(fd) - username, err := reader.ReadString('\n') + // Prep the http request (note: the only case where this would error is if ctx is nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryHost.Scheme+"://"+registryHost.Host+registryHost.Path, nil) + // The only reason for this to fail is ctx == nil, which is not normal if err != nil { - return "", fmt.Errorf("error reading username: %w", err) + panic(fmt.Sprintf("login: http.NewRequestWithContext errored with: %v - please report this as a bug", err)) } - username = strings.TrimSpace(username) - return username, nil -} + req.Header = http.Header{} + // Set default user-agent - this will get overridden if hosts.toml defines it too + req.Header.Set("user-agent", defaultNerdUserAgent) + // Add headers if any are specified in the regHost object (eg: in the hosts.toml file) + if registryHost.Header != nil { + req.Header = registryHost.Header.Clone() + } -func convertToHostname(serverAddress string) (string, error) { - // Ensure that URL contains scheme for a good parsing process - if strings.Contains(serverAddress, "://") { - u, err := url.Parse(serverAddress) - if err != nil { - return "", err + // Attach a redirect handler, to limit the number of redirects, and to be able to reauthorize + if registryHost.Client.CheckRedirect == nil { + registryHost.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + log.L.Debugf("redirecting for the %d-th time to %q", len(via), req.URL) + if len(via) >= redirectLimit { + return ErrServerTooManyRedirects + } + if registryHost.Authorizer != nil { + if err = registryHost.Authorizer.Authorize(ctx, req); err != nil { + log.L.Debugf("authorizer errored on the redirect for url %q", req.URL) + return errors.Join(ErrAuthorizerRedirectError, err) + } + } + return nil } - serverAddress = u.Host - } else { - u, err := url.Parse("https://" + serverAddress) - if err != nil { - return "", err + } + + // Authorize if we have an authorizer + if registryHost.Authorizer != nil { + if err = registryHost.Authorizer.Authorize(ctx, req); err != nil { + log.L.Debugf("authorizer errored for url %q", req.URL) + return nil, errors.Join(ErrAuthorizerError, err) } - serverAddress = u.Host } - return serverAddress, nil + // Do the request and return + resp, err := registryHost.Client.Do(req) + if err != nil { + log.L.Debugf("http client do errored on url %q", req.URL) + err = errors.Join(ErrConnectionFailed, err) + } + + return resp, err } diff --git a/pkg/cmd/login/prompt.go b/pkg/cmd/login/prompt.go new file mode 100644 index 00000000000..1697d546e16 --- /dev/null +++ b/pkg/cmd/login/prompt.go @@ -0,0 +1,101 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package login + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" + + "github.com/containerd/nerdctl/v2/pkg/dockerutil" +) + +var ( + ErrUsernameIsRequired = errors.New("username is required") + ErrPasswordIsRequired = errors.New("password is required") + ErrReadingUsername = errors.New("unable to read username") + ErrReadingPassword = errors.New("error reading password") + ErrNotATerminal = errors.New("stdin is not a terminal (Hint: use `nerdctl login --username=USERNAME --password-stdin`)") + ErrCannotAllocateTerminal = errors.New("error allocating terminal") +) + +// promptUserForAuthentication will prompt the user for credentials if needed +// It might error with any of the errors defined above. +func promptUserForAuthentication(credentials *dockerutil.Credentials, username, password string, stdout io.Writer) error { + var err error + + // If the provided username is empty... + if username = strings.TrimSpace(username); username == "" { + // Use the one we know of (from the store) + username = credentials.Username + // If the one from the store was empty as well, prompt and read the username + if username == "" { + _, _ = fmt.Fprint(stdout, "Enter Username: ") + username, err = readUsername() + if err != nil { + return err + } + // If it still is empty, that is an error + if username == "" { + return ErrUsernameIsRequired + } + } + } + + // If password was NOT passed along, ask for it + if password == "" { + _, _ = fmt.Fprint(stdout, "Enter Password: ") + password, err = readPassword() + _, _ = fmt.Fprintln(stdout) + if err != nil { + return err + } + // If nothing was provided, error out + if password == "" { + return ErrPasswordIsRequired + } + } + + // Attach credentials to the auth object + credentials.Username = username + credentials.Password = password + + return nil +} + +// readUsername will try to read from user input +// It might error with: +// - ErrNotATerminal +// - ErrReadingUsername +func readUsername() (string, error) { + fd := os.Stdin + if !term.IsTerminal(int(fd.Fd())) { + return "", ErrNotATerminal + } + + username, err := bufio.NewReader(fd).ReadString('\n') + if err != nil { + return "", errors.Join(ErrReadingUsername, err) + } + + return strings.TrimSpace(username), nil +} diff --git a/pkg/cmd/login/login_unix.go b/pkg/cmd/login/prompt_unix.go similarity index 73% rename from pkg/cmd/login/login_unix.go rename to pkg/cmd/login/prompt_unix.go index c1eec8fdf01..69529473f6d 100644 --- a/pkg/cmd/login/login_unix.go +++ b/pkg/cmd/login/prompt_unix.go @@ -19,28 +19,34 @@ package login import ( - "fmt" + "errors" "os" "syscall" "golang.org/x/term" + + "github.com/containerd/log" ) func readPassword() (string, error) { - var fd int - if term.IsTerminal(syscall.Stdin) { - fd = syscall.Stdin - } else { + fd := syscall.Stdin + if !term.IsTerminal(fd) { tty, err := os.Open("/dev/tty") if err != nil { - return "", fmt.Errorf("error allocating terminal: %w", err) + return "", errors.Join(ErrCannotAllocateTerminal, err) } - defer tty.Close() + defer func() { + err = tty.Close() + if err != nil { + log.L.WithError(err).Error("failed closing tty") + } + }() fd = int(tty.Fd()) } + bytePassword, err := term.ReadPassword(fd) if err != nil { - return "", fmt.Errorf("error reading password: %w", err) + return "", errors.Join(ErrReadingPassword, err) } return string(bytePassword), nil diff --git a/pkg/cmd/login/login_windows.go b/pkg/cmd/login/prompt_windows.go similarity index 79% rename from pkg/cmd/login/login_windows.go rename to pkg/cmd/login/prompt_windows.go index 89c3834fb92..913e6ff5f98 100644 --- a/pkg/cmd/login/login_windows.go +++ b/pkg/cmd/login/prompt_windows.go @@ -17,22 +17,21 @@ package login import ( - "fmt" + "errors" "syscall" "golang.org/x/term" ) func readPassword() (string, error) { - var fd int - if term.IsTerminal(int(syscall.Stdin)) { - fd = int(syscall.Stdin) - } else { - return "", fmt.Errorf("error allocating terminal") + fd := int(syscall.Stdin) + if !term.IsTerminal(fd) { + return "", ErrNotATerminal } + bytePassword, err := term.ReadPassword(fd) if err != nil { - return "", fmt.Errorf("error reading password: %w", err) + return "", errors.Join(ErrReadingPassword, err) } return string(bytePassword), nil diff --git a/pkg/cmd/logout/logout.go b/pkg/cmd/logout/logout.go new file mode 100644 index 00000000000..b372569f4ae --- /dev/null +++ b/pkg/cmd/logout/logout.go @@ -0,0 +1,46 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package logout + +import ( + "context" + + "github.com/containerd/nerdctl/v2/pkg/dockerutil" +) + +func Logout(ctx context.Context, logoutServer string) (map[string]error, error) { + reg, err := dockerutil.Parse(logoutServer) + if err != nil { + return nil, err + } + + credentialsStore, err := dockerutil.New("") + if err != nil { + return nil, err + } + + return credentialsStore.Erase(reg) +} + +func ShellCompletion() ([]string, error) { + credentialsStore, err := dockerutil.New("") + if err != nil { + return nil, err + } + + return credentialsStore.ShellCompletion() +} diff --git a/pkg/config/config.go b/pkg/config/config.go index eaffe583260..3b4ad78cc90 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,19 +25,20 @@ import ( // Config corresponds to nerdctl.toml . // See docs/config.md . type Config struct { - Debug bool `toml:"debug"` - DebugFull bool `toml:"debug_full"` - Address string `toml:"address"` - Namespace string `toml:"namespace"` - Snapshotter string `toml:"snapshotter"` - CNIPath string `toml:"cni_path"` - CNINetConfPath string `toml:"cni_netconfpath"` - DataRoot string `toml:"data_root"` - CgroupManager string `toml:"cgroup_manager"` - InsecureRegistry bool `toml:"insecure_registry"` - HostsDir []string `toml:"hosts_dir"` - Experimental bool `toml:"experimental"` - HostGatewayIP string `toml:"host_gateway_ip"` + Debug bool `toml:"debug"` + DebugFull bool `toml:"debug_full"` + Address string `toml:"address"` + Namespace string `toml:"namespace"` + Snapshotter string `toml:"snapshotter"` + CNIPath string `toml:"cni_path"` + CNINetConfPath string `toml:"cni_netconfpath"` + DataRoot string `toml:"data_root"` + CgroupManager string `toml:"cgroup_manager"` + InsecureRegistry bool `toml:"insecure_registry"` + ExplicitInsecureRegistry bool `toml:"explicit_insecure_registry"` + HostsDir []string `toml:"hosts_dir"` + Experimental bool `toml:"experimental"` + HostGatewayIP string `toml:"host_gateway_ip"` } // New creates a default Config object statically, diff --git a/pkg/dockerutil/consts.go b/pkg/dockerutil/consts.go new file mode 100644 index 00000000000..3d2e3c05ac6 --- /dev/null +++ b/pkg/dockerutil/consts.go @@ -0,0 +1,56 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import "errors" + +type scheme string + +const ( + standardHTTPSPort = "443" + schemeHTTP scheme = "http" + schemeHTTPS scheme = "https" + // schemeNerdctlExperimental is currently provisional, to unlock namespace based host authentication + // This may change or break without notice, and you should have no expectations that credentials saved like that + // will be supported in the future + schemeNerdctlExperimental scheme = "nerdctl-experimental" + // See https://github.com/moby/moby/blob/v27.1.1/registry/config.go#L42-L48 + // especially Sebastiaan comments on future domain consolidation + dockerIndexServer = "https://index.docker.io/v1/" + // The query parameter that containerd will slap on namespaced hosts + namespaceQueryParameter = "ns" +) + +// Errors returned by `Parse` +var ( + ErrUnparsableURL = errors.New("unparsable URL") + ErrUnsupportedScheme = errors.New("unsupported scheme") +) + +// Errors returned by the credentials store +var ( + ErrUnableToInstantiate = errors.New("unable to instantiate docker credentials store") + ErrUnableToErase = errors.New("unable to erase credentials") + ErrUnableToStore = errors.New("unable to store credentials") + ErrUnableToRetrieve = errors.New("unable to retrieve credentials") +) + +// Errors returned by the Resolver +var ( + ErrNoHostsForNamespace = errors.New("no hosts found for registry namespace") + ErrNoSuchHostForNamespace = errors.New("no such host for registry namespace") +) diff --git a/pkg/dockerutil/credentialstore.go b/pkg/dockerutil/credentialstore.go new file mode 100644 index 00000000000..28a43000b84 --- /dev/null +++ b/pkg/dockerutil/credentialstore.go @@ -0,0 +1,162 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import ( + "errors" + "strings" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/config/types" +) + +type Credentials = types.AuthConfig + +// New returns a CredentialsStore from a directory +// If path is left empty, the default docker `~/.docker/config.json` will be used +// In case the docker call fails, we wrap the error with ErrUnableToInstantiate +func New(path string) (*CredentialsStore, error) { + dockerConfigFile, err := config.Load(path) + if err != nil { + return nil, errors.Join(ErrUnableToInstantiate, err) + } + + return &CredentialsStore{ + dockerConfigFile: dockerConfigFile, + }, nil +} + +// CredentialsStore is an abstraction in front of docker config API manipulation +// exposing just the limited functions we need and hiding away url normalization / identifiers magic, and handling of +// backward compatibility +type CredentialsStore struct { + dockerConfigFile *configfile.ConfigFile +} + +// Erase will remove any and all stored credentials for that registry namespace (including all legacy variants) +// If we do not find at least ONE variant matching the namespace, this will error with ErrUnableToErase +func (cs *CredentialsStore) Erase(registryURL *RegistryURL) (map[string]error, error) { + // Get all associated identifiers for that registry including legacy ones and variants + logoutList := registryURL.AllIdentifiers() + + // Iterate through and delete them one by one + errs := make(map[string]error) + for _, serverAddress := range logoutList { + if err := cs.dockerConfigFile.GetCredentialsStore(serverAddress).Erase(serverAddress); err != nil { + errs[serverAddress] = err + } + } + + // If we succeeded removing at least one, it is a success. + // The only error condition is if we failed removing *everything* - meaning there was no such credential information + // in whatever format - or the store is broken. + if len(errs) == len(logoutList) { + return errs, ErrUnableToErase + } + + return nil, nil +} + +// Store will save credentials for a given registry +// On error, ErrUnableToStore +func (cs *CredentialsStore) Store(registry *RegistryURL, credentials *Credentials) error { + // XXX confirm this works ok with namespaces + if err := cs.dockerConfigFile.GetCredentialsStore(registry.CanonicalIdentifier()).Store(*(credentials)); err != nil { + return errors.Join(ErrUnableToStore, err) + } + + return nil +} + +// ShellCompletion will return candidate strings for nerdctl logout +func (cs *CredentialsStore) ShellCompletion() ([]string, error) { + candidates := []string{} + for key := range cs.dockerConfigFile.AuthConfigs { + candidates = append(candidates, key) + } + + return candidates, nil +} + +// FileStorageLocation will return the file where credentials are stored for a given registry, or the empty string +// if it is stored / to be stored in a different place (like an OS keychain, with docker credential helpers) +func (cs *CredentialsStore) FileStorageLocation(registryURL *RegistryURL) string { + if store, isFile := (cs.dockerConfigFile.GetCredentialsStore(registryURL.CanonicalIdentifier())).(isFileStore); isFile { + return store.GetFilename() + } + + return "" +} + +// Retrieve gets existing credentials from the store for a certain registry. +// If none are found, an empty Credentials struct is returned. +// If we haerd-fail reading from the store, indicative of a broken system, we wrap the error with ErrUnableToRetrieve +func (cs *CredentialsStore) Retrieve(registryURL *RegistryURL, checkCredStore bool) (*Credentials, error) { + var err error + returnedCredentials := &Credentials{} + + if !checkCredStore { + return returnedCredentials, nil + } + + // Get the legacy variants (w/o scheme or port), and iterate over until we find one with credentials + variants := registryURL.AllIdentifiers() + for _, identifier := range variants { + var credentials types.AuthConfig + // Note that Get does not raise an error on ENOENT + credentials, err = cs.dockerConfigFile.GetCredentialsStore(identifier).Get(identifier) + if err != nil { + continue + } + returnedCredentials = &credentials + // Clean-up the username + returnedCredentials.Username = strings.TrimSpace(returnedCredentials.Username) + // Stop here if we found credentials with this variant + if returnedCredentials.IdentityToken != "" || + returnedCredentials.Username != "" || + returnedCredentials.Password != "" || + returnedCredentials.RegistryToken != "" { + break + } + } + + // We just overwrite the server property here with the host + // Whether it was one of the variants, or was not set at all (see for example Amazon ECR, https://github.com/containerd/nerdctl/issues/733) + // it doesn't matter. This is the credentials being returned for that host, by the docker credentials store. + // XXX none of this serves any purpose whatsoever + /* + if registryURL.Namespace == nil { + returnedCredentials.ServerAddress = registryURL.Host + } else { + returnedCredentials.ServerAddress = fmt.Sprintf("%s%s?%s", registryURL.Host, registryURL.Path, registryURL.RawQuery) + } + */ + + // (Last non nil) credential store error gets wrapped into ErrUnableToRetrieve + if err != nil { + err = errors.Join(ErrUnableToRetrieve, err) + } + + return returnedCredentials, err +} + +// isFileStore is an internal mock interface purely meant to help identify that the docker credential backend is a filesystem one +type isFileStore interface { + IsFileStore() bool + GetFilename() string +} diff --git a/pkg/dockerutil/credentialstore_test.go b/pkg/dockerutil/credentialstore_test.go new file mode 100644 index 00000000000..ae2850484dd --- /dev/null +++ b/pkg/dockerutil/credentialstore_test.go @@ -0,0 +1,372 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import ( + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func createTempDir(t *testing.T, mode os.FileMode) string { + tmpDir, err := os.MkdirTemp(t.TempDir(), "docker-config") + if err != nil { + t.Fatal(err) + } + err = os.Chmod(tmpDir, mode) + if err != nil { + t.Fatal(err) + } + return tmpDir +} + +func TestBrokenCredentialsStore(t *testing.T) { + testCases := []struct { + description string + setup func() string + errorNew error + errorRead error + errorWrite error + }{ + { + description: "Pointing DOCKER_CONFIG at a non-existent directory inside an unreadable directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0000) + return filepath.Join(tmpDir, "doesnotexistcantcreate") + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a non-existent directory inside a read-only directory will prevent saving credentials", + setup: func() string { + tmpDir := createTempDir(t, 0500) + return filepath.Join(tmpDir, "doesnotexistcantcreate") + }, + errorWrite: ErrUnableToStore, + }, + { + description: "Pointing DOCKER_CONFIG at an unreadable directory will prevent instantiation", + setup: func() string { + return createTempDir(t, 0000) + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a read-only directory will prevent saving credentials", + setup: func() string { + return createTempDir(t, 0500) + }, + errorWrite: ErrUnableToStore, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing am unparsable `config.json` will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("porked"), 0600) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a file instead of a directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + fd, err := os.OpenFile(filepath.Join(tmpDir, "isafile"), os.O_CREATE, 0600) + if err != nil { + t.Fatal(err) + } + err = fd.Close() + if err != nil { + t.Fatal(err) + } + return filepath.Join(tmpDir, "isafile") + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing a `config.json` directory will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.Mkdir(filepath.Join(tmpDir, "config.json"), 0600) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing a `config.json` dangling symlink will prevent saving credentials", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.Symlink("doesnotexist", filepath.Join(tmpDir, "config.json")) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorWrite: ErrUnableToStore, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing an unreadable, valid `config.json` file will prevent instantiation", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) + if err != nil { + t.Fatal(err) + } + err = os.Chmod(filepath.Join(tmpDir, "config.json"), 0000) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + errorNew: ErrUnableToInstantiate, + }, + { + description: "Pointing DOCKER_CONFIG at a directory containing a read-only, valid `config.json` file will NOT prevent saving credentials", + setup: func() string { + tmpDir := createTempDir(t, 0700) + err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0600) + if err != nil { + t.Fatal(err) + } + err = os.Chmod(filepath.Join(tmpDir, "config.json"), 0400) + if err != nil { + t.Fatal(err) + } + return tmpDir + }, + }, + } + + t.Run("Broken Docker Config testing", func(t *testing.T) { + registryURL, err := Parse("registry") + if err != nil { + t.Fatal(err) + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + directory := tc.setup() + cs, err := New(directory) + assert.ErrorIs(t, err, tc.errorNew) + if err != nil { + return + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(t, err, tc.errorRead) + + err = cs.Store(registryURL, af) + assert.ErrorIs(t, err, tc.errorWrite) + }) + } + }) +} + +func writeContent(t *testing.T, content string) string { + t.Helper() + tmpDir := createTempDir(t, 0700) + err := os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte(content), 0600) + if err != nil { + t.Fatal(err) + } + return tmpDir +} + +func TestWorkingCredentialsStore(t *testing.T) { + testCases := []struct { + description string + setup func() string + username string + password string + }{ + { + description: "Reading credentials from `auth` using canonical identifier", + username: "username", + password: "password", + setup: func() string { + content := `{ + "auths": { + "someregistry:443": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: someregistry", + username: "username", + setup: func() string { + content := `{ + "auths": { + "someregistry": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: http://someregistry", + username: "username", + setup: func() string { + content := `{ + "auths": { + "http://someregistry": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: https://someregistry", + username: "username", + setup: func() string { + content := `{ + "auths": { + "https://someregistry": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: http://someregistry:443", + username: "username", + setup: func() string { + content := `{ + "auths": { + "http://someregistry:443": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Reading from legacy / alternative identifiers: https://someregistry:443", + username: "username", + setup: func() string { + content := `{ + "auths": { + "https://someregistry:443": { + "username": "username" + } + } + }` + return writeContent(t, content) + }, + }, + { + description: "Canonical form is preferred over legacy forms", + username: "pick", + setup: func() string { + content := `{ + "auths": { + "http://someregistry:443": { + "username": "ignore" + }, + "https://someregistry:443": { + "username": "ignore" + }, + "someregistry": { + "username": "ignore" + }, + "someregistry:443": { + "serveraddress": "bla", + "username": "pick" + }, + "http://someregistry": { + "username": "ignore" + }, + "https://someregistry": { + "username": "ignore" + } + } +}` + return writeContent(t, content) + }, + }, + } + + t.Run("Working credentials store", func(t *testing.T) { + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + registryURL, err := Parse("someregistry") + if err != nil { + t.Fatal(err) + } + cs, err := New(tc.setup()) + if err != nil { + t.Fatal(err) + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(t, err, nil) + assert.Equal(t, af.Username, tc.username) + assert.Equal(t, af.ServerAddress, "someregistry:443") + assert.Equal(t, af.Password, tc.password) + }) + } + }) + + t.Run("Namespaced host", func(t *testing.T) { + server := "somehost.com/path?ns=someregistry.com" + registryURL, err := Parse(server) + if err != nil { + t.Fatal(err) + } + + content := `{ + "auths": { + "nerdctl-experimental://someregistry.com:443/host/somehost.com:443/path": { + "username": "username" + } + } + }` + dir := writeContent(t, content) + cs, err := New(dir) + if err != nil { + t.Fatal(err) + } + + var af *Credentials + af, err = cs.Retrieve(registryURL, true) + assert.ErrorIs(t, err, nil) + assert.Equal(t, af.Username, "username") + assert.Equal(t, af.ServerAddress, "somehost.com:443/path?ns=someregistry.com") + + }) +} diff --git a/pkg/dockerutil/hoststore.go b/pkg/dockerutil/hoststore.go new file mode 100644 index 00000000000..83dab16a34b --- /dev/null +++ b/pkg/dockerutil/hoststore.go @@ -0,0 +1,343 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + + "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/containerd/remotes/docker/config" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/nerderr" +) + +// validateDirectories inspect a slice of strings and returns the ones that are valid readable directories +func validateDirectories(orig []string) []string { + ss := []string{} + for _, v := range orig { + fi, err := os.Stat(v) + if err != nil || !fi.IsDir() { + if !errors.Is(err, os.ErrNotExist) { + log.L.WithError(err).Warnf("Ignoring hosts location %q", v) + } + continue + } + ss = append(ss, v) + } + return ss +} + +type ResolveOptions struct { + Insecure bool + ExplicitInsecure bool + HostsDirs []string + Username string + Password string +} + +type ResolverNG struct { + // A RegistryNamespace should be obtained from Parse(registryString) + RegistryNamespace *RegistryURL + + // server will yield the resolved server location for that namespace (implied or explicit) + server docker.RegistryHost + // hosts will yield any other resolved hosts for that namespace, excluding the server + hosts []docker.RegistryHost + // options for the resolver + options *ResolveOptions + // A ref to the credentialsStore that we will query for the authorizer + credentialsStore *CredentialsStore + + // XXX reconsider + refreshTokens map[string]string +} + +func (rng *ResolverNG) GetServer() docker.RegistryHost { + return rng.server +} + +func (rng *ResolverNG) GetHosts() []docker.RegistryHost { + return rng.hosts +} + +// IdentityTokenForHost will return a potential refresh token retrieved during authentication +func (res *ResolverNG) IdentityTokenForHost(host string) string { + return res.refreshTokens[host] +} + +// IsInsecure +//func (rng *ResolverNG) IsInsecure() bool { +// return rng.insecure || (rng.RegistryNamespace.IsLocalhost() && !rng.explicitInsecure) +//} + +/* +func (rng *ResolverNG) GetResolver(ctx context.Context, tracker docker.StatusTracker, name string) remotes.Resolver { + ro := docker.ResolverOptions{ + Tracker: tracker, + Hosts: func(string) ([]docker.RegistryHost, error) { + return + }, + } + return docker.NewResolver(ro) +}*/ + +func NewResolver(serverAddress string, credStore *CredentialsStore, options *ResolveOptions) (*ResolverNG, error) { + ctx := context.Background() + + // If we cannot even parse the address, bail out + registryURL, err := Parse(serverAddress) + if err != nil { + return nil, errors.Join(nerderr.ErrInvalidArgument, err) + } + + ns := registryURL.Namespace + if ns == nil { + ns = registryURL + } + + // Create a resolver with the options, for that registry namespace, and the passed credentialStore + resolver := &ResolverNG{ + credentialsStore: credStore, + RegistryNamespace: ns, + options: options, + } + + // Build docker host options to be used + hostOptions := &config.HostOptions{ + // Always start with https + // Note that doing WILL bypass some of the localhost/default port logic in containerd + // and will make it so that we ALWAYS try https first for every host, before considering falling back to http + // This is desirable, as containerd will otherwise prevent tls communication with localhost + DefaultScheme: string(schemeHTTPS), + // Credentials retrieval function + Credentials: func(host string) (string, string, error) { + // If we were passed an explicit username/password, we should use that + // (if it matches the host the user expects) + if resolver.options.Username != "" && resolver.options.Password != "" { + if host != registryURL.Host { + return "", "", errors.New("wrong host thing") + } + return resolver.options.Username, resolver.options.Password, nil + } + // Otherwise, retrieve from the store with that url + servURL, err := Parse(host) + if err != nil { + return "", "", err + } + credentials, credErr := credStore.Retrieve(servURL, true) + if credErr != nil { + return "", "", credErr + } + + if credentials.IdentityToken != "" { + return "", credentials.IdentityToken, nil + } + if credentials.RegistryToken != "" { + // Even containerd/CRI does not support RegistryToken as of v1.4.3, + // so, nobody is actually using RegistryToken? + log.L.Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host) + return "", "", errors.New("unsuported authentication method") + } + + return credentials.Username, credentials.Password, nil + }, + // HostDir resolution function will retrieve a host.toml file for the namespace host + HostDir: func(host string) (string, error) { + servURL := ns + hostsDirs := validateDirectories(resolver.options.HostsDirs) + + // Go through the configured system location to consider for hosts.toml files + for _, hostsDir := range hostsDirs { + found, err := config.HostDirFromRoot(hostsDir)(servURL.Host) + if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { + return found, err + } + // If not found, and the port is standard, try again without the port + if servURL.Port() == standardHTTPSPort { + found, err = config.HostDirFromRoot(hostsDir)(servURL.Hostname()) + if (err != nil && !errors.Is(err, errdefs.ErrNotFound)) || (found != "") { + return found, err + } + } + } + return "", nil + }, + } + + resolver.refreshTokens = make(map[string]string) + // Additional authorizer opt to capture the refresh token + onFetchRefreshToken := func(ctx context.Context, s string, req *http.Request) { + fmt.Println("Got refresh token", s) + // XXX add NS query / path? + resolver.refreshTokens[req.URL.Host] = s + } + hostOptions.AuthorizerOpts = append(hostOptions.AuthorizerOpts, docker.WithFetchRefreshToken(onFetchRefreshToken)) + + // Finally, get the list of configured hosts for that namespace + regHosts, err := config.ConfigureHosts(ctx, *hostOptions)(resolver.RegistryNamespace.Host) + + // If there is none (eg: an existing empty hosts.toml file, which is a legit use case preventing any interaction + // for that registry namespace), return an error + if err == nil && len(regHosts) == 0 { + err = ErrNoHostsForNamespace + } + + found := false + for _, host := range regHosts { + log.L.Debugf("inspecting: %q (against: %q - namespace: %q)", host.Host, registryURL.Host, resolver.RegistryNamespace.Host) + // Ensure we disable TLS verification if host is on localhost and no --insecure-registry=false has been passed + test, _, err := net.SplitHostPort(host.Host) + if err != nil { + test = host.Host + } + if resolver.options.Insecure || + ((test == "localhost" || net.ParseIP(test).IsLoopback()) && !resolver.options.ExplicitInsecure) { + host.Client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true + } + // If we are on the namespace, or if the target matches the hosts here, mark it found + if resolver.RegistryNamespace.Host == registryURL.Host || registryURL.Host == host.Host { + found = true + } + } + + if !found { + err = ErrNoSuchHostForNamespace + } + + // Split out the server and hosts, and retain them + if len(regHosts) > 0 { + resolver.server = regHosts[len(regHosts)-1] + resolver.hosts = regHosts[0 : len(regHosts)-1] + } + + return resolver, err +} + +/* + +UX + +nerdctl login --namespace upregistry.com server.com + + +*/ + +/* +// A Resolver will provide a list of hosts configured with the appropriate headers and authorizers, from a RegistryURL and Credentials +type Resolver struct { + // Insecure should reflect the value of the --insecure flag + Insecure bool + // ExplicitInsecure should be set to true if the --insecure flag was provided explicitly regardless of value + ExplicitInsecure bool + // A RegistryURL should be obtained from Parse(registryString) + RegistryURL *RegistryURL + // Credentials should be obtained from credentialsStore.Retrieve(RegistryURL) + Credentials *Credentials + // The directories to look into for hosts.toml configuration + HostsDirs []string + + refreshTokens map[string]string +} + +// IdentityTokenForHost will return a potential refresh token retrieved during authentication +func (res *Resolver) IdentityTokenForHost(host string) string { + return res.refreshTokens[host] +} + +// IsInsecure +func (res *Resolver) IsInsecure() bool { + return res.Insecure || (res.RegistryURL.IsLocalhost() && !res.ExplicitInsecure) +} + +func (res *Resolver) GetHosts(ctx context.Context) ([]docker.RegistryHost, error) { + hostsDirs := validateDirectories(res.HostsDirs) + + // Prepare host options + hostOptions := &config.HostOptions{ + // Always start with https + // Note that using an explicit scheme here will bypass some of the localhost/default port logic in containerd + // and will make it so that we ALWAYS try https first for every host, before considering falling back to http + DefaultScheme: string(schemeHTTPS), + // Credentials retrieval function + Credentials: func(host string) (string, string, error) { + // XXX remove + fmt.Printf("Host being passed: %q %q\n", host, res.Credentials.ServerAddress) + + if res.Credentials.IdentityToken != "" { + return "", res.Credentials.IdentityToken, nil + } + if res.Credentials.RegistryToken != "" { + // Even containerd/CRI does not support RegistryToken as of v1.4.3, + // so, nobody is actually using RegistryToken? + log.G(ctx).Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host) + } + + return res.Credentials.Username, res.Credentials.Password, nil + }, + // HostDir resolution function will retrieve a host.toml file for the given host + HostDir: func(s string) (string, error) { + for _, hostsDir := range hostsDirs { + found, err := config.HostDirFromRoot(hostsDir)(s) + if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { + return found, err + } + servURL, _ := Parse(s) + // If not found, and the port is standard, try again without the port + if servURL.Port() == standardHTTPSPort { + found, err = config.HostDirFromRoot(hostsDir)(servURL.Hostname()) + if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { + return found, err + } + } + } + return "", nil + }, + } + + // Set to insecure if asked by the user, or if it is localhost and the user did NOT set the flag explicitly to false + if res.IsInsecure() { + log.G(ctx).Warnf("WARNING! When using `insecure`, nerdctl will skip any verification of HTTPS certificates, and will potentially switch to plain HTTP. " + + "This can be trivially exploited and carries significant security risks.") + hostOptions.DefaultTLS = &tls.Config{ + InsecureSkipVerify: true, + } + } + + res.refreshTokens = make(map[string]string) + // Additional authorizer opt to capture the refresh token + onFetchRefreshToken := func(ctx context.Context, s string, req *http.Request) { + fmt.Println("Got refresh token", s) + res.refreshTokens[req.URL.Host] = s + } + hostOptions.AuthorizerOpts = append(hostOptions.AuthorizerOpts, docker.WithFetchRefreshToken(onFetchRefreshToken)) + + regHosts, err := config.ConfigureHosts(ctx, *hostOptions)(res.RegistryURL.Host) + if err == nil && len(regHosts) == 0 { + err = ErrNoHostsForNamespace + } + + return regHosts, err +} + + +*/ diff --git a/pkg/dockerutil/url.go b/pkg/dockerutil/url.go new file mode 100644 index 00000000000..bf454cff3f3 --- /dev/null +++ b/pkg/dockerutil/url.go @@ -0,0 +1,146 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import ( + "errors" + "fmt" + "github.com/containerd/log" + "net" + "net/url" + "strings" +) + +// Parse will return a normalized Docker Registry url from the provided string address (with or without scheme and port) +func Parse(address string) (*RegistryURL, error) { + log.L.Debugf("parsing docker registry URL %q", address) + var err error + // No address or address as docker.io? Default to standardized index + if address == "" || address == "docker.io" { + log.L.Debugf("normalized to %q", dockerIndexServer) + address = dockerIndexServer + } + // If it has no scheme, slap one just so we can parse + if !strings.Contains(address, "://") { + address = fmt.Sprintf("%s://%s", schemeHTTPS, address) + } + // Parse it + u, err := url.Parse(address) + if err != nil { + log.L.Debug("unparsable - giving up") + return nil, errors.Join(ErrUnparsableURL, err) + } + sch := scheme(u.Scheme) + // Scheme is entirely disregarded anyhow, so, just drop it all and set to https + if sch == schemeHTTP { + log.L.Debug("changing http to https") + u.Scheme = string(schemeHTTPS) + } else if sch != schemeHTTPS && sch != schemeNerdctlExperimental { + log.L.Debugf("unrecognized scheme %q", sch) + // Docker is wildly buggy when it comes to non-http schemes. Being more defensive. + return nil, ErrUnsupportedScheme + } + // If it has no port, add the standard port explicitly + if u.Port() == "" { + log.L.Debug("adding standard port") + u.Host = u.Hostname() + ":" + standardHTTPSPort + } + reg := &RegistryURL{URL: *u} + queryParams := u.Query() + nsQuery := queryParams.Get(namespaceQueryParameter) + if nsQuery != "" { + log.L.Debugf("this is a namespaced url, parsing namespace %q", nsQuery) + reg.Namespace, err = Parse(nsQuery) + if err != nil { + return nil, err + } + } + return reg, nil +} + +// RegistryURL is a struct that represents a registry namespace or host, meant specifically to deal with +// credentials storage and retrieval inside Docker config file. +type RegistryURL struct { + url.URL + Namespace *RegistryURL +} + +// CanonicalIdentifier returns the identifier expected to be used to save credentials to docker auth config +func (rn *RegistryURL) CanonicalIdentifier() string { + log.L.Debugf("retrieving canonical identifier for %q", rn.URL.String()) + // If it is the docker index over https, port 443, on the /v1/ path, we use the docker fully qualified identifier + if rn.Scheme == string(schemeHTTPS) && rn.Hostname() == "index.docker.io" && rn.Path == "/v1/" && rn.Port() == standardHTTPSPort || + rn.URL.String() == dockerIndexServer { + log.L.Debugf("assimilated to docker %q", dockerIndexServer) + return dockerIndexServer + } + // Otherwise, for anything else, we use the hostname+port part + identifier := rn.Host + // If this is a namespaced entry, wrap it, and slap the path as well, as hosts can be non-compliant + if rn.Namespace != nil { + log.L.Debug("namespaced identifier") + identifier = fmt.Sprintf("%s://%s/host/%s%s", schemeNerdctlExperimental, rn.Namespace.CanonicalIdentifier(), identifier, rn.Path) + } + log.L.Debugf("final value: %q", identifier) + return identifier +} + +// AllIdentifiers returns a list of identifiers that may have been used to save credentials, +// accounting for legacy formats including scheme, with and without ports +func (rn *RegistryURL) AllIdentifiers() []string { + canonicalID := rn.CanonicalIdentifier() + fullList := []string{ + // This is rn.Host, and always have a port (see parsing) + canonicalID, + } + // If the canonical identifier points to Docker Hub, or is one of our experimental ids, there is no alternative / legacy id + if canonicalID == dockerIndexServer || rn.Namespace != nil { + return fullList + } + + // Docker behavior: if the domain was index.docker.io over 443, we are allowed to additionally read the canonical + // docker credentials + if rn.Hostname() == "index.docker.io" && rn.Port() == standardHTTPSPort { + fullList = append(fullList, dockerIndexServer) + } + + // Add legacy variants + fullList = append(fullList, + fmt.Sprintf("%s://%s", schemeHTTPS, rn.Host), + fmt.Sprintf("%s://%s", schemeHTTP, rn.Host), + ) + + // Note that docker does not try to be smart wrt explicit port vs. implied port + // If standard port, allow retrieving credentials from the variant without a port as well + if rn.Port() == standardHTTPSPort { + fullList = append( + fullList, + rn.Hostname(), + fmt.Sprintf("%s://%s", schemeHTTPS, rn.Hostname()), + fmt.Sprintf("%s://%s", schemeHTTP, rn.Hostname()), + ) + } + + return fullList +} + +func (rn *RegistryURL) IsLocalhost() bool { + // Containerd exposes both a IsLocalhost and a MatchLocalhost method + // There does not seem to be a clear reason for the duplication, nor the differences in implementation. + // Either way, they both reparse the host with net.SplitHostPort, which is unnecessary + return rn.Hostname() == "localhost" || net.ParseIP(rn.Hostname()).IsLoopback() +} diff --git a/pkg/dockerutil/url_test.go b/pkg/dockerutil/url_test.go new file mode 100644 index 00000000000..8de7fa8220c --- /dev/null +++ b/pkg/dockerutil/url_test.go @@ -0,0 +1,189 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dockerutil + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestURLParsingAndID(t *testing.T) { + tests := []struct { + address string + error error + identifier string + allIDs []string + isLocalhost bool + }{ + { + address: "∞://", + error: ErrUnparsableURL, + }, + { + address: "whatever://", + error: ErrUnsupportedScheme, + }, + { + address: "https://index.docker.io/v1/", + identifier: "https://index.docker.io/v1/", + allIDs: []string{"https://index.docker.io/v1/"}, + }, + { + address: "index.docker.io", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "index.docker.io/whatever", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "http://index.docker.io", + identifier: "index.docker.io:443", + allIDs: []string{ + "index.docker.io:443", + "https://index.docker.io/v1/", + "https://index.docker.io:443", "http://index.docker.io:443", + "index.docker.io", "https://index.docker.io", "http://index.docker.io", + }, + }, + { + address: "index.docker.io:80", + identifier: "index.docker.io:80", + allIDs: []string{ + "index.docker.io:80", + "https://index.docker.io:80", "http://index.docker.io:80", + }, + }, + { + address: "index.docker.io:8080", + identifier: "index.docker.io:8080", + allIDs: []string{ + "index.docker.io:8080", + "https://index.docker.io:8080", "http://index.docker.io:8080", + }, + }, + { + address: "foo.docker.io", + identifier: "foo.docker.io:443", + allIDs: []string{ + "foo.docker.io:443", "https://foo.docker.io:443", "http://foo.docker.io:443", + "foo.docker.io", "https://foo.docker.io", "http://foo.docker.io", + }, + }, + { + address: "docker.io", + identifier: "https://index.docker.io/v1/", + allIDs: []string{"https://index.docker.io/v1/"}, + }, + { + address: "docker.io/whatever", + identifier: "docker.io:443", + allIDs: []string{ + "docker.io:443", "https://docker.io:443", "http://docker.io:443", + "docker.io", "https://docker.io", "http://docker.io", + }, + }, + { + address: "http://docker.io", + identifier: "docker.io:443", + allIDs: []string{ + "docker.io:443", "https://docker.io:443", "http://docker.io:443", + "docker.io", "https://docker.io", "http://docker.io", + }, + }, + { + address: "docker.io:80", + identifier: "docker.io:80", + allIDs: []string{ + "docker.io:80", + "https://docker.io:80", "http://docker.io:80", + }, + }, + { + address: "docker.io:8080", + identifier: "docker.io:8080", + allIDs: []string{ + "docker.io:8080", + "https://docker.io:8080", "http://docker.io:8080", + }, + }, + { + address: "anything/whatever?u=v&w=y;foo=bar#frag=o", + identifier: "anything:443", + allIDs: []string{ + "anything:443", "https://anything:443", "http://anything:443", + "anything", "https://anything", "http://anything", + }, + }, + { + address: "https://registry-host.com/subpath/something?bar=bar&ns=registry-namespace.com&foo=foo", + identifier: "nerdctl-experimental://registry-namespace.com:443/host/registry-host.com:443/subpath/something", + allIDs: []string{ + "nerdctl-experimental://registry-namespace.com:443/host/registry-host.com:443/subpath/something", + }, + }, + { + address: "localhost:1234", + identifier: "localhost:1234", + allIDs: []string{ + "localhost:1234", "https://localhost:1234", "http://localhost:1234", + }, + }, + { + address: "127.0.0.1:1234", + identifier: "127.0.0.1:1234", + allIDs: []string{ + "127.0.0.1:1234", "https://127.0.0.1:1234", "http://127.0.0.1:1234", + }, + }, + { + address: "[::1]:1234", + identifier: "[::1]:1234", + allIDs: []string{ + "[::1]:1234", "https://[::1]:1234", "http://[::1]:1234", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.address, func(t *testing.T) { + reg, err := Parse(tc.address) + assert.ErrorIs(t, err, tc.error) + if err == nil { + assert.Equal(t, reg.CanonicalIdentifier(), tc.identifier) + allIDs := reg.AllIdentifiers() + assert.Equal(t, len(allIDs), len(tc.allIDs)) + for k, v := range tc.allIDs { + assert.Equal(t, allIDs[k], v) + } + } + }) + } +} diff --git a/pkg/errutil/errors_check.go b/pkg/errutil/errors_check.go index 755fbf2582e..233ffe93e8b 100644 --- a/pkg/errutil/errors_check.go +++ b/pkg/errutil/errors_check.go @@ -18,18 +18,27 @@ package errutil import "strings" +const ( + httpResponseToHTTPS = "server gave HTTP response to HTTPS client" + connectionRefused = "connect: connection refused" +) + // IsErrHTTPResponseToHTTPSClient returns whether err is // "http: server gave HTTP response to HTTPS client" func IsErrHTTPResponseToHTTPSClient(err error) bool { + if err == nil { + return false + } // The error string is unexposed as of Go 1.16, so we can't use `errors.Is`. // https://github.com/golang/go/issues/44855 - const unexposed = "server gave HTTP response to HTTPS client" - return strings.Contains(err.Error(), unexposed) + return strings.Contains(err.Error(), httpResponseToHTTPS) } // IsErrConnectionRefused return whether err is // "connect: connection refused" func IsErrConnectionRefused(err error) bool { - const errMessage = "connect: connection refused" - return strings.Contains(err.Error(), errMessage) + if err == nil { + return false + } + return strings.Contains(err.Error(), connectionRefused) } diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go index af59898e93f..184962ef8cd 100644 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go +++ b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go @@ -18,19 +18,14 @@ package dockerconfigresolver import ( "context" - "crypto/tls" "errors" - "fmt" "os" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" - dockerconfig "github.com/containerd/containerd/v2/core/remotes/docker/config" - "github.com/containerd/errdefs" "github.com/containerd/log" - dockercliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/config/credentials" - dockercliconfigtypes "github.com/docker/cli/cli/config/types" + "github.com/containerd/nerdctl/v2/pkg/dockerutil" + "github.com/containerd/nerdctl/v2/pkg/nerderr" ) var PushTracker = docker.NewInMemoryTracker() @@ -39,7 +34,6 @@ type opts struct { plainHTTP bool skipVerifyCerts bool hostsDirs []string - authCreds AuthCreds } // Opt for New @@ -82,172 +76,34 @@ func WithHostsDirs(orig []string) Opt { } } -func WithAuthCreds(ac AuthCreds) Opt { - return func(o *opts) { - o.authCreds = ac - } -} - -// NewHostOptions instantiates a HostOptions struct using $DOCKER_CONFIG/config.json . -// -// $DOCKER_CONFIG defaults to "~/.docker". -// -// refHostname is like "docker.io". -func NewHostOptions(ctx context.Context, refHostname string, optFuncs ...Opt) (*dockerconfig.HostOptions, error) { - var o opts - for _, of := range optFuncs { - of(&o) - } - var ho dockerconfig.HostOptions - - ho.HostDir = func(s string) (string, error) { - for _, hostsDir := range o.hostsDirs { - found, err := dockerconfig.HostDirFromRoot(hostsDir)(s) - if (err != nil && !errdefs.IsNotFound(err)) || (found != "") { - return found, err - } - } - return "", nil - } - - if o.authCreds != nil { - ho.Credentials = o.authCreds - } else { - authCreds, err := NewAuthCreds(refHostname) - if err != nil { - return nil, err - } - ho.Credentials = authCreds - - } - - if o.skipVerifyCerts { - ho.DefaultTLS = &tls.Config{ - InsecureSkipVerify: true, - } - } - - if o.plainHTTP { - ho.DefaultScheme = "http" - } else { - if isLocalHost, err := docker.MatchLocalhost(refHostname); err != nil { - return nil, err - } else if isLocalHost { - ho.DefaultScheme = "http" - } - } - if ho.DefaultScheme == "http" { - // https://github.com/containerd/containerd/issues/9208 - ho.DefaultTLS = nil - } - return &ho, nil +type ResolverOptions struct { + Insecure bool + ExplicitInsecure bool + HostsDirs []string } -// New instantiates a resolver using $DOCKER_CONFIG/config.json . -// -// $DOCKER_CONFIG defaults to "~/.docker". -// -// refHostname is like "docker.io". -func New(ctx context.Context, refHostname string, optFuncs ...Opt) (remotes.Resolver, error) { - ho, err := NewHostOptions(ctx, refHostname, optFuncs...) +func New(ctx context.Context, refHostname string, options *ResolverOptions) (remotes.Resolver, error) { + // Get a credentialStore (does not error on ENOENT). + // If it errors, it is a hard filesystem error or a JSON parsing error for an existing credentials file, + // and login in that context does not make sense as we will not be able to save anything, so, just stop here. + credentialsStore, err := dockerutil.New("") if err != nil { - return nil, err + return nil, errors.Join(nerderr.ErrSystemIsBroken, err) } + // Get a resolver with requested options + resolver, err := dockerutil.NewResolver(refHostname, credentialsStore, &dockerutil.ResolveOptions{ + Insecure: options.Insecure, + ExplicitInsecure: options.ExplicitInsecure, + HostsDirs: options.HostsDirs, + }) + resolverOpts := docker.ResolverOptions{ Tracker: PushTracker, - Hosts: dockerconfig.ConfigureHosts(ctx, *ho), - } - - resolver := docker.NewResolver(resolverOpts) - return resolver, nil -} - -// AuthCreds is for docker.WithAuthCreds -type AuthCreds func(string) (string, string, error) - -// NewAuthCreds returns AuthCreds that uses $DOCKER_CONFIG/config.json . -// AuthCreds can be nil. -func NewAuthCreds(refHostname string) (AuthCreds, error) { - // Load does not raise an error on ENOENT - dockerConfigFile, err := dockercliconfig.Load("") - if err != nil { - return nil, err - } - - // DefaultHost converts "docker.io" to "registry-1.docker.io", - // which is wanted by credFunc . - credFuncExpectedHostname, err := docker.DefaultHost(refHostname) - if err != nil { - return nil, err + Hosts: func(string) ([]docker.RegistryHost, error) { + return append(resolver.GetHosts(), resolver.GetServer()), nil + }, } - var credFunc AuthCreds - - authConfigHostnames := []string{refHostname} - if refHostname == "docker.io" || refHostname == "registry-1.docker.io" { - // "docker.io" appears as ""https://index.docker.io/v1/" in ~/.docker/config.json . - // Unlike other registries, we have to pass the full URL to GetAuthConfig. - authConfigHostnames = append([]string{IndexServer}, refHostname) - } - - for _, authConfigHostname := range authConfigHostnames { - // GetAuthConfig does not raise an error on ENOENT - ac, err := dockerConfigFile.GetAuthConfig(authConfigHostname) - if err != nil { - log.L.WithError(err).Warnf("cannot get auth config for authConfigHostname=%q (refHostname=%q)", - authConfigHostname, refHostname) - } else { - // When refHostname is "docker.io": - // - credFuncExpectedHostname: "registry-1.docker.io" - // - credFuncArg: "registry-1.docker.io" - // - authConfigHostname: "https://index.docker.io/v1/" (IndexServer) - // - ac.ServerAddress: "https://index.docker.io/v1/". - if !isAuthConfigEmpty(ac) { - if ac.ServerAddress == "" { - // This can happen with Amazon ECR: https://github.com/containerd/nerdctl/issues/733 - log.L.Debugf("failed to get ac.ServerAddress for authConfigHostname=%q (refHostname=%q)", - authConfigHostname, refHostname) - } else if authConfigHostname == IndexServer { - if ac.ServerAddress != IndexServer { - return nil, fmt.Errorf("expected ac.ServerAddress (%q) to be %q", ac.ServerAddress, IndexServer) - } - } else { - acsaHostname := credentials.ConvertToHostname(ac.ServerAddress) - if acsaHostname != authConfigHostname { - return nil, fmt.Errorf("expected the hostname part of ac.ServerAddress (%q) to be authConfigHostname=%q, got %q", - ac.ServerAddress, authConfigHostname, acsaHostname) - } - } - - if ac.RegistryToken != "" { - // Even containerd/CRI does not support RegistryToken as of v1.4.3, - // so, nobody is actually using RegistryToken? - log.L.Warnf("ac.RegistryToken (for %q) is not supported yet (FIXME)", authConfigHostname) - } - - credFunc = func(credFuncArg string) (string, string, error) { - // credFuncArg should be like "registry-1.docker.io" - if credFuncArg != credFuncExpectedHostname { - return "", "", fmt.Errorf("expected credFuncExpectedHostname=%q (refHostname=%q), got credFuncArg=%q", - credFuncExpectedHostname, refHostname, credFuncArg) - } - if ac.IdentityToken != "" { - return "", ac.IdentityToken, nil - } - return ac.Username, ac.Password, nil - } - break - } - } - } - // credsFunc can be nil here - return credFunc, nil -} - -func isAuthConfigEmpty(ac dockercliconfigtypes.AuthConfig) bool { - if ac.IdentityToken != "" || ac.Username != "" || ac.Password != "" || ac.RegistryToken != "" { - return false - } - return true + return docker.NewResolver(resolverOpts), nil } diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go deleted file mode 100644 index b452425e7ba..00000000000 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver_util.go +++ /dev/null @@ -1,50 +0,0 @@ -/* - Copyright The containerd Authors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -/* - Portions from https://github.com/moby/moby/blob/v20.10.18/registry/auth.go#L154-L167 - Copyright (C) Docker/Moby authors. - Licensed under the Apache License, Version 2.0 - NOTICE: https://github.com/moby/moby/blob/v20.10.18/NOTICE -*/ - -package dockerconfigresolver - -import ( - "strings" -) - -// IndexServer is used for user auth and image search -// -// From https://github.com/moby/moby/blob/v20.10.18/registry/config.go#L36-L39 -const IndexServer = "https://index.docker.io/v1/" - -// ConvertToHostname converts a registry url which has http|https prepended -// to just an hostname. -// -// From https://github.com/moby/moby/blob/v20.10.18/registry/auth.go#L154-L167 -func ConvertToHostname(url string) string { - stripped := url - if strings.HasPrefix(url, "http://") { - stripped = strings.TrimPrefix(url, "http://") - } else if strings.HasPrefix(url, "https://") { - stripped = strings.TrimPrefix(url, "https://") - } - - nameParts := strings.SplitN(stripped, "/", 2) - - return nameParts[0] -} diff --git a/pkg/imgutil/imgutil.go b/pkg/imgutil/imgutil.go index baa54dd60a6..25660f097b2 100644 --- a/pkg/imgutil/imgutil.go +++ b/pkg/imgutil/imgutil.go @@ -27,6 +27,7 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/errdefs" "github.com/containerd/imgcrypt" "github.com/containerd/imgcrypt/images/encryption" "github.com/containerd/log" @@ -37,7 +38,6 @@ import ( "github.com/containerd/nerdctl/v2/pkg/imgutil/pull" "github.com/containerd/platforms" distributionref "github.com/distribution/reference" - "github.com/docker/docker/errdefs" "github.com/opencontainers/image-spec/identity" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) @@ -129,13 +129,11 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, ref := named.String() refDomain := distributionref.Domain(named) - var dOpts []dockerconfigresolver.Opt - if options.GOptions.InsecureRegistry { - log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) - } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(options.GOptions.HostsDir)) - resolver, err := dockerconfigresolver.New(ctx, refDomain, dOpts...) + resolver, err := dockerconfigresolver.New(ctx, refDomain, &dockerconfigresolver.ResolverOptions{ + HostsDirs: options.GOptions.HostsDir, + Insecure: options.GOptions.InsecureRegistry, + ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + }) if err != nil { return nil, err } @@ -148,8 +146,12 @@ func EnsureImage(ctx context.Context, client *containerd.Client, rawRef string, } if options.GOptions.InsecureRegistry { log.G(ctx).WithError(err).Warnf("server %q does not seem to support HTTPS, falling back to plain HTTP", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) - resolver, err = dockerconfigresolver.New(ctx, refDomain, dOpts...) + // dOpts = append(dOpts, dockerconfigresolver.WithPlainHTTP(true)) + resolver, err = dockerconfigresolver.New(ctx, refDomain, &dockerconfigresolver.ResolverOptions{ + HostsDirs: options.GOptions.HostsDir, + Insecure: options.GOptions.InsecureRegistry, + ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + }) if err != nil { return nil, err } @@ -172,13 +174,11 @@ func ResolveDigest(ctx context.Context, rawRef string, insecure bool, hostsDirs ref := named.String() refDomain := distributionref.Domain(named) - var dOpts []dockerconfigresolver.Opt - if insecure { - log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", refDomain) - dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) - } - dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(hostsDirs)) - resolver, err := dockerconfigresolver.New(ctx, refDomain, dOpts...) + resolver, err := dockerconfigresolver.New(ctx, refDomain, &dockerconfigresolver.ResolverOptions{ + HostsDirs: hostsDirs, + Insecure: insecure, + // ExplicitInsecure: options.GOptions.ExplicitInsecureRegistry, + }) if err != nil { return "", err } diff --git a/pkg/ipfs/image.go b/pkg/ipfs/image.go index ce6be9b500c..00e5f77faf7 100644 --- a/pkg/ipfs/image.go +++ b/pkg/ipfs/image.go @@ -26,6 +26,7 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/images/converter" "github.com/containerd/containerd/v2/core/remotes" + "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" @@ -34,7 +35,6 @@ import ( "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/stargz-snapshotter/ipfs" ipfsclient "github.com/containerd/stargz-snapshotter/ipfs/client" - "github.com/docker/docker/errdefs" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) diff --git a/pkg/nerderr/errors.go b/pkg/nerderr/errors.go new file mode 100644 index 00000000000..40768f2bfa1 --- /dev/null +++ b/pkg/nerderr/errors.go @@ -0,0 +1,32 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package nerderr + +import "errors" + +var ( + // ErrSystemIsBroken should wrap all system-level errors (filesystem unexpected conditions, hosed files, misbehaving subsystems) + ErrSystemIsBroken = errors.New("system error") + + // ErrInvalidArgument should wrap all cases where an argument does not match expected syntax, or prevents an operation from succeeding + // because of its value + ErrInvalidArgument = errors.New("invalid argument") + + // ErrServerIsMisbehaving should wrap all server errors (eg: status code 50x) + // but NOT dns, tcp, or tls errors + ErrServerIsMisbehaving = errors.New("server error") +) diff --git a/pkg/testutil/testregistry/certsd.go b/pkg/testutil/testregistry/certsd.go index 955bc4ba12f..3aa07af62d6 100644 --- a/pkg/testutil/testregistry/certsd.go +++ b/pkg/testutil/testregistry/certsd.go @@ -37,11 +37,14 @@ func generateCertsd(dir string, certPath string, hostIP string, port int) error } hostsTOMLPath := filepath.Join(hostsSubDir, "hosts.toml") - // See https://github.com/containerd/containerd/blob/main/docs/hosts.md hostsTOML := fmt.Sprintf(` -server = "https://%s" -[host."https://%s"] - ca = %q - `, joined, joined, certPath) +server = %q +ca = %q +[ host.%q ] +# %s + `, hostIP+":"+strconv.Itoa(port), + certPath, + hostIP+":"+strconv.Itoa(port), + hostsTOMLPath) return os.WriteFile(hostsTOMLPath, []byte(hostsTOML), 0700) } diff --git a/pkg/testutil/testregistry/testregistry_linux.go b/pkg/testutil/testregistry/testregistry_linux.go index 811176c4c3c..42784ecebe7 100644 --- a/pkg/testutil/testregistry/testregistry_linux.go +++ b/pkg/testutil/testregistry/testregistry_linux.go @@ -17,6 +17,8 @@ package testregistry import ( + "crypto/rand" + "encoding/base64" "fmt" "net" "os" @@ -68,6 +70,9 @@ func EnsureImages(base *testutil.Base) { } func NewAuthServer(base *testutil.Base, ca *testca.CA, port int, user, pass string, tls bool) *TokenAuthServer { + // TODO: centralize these + EnsureImages(base) + name := testutil.Identifier(base.T) // listen on 0.0.0.0 to enable 127.0.0.1 listenIP := net.ParseIP("0.0.0.0") @@ -245,6 +250,7 @@ func (ba *BasicAuth) Params(base *testutil.Base) []string { } func NewIPFSRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundCleanup func(error)) *RegistryServer { + // TODO: centralize these EnsureImages(base) name := testutil.Identifier(base.T) @@ -314,6 +320,7 @@ func NewIPFSRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, bo } func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundCleanup func(error)) *RegistryServer { + // TODO: centralize these EnsureImages(base) name := testutil.Identifier(base.T) @@ -339,7 +346,7 @@ func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundC var cert *testca.Cert if ca != nil { scheme = "https" - cert = ca.NewCert(hostIP.String(), "127.0.0.1") + cert = ca.NewCert(hostIP.String(), "127.0.0.1", "localhost", "::1") args = append(args, "--env", "REGISTRY_HTTP_TLS_CERTIFICATE=/registry/domain.crt", "--env", "REGISTRY_HTTP_TLS_KEY=/registry/domain.key", @@ -394,21 +401,14 @@ func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundC if err != nil { return "", err } - if port == 443 { - err = generateCertsd(hDir, ca.CertPath, hostIP.String(), 0) - if err != nil { - return "", err - } - err = generateCertsd(hDir, ca.CertPath, "127.0.0.1", 0) - if err != nil { - return "", err - } + err = generateCertsd(hDir, ca.CertPath, "localhost", port) + if err != nil { + return "", err } } cmd := base.Cmd(args...).Run() if cmd.Error != nil { - base.T.Logf("%s:\n%s\n%s\n-------\n%s", containerName, cmd.Cmd, cmd.Stdout(), cmd.Stderr()) return "", cmd.Error } @@ -421,7 +421,7 @@ func NewRegistry(base *testutil.Base, ca *testca.CA, port int, auth Auth, boundC if err != nil { cl := base.Cmd("logs", containerName).Run() - base.T.Logf("%s:\n%s\n%s\n=========================\n%s", containerName, cl.Cmd, cl.Stdout(), cl.Stderr()) + base.T.Errorf("%s:\n%s\n%s\n=========================\n%s", containerName, cl.Cmd, cl.Stdout(), cl.Stderr()) cleanup(err) } assert.NilError(base.T, err, fmt.Errorf("failed starting registry container in a timely manner: %w", err)) @@ -468,3 +468,12 @@ func NewWithBasicAuth(base *testutil.Base, user, pass string, port int, tls bool } return NewRegistry(base, ca, port, auth, nil) } + +func SafeRandomString(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + // XXX WARNING there is something in the registry (or more likely in the way we generate htpasswd files) + // that is broken and does not resist truly random strings + // return string(b) + return base64.URLEncoding.EncodeToString(b) +} diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 058b4417b40..fddb13150bd 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -380,18 +380,20 @@ func (c *Cmd) CmdOption(cmdOptions ...func(*Cmd)) *Cmd { func (c *Cmd) Assert(expected icmd.Expected) { c.Base.T.Helper() - c.runIfNecessary().Assert(c.Base.T, expected) + res := c.runIfNecessary() + assert.Assert(c.Base.T, expected, res) } func (c *Cmd) AssertOK() { c.Base.T.Helper() - c.AssertExitCode(0) + res := c.runIfNecessary() + assert.Assert(c.Base.T, res.ExitCode == 0, res) } func (c *Cmd) AssertFail() { c.Base.T.Helper() res := c.runIfNecessary() - assert.Assert(c.Base.T, res.ExitCode != 0) + assert.Assert(c.Base.T, res.ExitCode != 0, res) } func (c *Cmd) AssertExitCode(exitCode int) {