675 lines
16 KiB
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()
|
|
}
|