diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f15b74d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +`registry-auth` is a Go service that provides authentication and authorization for the [Docker Registry](https://github.com/distribution/distribution). It implements two of the Registry's auth flows: + +- **Token auth**: Registry-auth runs alongside the Registry. The client first hits the Registry, gets a 401 with the `WWW-Authenticate` URL, then exchanges Basic credentials at `/auth/token` for a JWT that the Registry verifies against the shared certificate. +- **Proxy auth**: Registry-auth sits in front of the Registry. It accepts Basic auth on `/v2/*`, mints a token internally, swaps it onto the request's `Authorization` header, and reverse-proxies to the Registry backend. + +Both flows share the same JWT signer; the Docker Registry must be configured with the same `rootcertbundle` and `issuer` as registry-auth's `--auth-public-cert-file` and `--auth-issuer`. + +## Common commands + +```bash +make build # build _output///registry-auth (CGO_ENABLED=0) +make test # go test -v -cover ./... +make fmt vet # gofmt + go vet +make image # docker build -> ghcr.io/alauda/registry-auth: +make all # fmt + vet + test + build + strip + +# Single test (no shortcut in Makefile) +go test -v ./pkg/server -run TestHandleAuth + +# End-to-end integration test — pulls a real registry binary + skopeo, runs +# registry-auth, push/pull image. Used by the Dockerfile's RUN_TEST stage. +# Requires curl, openssl, go, and root-ish perms to write /usr/bin. Run from repo root. +bash scripts/simple-tests.sh +``` + +The version string baked into the binary comes from `git describe`; building from a detached or dirty tree marks `GitTreeState=dirty`. + +## Architecture + +### Layered entrypoint + +`cmd/registry-auth/main.go` → `cmd/registry-auth/app/app.go` wires together option groups and calls `pkg/app.NewApp`. `pkg/app` is a small cobra+viper+pflag harness shared across Alauda apps: + +- `Options` (`pkg/server/options/interface.go`) aggregates a list of `Optioner`s; each option group owns its flags, viper bindings, and an `ApplyToServer` hook. +- `app.runCommand` calls `ApplyFlags` (CLI → struct), then `ApplyToServer` (struct → `*server.Server`), then `server.Start`. +- To add a new flag, add it to an existing options file under `pkg/server/options/` (or create a new `Optioner` and register it in `cmd/registry-auth/app/app.go`). + +### Server + +`pkg/server/server.go` holds the long-lived `Server`. `ApplyToServer` wires routes onto a `go-restful` container: + +- `GET /auth/token` → `HandleAuth` (token mode) +- `/v2/*` → `HandleProxy` (proxy mode; reverse-proxies to `--registry-backend`) +- `/health` → static OK + +Both modes funnel through `Server.signToken` (`pkg/server/handlers.go`), which calls `AuthProcessor.Authenticate` → `Authorize` → `Sign`. + +### AuthProcessor (`pkg/server/auth.go`) + +The single source of auth truth. It holds two parallel sets of user/auth maps: + +- `StaticUsers` / `StaticAuths` — populated from the YAML/JSON file (`--auth-config-file`). +- `SecretUsers` / `SecretAuths` — populated from Kubernetes Secrets (`--auth-config-namespace` + `--auth-config-selector`). + +**Lookup order** in both `Authenticate` and `Authorize`: Secret first, then Static, then third-party (`pkg/server/thirdparty.go`). If a request's user has no matching authorization, `signToken` retries authorization as the special `_anonymous` user — that's how unauthenticated pulls are configured. + +Password verification supports three formats in this priority: bcrypt (`$...`), reserved `PBKDF2:` prefix (not implemented), and plaintext fallback. Use `htpasswd -nbB user pass` to generate bcrypt hashes. + +`DecodeScope` parses the `scope=` query parameter (token mode). `DecodeScopeFromUrl` infers `type:name:actions` from the URL/method (proxy mode); HTTP methods map to actions as GET/HEAD→`pull`, PUT/PATCH/POST→`push`, DELETE→`delete`. `/v2/_catalog` uses `registry:catalog:*`. + +In proxy mode `IsScopeActionMatch` short-circuits: if the request's actions are already a subset of what was granted, `signToken` returns `ErrNotHandleAuthHeader` and the handler leaves the existing `Authorization` header alone (avoids re-signing on every blob request). + +### Config sources (all watched live) + +- `file.go` — `fsnotify` watcher on `--auth-config-file`. Calls `LoadFromFile` on Create/Write events. +- `secret.go` — client-go informer over Secrets in `--auth-config-namespace` matching `--auth-config-selector` (default `registry-auth-config=true`). The Secret's `config` key contains the same YAML schema as the file. Add/Update/Delete events are reflected into `SecretUsers`/`SecretAuths` via `LoadFromSecret`. +- `thirdparty.go` — `--auth-thirdparty-server` URL. Registry-auth POSTs Basic-auth'd login requests there; the response is a list of `Authorization` entries cached per user. Only consulted when the user is in neither map. + +### JWT signing + +`NewAuthProcessor` parses the public cert (`loadCert`) and constructs a `jose.Signer` with RS256, emitting the signing cert as a JWS `x5c` header (`encoding/base64` StdEncoding of `cert.Raw`). The private key must be RSA. Claims include `iss`, `sub`, `aud` (from `?service=` or `--service`), `exp`, `nbf`, `iat`, `jti`, and the `access` array. + +Why `x5c` and not `kid`: distribution v2 keys its `trustedKeys` map by `libtrust.KeyID()` (libtrust is archived), while distribution v3 keys it by RFC 7638 JWK thumbprint — the two formats don't agree. Both versions, however, first verify the JWT's `x5c` cert chain against the `rootcertbundle`, so emitting `x5c` makes tokens accepted by both v2.x and v3.x without depending on libtrust. + +## Configuration schema + +`auth-config-file` and the `config` key of watched Secrets share this YAML schema: + +```yaml +users: + user1: plaintext-password + user2: $2y$05$...bcrypt-hash +auths: + user1: + - target: repo/path # literal match + actions: [pull, push] + - target: team/.* # regex match + useRegexp: true + actions: [pull] + _anonymous: # fallthrough for unauthenticated or unmatched users + - target: .* + useRegexp: true + actions: [pull] +``` + +`Authorization.Type` defaults to `repository` if omitted; set it to `registry` for catalog-scoped grants. + +## Notes / gotchas + +- `BasicOptions.ApplyToServer` (`pkg/server/options/basic.go:130`) assigns `o.AuthConfigNamespace` into `server.BasicConfig.AuthConfigLabelSelector` — looks like a copy-paste bug. Selector configured via the flag will not reach the server. If you touch this code, fix to use `o.AuthConfigLabelSelector`. +- `AuthProcessor.LoadFromSecret` (`pkg/server/auth.go:295`) reads `dataOld[SecretKey]` when populating new entries (should be `dataNew`). Updates to Secrets may not behave as expected — verify before relying on dynamic Secret updates. +- The `go.mod` pins `k8s.io/client-go` to `v0.24.4` via `replace`; other `k8s.io/*` deps must stay compatible with that version. +- `pkg/server/auth.go` still uses `io/ioutil`; that's fine, just don't "modernize" mid-task without reason. +- Tokens are verified via `x5c` (not `kid`). Don't reintroduce `libtrust` or a `kid` header without also handling v2/v3 thumbprint differences — see the JWT signing section above. diff --git a/README.md b/README.md index 29e464d..df4aebb 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,9 @@ data: The Docker Registry need configure to work with Registry-Auth. You can configure it by config file or environment variables. -You can add the following configs in /etc/docker/registry/config.yml: +Registry-Auth works with both `distribution/distribution` v2.x and v3.x. The token configuration keys (`realm`, `service`, `issuer`, `rootcertbundle`, `autoredirect`) and `REGISTRY_AUTH_TOKEN_*` environment variables are identical across both versions. Note that v3 changed the default config path from `/etc/docker/registry/config.yml` to `/etc/distribution/config.yml`. + +You can add the following configs in /etc/docker/registry/config.yml (v2) or /etc/distribution/config.yml (v3): ``` yaml auth: diff --git a/README_zh.md b/README_zh.md index 55ca671..ecf4245 100644 --- a/README_zh.md +++ b/README_zh.md @@ -157,7 +157,9 @@ data: 当使用 Registry-Auth 时,Docker Registry 也要做必要的设置。 你可以选择通过 **配置文件** 或 **环境变量** 的方式配置 Docker Registry。 -你可以在 /etc/docker/registry/config.yml 中配置如下字段: +Registry-Auth 同时兼容 `distribution/distribution` v2.x 与 v3.x,token 段的配置项(`realm`、`service`、`issuer`、`rootcertbundle`、`autoredirect`)与 `REGISTRY_AUTH_TOKEN_*` 环境变量两版完全一致。注意 v3 的默认配置路径由 `/etc/docker/registry/config.yml` 改为 `/etc/distribution/config.yml`。 + +你可以在 /etc/docker/registry/config.yml(v2)或 /etc/distribution/config.yml(v3)中配置如下字段: ``` yaml auth: diff --git a/go.mod b/go.mod index 0ce4f47..a4f5b3f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ toolchain go1.24.11 replace k8s.io/client-go => k8s.io/client-go v0.24.4 require ( - github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 github.com/emicklei/go-restful v2.16.0+incompatible github.com/fatih/color v1.13.0 github.com/google/uuid v1.1.2 diff --git a/go.sum b/go.sum index 3279df4..4e85dc1 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= diff --git a/pkg/server/auth.go b/pkg/server/auth.go index fc56b1f..077fab5 100644 --- a/pkg/server/auth.go +++ b/pkg/server/auth.go @@ -1,7 +1,6 @@ package server import ( - "crypto" "crypto/rsa" "crypto/x509" "encoding/base64" @@ -19,7 +18,6 @@ import ( "github.com/thoas/go-funk" - "github.com/docker/libtrust" "github.com/google/uuid" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -63,9 +61,9 @@ type UserAuthorization struct { } type ClaimAccess struct { - Type string - Name string - Actions []string + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` } type AccessScope []ClaimAccess @@ -81,7 +79,7 @@ type AuthProcessor struct { ThirdpartyAuth ThirdpartyAuth signer jose.Signer - kid string + cert *x509.Certificate } type ConfigFile struct { @@ -195,7 +193,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati Issuer: issuer, TokenDuration: time.Second * time.Duration(tokenDuration), } - if err := a.getKid(publicCertFile); err != nil { + if err := a.loadCert(publicCertFile); err != nil { return nil, err } @@ -206,7 +204,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati return a, nil } -func (a *AuthProcessor) getKid(publicCertFile string) error { +func (a *AuthProcessor) loadCert(publicCertFile string) error { data, err := ioutil.ReadFile(publicCertFile) if err != nil { return err @@ -221,12 +219,7 @@ func (a *AuthProcessor) getKid(publicCertFile string) error { return fmt.Errorf("parse certificate '%s' error: %v ", publicCertFile, err) } - pubKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey)) - if err != nil { - return fmt.Errorf("parse public key '%s' error: %v ", publicCertFile, err) - } - - a.kid = pubKey.KeyID() + a.cert = cert return nil } @@ -245,12 +238,15 @@ func (a *AuthProcessor) generateSigner(privateKeyFile string) error { return fmt.Errorf("the private key is not in RSA format") } - op := &jose.SignerOptions{} + op := (&jose.SignerOptions{}).WithHeader( + jose.HeaderKey("x5c"), + []string{base64.StdEncoding.EncodeToString(a.cert.Raw)}, + ) a.signer, err = jose.NewSigner(jose.SigningKey{ Algorithm: jose.RS256, Key: rsaPrivKey, - }, op.WithHeader(jose.HeaderKey("kid"), a.kid)) + }, op) return err } diff --git a/pkg/server/options/basic.go b/pkg/server/options/basic.go index 0772382..0b59bc2 100644 --- a/pkg/server/options/basic.go +++ b/pkg/server/options/basic.go @@ -40,7 +40,7 @@ type BasicOptions struct { config.BasicConfig } -//// NewConsoleOptions creates a ConsoleOptions object with default parameters. +// // NewConsoleOptions creates a ConsoleOptions object with default parameters. func NewBasicOptions() *BasicOptions { return &BasicOptions{} } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index abb4e43..5f5b373 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -1 +1,400 @@ package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + jose "gopkg.in/go-jose/go-jose.v2" + "gopkg.in/go-jose/go-jose.v2/jwt" +) + +func TestMain(m *testing.M) { + // parseAuths / file watch paths call logger.Error on errors; make it a no-op for tests. + logger = zap.NewNop() + os.Exit(m.Run()) +} + +func generateCertPair(t *testing.T, dir string) (certPath, keyPath string, cert *x509.Certificate) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "registry-auth-test"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + cert, err = x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + certPath = filepath.Join(dir, "token.crt") + keyPath = filepath.Join(dir, "token.key") + if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil { + t.Fatal(err) + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER}), 0600); err != nil { + t.Fatal(err) + } + return +} + +func newTestProcessor(t *testing.T) *AuthProcessor { + t.Helper() + certPath, keyPath, _ := generateCertPair(t, t.TempDir()) + p, err := NewAuthProcessor(keyPath, certPath, "test-issuer", 600) + if err != nil { + t.Fatalf("NewAuthProcessor: %v", err) + } + return p +} + +// TestSign_EmitsX5cHeaderAndVerifies is the key regression test for the v3 compatibility +// change: tokens must carry an `x5c` JWS header (and no `kid`) so that both distribution +// v2.8.1 (parseAndVerifyCertChain) and v3.0.0 (verifyCertChain) verify them against the +// rootcertbundle. +func TestSign_EmitsX5cHeaderAndVerifies(t *testing.T) { + certPath, keyPath, cert := generateCertPair(t, t.TempDir()) + p, err := NewAuthProcessor(keyPath, certPath, "iss", 600) + if err != nil { + t.Fatalf("NewAuthProcessor: %v", err) + } + + scope := AccessScope{{Type: RepositoryAccessType, Name: "foo/bar", Actions: []string{PullAction}}} + tok, err := p.Sign("alice", "svc", scope) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + // 1. Raw protected header must carry x5c (and must not carry kid). + parts := strings.Split(tok.Token, ".") + if len(parts) != 3 { + t.Fatalf("unexpected JWT segment count: %d", len(parts)) + } + rawHdr, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("decode header: %v", err) + } + if !strings.Contains(string(rawHdr), `"x5c":`) { + t.Fatalf("expected x5c header, got %s", rawHdr) + } + if strings.Contains(string(rawHdr), `"kid":`) { + t.Fatalf("unexpected kid header: %s", rawHdr) + } + + // 2. Reparse and verify the cert chain against the cert as root — mirrors the + // distribution v2/v3 verify path. + jws, err := jose.ParseSigned(tok.Token) + if err != nil { + t.Fatalf("ParseSigned: %v", err) + } + h := jws.Signatures[0].Header + roots := x509.NewCertPool() + roots.AddCert(cert) + chains, err := h.Certificates(x509.VerifyOptions{ + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }) + if err != nil { + t.Fatalf("Header.Certificates verify: %v", err) + } + if len(chains) == 0 || len(chains[0]) == 0 { + t.Fatal("no verified chain returned") + } + + // 3. Validate signature and check claim contents. + parsed, err := jwt.ParseSigned(tok.Token) + if err != nil { + t.Fatalf("ParseSigned (jwt): %v", err) + } + var claims Claims + if err := parsed.Claims(cert.PublicKey, &claims); err != nil { + t.Fatalf("Claims verify: %v", err) + } + if claims.Issuer != "iss" { + t.Fatalf("iss = %q", claims.Issuer) + } + if claims.Subject != "alice" { + t.Fatalf("sub = %q", claims.Subject) + } + if claims.Audience != "svc" { + t.Fatalf("aud = %q", claims.Audience) + } + if len(claims.Access) != 1 || claims.Access[0].Name != "foo/bar" { + t.Fatalf("access = %+v", claims.Access) + } + + // distribution v3 uses go-jose v3's JSON fork which (unlike encoding/json) + // does NOT fall back to a case-insensitive field match, so the access + // claim must serialize with lowercase keys (type/name/actions). Decode the + // raw JWT payload and assert that. + rawPayload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + if !strings.Contains(string(rawPayload), `"type":"repository"`) || + !strings.Contains(string(rawPayload), `"name":"foo/bar"`) || + !strings.Contains(string(rawPayload), `"actions":["pull"]`) { + t.Fatalf("access claim must use lowercase keys (type/name/actions); got payload: %s", rawPayload) + } +} + +func basicHeader(user, pass string) string { + return BasicPrefix + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +func TestAuthenticate_EmptyHeaderIsAnonymous(t *testing.T) { + p := newTestProcessor(t) + user, err := p.Authenticate("") + if err != nil { + t.Fatalf("err: %v", err) + } + if user != AnonymousUser { + t.Fatalf("want %q, got %q", AnonymousUser, user) + } +} + +func TestAuthenticate_Plaintext(t *testing.T) { + p := newTestProcessor(t) + p.StaticUsers = map[string]string{"alice": "wonderland"} + got, err := p.Authenticate(basicHeader("alice", "wonderland")) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "alice" { + t.Fatalf("got %q", got) + } +} + +func TestAuthenticate_Bcrypt(t *testing.T) { + p := newTestProcessor(t) + hash, err := bcrypt.GenerateFromPassword([]byte("s3cret"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + p.StaticUsers = map[string]string{"bob": string(hash)} + got, err := p.Authenticate(basicHeader("bob", "s3cret")) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "bob" { + t.Fatalf("got %q", got) + } +} + +func TestAuthenticate_WrongPassword(t *testing.T) { + p := newTestProcessor(t) + p.StaticUsers = map[string]string{"alice": "wonderland"} + if _, err := p.Authenticate(basicHeader("alice", "nope")); err != ErrAuthFailed { + t.Fatalf("want ErrAuthFailed, got %v", err) + } +} + +func TestAuthenticate_UnknownUserNoThirdparty(t *testing.T) { + p := newTestProcessor(t) + if _, err := p.Authenticate(basicHeader("ghost", "x")); err != ErrAuthFailed { + t.Fatalf("want ErrAuthFailed, got %v", err) + } +} + +func TestLoadFromFile_YAMLAndAuthorize(t *testing.T) { + p := newTestProcessor(t) + cfg := []byte(`users: + alice: pwd1 +auths: + alice: + - target: foo/bar + actions: [pull] + - target: "team/.*" + useRegexp: true + actions: [pull, push] + _anonymous: + - target: "public/.*" + useRegexp: true + actions: [pull] +`) + if err := p.LoadFromFile(cfg); err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + + if p.StaticUsers["alice"] != "pwd1" { + t.Fatalf("users: %+v", p.StaticUsers) + } + + // Default Type filled in by parseAuths. + if p.StaticAuths["alice"][0].Type != RepositoryAccessType { + t.Fatalf("expected default repository type, got %q", p.StaticAuths["alice"][0].Type) + } + // Regex compiled. + if p.StaticAuths["alice"][1].regexp == nil { + t.Fatalf("expected regexp compiled for team/.*") + } + + // Literal match. + got := p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "foo/bar"}}) + if len(got) != 1 || got[0].Actions[0] != PullAction { + t.Fatalf("literal authorize: %+v", got) + } + + // Regex match. + got = p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "team/svc"}}) + if len(got) != 1 || len(got[0].Actions) != 2 { + t.Fatalf("regex authorize: %+v", got) + } + + // No matching rule for user → nil. + if got := p.Authorize("alice", AccessScope{{Type: RepositoryAccessType, Name: "other/repo"}}); got != nil { + t.Fatalf("expected nil, got %+v", got) + } + + // Anonymous fallback rule reachable for public/* with the "_anonymous" key. + got = p.Authorize(AnonymousUser, AccessScope{{Type: RepositoryAccessType, Name: "public/x"}}) + if len(got) != 1 || got[0].Actions[0] != PullAction { + t.Fatalf("anonymous authorize: %+v", got) + } +} + +func TestDecodeScope_FromQuery(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token?service=svc&scope=repository:foo/bar:pull,push", nil) + got, err := DecodeScope(req) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d entries", len(got)) + } + if got[0].Type != RepositoryAccessType || got[0].Name != "foo/bar" { + t.Fatalf("got %+v", got[0]) + } + if len(got[0].Actions) != 2 || got[0].Actions[0] != PullAction || got[0].Actions[1] != PushAction { + t.Fatalf("actions: %+v", got[0].Actions) + } +} + +func TestDecodeScope_Empty(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token", nil) + got, err := DecodeScope(req) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Fatalf("expected nil scope, got %+v", got) + } +} + +func TestDecodeScope_Malformed(t *testing.T) { + req := httptest.NewRequest("GET", "http://x/auth/token?scope=garbage", nil) + if _, err := DecodeScope(req); err == nil { + t.Fatal("expected error for malformed scope") + } +} + +func TestDecodeScopeFromUrl(t *testing.T) { + cases := []struct { + name string + method string + path string + wantType string + wantName string + wantAction string + wantErr bool + }{ + {"base", "GET", "/v2/", "", "", "", false}, + {"catalog", "GET", "/v2/_catalog", RegistryAccessType, "catalog", CatalogAction, false}, + {"manifest_pull", "GET", "/v2/foo/bar/manifests/v1.0.0", RepositoryAccessType, "foo/bar", PullAction, false}, + {"manifest_push", "PUT", "/v2/foo/bar/manifests/v1", RepositoryAccessType, "foo/bar", PushAction, false}, + {"blob_pull", "GET", "/v2/foo/bar/blobs/sha256:deadbeef", RepositoryAccessType, "foo/bar", PullAction, false}, + {"blob_push", "POST", "/v2/foo/bar/blobs/uploads/", RepositoryAccessType, "foo/bar", PushAction, false}, + {"tags_list", "GET", "/v2/foo/bar/tags/list", RepositoryAccessType, "foo/bar", PullAction, false}, + {"manifest_delete", "DELETE", "/v2/foo/bar/manifests/sha256:abc", RepositoryAccessType, "foo/bar", DeleteAction, false}, + {"unknown", "GET", "/v2/foo/bar/weird/x", "", "", "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(c.method, "http://x"+c.path, nil) + got, err := DecodeScopeFromUrl(req) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + return + } + if err != nil { + t.Fatalf("err: %v", err) + } + if c.path == "/v2/" { + if got != nil { + t.Fatalf("base path: expected nil, got %+v", got) + } + return + } + if len(got) != 1 { + t.Fatalf("got %d entries", len(got)) + } + if got[0].Type != c.wantType { + t.Fatalf("type: want %q, got %q", c.wantType, got[0].Type) + } + if got[0].Name != c.wantName { + t.Fatalf("name: want %q, got %q", c.wantName, got[0].Name) + } + if len(got[0].Actions) != 1 || got[0].Actions[0] != c.wantAction { + t.Fatalf("actions: want [%s], got %+v", c.wantAction, got[0].Actions) + } + }) + } +} + +func TestIsScopeActionMatch(t *testing.T) { + // /v2/ always matches (auth bypass for the ping endpoint). + if !IsScopeActionMatch( + httptest.NewRequest("GET", "http://x/v2/", nil), + nil, nil, + ) { + t.Fatal("/v2/ should always match") + } + + // Requested actions are a subset of result → match. + if !IsScopeActionMatch( + httptest.NewRequest("GET", "http://x/v2/foo/manifests/v1", nil), + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction, PushAction}}}, + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction}}}, + ) { + t.Fatal("subset should match") + } + + // Requested includes push but result only has pull → no match. + if IsScopeActionMatch( + httptest.NewRequest("PUT", "http://x/v2/foo/blobs/uploads/x", nil), + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PullAction}}}, + AccessScope{{Type: RepositoryAccessType, Name: "foo", Actions: []string{PushAction}}}, + ) { + t.Fatal("non-subset should not match") + } +} diff --git a/scripts/simple-tests.sh b/scripts/simple-tests.sh index 1c75c25..91dbf20 100644 --- a/scripts/simple-tests.sh +++ b/scripts/simple-tests.sh @@ -1,5 +1,5 @@ #!/bin/bash -REGISTRY_VERSION=${REGISTRY_VERSION:-"v2.8.1"} +REGISTRY_VERSIONS=${REGISTRY_VERSIONS:-"v2.8.1 v3.0.0"} SKOPEO_VERSION=${SKOPEO_VERSION:-"v1.10.0"} ADMIN_PASSWORD=${ADMIN_PASSWORD:-"$(head -c 20 /dev/random | base64 -w 0)"} @@ -12,17 +12,31 @@ function prepare_certs() { openssl req -new -newkey rsa:2048 -days 365 -x509 -keyout token.key -out token.crt -nodes -subj '/CN=registry-auth-token' } -function prepare_registry() { - local url="https://github.com/distribution/distribution/releases/download/${REGISTRY_VERSION}/registry_${REGISTRY_VERSION:1}_linux_$(go env GOARCH).tar.gz" +function install_registry() { + local version="$1" + local url="https://github.com/distribution/distribution/releases/download/${version}/registry_${version:1}_linux_$(go env GOARCH).tar.gz" curl -L "${url}" | tar xvz -C /usr/bin/ --exclude LICENSE --exclude READEME.md - pwd - ls +} + +function start_registry() { + rm -rf /var/lib/registry REGISTRY_AUTH_TOKEN_AUTOREDIRECT=true \ REGISTRY_AUTH_TOKEN_REALM=/auth/token \ REGISTRY_AUTH_TOKEN_SERVICE=token-service \ REGISTRY_AUTH_TOKEN_ISSUER=registry-token-issuer \ REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=token.crt \ /usr/bin/registry serve registry.yaml & + REGISTRY_PID=$! + # give it a moment to bind :5000 + sleep 2 +} + +function stop_registry() { + if [ -n "${REGISTRY_PID}" ]; then + kill "${REGISTRY_PID}" 2>/dev/null || true + wait "${REGISTRY_PID}" 2>/dev/null || true + REGISTRY_PID="" + fi } function prepare_skopeo() { @@ -62,47 +76,62 @@ function prepare_registry_auth() { --auth-config-file=auth.yaml & } -function do_tests() { +function prepare_source_image() { + # Pull the upstream image into a docker-archive once. `docker-archive:` does + # not allow overwriting an existing file, so we cannot run this per-version + # inside the loop. + if [ ! -f /tmp/registry-src.tar ]; then + skopeo copy --insecure-policy --override-os=linux \ + docker://"registry:2.8.1" \ + docker-archive:"/tmp/registry-src.tar" + fi +} - # prepare artificat - skopeo copy --insecure-policy --override-os=linux \ - docker://"registry:${REGISTRY_VERSION:1}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ +function do_tests() { + local version="$1" + # version-tagged repo so different registry runs don't share state via the + # client-side archive name only — push target encodes the version too. + local repo_tag="registry-${version}" - # push expecting fail + # push expecting fail (no creds) set +e skopeo copy --insecure-policy --dest-tls-verify=false \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" + docker-archive:"/tmp/registry-src.tar" \ + docker://"127.0.0.1:8080/${repo_tag}:latest" if [ "$?" -eq "0" ]; then - echo "push without auth failed" - exit -1 + echo "push without auth against ${version} succeeded unexpectedly" + exit 1 fi set -e # push with auth skopeo copy --insecure-policy --dest-tls-verify=false --dest-creds="admin:${ADMIN_PASSWORD}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}.tar" \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" \ + docker-archive:"/tmp/registry-src.tar" \ + docker://"127.0.0.1:8080/${repo_tag}:latest" - # pull + # pull (anonymous, since _anonymous can pull) skopeo copy --insecure-policy --src-tls-verify=false \ - docker://"127.0.0.1:8080/registry:${REGISTRY_VERSION}" \ - docker-archive:"/tmp/registry-${REGISTRY_VERSION}-local.tar" - - - diff "/tmp/registry-${REGISTRY_VERSION}-local.tar" "/tmp/registry-${REGISTRY_VERSION}.tar" + docker://"127.0.0.1:8080/${repo_tag}:latest" \ + docker-archive:"/tmp/registry-pulled-${version}.tar" + diff "/tmp/registry-pulled-${version}.tar" "/tmp/registry-src.tar" } function main() { prepare_certs - prepare_registry prepare_skopeo prepare_auth prepare_registry_auth - do_tests + prepare_source_image + + for version in ${REGISTRY_VERSIONS}; do + echo "=== testing against distribution ${version} ===" + install_registry "${version}" + start_registry + do_tests "${version}" + stop_registry + done } main