Files
codit/backend/internal/docker/registry.go

675 lines
16 KiB
Go

package docker
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "net/http"
import "os"
import "path/filepath"
import "strconv"
import "strings"
import "sync"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/util"
type AuthFunc func(username, password string) (bool, error)
type HTTPServer struct {
store *db.Store
baseDir string
auth AuthFunc
logger *util.Logger
}
func NewHTTPServer(store *db.Store, baseDir string, auth AuthFunc, logger *util.Logger) *HTTPServer {
return &HTTPServer{
store: store,
baseDir: baseDir,
auth: auth,
logger: logger,
}
}
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var ok bool
var userLabel string
var username string
var password string
var status int
var recorder *statusRecorder
if s.auth != nil {
username, password, ok = r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
ok, _ = s.auth(username, password)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
}
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
recorder = &statusRecorder{ResponseWriter: w, status: http.StatusOK}
s.handle(recorder, r)
status = recorder.status
if s.logger != nil {
userLabel = "-"
if username != "" {
userLabel = username
}
s.logger.Write("docker", util.LOG_INFO, "method=%s path=%s remote=%s user=%s status=%d",
r.Method, r.URL.Path, r.RemoteAddr, userLabel, status)
}
}
func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
var path string
var action string
var repoName string
var rest string
var repo models.Repo
var project models.Project
var imageName string
var imagePath string
var err error
path = r.URL.Path
if path == "/v2" || path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
}
repoName, action, rest = parseV2Path(path)
if action == "" {
w.WriteHeader(http.StatusNotFound)
return
}
if action == "catalog" {
s.handleCatalog(w, r)
return
}
repo, project, imageName, err = s.resolveRepo(repoName)
_ = project
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
imagePath = ImagePath(repo.Path, imageName)
switch action {
case "tags":
s.handleTags(w, r, repo, repoName, imagePath)
case "manifest":
s.handleManifest(w, r, repo, repoName, rest, imagePath)
case "blob":
s.handleBlob(w, r, repo, repoName, rest, imagePath)
case "upload_start":
s.handleUploadStart(w, r, repo, repoName, imagePath)
case "upload":
s.handleUpload(w, r, repo, repoName, rest, imagePath)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func parseV2Path(path string) (string, string, string) {
var p string
var idx int
p = strings.TrimPrefix(path, "/v2/")
if p == "" || p == "/v2" {
return "", "", ""
}
if strings.HasPrefix(p, "_catalog") {
return "", "catalog", ""
}
if strings.HasSuffix(p, "/tags/list") {
p = strings.TrimSuffix(p, "/tags/list")
p = strings.TrimSuffix(p, "/")
if p == "" {
return "", "", ""
}
return p, "tags", ""
}
idx = strings.Index(p, "/manifests/")
if idx >= 0 {
return p[:idx], "manifest", p[idx+len("/manifests/"):]
}
idx = strings.Index(p, "/blobs/uploads/")
if idx >= 0 {
if strings.HasSuffix(p, "/blobs/uploads/") {
return p[:idx], "upload_start", ""
}
return p[:idx], "upload", p[idx+len("/blobs/uploads/"):]
}
if strings.HasSuffix(p, "/blobs/uploads") {
p = strings.TrimSuffix(p, "/blobs/uploads")
p = strings.TrimSuffix(p, "/")
return p, "upload_start", ""
}
idx = strings.Index(p, "/blobs/")
if idx >= 0 {
return p[:idx], "blob", p[idx+len("/blobs/"):]
}
return "", "", ""
}
func (s *HTTPServer) resolveRepo(repoName string) (models.Repo, models.Project, string, error) {
var parts []string
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var err error
var slug string
var name string
var image string
repoName = strings.Trim(repoName, "/")
parts = strings.Split(repoName, "/")
if len(parts) < 2 {
return repo, project, "", errors.New("invalid repo name")
}
slug = parts[0]
name = parts[1]
if len(parts) > 2 {
image = strings.Join(parts[2:], "/")
}
if IsReservedImagePath(image) {
return repo, project, "", errors.New("invalid image name")
}
project, err = s.store.GetProjectBySlug(slug)
if err != nil {
return repo, project, "", err
}
repo, err = s.store.GetRepoByProjectNameType(project.ID, name, "docker")
if err != nil {
return repo, project, "", err
}
projectStorageID, repoStorageID, err = s.store.GetRepoStorageIDs(repo.ID)
if err != nil {
return repo, project, "", err
}
repo.Path = filepath.Join(s.baseDir, storageIDSegment(projectStorageID), storageIDSegment(repoStorageID))
return repo, project, image, nil
}
func storageIDSegment(id int64) string {
return fmt.Sprintf("%016x", id)
}
func IsReservedImagePath(image string) bool {
var cleaned string
var parts []string
var i int
var part string
if image == "" {
return false
}
cleaned = strings.Trim(image, "/")
if cleaned == "" {
return false
}
parts = strings.Split(cleaned, "/")
for i = 0; i < len(parts); i++ {
part = parts[i]
if part == ".root" || part == "blobs" || part == "uploads" || part == "oci-layout" || part == "index.json" {
return true
}
}
return false
}
func (s *HTTPServer) handleCatalog(w http.ResponseWriter, r *http.Request) {
var repos []models.Repo
var err error
var names []string
var i int
var repo models.Repo
var project models.Project
var data []byte
var response map[string][]string
var images []string
var j int
var image string
repos, err = s.store.ListAllRepos()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
names = []string{}
for i = 0; i < len(repos); i++ {
repo = repos[i]
if repo.Type != "docker" {
continue
}
project, err = s.store.GetProject(repo.ProjectID)
if err != nil {
continue
}
images, err = ListImages(repo.Path)
if err != nil {
continue
}
if len(images) == 0 {
names = append(names, project.Slug+"/"+repo.Name)
continue
}
for j = 0; j < len(images); j++ {
image = images[j]
if image == "" {
names = append(names, project.Slug+"/"+repo.Name)
} else {
names = append(names, project.Slug+"/"+repo.Name+"/"+image)
}
}
}
response = map[string][]string{"repositories": names}
data, err = json.Marshal(response)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *HTTPServer) handleTags(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
var tags []string
var err error
var response map[string]interface{}
var data []byte
tags, err = listTags(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
response = map[string]interface{}{
"name": name,
"tags": tags,
}
data, err = json.Marshal(response)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *HTTPServer) handleManifest(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, reference string, imagePath string) {
var desc ociDescriptor
var err error
var digest string
var data []byte
var mediaType string
var ok bool
if reference == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Method == http.MethodPut {
data, err = io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if r.Header.Get("Content-Type") != "" {
mediaType = r.Header.Get("Content-Type")
} else {
mediaType = detectManifestMediaType(data)
}
digest = ComputeDigest(data)
err = EnsureLayout(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
ok, err = HasBlob(imagePath, digest)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !ok {
err = WriteBlob(imagePath, digest, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
desc = ociDescriptor{MediaType: mediaType, Digest: digest, Size: int64(len(data))}
if !isDigestRef(reference) {
err = withRepoLock(imagePath, func() error {
return updateTag(imagePath, reference, desc)
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
w.WriteHeader(http.StatusCreated)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
desc, err = resolveManifest(imagePath, reference)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
data, err = ReadBlob(imagePath, desc.Digest)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
if desc.MediaType != "" {
w.Header().Set("Content-Type", desc.MediaType)
}
w.Header().Set("Docker-Content-Digest", desc.Digest)
if r.Method == http.MethodHead {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}
func (s *HTTPServer) handleBlob(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, digest string, imagePath string) {
var data []byte
var err error
if digest == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
data, err = ReadBlob(imagePath, digest)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", "application/octet-stream")
if r.Method == http.MethodHead {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}
func (s *HTTPServer) handleUploadStart(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
var id string
var err error
var uploadPath string
var uploadDir string
var created *os.File
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id, err = util.NewID()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
uploadDir = filepath.Join(imagePath, "uploads")
err = os.MkdirAll(uploadDir, 0o755)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
uploadPath = filepath.Join(uploadDir, id)
_, err = os.Stat(uploadPath)
if err == nil {
w.WriteHeader(http.StatusConflict)
return
}
created, err = os.Create(uploadPath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = created.Close()
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, id))
w.Header().Set("Docker-Upload-UUID", id)
w.Header().Set("Range", "0-0")
w.WriteHeader(http.StatusAccepted)
}
func (s *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, uploadID string, imagePath string) {
var uploadDir string
var uploadPath string
var err error
var file *os.File
var size int64
var digest string
var computed string
var ok bool
var data []byte
var info os.FileInfo
var appendFile *os.File
var appendSize int64
if uploadID == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
uploadDir = filepath.Join(imagePath, "uploads")
uploadPath = filepath.Join(uploadDir, uploadID)
if r.Method == http.MethodDelete {
_ = os.Remove(uploadPath)
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method == http.MethodPatch {
file, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
defer file.Close()
_, err = io.Copy(file, r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
info, err = file.Stat()
if err == nil {
size = info.Size()
}
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uploadID))
w.Header().Set("Docker-Upload-UUID", uploadID)
if size > 0 {
w.Header().Set("Range", fmt.Sprintf("0-%d", size-1))
}
w.WriteHeader(http.StatusAccepted)
return
}
if r.Method == http.MethodPut {
digest = r.URL.Query().Get("digest")
if digest == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
appendFile, err = os.OpenFile(uploadPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
appendSize, err = io.Copy(appendFile, r.Body)
_ = appendSize
_ = appendFile.Close()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
file, err = os.Open(uploadPath)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
defer file.Close()
computed, size, err = computeDigestFromReader(file)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if computed != digest {
w.WriteHeader(http.StatusBadRequest)
return
}
data, err = os.ReadFile(uploadPath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
err = EnsureLayout(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
ok, err = HasBlob(imagePath, digest)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !ok {
err = WriteBlob(imagePath, digest, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
_ = os.Remove(uploadPath)
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusCreated)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}
func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
var desc ociDescriptor
var err error
var ok bool
var data []byte
if isDigestRef(reference) {
ok, err = HasBlob(repoPath, reference)
if err != nil {
return desc, err
}
if !ok {
return desc, ErrNotFound
}
desc = ociDescriptor{Digest: reference}
data, err = ReadBlob(repoPath, reference)
if err == nil {
desc.Size = int64(len(data))
desc.MediaType = detectManifestMediaType(data)
}
return desc, nil
}
desc, err = resolveTag(repoPath, reference)
if err != nil {
return desc, err
}
return desc, nil
}
func isDigestRef(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
func detectManifestMediaType(data []byte) string {
var tmp map[string]interface{}
var value interface{}
var s string
_ = json.Unmarshal(data, &tmp)
if tmp == nil {
return ""
}
value = tmp["mediaType"]
if value == nil {
return ""
}
s, _ = value.(string)
return s
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Flush() {
var flusher http.Flusher
var ok bool
flusher, ok = r.ResponseWriter.(http.Flusher)
if ok {
flusher.Flush()
}
}
var repoLocksMu sync.Mutex
var repoLocks map[string]*sync.Mutex = map[string]*sync.Mutex{}
func repoLock(path string) *sync.Mutex {
var lock *sync.Mutex
var ok bool
repoLocksMu.Lock()
lock, ok = repoLocks[path]
if !ok {
lock = &sync.Mutex{}
repoLocks[path] = lock
}
repoLocksMu.Unlock()
return lock
}
func withRepoLock(path string, fn func() error) error {
var lock *sync.Mutex
lock = repoLock(path)
lock.Lock()
defer lock.Unlock()
return fn()
}
func init() {
_ = time.Now()
}