Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,9 @@ kubeconfig_path = "/etc/kubernetes/snapshotter/config.conf"
Please note that kubeconfig-based authentication requires additional privilege (i.e. kubeconfig to list/watch secrets) to the node.
And this doesn't work if kubelet retrieve creds from somewhere not API server (e.g. [credential provider](https://kubernetes.io/docs/tasks/kubelet-credential-provider/kubelet-credential-provider/)).

### Registry mirrors and insecure connection
### Registry mirrors and connection settings

The hostname used as a mirror host can be specified using `host` option.
If an optional field `insecure` is `true`, snapshotter tries to connect to the registry using plain HTTP instead of HTTPS.
`request_timeout_sec` can also be specified for each mirror to override the global setting.
Configure alternative endpoints for a registry by adding `mirrors` entries under its hostname. Each entry requires a `host` and uses HTTPS by default.

```toml
# Use `mirrorhost.io` as a mirrored host of `exampleregistry.io` and
Expand All @@ -248,16 +246,37 @@ host = "exampleregistry.io"
insecure = true
```

`header` field allows to set headers to send to the server.
Set `insecure = true` to use plain HTTP instead of HTTPS. Use `request_timeout_sec` to override the global request timeout for a mirror.

#### TLS

For a registry that requires custom TLS settings, add a `tls` table to its mirror entry:

```toml
[[resolver.host."exampleregistry.io".mirrors]]
host = "exampleregistry.io"

[resolver.host."exampleregistry.io".mirrors.tls]
ca_file = "/etc/ssl/certs/registry-ca.pem"
cert_file = "/etc/ssl/certs/client.pem"
key_file = "/etc/ssl/private/client.key"
```

`ca_file` specifies a CA bundle used in addition to the system certificate pool. To use mTLS, set both `cert_file` and `key_file` to the client certificate and its private key. The optional `insecure_skip_verify` field disables server certificate verification and should be used only for testing.

#### Headers

Add a `header` table to send custom headers to a mirror:

```toml
[[resolver.host."registry2:5000".mirrors]]
host = "registry2:5000"
[resolver.host."registry2:5000".mirrors.header]
x-custom-2 = ["value3", "value4"]
[[resolver.host."exampleregistry.io".mirrors]]
host = "exampleregistry.io"

[resolver.host."exampleregistry.io".mirrors.header]
x-custom-2 = ["value3", "value4"]
```

> NOTE: Headers aren't passed to the redirected location.
> NOTE: Custom headers are not passed to redirected locations.

### Request timeout

Expand Down
18 changes: 18 additions & 0 deletions service/resolver/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package resolver

import (
"errors"
"fmt"
"net/http"
"time"
Expand Down Expand Up @@ -57,6 +58,10 @@ type MirrorConfig struct {

// Header are additional headers to send to the server
Header map[string]any `toml:"header" json:"header"`

// TLS is a pair of CA/Cert/Key which are used when creating the transport
// that communicates with the registry.
TLS *TLSConfig `toml:"tls" json:"tls"`
}

type Credential func(string, reference.Spec) (string, string, error)
Expand All @@ -81,6 +86,19 @@ func RegistryHostsFromConfig(cfg Config, credsFuncs ...Credential) source.Regist
client.HTTPClient.Timeout = time.Duration(h.RequestTimeoutSec) * time.Second
}
} // h.RequestTimeoutSec < 0 means "no timeout"

if h.TLS != nil {
if tr, ok := client.HTTPClient.Transport.(*http.Transport); ok {
var err error
tr.TLSClientConfig, err = getTLSConfig(*h.TLS)
if err != nil {
return nil, fmt.Errorf("get TLSConfig for registry %q: %w", h.Host, err)
}
} else {
return nil, errors.New("TLS config cannot be applied; Client.Transport is not *http.Transport")
}
}

tr := client.StandardClient()
var header http.Header
var err error
Expand Down
266 changes: 266 additions & 0 deletions service/resolver/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*
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 resolver

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"

"github.com/containerd/containerd/v2/pkg/reference"
)

const testRegistryHost = "registry.example.com"

type testCertBundle struct {
caCert *x509.Certificate
caKey *ecdsa.PrivateKey
serverCert tls.Certificate
clientCert tls.Certificate
}

func generateTestCertBundle(t *testing.T, serverHost string) testCertBundle {
t.Helper()

caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}

serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
serverTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: serverHost},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(serverHost); ip != nil {
serverTemplate.IPAddresses = []net.IP{ip}
} else {
serverTemplate.DNSNames = []string{serverHost}
}
serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
serverCert, err := tls.X509KeyPair(
pemEncodeCertificate(serverDER),
pemEncodePrivateKey(serverKey),
)
if err != nil {
t.Fatal(err)
}

clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
clientTemplate := &x509.Certificate{
SerialNumber: big.NewInt(3),
Subject: pkix.Name{CommonName: "test-client"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
clientCert, err := tls.X509KeyPair(
pemEncodeCertificate(clientDER),
pemEncodePrivateKey(clientKey),
)
if err != nil {
t.Fatal(err)
}

return testCertBundle{
caCert: caCert,
caKey: caKey,
serverCert: serverCert,
clientCert: clientCert,
}
}

func pemEncodeCertificate(der []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}

func pemEncodePrivateKey(key *ecdsa.PrivateKey) []byte {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
}

func writeCertBundleFiles(t *testing.T, dir string, bundle testCertBundle) (caPath, clientCertPath, clientKeyPath string) {
t.Helper()

caPath = filepath.Join(dir, "ca.pem")
if err := os.WriteFile(caPath, pemEncodeCertificate(bundle.caCert.Raw), 0o600); err != nil {
t.Fatal(err)
}
clientCertPath = filepath.Join(dir, "client.pem")
clientKeyPath = filepath.Join(dir, "client.key")
if err := os.WriteFile(clientCertPath, pemEncodeCertificate(bundle.clientCert.Certificate[0]), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(clientKeyPath, pemEncodePrivateKey(bundle.clientCert.PrivateKey.(*ecdsa.PrivateKey)), 0o600); err != nil {
t.Fatal(err)
}
return caPath, clientCertPath, clientKeyPath
}

func startMTLSTestServer(t *testing.T, bundle testCertBundle) *httptest.Server {
t.Helper()

clientCAPool := x509.NewCertPool()
clientCAPool.AddCert(bundle.caCert)

server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ok")
}))
server.TLS = &tls.Config{
Certificates: []tls.Certificate{bundle.serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAPool,
MinVersion: tls.VersionTLS12,
}
server.StartTLS()
t.Cleanup(server.Close)
return server
}

func testReference(host string) reference.Spec {
ref, err := reference.Parse(host + "/repo:latest")
if err != nil {
panic(err)
}
return ref
}

func TestRegistryHostsFromConfigInlineTLS(t *testing.T) {
bundle := generateTestCertBundle(t, "127.0.0.1")
server := startMTLSTestServer(t, bundle)
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}

certDir := t.TempDir()
caPath, clientCertPath, clientKeyPath := writeCertBundleFiles(t, certDir, bundle)

hostsFn := RegistryHostsFromConfig(Config{
Host: map[string]HostConfig{
testRegistryHost: {
Mirrors: []MirrorConfig{{
Host: serverURL.Host,
TLS: &TLSConfig{
CAFile: caPath,
CertFile: clientCertPath,
KeyFile: clientKeyPath,
},
}},
},
},
})
hosts, err := hostsFn(testReference(testRegistryHost))
if err != nil {
t.Fatal(err)
}
if len(hosts) < 1 {
t.Fatalf("expected at least 1 host, got %d", len(hosts))
}

host := hosts[0]
if host.Host != serverURL.Host {
t.Fatalf("expected host %q, got %q", serverURL.Host, host.Host)
}

resp, err := host.Client.Get(server.URL)
if err != nil {
t.Fatalf("expected mTLS request to succeed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
}

func TestRegistryHostsFromConfigInlineTLSError(t *testing.T) {
certDir := t.TempDir()
certPath := filepath.Join(certDir, "client.pem")
if err := os.WriteFile(certPath, []byte("not-a-cert"), 0o600); err != nil {
t.Fatal(err)
}

hostsFn := RegistryHostsFromConfig(Config{
Host: map[string]HostConfig{
testRegistryHost: {
Mirrors: []MirrorConfig{{
Host: testRegistryHost,
TLS: &TLSConfig{
CertFile: certPath,
KeyFile: filepath.Join(certDir, "missing.key"),
},
}},
},
},
})
_, err := hostsFn(testReference(testRegistryHost))
if err == nil {
t.Fatal("expected TLS configuration error")
}
}
Loading