diff --git a/README.md b/README.md index 1df195f7788..3ab0ec06133 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,7 @@ Show the nerdctl version information - :nerd_face: `--cni-netconfpath`: CNI netconf path (default: `/etc/cni/net.d`) [`$NETCONFPATH`] - :nerd_face: `--data-root`: nerdctl data root, e.g. "/var/lib/nerdctl" - :nerd_face: `--cgroup-manager=(cgroupfs|systemd)`: cgroup manager +- :nerd_face: `--insecure-registry`: skips verifying HTTPS certs, and allows falling back to plain HTTP ## Unimplemented Docker commands Container management: diff --git a/Vagrantfile b/Vagrantfile index ae1e248d668..354dd4170c7 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -9,6 +9,8 @@ Vagrant.configure("2") do |config| config.vm.provider :virtualbox do |v| v.memory = memory v.cpus = cpus + # The default CIDR conflicts with slirp4netns CIDR (10.0.2.0/24) + v.customize ["modifyvm", :id, "--natnet1", "192.168.42.0/24"] end config.vm.provider :libvirt do |v| v.memory = memory diff --git a/docs/registry.md b/docs/registry.md index 1d5d344277b..45b335d50b7 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -3,3 +3,19 @@ nerdctl uses `${DOCKER_CONFIG}/config.json` for the authentication with image registries. `$DOCKER_CONFIG` defaults to `$HOME/.docker`. + +## Using insecure registry + +If you face `http: server gave HTTP response to HTTPS client` and you cannot configure TLS for the registry, try `--insecure-registry` flag: + +e.g., +```console +$ nerdctl --insecure-registry run --rm 192.168.12.34:5000/foo +``` + +## Accessing 127.0.0.1 from rootless nerdctl + +Currently, rootless nerdctl cannot pull images from 127.0.0.1, because +the pull operation occurs in RootlessKit's network namespace. + +See https://github.com/AkihiroSuda/nerdctl/issues/86 for the discussion about workarounds. diff --git a/main.go b/main.go index 651c9235f09..c5af8d28734 100644 --- a/main.go +++ b/main.go @@ -110,6 +110,10 @@ func newApp() *cli.App { Usage: "Cgroup manager to use (\"cgroupfs\"|\"systemd\")", Value: ncdefaults.CgroupManager(), }, + &cli.BoolFlag{ + Name: "insecure-registry", + Usage: "skips verifying HTTPS certs, and allows falling back to plain HTTP", + }, } app.Before = func(clicontext *cli.Context) error { if debug { diff --git a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go index ee1791483ae..089f6b5f098 100644 --- a/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go +++ b/pkg/imgutil/dockerconfigresolver/dockerconfigresolver.go @@ -18,7 +18,9 @@ package dockerconfigresolver import ( + "crypto/tls" "net" + "net/http" "net/url" "github.com/containerd/containerd/remotes" @@ -29,12 +31,77 @@ import ( "github.com/sirupsen/logrus" ) +type opts struct { + plainHTTP bool + skipVerifyCerts bool +} + +// Opt for New +type Opt func(*opts) + +// WithPlainHTTP enables insecure plain HTTP +func WithPlainHTTP(b bool) Opt { + return func(o *opts) { + o.plainHTTP = b + } +} + +// WithSkipVerifyCerts skips verifying TLS certs +func WithSkipVerifyCerts(b bool) Opt { + return func(o *opts) { + o.skipVerifyCerts = b + } +} + // New instantiates a resolver using $DOCKER_CONFIG/config.json . // // $DOCKER_CONFIG defaults to "~/.docker". // // refHostname is like "docker.io". -func New(refHostname string) (remotes.Resolver, error) { +func New(refHostname string, optFuncs ...Opt) (remotes.Resolver, error) { + var o opts + for _, of := range optFuncs { + of(&o) + } + var authzOpts []docker.AuthorizerOpt + if authCreds, err := NewAuthCreds(refHostname); err != nil { + return nil, err + } else { + authzOpts = append(authzOpts, docker.WithAuthCreds(authCreds)) + } + authz := docker.NewDockerAuthorizer(authzOpts...) + plainHTTPFunc := docker.MatchLocalhost + if o.plainHTTP { + plainHTTPFunc = docker.MatchAllHosts + } + regOpts := []docker.RegistryOpt{ + docker.WithAuthorizer(authz), + docker.WithPlainHTTP(plainHTTPFunc), + } + if o.skipVerifyCerts { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + client := &http.Client{ + Transport: tr, + } + regOpts = append(regOpts, docker.WithClient(client)) + } + resovlerOpts := docker.ResolverOptions{ + Hosts: docker.ConfigureDefaultRegistries(regOpts...), + } + resolver := docker.NewResolver(resovlerOpts) + 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 { @@ -48,7 +115,7 @@ func New(refHostname string) (remotes.Resolver, error) { return nil, err } - var credFunc func(string) (string, string, error) + var credFunc AuthCreds authConfigHostnames := []string{refHostname} if refHostname == "docker.io" || refHostname == "registry-1.docker.io" { @@ -110,13 +177,8 @@ func New(refHostname string) (remotes.Resolver, error) { } } } - - resovlerOpts := docker.ResolverOptions{ - // FIXME: Credentials is deprecated - Credentials: credFunc, - } - resolver := docker.NewResolver(resovlerOpts) - return resolver, nil + // credsFunc can be nil here + return credFunc, nil } func isAuthConfigEmpty(ac dockercliconfigtypes.AuthConfig) bool { diff --git a/pkg/imgutil/imgutil.go b/pkg/imgutil/imgutil.go index 6883d76b509..a12fa2e7e88 100644 --- a/pkg/imgutil/imgutil.go +++ b/pkg/imgutil/imgutil.go @@ -26,6 +26,7 @@ import ( "github.com/AkihiroSuda/nerdctl/pkg/imgutil/pull" "github.com/containerd/containerd" refdocker "github.com/containerd/containerd/reference/docker" + "github.com/containerd/containerd/remotes" "github.com/containerd/stargz-snapshotter/fs/source" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -41,7 +42,10 @@ type EnsuredImage struct { // PullMode is either one of "always", "missing", "never" type PullMode = string -func EnsureImage(ctx context.Context, client *containerd.Client, stdout io.Writer, snapshotter, rawRef string, mode PullMode) (*EnsuredImage, error) { +// EnsureImage ensures the image. +// +// When insecure is set, skips verifying certs, and also falls back to HTTP when the registry does not speak HTTPS +func EnsureImage(ctx context.Context, client *containerd.Client, stdout io.Writer, snapshotter, rawRef string, mode PullMode, insecure bool) (*EnsuredImage, error) { named, err := refdocker.ParseDockerRef(rawRef) if err != nil { return nil, err @@ -70,11 +74,50 @@ func EnsureImage(ctx context.Context, client *containerd.Client, stdout io.Write return nil, errors.Errorf("image %q is not available", rawRef) } - resolver, err := dockerconfigresolver.New(refdocker.Domain(named)) + refDomain := refdocker.Domain(named) + + var dOpts []dockerconfigresolver.Opt + if insecure { + logrus.Warnf("skipping verifying HTTPS certs for %q", refDomain) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + resolver, err := dockerconfigresolver.New(refDomain, dOpts...) if err != nil { return nil, err } + img, err := pullImage(ctx, client, stdout, snapshotter, resolver, ref) + if err != nil { + if !IsErrHTTPResponseToHTTPSClient(err) { + return nil, err + } + if insecure { + logrus.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(refDomain, dOpts...) + if err != nil { + return nil, err + } + return pullImage(ctx, client, stdout, snapshotter, resolver, ref) + } else { + logrus.WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) + logrus.Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + return nil, err + } + } + return img, nil +} + +// IsErrHTTPResponseToHTTPSClient returns whether err is +// "http: server gave HTTP response to HTTPS client" +func IsErrHTTPResponseToHTTPSClient(err error) bool { + // 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) +} + +func pullImage(ctx context.Context, client *containerd.Client, stdout io.Writer, snapshotter string, resolver remotes.Resolver, ref string) (*EnsuredImage, error) { ctx, done, err := client.WithLease(ctx) if err != nil { return nil, err @@ -109,6 +152,7 @@ func EnsureImage(ctx context.Context, client *containerd.Client, stdout io.Write Remote: sgz, } return res, nil + } func isStargz(sn string) bool { diff --git a/pkg/imgutil/pull/pull.go b/pkg/imgutil/pull/pull.go index c863c0c2b56..e1c16f71039 100644 --- a/pkg/imgutil/pull/pull.go +++ b/pkg/imgutil/pull/pull.go @@ -73,6 +73,7 @@ func Pull(ctx context.Context, client *containerd.Client, ref string, config *Co } opts = append(opts, config.RemoteOpts...) + // client.Pull is for single-platform (TODO: support multi) img, err := client.Pull(pctx, ref, opts...) stopProgress() if err != nil { diff --git a/pkg/imgutil/push/push.go b/pkg/imgutil/push/push.go index fc046dd2368..3b2437d7c4a 100644 --- a/pkg/imgutil/push/push.go +++ b/pkg/imgutil/push/push.go @@ -32,6 +32,7 @@ import ( "github.com/containerd/containerd/images" "github.com/containerd/containerd/log" "github.com/containerd/containerd/pkg/progress" + "github.com/containerd/containerd/platforms" "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -39,7 +40,8 @@ import ( "golang.org/x/sync/errgroup" ) -func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resolver, stdout io.Writer, localRef, remoteRef string) error { +func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resolver, stdout io.Writer, + localRef, remoteRef string, platform platforms.MatchComparer) error { img, err := client.ImageService().Get(ctx, localRef) if err != nil { return errors.Wrap(err, "unable to resolve image to manifest") @@ -66,6 +68,7 @@ func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resol return client.Push(ctx, remoteRef, desc, containerd.WithResolver(resolver), containerd.WithImageHandler(jobHandler), + containerd.WithPlatformMatcher(platform), ) }) diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 9144dcedf5d..68b17ba9ff5 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -214,4 +214,5 @@ const ( AlpineImage = "mirror.gcr.io/library/alpine:3.13" NginxAlpineImage = "mirror.gcr.io/library/nginx:1.19-alpine" NginxAlpineIndexHTMLSnippet = "Welcome to nginx!" + RegistryImage = "mirror.gcr.io/library/registry:2" ) diff --git a/pull.go b/pull.go index 5055f85efdc..d4287a94002 100644 --- a/pull.go +++ b/pull.go @@ -40,6 +40,8 @@ func pullAction(clicontext *cli.Context) error { return err } defer cancel() - _, err = imgutil.EnsureImage(ctx, client, clicontext.App.Writer, clicontext.String("snapshotter"), clicontext.Args().First(), "always") + insecure := clicontext.Bool("insecure-registry") + _, err = imgutil.EnsureImage(ctx, client, clicontext.App.Writer, clicontext.String("snapshotter"), clicontext.Args().First(), + "always", insecure) return err } diff --git a/push.go b/push.go index e5f7e42f234..1c2d25da2f3 100644 --- a/push.go +++ b/push.go @@ -18,10 +18,17 @@ package main import ( + "context" + + "github.com/AkihiroSuda/nerdctl/pkg/imgutil" "github.com/AkihiroSuda/nerdctl/pkg/imgutil/dockerconfigresolver" "github.com/AkihiroSuda/nerdctl/pkg/imgutil/push" + "github.com/containerd/containerd/images/converter" + "github.com/containerd/containerd/platforms" refdocker "github.com/containerd/containerd/reference/docker" + "github.com/containerd/containerd/remotes" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) @@ -44,15 +51,58 @@ func pushAction(clicontext *cli.Context) error { return err } ref := named.String() - resolver, err := dockerconfigresolver.New(refdocker.Domain(named)) + refDomain := refdocker.Domain(named) + + insecure := clicontext.Bool("insecure-registry") + + client, ctx, cancel, err := newClient(clicontext) if err != nil { return err } + defer cancel() - client, ctx, cancel, err := newClient(clicontext) + // Push fails with "400 Bad Request" when the manifest is multi-platform but we do not locally have multi-platform blobs. + // So we create a tmp single-platform image to avoid the error. + // TODO: support pushing multi-platform + singlePlatform := platforms.DefaultStrict() + singlePlatformRef := ref + "-tmp-single" + singlePlatformImg, err := converter.Convert(ctx, client, singlePlatformRef, ref, + converter.WithPlatform(singlePlatform)) + if err != nil { + return errors.Wrapf(err, "failed to create a tmp single-platform image %q", singlePlatformRef) + } + defer client.ImageService().Delete(context.TODO(), singlePlatformImg.Name) + + pushFunc := func(r remotes.Resolver) error { + return push.Push(ctx, client, r, clicontext.App.Writer, singlePlatformRef, ref, singlePlatform) + } + + var dOpts []dockerconfigresolver.Opt + if insecure { + logrus.Warnf("skipping verifying HTTPS certs for %q", refDomain) + dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true)) + } + resolver, err := dockerconfigresolver.New(refDomain, dOpts...) if err != nil { return err } - defer cancel() - return push.Push(ctx, client, resolver, clicontext.App.Writer, ref, ref) + if err = pushFunc(resolver); err != nil { + if !imgutil.IsErrHTTPResponseToHTTPSClient(err) { + return err + } + if insecure { + logrus.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(refDomain, dOpts...) + if err != nil { + return err + } + return pushFunc(resolver) + } else { + logrus.WithError(err).Errorf("server %q does not seem to support HTTPS", refDomain) + logrus.Info("Hint: you may want to try --insecure-registry to allow plain HTTP (if you are in a trusted network)") + return err + } + } + return nil } diff --git a/push_test.go b/push_test.go new file mode 100644 index 00000000000..4598ee893b2 --- /dev/null +++ b/push_test.go @@ -0,0 +1,137 @@ +/* + Copyright (C) nerdctl authors. + Copyright (C) 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 main + +import ( + "fmt" + "net" + "strings" + "testing" + + "github.com/AkihiroSuda/nerdctl/pkg/testutil" + "github.com/containerd/containerd/errdefs" + "github.com/pkg/errors" + "gotest.tools/v3/assert" +) + +func getNonLoopbackIPv4() (net.IP, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil, err + } + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + ipv4 := ip.To4() + if ipv4 == nil { + continue + } + if ipv4.IsLoopback() { + continue + } + return ipv4, nil + } + return nil, errors.Wrapf(errdefs.ErrNotFound, "non-loopback IPv4 address not found, attempted=%+v", addrs) +} + +type testRegistry struct { + ip net.IP + listenIP net.IP + listenPort int + cleanup func() +} + +func newTestRegistry(base *testutil.Base, name string) *testRegistry { + hostIP, err := getNonLoopbackIPv4() + assert.NilError(base.T, err) + // listen on 0.0.0.0 to enable 127.0.0.1 + listenIP := net.ParseIP("0.0.0.0") + const listenPort = 5000 // TODO: choose random empty port + base.T.Logf("hostIP=%q, listenIP=%q, listenPort=%d", hostIP, listenIP, listenPort) + + registryContainerName := "reg-" + name + cmd := base.Cmd("run", + "-d", + "-p", fmt.Sprintf("%s:%d:5000", listenIP, listenPort), + "--name", registryContainerName, + testutil.RegistryImage) + cmd.AssertOK() + if _, err = httpGet(fmt.Sprintf("http://%s:%d/v2", hostIP.String(), listenPort), 30); err != nil { + base.Cmd("rm", "-f", registryContainerName).Run() + base.T.Fatal(err) + } + return &testRegistry{ + ip: hostIP, + listenIP: listenIP, + listenPort: listenPort, + cleanup: func() { base.Cmd("rm", "-f", registryContainerName).Run() }, + } +} + +func TestPushPlainHTTPFails(t *testing.T) { + base := testutil.NewBase(t) + reg := newTestRegistry(base, "test-push-plain-http-fails") + defer reg.cleanup() + + base.Cmd("pull", testutil.AlpineImage).AssertOK() + testImageRef := fmt.Sprintf("%s:%d/test-push-plain-http-fails:%s", + reg.ip.String(), reg.listenPort, strings.Split(testutil.AlpineImage, ":")[1]) + t.Logf("testImageRef=%q", testImageRef) + base.Cmd("tag", testutil.AlpineImage, testImageRef).AssertOK() + + res := base.Cmd("push", testImageRef).Run() + resCombined := res.Combined() + t.Logf("result: exitCode=%d, out=%q", res.ExitCode, res.Combined()) + assert.Assert(t, res.ExitCode != 0) + assert.Assert(t, strings.Contains(resCombined, "server gave HTTP response to HTTPS client")) +} + +func TestPushPlainHTTPLocalhost(t *testing.T) { + base := testutil.NewBase(t) + reg := newTestRegistry(base, "test-push-plain-localhost") + defer reg.cleanup() + localhostIP := "127.0.0.1" + t.Logf("localhost IP=%q", localhostIP) + + base.Cmd("pull", testutil.AlpineImage).AssertOK() + testImageRef := fmt.Sprintf("%s:%d/test-push-plain-http-insecure:%s", + localhostIP, reg.listenPort, strings.Split(testutil.AlpineImage, ":")[1]) + t.Logf("testImageRef=%q", testImageRef) + base.Cmd("tag", testutil.AlpineImage, testImageRef).AssertOK() + + base.Cmd("push", testImageRef).AssertOK() +} + +func TestPushPlainHTTPInsecure(t *testing.T) { + // Skip docker, because "dockerd --insecure-registries" requires restarting the daemon + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + reg := newTestRegistry(base, "test-push-plain-http-insecure") + defer reg.cleanup() + + base.Cmd("pull", testutil.AlpineImage).AssertOK() + testImageRef := fmt.Sprintf("%s:%d/test-push-plain-http-insecure:%s", + reg.ip.String(), reg.listenPort, strings.Split(testutil.AlpineImage, ":")[1]) + t.Logf("testImageRef=%q", testImageRef) + base.Cmd("tag", testutil.AlpineImage, testImageRef).AssertOK() + + base.Cmd("--insecure-registry", "push", testImageRef).AssertOK() +} diff --git a/run.go b/run.go index 494357d481a..b6739ebbfdd 100644 --- a/run.go +++ b/run.go @@ -235,7 +235,8 @@ func runAction(clicontext *cli.Context) error { imageless := clicontext.Bool("rootfs") var ensured *imgutil.EnsuredImage if !imageless { - ensured, err = imgutil.EnsureImage(ctx, client, clicontext.App.Writer, clicontext.String("snapshotter"), clicontext.Args().First(), clicontext.String("pull")) + ensured, err = imgutil.EnsureImage(ctx, client, clicontext.App.Writer, clicontext.String("snapshotter"), clicontext.Args().First(), + clicontext.String("pull"), clicontext.Bool("insecure-registry")) if err != nil { return err }