feat(registry): 写入改走鉴权网关,构建先扫描再推送

This commit is contained in:
Lemon-miaow committed 2026-09-24 14:25:17 +08:00
1 parent 8f684cedc0
commit 3424852a39
16 files changed
+1814 -145

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
// Package registrygate is the write-authorization front of the in-cluster image
// registry. registry:2 runs with no auth of its own and listens on the pod's
// loopback only; this gate owns the registry port and forwards to it.
//
// Why a gate rather than registry:2's own htpasswd auth: htpasswd is all-or-nothing
// (every principal may write every repository, and anonymous pulls stop working),
// while the platform needs two distinct writers and anonymous reads:
//
// - reads (GET/HEAD) stay anonymous, because the node's containerd pulls through
// the loopback hostPort, Kaniko pulls FROM images and Trivy pulls its DB mirror,
// and none of them should carry a credential;
// - the "platform" principal (the installer) may write anything;
// - the "build" principal (the push step of a build Job) may write any repository
// outside the platform-reserved ones (felis/…, mirror/…), and may not delete.
//
// Before this gate any pod that could reach the registry — a game server running a
// tenant's plugin, or a Dockerfile RUN step inside Kaniko — could overwrite
// felis/felis and take over the control plane on its next pull.
package registrygate
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
)
// Principal names. They are the basic-auth usernames and the file names under the
// gate's auth directory (cmd/felis registry-gate --auth-dir).
const (
PrincipalPlatform = "platform"
PrincipalBuild = "build"
)
// ReservedRepoRoots are the first path components the build principal may never
// write: felis/ holds the control-plane and game images the platform runs, mirror/
// holds the Trivy DB mirrors the scan gate trusts. A build that could overwrite
// either would own the platform or blind its own scanner.
var ReservedRepoRoots = []string{"felis", "mirror"}
// Realm is the basic-auth realm the gate challenges with.
const Realm = "felis-registry"
// Gate authorizes registry requests and proxies the allowed ones upstream.
type Gate struct {
// Tokens maps a principal to its secret. A principal with an empty or missing
// token cannot authenticate: writes fail closed while reads keep working.
Tokens map[string]string
// Upstream is the loopback registry, e.g. http://127.0.0.1:5001.
Upstream *url.URL
// Log receives one line per refused write. Nil discards.
Log *slog.Logger
proxy *httputil.ReverseProxy
health *http.Client
}
// New builds a Gate for upstream.
func New(upstream *url.URL, tokens map[string]string, log *slog.Logger) *Gate {
g := &Gate{Tokens: tokens, Upstream: upstream, Log: log}
rp := httputil.NewSingleHostReverseProxy(upstream)
base := rp.Director
rp.Director = func(r *http.Request) {
base(r)
// The registry has no auth of its own; the credential stops here.
r.Header.Del("Authorization")
}
// Blob uploads and pulls are streamed; flush as bytes arrive so a large layer
// pull is not buffered in the gate.
rp.FlushInterval = -1
g.proxy = rp
g.health = &http.Client{Timeout: 3 * time.Second}
return g
}
// ServeHTTP implements http.Handler.
func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/livez":
w.WriteHeader(http.StatusOK)
return
case "/healthz":
g.serveHealth(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, "/v2/") && r.URL.Path != "/v2" {
writeError(w, http.StatusNotFound, "NAME_UNKNOWN", "not a registry API path")
return
}
// The gate and the registry must read the same path, or an authorization check
// on one repository could be spent on another. Refuse every shape that a later
// clean-up or decode could turn into a different path.
if !canonicalPath(r) {
writeError(w, http.StatusBadRequest, "NAME_INVALID", "non-canonical request path")
return
}
principal, authErr := g.authenticate(r)
if authErr != nil {
challenge(w, "invalid credentials")
return
}
switch r.Method {
case http.MethodGet, http.MethodHead:
// The API root is where Docker-compatible clients learn which auth scheme
// the registry wants; the daemon sends credentials on later writes only if
// this answer challenged it. Everything else stays anonymous for reads.
if isAPIRoot(r.URL.Path) && principal == "" {
challenge(w, "authentication required")
return
}
g.proxy.ServeHTTP(w, r)
return
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
writeError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
return
}
if principal == "" {
challenge(w, "authentication required")
return
}
if reason := Authorize(principal, r.Method, r.URL.Path); reason != "" {
if g.Log != nil {
g.Log.Warn("registry write refused", "principal", principal, "method", r.Method, "path", r.URL.Path, "reason", reason)
}
writeError(w, http.StatusForbidden, "DENIED", reason)
return
}
g.proxy.ServeHTTP(w, r)
}
// authenticate returns the principal the request's basic credentials name, "" for
// an anonymous request, and an error for credentials that do not verify.
func (g *Gate) authenticate(r *http.Request) (string, error) {
if r.Header.Get("Authorization") == "" {
return "", nil
}
user, pass, ok := r.BasicAuth()
if !ok {
return "", fmt.Errorf("malformed authorization")
}
want, known := g.Tokens[user]
if !known || want == "" {
// Still spend a comparison so an unknown user is not faster to refuse.
subtle.ConstantTimeCompare([]byte(pass), []byte("x"))
return "", fmt.Errorf("unknown principal")
}
if subtle.ConstantTimeCompare([]byte(pass), []byte(want)) != 1 {
return "", fmt.Errorf("bad secret")
}
return user, nil
}
// Authorize decides whether an authenticated principal may send a write request
// for path. It returns "" to allow, or the refusal reason.
func Authorize(principal, method, path string) string {
switch principal {
case PrincipalPlatform:
return ""
case PrincipalBuild:
if method == http.MethodDelete {
return "the build principal may not delete"
}
repo := RepoFromPath(path)
if repo == "" {
return "the build principal may only write to a repository"
}
root, _, _ := strings.Cut(repo, "/")
for _, reserved := range ReservedRepoRoots {
if root == reserved {
return fmt.Sprintf("repository %s/ is reserved for the platform", reserved)
}
}
return ""
default:
return "unknown principal"
}
}
// RepoFromPath extracts the repository name from a registry API v2 path, or ""
// when the path addresses no repository (/v2/, /v2/_catalog). The shapes are the
// distribution API's: <name>/manifests/<ref>, <name>/blobs/<digest>,
// <name>/blobs/uploads/[<uuid>], <name>/tags/list, <name>/referrers/<digest>.
// Neither a reference, a digest nor an upload id contains '/', so the repository
// is everything before the fixed tail.
func RepoFromPath(path string) string {
rest, ok := strings.CutPrefix(path, "/v2/")
if !ok {
return ""
}
seg := strings.Split(rest, "/")
n := len(seg)
switch {
case n >= 4 && seg[n-3] == "blobs" && seg[n-2] == "uploads":
return strings.Join(seg[:n-3], "/")
case n >= 3 && (seg[n-2] == "manifests" || seg[n-2] == "blobs" || seg[n-2] == "tags" || seg[n-2] == "referrers"):
return strings.Join(seg[:n-2], "/")
}
return ""
}
// canonicalPath rejects any request path the upstream could read differently from
// the gate: percent-escapes Go would decode (RawPath set), dot segments a router
// would clean, and empty segments other than the trailing slash of an upload POST.
func canonicalPath(r *http.Request) bool {
if r.URL.RawPath != "" && r.URL.RawPath != r.URL.Path {
return false
}
p := r.URL.Path
if strings.ContainsAny(p, "%\\") {
return false
}
seg := strings.Split(strings.TrimPrefix(p, "/"), "/")
for i, s := range seg {
if s == "." || s == ".." {
return false
}
if s == "" && i != len(seg)-1 {
return false
}
}
return true
}
func isAPIRoot(p string) bool { return p == "/v2/" || p == "/v2" }
// serveHealth answers 200 when the upstream registry answers its API root, the
// check registry:2's own probes used before the gate took its port.
func (g *Gate) serveHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.Upstream.JoinPath("/v2/").String(), nil)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
resp, err := g.health.Do(req)
if err != nil {
http.Error(w, "upstream: "+err.Error(), http.StatusServiceUnavailable)
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, fmt.Sprintf("upstream answered %d", resp.StatusCode), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
func challenge(w http.ResponseWriter, msg string) {
w.Header().Set("WWW-Authenticate", `Basic realm="`+Realm+`"`)
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", msg)
}
// writeError answers in the registry's own error envelope, which Docker and
// containerd both surface verbatim to the operator.
func writeError(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]string{{"code": code, "message": msg}},
})
}
+258
View File
@@ -0,0 +1,258 @@
package registrygate
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
type upstreamLog struct {
mu sync.Mutex
seen []string
auth []string
}
func (u *upstreamLog) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.seen = append(u.seen, r.Method+" "+r.URL.RequestURI())
u.auth = append(u.auth, r.Header.Get("Authorization"))
u.mu.Unlock()
switch r.Method {
case http.MethodPost:
w.Header().Set("Location", "/v2/x/blobs/uploads/abc")
w.WriteHeader(http.StatusAccepted)
case http.MethodPut:
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusOK)
}
})
}
func (u *upstreamLog) count() int {
u.mu.Lock()
defer u.mu.Unlock()
return len(u.seen)
}
func newGate(t *testing.T) (*httptest.Server, *upstreamLog) {
t.Helper()
up := &upstreamLog{}
upSrv := httptest.NewServer(up.handler())
t.Cleanup(upSrv.Close)
target, _ := url.Parse(upSrv.URL)
g := New(target, map[string]string{PrincipalPlatform: "plat-secret", PrincipalBuild: "build-secret"}, nil)
gs := httptest.NewServer(g)
t.Cleanup(gs.Close)
return gs, up
}
func do(t *testing.T, srv *httptest.Server, method, path, user, pass string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(""))
if err != nil {
t.Fatal(err)
}
if user != "" {
req.SetBasicAuth(user, pass)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
func TestAnonymousReadsPassButTheAPIRootChallenges(t *testing.T) {
gs, up := newGate(t)
for _, p := range []string{
"/v2/felis/felis/manifests/v0.1.0",
"/v2/felis/felis/blobs/sha256:abc",
"/v2/mirror/trivy-db/manifests/2",
"/v2/_catalog",
"/v2/user-uploads/s1/tags/list",
} {
for _, m := range []string{http.MethodGet, http.MethodHead} {
if resp := do(t, gs, m, p, "", ""); resp.StatusCode != http.StatusOK {
t.Errorf("%s %s anonymous = %d, want 200", m, p, resp.StatusCode)
}
}
}
// Docker's daemon only sends credentials on a push if the API root challenged
// it, so the root must say 401 + Basic to anonymous callers.
resp := do(t, gs, http.MethodGet, "/v2/", "", "")
if resp.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Basic ") {
t.Fatalf("anonymous GET /v2/ = %d %q, want 401 Basic challenge", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
}
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated GET /v2/ = %d, want 200", resp.StatusCode)
}
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "wrong"); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("GET /v2/ with a bad secret = %d, want 401", resp.StatusCode)
}
_ = up
}
func TestAnonymousWritesNeverReachTheRegistry(t *testing.T) {
gs, up := newGate(t)
for _, c := range []struct{ method, path string }{
{http.MethodPost, "/v2/felis/felis/blobs/uploads/"},
{http.MethodPut, "/v2/felis/felis/manifests/v0.1.0"},
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc"},
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc"},
} {
resp := do(t, gs, c.method, c.path, "", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("anonymous %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
}
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "not-it"); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("bad-secret %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
}
if resp := do(t, gs, c.method, c.path, "intruder", "plat-secret"); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unknown-user %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
}
}
if n := up.count(); n != 0 {
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
}
}
func TestBuildPrincipalIsFencedOffPlatformRepos(t *testing.T) {
gs, up := newGate(t)
for _, p := range []string{
"/v2/felis/felis/manifests/v0.1.0",
"/v2/felis/limbo/blobs/uploads/",
"/v2/felis/manifests/latest",
"/v2/mirror/trivy-db/manifests/2",
"/v2/mirror/trivy-java-db/blobs/uploads/abc",
} {
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch} {
if resp := do(t, gs, m, p, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
t.Errorf("build %s %s = %d, want 403", m, p, resp.StatusCode)
}
}
}
if resp := do(t, gs, http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
t.Errorf("build DELETE = %d, want 403", resp.StatusCode)
}
if n := up.count(); n != 0 {
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
}
for _, c := range []struct {
method, path string
want int
}{
{http.MethodPost, "/v2/user-uploads/s1/blobs/uploads/", http.StatusAccepted},
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc", http.StatusOK},
{http.MethodPut, "/v2/user-uploads/s1/blobs/uploads/abc?digest=sha256:0", http.StatusCreated},
{http.MethodPut, "/v2/user-uploads/s1/manifests/latest", http.StatusCreated},
{http.MethodPut, "/v2/modpacks/pack/manifests/1.0", http.StatusCreated},
// A repository merely containing "felis" deeper down is not reserved.
{http.MethodPut, "/v2/builds/felis/manifests/1", http.StatusCreated},
} {
if resp := do(t, gs, c.method, c.path, PrincipalBuild, "build-secret"); resp.StatusCode != c.want {
t.Errorf("build %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
}
}
for i, a := range up.auth {
if a != "" {
t.Errorf("request %d (%s) reached the registry with an Authorization header", i, up.seen[i])
}
}
}
func TestPlatformPrincipalMayWriteAndDeleteAnything(t *testing.T) {
gs, _ := newGate(t)
for _, c := range []struct {
method, path string
want int
}{
{http.MethodPut, "/v2/felis/felis/manifests/v0.2.0", http.StatusCreated},
{http.MethodPost, "/v2/mirror/trivy-db/blobs/uploads/", http.StatusAccepted},
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", http.StatusOK},
} {
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "plat-secret"); resp.StatusCode != c.want {
t.Errorf("platform %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
}
}
}
func TestPathTricksAreRefusedBeforeAuthorization(t *testing.T) {
gs, up := newGate(t)
for _, p := range []string{
"/v2/user-uploads/../felis/felis/manifests/v1",
"/v2/user-uploads/./x/manifests/v1",
"/v2/user-uploads%2F..%2Ffelis/felis/manifests/v1",
"/v2/user-uploads//felis/manifests/v1",
} {
req, _ := http.NewRequest(http.MethodPut, gs.URL, nil)
req.URL.Opaque = p // send the path exactly as written, no client-side cleaning
req.SetBasicAuth(PrincipalBuild, "build-secret")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("PUT %s = %d, want 400", p, resp.StatusCode)
}
}
if n := up.count(); n != 0 {
t.Fatalf("%d crafted paths reached the registry: %v", n, up.seen)
}
}
func TestMissingTokenFailsClosed(t *testing.T) {
up := &upstreamLog{}
upSrv := httptest.NewServer(up.handler())
defer upSrv.Close()
target, _ := url.Parse(upSrv.URL)
gs := httptest.NewServer(New(target, map[string]string{PrincipalBuild: ""}, nil))
defer gs.Close()
if resp := do(t, gs, http.MethodPut, "/v2/user-uploads/s1/manifests/latest", PrincipalBuild, ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("empty configured token accepted an empty password: %d", resp.StatusCode)
}
if resp := do(t, gs, http.MethodGet, "/v2/user-uploads/s1/manifests/latest", "", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("reads must keep working without tokens: %d", resp.StatusCode)
}
}
func TestHealth(t *testing.T) {
gs, _ := newGate(t)
if resp := do(t, gs, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("healthz = %d", resp.StatusCode)
}
dead, _ := url.Parse("http://127.0.0.1:1")
ds := httptest.NewServer(New(dead, nil, nil))
defer ds.Close()
if resp := do(t, ds, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("healthz with a dead upstream = %d, want 503", resp.StatusCode)
}
if resp := do(t, ds, http.MethodGet, "/livez", "", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("livez = %d", resp.StatusCode)
}
}
func TestRepoFromPath(t *testing.T) {
for path, want := range map[string]string{
"/v2/": "",
"/v2/_catalog": "",
"/v2/a/manifests/latest": "a",
"/v2/a/b/c/manifests/sha256:0": "a/b/c",
"/v2/a/blobs/sha256:0": "a",
"/v2/a/b/blobs/uploads/": "a/b",
"/v2/a/b/blobs/uploads/uuid-1": "a/b",
"/v2/a/tags/list": "a",
"/v2/user-uploads/blobs/manifests/one": "user-uploads/blobs",
} {
if got := RepoFromPath(path); got != want {
t.Errorf("RepoFromPath(%q) = %q, want %q", path, got, want)
}
}
}