Add basic authentication support (#771)

This commit is contained in:
Philip Laine 2025-03-06 21:34:26 +01:00 committed by GitHub
commit 435da8e8e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 184 additions and 9 deletions

View File

@ -9,6 +9,7 @@ Read the [getting started](https://spegel.dev/docs/getting-started/) guide to de
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | Affinity settings for pod assignment. |
| basicAuthSecretName | string | `""` | Name of secret containing basic authentication credentials for registry. |
| clusterDomain | string | `"cluster.local."` | Domain configured for service domain names. |
| commonLabels | object | `{}` | Common labels to apply to all rendered resources. |
| fullnameOverride | string | `""` | Overrides the full name of the chart. |

View File

@ -67,6 +67,11 @@ spec:
volumeMounts:
- name: containerd-config
mountPath: {{ .Values.spegel.containerdRegistryConfigPath }}
{{- if .Values.basicAuthSecretName }}
- name: basic-auth
mountPath: "/etc/secrets/basic-auth"
readOnly: true
{{- end }}
{{- end }}
containers:
- name: registry
@ -139,6 +144,11 @@ spec:
path: /healthz
port: registry
volumeMounts:
{{- if .Values.basicAuthSecretName }}
- name: basic-auth
mountPath: "/etc/secrets/basic-auth"
readOnly: true
{{- end }}
- name: containerd-sock
mountPath: {{ .Values.spegel.containerdSock }}
{{- with .Values.spegel.containerdContentPath }}
@ -149,6 +159,11 @@ spec:
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumes:
{{- with .Values.basicAuthSecretName }}
- name: basic-auth
secret:
secretName: {{ . }}
{{- end }}
- name: containerd-sock
hostPath:
path: {{ .Values.spegel.containerdSock }}

View File

@ -133,6 +133,9 @@ grafanaDashboard:
# -- Priority class name to use for the pod.
priorityClassName: system-node-critical
# -- Name of secret containing basic authentication credentials for registry.
basicAuthSecretName: ""
spegel:
# -- Minimum log level to output. Value should be DEBUG, INFO, WARN, or ERROR.
logLevel: "INFO"

26
main.go
View File

@ -11,6 +11,7 @@ import (
"net/url"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@ -104,8 +105,12 @@ func run(ctx context.Context, args *Arguments) error {
}
func configurationCommand(ctx context.Context, args *ConfigurationCmd) error {
username, password, err := loadBasicAuth()
if err != nil {
return err
}
fs := afero.NewOsFs()
err := oci.AddMirrorConfiguration(ctx, fs, args.ContainerdRegistryConfigPath, args.MirroredRegistries, args.MirrorTargets, args.ResolveTags, args.PrependExisting)
err = oci.AddMirrorConfiguration(ctx, fs, args.ContainerdRegistryConfigPath, args.MirroredRegistries, args.MirrorTargets, args.ResolveTags, args.PrependExisting, username, password)
if err != nil {
return err
}
@ -116,6 +121,11 @@ func registryCommand(ctx context.Context, args *RegistryCmd) (err error) {
log := logr.FromContextOrDiscard(ctx)
g, ctx := errgroup.WithContext(ctx)
username, password, err := loadBasicAuth()
if err != nil {
return err
}
// OCI Client
ociClient, err := oci.NewContainerd(args.ContainerdSock, args.ContainerdNamespace, args.ContainerdRegistryConfigPath, args.MirroredRegistries, oci.WithContentPath(args.ContainerdContentPath))
if err != nil {
@ -190,6 +200,7 @@ func registryCommand(ctx context.Context, args *RegistryCmd) (err error) {
registry.WithResolveTimeout(args.MirrorResolveTimeout),
registry.WithLocalAddress(args.LocalAddr),
registry.WithLogger(log),
registry.WithBasicAuth(username, password),
}
reg := registry.NewRegistry(ociClient, router, registryOpts...)
regSrv, err := reg.Server(args.RegistryAddr)
@ -233,3 +244,16 @@ func getBootstrapper(cfg BootstrapConfig) (routing.Bootstrapper, error) { //noli
return nil, fmt.Errorf("unknown bootstrap kind %s", cfg.BootstrapKind)
}
}
func loadBasicAuth() (string, string, error) {
dirPath := "/etc/secrets/basic-auth"
username, err := os.ReadFile(filepath.Join(dirPath, "username"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", "", err
}
password, err := os.ReadFile(filepath.Join(dirPath, "password"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", "", err
}
return string(username), string(password), nil
}

View File

@ -3,6 +3,7 @@ package oci
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -350,7 +351,7 @@ func createFilters(mirroredRegistries []url.URL) (string, string) {
// Refer to containerd registry configuration documentation for more information about required configuration.
// https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
// https://github.com/containerd/containerd/blob/main/docs/hosts.md#registry-configuration---examples
func AddMirrorConfiguration(ctx context.Context, fs afero.Fs, configPath string, mirroredRegistries, mirrorTargets []url.URL, resolveTags, prependExisting bool) error {
func AddMirrorConfiguration(ctx context.Context, fs afero.Fs, configPath string, mirroredRegistries, mirrorTargets []url.URL, resolveTags, prependExisting bool, username, password string) error {
log := logr.FromContextOrDiscard(ctx)
err := validateRegistries(mirroredRegistries)
if err != nil {
@ -379,7 +380,7 @@ func AddMirrorConfiguration(ctx context.Context, fs afero.Fs, configPath string,
// Write mirror configuration
for _, mirroredRegistry := range mirroredRegistries {
templatedHosts, err := templateHosts(mirroredRegistry, mirrorTargets, capabilities)
templatedHosts, err := templateHosts(mirroredRegistry, mirrorTargets, capabilities, username, password)
if err != nil {
return err
}
@ -477,13 +478,21 @@ func clearConfig(fs afero.Fs, configPath string) error {
return nil
}
func templateHosts(mirroredRegistry url.URL, mirrorTargets []url.URL, capabilities []string) (string, error) {
func templateHosts(mirroredRegistry url.URL, mirrorTargets []url.URL, capabilities []string, username, password string) (string, error) {
server := mirroredRegistry.String()
if mirroredRegistry.String() == "https://docker.io" {
server = "https://registry-1.docker.io"
}
authorization := ""
if username != "" || password != "" {
authorization = username + ":" + password
authorization = base64.StdEncoding.EncodeToString([]byte(authorization))
authorization = "Basic " + authorization
}
hc := struct {
Authorization string
Server string
Capabilities string
MirrorTargets []url.URL
@ -491,11 +500,17 @@ func templateHosts(mirroredRegistry url.URL, mirrorTargets []url.URL, capabiliti
Server: server,
Capabilities: fmt.Sprintf("['%s']", strings.Join(capabilities, "', '")),
MirrorTargets: mirrorTargets,
Authorization: authorization,
}
tmpl, err := template.New("").Parse(`{{- with .Server }}server = '{{ . }}'{{ end }}
{{- $authorization := .Authorization }}
{{ range .MirrorTargets }}
[host.'{{ .String }}']
capabilities = {{ $.Capabilities }}
{{- if $authorization }}
[host.'{{ .String }}'.header]
Authorization = '{{ $authorization }}'
{{- end }}
{{ end }}`)
if err != nil {
return "", err

View File

@ -280,6 +280,8 @@ func TestMirrorConfiguration(t *testing.T) {
existingFiles map[string]string
expectedFiles map[string]string
name string
username string
password string
registries []url.URL
mirrors []url.URL
resolveTags bool
@ -523,6 +525,28 @@ capabilities = ['pull', 'resolve']`,
capabilities = ['pull', 'resolve']`,
},
},
{
name: "with basic authentication",
resolveTags: true,
registries: stringListToUrlList(t, []string{"http://foo.bar:5000"}),
mirrors: stringListToUrlList(t, []string{"http://127.0.0.1:5000", "http://127.0.0.1:5001"}),
prependExisting: false,
username: "hello",
password: "world",
expectedFiles: map[string]string{
"/etc/containerd/certs.d/foo.bar:5000/hosts.toml": `server = 'http://foo.bar:5000'
[host.'http://127.0.0.1:5000']
capabilities = ['pull', 'resolve']
[host.'http://127.0.0.1:5000'.header]
Authorization = 'Basic aGVsbG86d29ybGQ='
[host.'http://127.0.0.1:5001']
capabilities = ['pull', 'resolve']
[host.'http://127.0.0.1:5001'.header]
Authorization = 'Basic aGVsbG86d29ybGQ='`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -537,7 +561,7 @@ capabilities = ['pull', 'resolve']`,
err := afero.WriteFile(fs, k, []byte(v), 0o644)
require.NoError(t, err)
}
err := AddMirrorConfiguration(context.TODO(), fs, registryConfigPath, tt.registries, tt.mirrors, tt.resolveTags, tt.prependExisting)
err := AddMirrorConfiguration(context.TODO(), fs, registryConfigPath, tt.registries, tt.mirrors, tt.resolveTags, tt.prependExisting, tt.username, tt.password)
require.NoError(t, err)
ok, err := afero.DirExists(fs, "/etc/containerd/certs.d/_backup")
require.NoError(t, err)
@ -568,19 +592,19 @@ func TestMirrorConfigurationInvalidMirrorURL(t *testing.T) {
mirrors := stringListToUrlList(t, []string{"http://127.0.0.1:5000"})
registries := stringListToUrlList(t, []string{"ftp://docker.io"})
err := AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false)
err := AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false, "", "")
require.EqualError(t, err, "invalid registry url scheme must be http or https: ftp://docker.io")
registries = stringListToUrlList(t, []string{"https://docker.io/foo/bar"})
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false)
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false, "", "")
require.EqualError(t, err, "invalid registry url path has to be empty: https://docker.io/foo/bar")
registries = stringListToUrlList(t, []string{"https://docker.io?foo=bar"})
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false)
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false, "", "")
require.EqualError(t, err, "invalid registry url query has to be empty: https://docker.io?foo=bar")
registries = stringListToUrlList(t, []string{"https://foo@docker.io"})
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false)
err = AddMirrorConfiguration(context.TODO(), fs, "/etc/containerd/certs.d", registries, mirrors, true, false, "", "")
require.EqualError(t, err, "invalid registry url user has to be empty: https://foo@docker.io")
}

View File

@ -33,6 +33,8 @@ type Registry struct {
router routing.Router
transport http.RoundTripper
localAddr string
username string
password string
resolveRetries int
resolveTimeout time.Duration
resolveLatestTag bool
@ -76,6 +78,13 @@ func WithLogger(log logr.Logger) Option {
}
}
func WithBasicAuth(username, password string) Option {
return func(r *Registry) {
r.username = username
r.password = password
}
}
func NewRegistry(ociClient oci.Client, router routing.Router, opts ...Option) *Registry {
r := &Registry{
ociClient: ociClient,
@ -170,6 +179,15 @@ func (r *Registry) readyHandler(rw mux.ResponseWriter, req *http.Request) {
}
func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) string {
// Check basic authentication
if r.username != "" || r.password != "" {
username, password, _ := req.BasicAuth()
if r.username != username || r.password != password {
rw.WriteError(http.StatusUnauthorized, errors.New("invalid basic authentication"))
return "registry"
}
}
// Quickly return 200 for /v2 to indicate that registry supports v2.
if path.Clean(req.URL.Path) == "/v2" {
rw.WriteHeader(http.StatusOK)

View File

@ -14,6 +14,81 @@ import (
"github.com/spegel-org/spegel/pkg/routing"
)
func TestBasicAuth(t *testing.T) {
t.Parallel()
tests := []struct {
name string
username string
password string
reqUsername string
reqPassword string
expected int
}{
{
name: "no registry authentication",
expected: http.StatusOK,
},
{
name: "unnecessary authentication",
reqUsername: "foo",
reqPassword: "bar",
expected: http.StatusOK,
},
{
name: "correct authentication",
username: "foo",
password: "bar",
reqUsername: "foo",
reqPassword: "bar",
expected: http.StatusOK,
},
{
name: "invalid username",
username: "foo",
password: "bar",
reqUsername: "wrong",
reqPassword: "bar",
expected: http.StatusUnauthorized,
},
{
name: "invalid password",
username: "foo",
password: "bar",
reqUsername: "foo",
reqPassword: "wrong",
expected: http.StatusUnauthorized,
},
{
name: "missing authentication",
username: "foo",
password: "bar",
expected: http.StatusUnauthorized,
},
{
name: "missing authentication",
username: "foo",
password: "bar",
expected: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
reg := NewRegistry(nil, nil, WithBasicAuth(tt.username, tt.password))
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://localhost/v2", nil)
req.SetBasicAuth(tt.reqUsername, tt.reqPassword)
m, err := mux.NewServeMux(reg.handle)
require.NoError(t, err)
m.ServeHTTP(rw, req)
require.Equal(t, tt.expected, rw.Result().StatusCode)
})
}
}
func TestMirrorHandler(t *testing.T) {
t.Parallel()