package web import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "hearthforge/internal/db" "hearthforge/internal/ratelimit" "hearthforge/internal/util" ) // The registry implements the OCI Distribution Spec under /v2/. An image // name is "" or "/"; the first segment must be an existing // repository. Blob and manifest bodies are content-addressed files under // DATA_DIR/registry, the per-image index lives in SQLite. // // Admins push and delete. Pull access follows REGISTRY_PULL, except that // images of private repositories are always admin-only. const ( // maxManifestBytes bounds a manifest body. Real ones are a few KiB. maxManifestBytes = 4 << 20 registryRealm = `Basic realm="Hearthforge registry"` ) var ( digestRe = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) uploadIDRe = regexp.MustCompile(`^[a-f0-9]{32}$`) tagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`) imagePathRe = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) ) // registryAuthLimiter counts argon2 checks per IP. Clients send Basic auth // on every request, so verified credentials are cached and skip it. var registryAuthLimiter = ratelimit.New(10, time.Minute) func (s *Server) registryRoutes(r chi.Router) { r.HandleFunc("/v2", s.registry) r.HandleFunc("/v2/", s.registry) r.HandleFunc("/v2/*", s.registry) } // registryError writes the spec's JSON error envelope. func registryError(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}}, }) } // registryUser resolves Basic auth. ok is false when the header is missing // or wrong. Each argon2 check is reserved in the limiter before it runs, so // parallel guesses count too. func (s *Server) registryUser(r *http.Request) (username string, isAdmin, ok bool) { username, password, found := r.BasicAuth() if !found || len(password) > s.Cfg.MaxPasswordBytes { return "", false, false } hash, exists, err := s.DB.ActivePasswordHash(r.Context(), username) if err != nil { return "", false, false } key := sha256.Sum256([]byte(username + "\x00" + password)) if cached, ok := registryAuth.Get(key); exists && ok && cached == hash { return username, username == db.AdminUsername, true } if !s.allowed(r, registryAuthLimiter, true) { return "", false, false } if !exists { return "", false, false } if valid, err := db.VerifyPassword(hash, password); err != nil || !valid { return "", false, false } registryAuth.Set(key, hash) return username, username == db.AdminUsername, true } // registryAuth remembers verified credentials. The value is the hash it // matched, so a password change invalidates the entry. var registryAuth = util.NewCache[[32]byte, string](1000, 5*time.Minute) // challenge answers 401 with the Basic scheme so clients retry with // credentials. func challenge(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", registryRealm) registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") } // registry dispatches one /v2/ request. Names may contain slashes, so the // path is split on the fixed keywords instead of chi params. func (s *Server) registry(w http.ResponseWriter, r *http.Request) { w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") _, isAdmin, authed := s.registryUser(r) write := r.Method != http.MethodGet && r.Method != http.MethodHead rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v2"), "/") if rest == "" { // The version check doubles as the auth probe for clients. if write || !s.Cfg.CanPullImages(false, authed, isAdmin) { challenge(w) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("{}")) return } var name, kind, ref string switch { case strings.Contains(rest, "/blobs/uploads/") || strings.HasSuffix(rest, "/blobs/uploads"): i := strings.LastIndex(rest, "/blobs/uploads") name, kind = rest[:i], "upload" ref = strings.TrimPrefix(rest[i+len("/blobs/uploads"):], "/") case strings.Contains(rest, "/blobs/"): i := strings.LastIndex(rest, "/blobs/") name, kind, ref = rest[:i], "blob", rest[i+len("/blobs/"):] case strings.Contains(rest, "/manifests/"): i := strings.LastIndex(rest, "/manifests/") name, kind, ref = rest[:i], "manifest", rest[i+len("/manifests/"):] case strings.HasSuffix(rest, "/tags/list"): name, kind = strings.TrimSuffix(rest, "/tags/list"), "tags" default: registryError(w, http.StatusNotFound, "UNSUPPORTED", "unknown endpoint") return } repo, image, ok := s.registryName(r, name) // A private repo must look exactly like an unknown one to non-admins, // or its name leaks through the status code. if ok && repo.IsPrivate && !isAdmin { ok = false } if !ok { // Do not reveal whether the repo exists before auth. if !authed && (write || !s.Cfg.CanPullImages(false, false, false)) { challenge(w) return } registryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository name not known to registry") return } if write && !isAdmin { if !authed { challenge(w) return } registryError(w, http.StatusForbidden, "DENIED", "only the admin may push") return } if !write && !s.Cfg.CanPullImages(repo.IsPrivate, authed, isAdmin) { if !authed { challenge(w) return } registryError(w, http.StatusForbidden, "DENIED", "pull access denied") return } switch kind { case "upload": s.registryUpload(w, r, repo, image, ref) case "blob": s.registryBlob(w, r, repo, image, ref) case "manifest": s.registryManifest(w, r, repo, image, ref) case "tags": s.registryTags(w, r, repo, image) } } // registryName maps "[/]" to the repository row and image path. // It reports false for an unknown repo or a path the spec would reject. func (s *Server) registryName(r *http.Request, name string) (*db.Repo, string, bool) { repoName, image, _ := strings.Cut(name, "/") for _, seg := range strings.Split(image, "/") { if seg != "" && !imagePathRe.MatchString(seg) { return nil, "", false } // The path parser splits on these words, so they cannot be segments. if seg == "blobs" || seg == "manifests" || seg == "tags" { return nil, "", false } } if image != "" && strings.Contains(image, "//") { return nil, "", false } repo, err := s.DB.RepoByNameFold(r.Context(), repoName) if err != nil || repo == nil { return nil, "", false } return repo, image, true } // imageBase is the URL prefix of an image. Image names are lowercase on the // wire, so the repo name is lowered even when the repository is not. func imageBase(repo *db.Repo, image string) string { return "/v2/" + strings.TrimSuffix(strings.ToLower(repo.Name)+"/"+image, "/") } // --- storage paths --- func (s *Server) blobPath(digest string) string { return filepath.Join(s.Cfg.RegistryDir(), "blobs", strings.Replace(digest, ":", "/", 1)) } func (s *Server) uploadPath(id string) string { return filepath.Join(s.Cfg.RegistryDir(), "uploads", id) } // --- blob uploads --- func (s *Server) registryUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id string) { base := imageBase(repo, image) switch { case r.Method == http.MethodPost && id == "": // A cross-repo mount request falls through to a normal upload, which // the spec allows. The client then uploads the blob. if digest := r.URL.Query().Get("digest"); digest != "" && r.URL.Query().Get("mount") == "" { // Monolithic upload in one request. tmp, err := s.newUpload() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if _, err := appendUpload(s.uploadPath(tmp), r.Body); err != nil { _ = os.Remove(s.uploadPath(tmp)) registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } s.finishUpload(w, r, repo, image, tmp, digest, base) return } id, err := s.newUpload() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Location", base+"/blobs/uploads/"+id) w.Header().Set("Docker-Upload-UUID", id) w.Header().Set("Range", "0-0") w.WriteHeader(http.StatusAccepted) case id == "" || !uploadIDRe.MatchString(id): registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") case r.Method == http.MethodGet: st, err := os.Stat(s.uploadPath(id)) if err != nil { registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") return } w.Header().Set("Location", base+"/blobs/uploads/"+id) w.Header().Set("Docker-Upload-UUID", id) w.Header().Set("Range", rangeHeader(st.Size())) w.WriteHeader(http.StatusNoContent) case r.Method == http.MethodPatch: path := s.uploadPath(id) st, err := os.Stat(path) if err != nil { registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") return } // Chunks must arrive in order. A stated start that is not the // current end means the client and server disagree on the state. if cr := r.Header.Get("Content-Range"); cr != "" { start, _, _ := strings.Cut(cr, "-") if n, err := strconv.ParseInt(start, 10, 64); err != nil || n != st.Size() { w.Header().Set("Location", base+"/blobs/uploads/"+id) w.Header().Set("Range", rangeHeader(st.Size())) registryError(w, http.StatusRequestedRangeNotSatisfiable, "BLOB_UPLOAD_INVALID", "chunk out of order") return } } n, err := appendUpload(path, r.Body) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Location", base+"/blobs/uploads/"+id) w.Header().Set("Docker-Upload-UUID", id) w.Header().Set("Range", rangeHeader(st.Size()+n)) w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodPut: path := s.uploadPath(id) if _, err := os.Stat(path); err != nil { registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") return } if _, err := appendUpload(path, r.Body); err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } s.finishUpload(w, r, repo, image, id, r.URL.Query().Get("digest"), base) case r.Method == http.MethodDelete: if err := os.Remove(s.uploadPath(id)); err != nil { registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") return } w.WriteHeader(http.StatusNoContent) default: registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed") } } func rangeHeader(size int64) string { if size == 0 { return "0-0" } return "0-" + strconv.FormatInt(size-1, 10) } // staleUploadAge is how long a partial upload may sit before it is removed. const staleUploadAge = 24 * time.Hour // sweepUploads removes upload files nobody finished. A client that // disconnects mid-push never sends the final request, so nothing else // would ever delete them. func (s *Server) sweepUploads() { entries, err := os.ReadDir(filepath.Dir(s.uploadPath("x"))) if err != nil { return } cutoff := time.Now().Add(-staleUploadAge) for _, e := range entries { if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) { _ = os.Remove(s.uploadPath(e.Name())) } } } // newUpload creates an empty upload file and returns its id. func (s *Server) newUpload() (string, error) { s.sweepUploads() var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } id := hex.EncodeToString(b[:]) if err := os.MkdirAll(filepath.Dir(s.uploadPath(id)), 0o755); err != nil { return "", err } f, err := os.OpenFile(s.uploadPath(id), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return "", err } return id, f.Close() } // appendUpload appends the body and returns how many bytes it added. func appendUpload(path string, body io.Reader) (int64, error) { f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return 0, err } n, err := io.Copy(f, body) if err != nil { f.Close() return n, err } return n, f.Close() } // finishUpload verifies the digest, moves the file into the blob store, and // links it to the image. func (s *Server) finishUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id, digest, base string) { // Claim the file under a private name first. A late PATCH on the upload // id then finds nothing, so the bytes hashed are the bytes stored. path := s.uploadPath(id) + ".final" if err := os.Rename(s.uploadPath(id), path); err != nil { registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found") return } if !digestRe.MatchString(digest) { _ = os.Remove(path) registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:") return } size, actual, err := fileDigest(path) if err != nil { _ = os.Remove(path) registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if actual != digest { _ = os.Remove(path) registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest does not match uploaded content") return } s.registryMu.Lock() err = s.storeBlob(path, digest) if err == nil { err = s.DB.LinkBlob(r.Context(), repo.ID, image, digest, size) } s.registryMu.Unlock() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Location", base+"/blobs/"+digest) w.Header().Set("Docker-Content-Digest", digest) w.WriteHeader(http.StatusCreated) } // storeBlob moves a verified file into place. An existing blob with the // same digest has identical content, so the upload is simply dropped. func (s *Server) storeBlob(src, digest string) error { dst := s.blobPath(digest) if _, err := os.Stat(dst); err == nil { return os.Remove(src) } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } return os.Rename(src, dst) } func fileDigest(path string) (int64, string, error) { f, err := os.Open(path) if err != nil { return 0, "", err } defer f.Close() h := sha256.New() n, err := io.Copy(h, f) if err != nil { return 0, "", err } return n, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil } // --- blobs --- func (s *Server) registryBlob(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, digest string) { if !digestRe.MatchString(digest) { registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:") return } linked, err := s.DB.BlobLinked(r.Context(), repo.ID, image, digest) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if !linked { registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry") return } switch r.Method { case http.MethodGet, http.MethodHead: s.serveDigest(w, r, digest, "application/octet-stream") case http.MethodDelete: s.registryMu.Lock() err = s.DB.UnlinkBlob(r.Context(), repo.ID, image, digest) if err == nil { s.removeUnreferenced(r, digest) } s.registryMu.Unlock() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.WriteHeader(http.StatusAccepted) default: registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed") } } // removeUnreferenced deletes the file behind digest once no image uses it. // The caller holds registryMu. The lookup failing keeps the file; a stray // file is cheaper than a broken pull. func (s *Server) removeUnreferenced(r *http.Request, digest string) { used, err := s.DB.DigestReferenced(r.Context(), digest) if err == nil && !used { _ = os.Remove(s.blobPath(digest)) } } // serveDigest streams a content-addressed file. ServeContent handles HEAD // and Range requests. func (s *Server) serveDigest(w http.ResponseWriter, r *http.Request, digest, contentType string) { f, err := os.Open(s.blobPath(digest)) if err != nil { registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob file missing") return } defer f.Close() st, err := f.Stat() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Content-Type", contentType) w.Header().Set("Docker-Content-Digest", digest) // Content-Disposition keeps a browser from rendering a layer or // manifest inline. The raw endpoint's sandbox CSP does not apply here. w.Header().Set("Content-Disposition", "attachment") http.ServeContent(w, r, "", st.ModTime(), f) } // --- manifests --- // manifestRefs is the part of a manifest or index the registry checks. type manifestRefs struct { MediaType string `json:"mediaType"` Config *struct { Digest string `json:"digest"` } `json:"config"` Layers []struct { Digest string `json:"digest"` } `json:"layers"` Manifests []struct { Digest string `json:"digest"` } `json:"manifests"` } func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string) { isDigest := digestRe.MatchString(ref) if !isDigest && !tagRe.MatchString(ref) { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid reference") return } switch r.Method { case http.MethodPut: s.putManifest(w, r, repo, image, ref, isDigest) return case http.MethodDelete: if isDigest { s.registryMu.Lock() found, err := s.DB.DeleteManifest(r.Context(), repo.ID, image, ref) if err == nil && found { s.removeUnreferenced(r, ref) } s.registryMu.Unlock() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if !found { registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown") return } } else { found, err := s.DB.DeleteTag(r.Context(), repo.ID, image, ref) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if !found { registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag unknown") return } } w.WriteHeader(http.StatusAccepted) return case http.MethodGet, http.MethodHead: default: registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed") return } var m *db.Manifest var err error if isDigest { m, err = s.DB.ManifestByDigest(r.Context(), repo.ID, image, ref) } else { m, err = s.DB.ManifestByTag(r.Context(), repo.ID, image, ref) } if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if m == nil { registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown") return } s.serveDigest(w, r, m.Digest, m.MediaType) } // putManifest stores a manifest after checking that everything it points // at is already in this image. That is what makes a pull reliable. func (s *Server) putManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) { body, err := io.ReadAll(io.LimitReader(r.Body, maxManifestBytes+1)) if err != nil { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "could not read body") return } if len(body) > maxManifestBytes { registryError(w, http.StatusRequestEntityTooLarge, "MANIFEST_INVALID", "manifest too large") return } sum := sha256.Sum256(body) digest := "sha256:" + hex.EncodeToString(sum[:]) if isDigest && ref != digest { registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "reference digest does not match body") return } var refs manifestRefs if err := json.Unmarshal(body, &refs); err != nil { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "manifest is not valid JSON") return } mediaType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") mediaType = strings.TrimSpace(mediaType) if mediaType == "" { mediaType = refs.MediaType } if mediaType == "" { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "manifest has no media type") return } // Every layer and config blob must be linked, every child manifest // stored. Otherwise a client could pull a manifest whose parts 404. var blobs []string if refs.Config != nil { blobs = append(blobs, refs.Config.Digest) } for _, l := range refs.Layers { blobs = append(blobs, l.Digest) } for _, d := range blobs { if !digestRe.MatchString(d) { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "unsupported digest "+d) return } linked, err := s.DB.BlobLinked(r.Context(), repo.ID, image, d) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if !linked { registryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "blob "+d+" not uploaded") return } } for _, c := range refs.Manifests { if !digestRe.MatchString(c.Digest) { registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "unsupported digest "+c.Digest) return } child, err := s.DB.ManifestByDigest(r.Context(), repo.ID, image, c.Digest) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } if child == nil { registryError(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "manifest "+c.Digest+" not uploaded") return } } tag := "" if !isDigest { tag = ref } m := db.Manifest{Digest: digest, MediaType: mediaType} s.registryMu.Lock() err = s.writeBlob(digest, body) if err == nil { err = s.DB.PutManifest(r.Context(), repo.ID, image, m, tag, db.NowISO()) } s.registryMu.Unlock() if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Location", imageBase(repo, image)+"/manifests/"+digest) w.Header().Set("Docker-Content-Digest", digest) w.WriteHeader(http.StatusCreated) } // writeBlob stores small content (a manifest) by digest. func (s *Server) writeBlob(digest string, body []byte) error { dst := s.blobPath(digest) if _, err := os.Stat(dst); err == nil { return nil } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), ".manifest-*") if err != nil { return err } if _, err := tmp.Write(body); err != nil { tmp.Close() _ = os.Remove(tmp.Name()) return err } if err := tmp.Close(); err != nil { _ = os.Remove(tmp.Name()) return err } return os.Rename(tmp.Name(), dst) } // --- tags --- func (s *Server) registryTags(w http.ResponseWriter, r *http.Request, repo *db.Repo, image string) { if r.Method != http.MethodGet && r.Method != http.MethodHead { registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed") return } tags, err := s.DB.ListTags(r.Context(), repo.ID, image) if err != nil { registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } // Pagination: `last` is exclusive, `n` caps the page. if last := r.URL.Query().Get("last"); last != "" { for len(tags) > 0 && tags[0] <= last { tags = tags[1:] } } if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n >= 0 && n < len(tags) { tags = tags[:n] } if tags == nil { tags = []string{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "name": strings.TrimPrefix(imageBase(repo, image), "/v2/"), "tags": tags, }) }