package registrygate import ( "encoding/json" "errors" "io/fs" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "time" ) // IndexPathPrefix serves the manifest index of one repository: // // GET /felis/manifests/ {"revisions":[{"digest":…,"pushed":…}],"tags":{"":""}} // // The registry API can list a repository's tags but not its manifests, so a // manifest a tag moved off (every rebuild of :demo or :latest leaves one) is // invisible to it, while it still holds every layer it names. felis-api's pruner // needs the full set to decide which to delete; the gate reads it off the data // volume it mounts read-only, from the filesystem driver's layout that registry // 2.x and 3.x share. "pushed" is the modification time of the revision link, which // the registry rewrites on every push of that manifest. // // Anonymous like every read: a digest list says no more than tags/list and // _catalog already do. const IndexPathPrefix = "/felis/manifests/" // Index is the manifest index of one repository. type Index struct { Revisions []Revision `json:"revisions"` Tags map[string]string `json:"tags"` } // Revision is one manifest stored in a repository. type Revision struct { Digest string `json:"digest"` Pushed time.Time `json:"pushed"` } // repoNameRE is the distribution reference grammar for a repository path. Every // component starts and ends alphanumeric, so no match can hold "." or "..". var repoNameRE = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`) var hexDigestRE = regexp.MustCompile(`^[0-9a-f]{64}$`) func (g *Gate) serveIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed") return } if g.DataDir == "" { writeError(w, http.StatusNotFound, "UNSUPPORTED", "this gate does not mount the registry data") return } repo := strings.TrimPrefix(r.URL.Path, IndexPathPrefix) if r.URL.RawPath != "" || !repoNameRE.MatchString(repo) { writeError(w, http.StatusBadRequest, "NAME_INVALID", "invalid repository name") return } idx, err := ReadIndex(g.DataDir, repo) if err != nil { writeError(w, http.StatusInternalServerError, "UNKNOWN", err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(idx) } // ReadIndex reads repo's manifests and tags from a registry filesystem root. A // repository that does not exist has an empty index. func ReadIndex(root, repo string) (*Index, error) { if !repoNameRE.MatchString(repo) { return nil, errors.New("invalid repository name") } base := filepath.Join(root, "docker", "registry", "v2", "repositories", filepath.FromSlash(repo), "_manifests") idx := &Index{Revisions: []Revision{}, Tags: map[string]string{}} revDir := filepath.Join(base, "revisions", "sha256") entries, err := os.ReadDir(revDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, err } for _, e := range entries { if !e.IsDir() || !hexDigestRE.MatchString(e.Name()) { continue } // A deleted manifest keeps its directory and loses its link. st, err := os.Stat(filepath.Join(revDir, e.Name(), "link")) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, err } idx.Revisions = append(idx.Revisions, Revision{Digest: "sha256:" + e.Name(), Pushed: st.ModTime().UTC()}) } sort.Slice(idx.Revisions, func(i, j int) bool { return idx.Revisions[i].Digest < idx.Revisions[j].Digest }) tagDir := filepath.Join(base, "tags") tags, err := os.ReadDir(tagDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, err } for _, e := range tags { if !e.IsDir() { continue } b, err := os.ReadFile(filepath.Join(tagDir, e.Name(), "current", "link")) if errors.Is(err, fs.ErrNotExist) { continue } if err != nil { return nil, err } if d := strings.TrimSpace(string(b)); digestRE.MatchString(d) { idx.Tags[e.Name()] = d } } return idx, nil }