328 lines
11 KiB
Go
328 lines
11 KiB
Go
// 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;
|
|
// - the "prune" principal (felis-api's registry pruner) may only delete a
|
|
// manifest by digest, the one write that frees space.
|
|
//
|
|
// The gate also holds the registry read-only while the GC sidecar runs registry
|
|
// garbage-collect (maint.go): a blob pushed during the sweep could be deleted
|
|
// under a manifest that is about to reference it.
|
|
//
|
|
// 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"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
|
|
// 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"
|
|
PrincipalPrune = "prune"
|
|
)
|
|
|
|
// Principals lists every principal the gate knows, for loading their tokens.
|
|
var Principals = []string{PrincipalPlatform, PrincipalBuild, PrincipalPrune}
|
|
|
|
// 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
|
|
// DataDir is the registry's storage root, mounted read-only, for the manifest
|
|
// index (index.go). Empty turns the index off.
|
|
DataDir string
|
|
|
|
proxy *httputil.ReverseProxy
|
|
health *http.Client
|
|
maint maintenance
|
|
}
|
|
|
|
// 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}
|
|
g.maint.now = time.Now
|
|
g.maint.quiet = DefaultQuiet
|
|
// A gate that just started cannot tell whether a push was mid-way through the
|
|
// previous one, and the installer pushes right after the registry rolls out:
|
|
// count the start as a write, so the first window waits for quiet too.
|
|
g.maint.lastWrite = g.maint.now()
|
|
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, IndexPathPrefix) {
|
|
g.serveIndex(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
|
|
}
|
|
if !g.maint.beginWrite() {
|
|
// Retry-After is what imagepush and docker push back off on; a GC sweep
|
|
// over a few GiB takes well under a minute.
|
|
w.Header().Set("Retry-After", "30")
|
|
writeError(w, http.StatusServiceUnavailable, "UNAVAILABLE",
|
|
"the registry is read-only while garbage collection runs; retry shortly")
|
|
return
|
|
}
|
|
defer g.maint.endWrite()
|
|
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 ""
|
|
case PrincipalPrune:
|
|
// Deleting a manifest only unlinks it; the blobs go at the next GC. The
|
|
// pruner never needs anything else, so a leaked prune token can neither
|
|
// plant an image nor delete a blob a live manifest still references.
|
|
if method != http.MethodDelete || !isManifestDigestPath(path) {
|
|
return "the prune principal may only delete a manifest by digest"
|
|
}
|
|
return ""
|
|
default:
|
|
return "unknown principal"
|
|
}
|
|
}
|
|
|
|
// isManifestDigestPath reports whether path is /v2/<repo>/manifests/sha256:<hex>.
|
|
func isManifestDigestPath(path string) bool {
|
|
rest, ok := strings.CutPrefix(path, "/v2/")
|
|
if !ok {
|
|
return false
|
|
}
|
|
seg := strings.Split(rest, "/")
|
|
n := len(seg)
|
|
return n >= 3 && seg[n-2] == "manifests" && digestRE.MatchString(seg[n-1])
|
|
}
|
|
|
|
// 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}},
|
|
})
|
|
}
|