Add Chef package registry (#22554)

This PR implements a [Chef registry](https://chef.io/) to manage
cookbooks. This package type was a bit complicated because Chef uses RSA
signed requests as authentication with the registry.


![grafik](https://user-images.githubusercontent.com/1666336/213747995-46819fd8-c3d6-45a2-afd4-a4c3c8505a4a.png)


![grafik](https://user-images.githubusercontent.com/1666336/213748145-d01c9e81-d4dd-41e3-a3cc-8241862c3166.png)

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
KN4CK3R 2023-02-06 02:49:21 +01:00 committed by GitHub
parent ff18d17442
commit d987ac6bf1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1737 additions and 20 deletions

View file

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/packages/cargo"
"code.gitea.io/gitea/routers/api/packages/chef"
"code.gitea.io/gitea/routers/api/packages/composer"
"code.gitea.io/gitea/routers/api/packages/conan"
"code.gitea.io/gitea/routers/api/packages/conda"
@ -54,6 +55,7 @@ func CommonRoutes(ctx gocontext.Context) *web.Route {
&auth.Basic{},
&nuget.Auth{},
&conan.Auth{},
&chef.Auth{},
}
if setting.Service.EnableReverseProxyAuth {
authMethods = append(authMethods, &auth.ReverseProxy{})
@ -86,6 +88,25 @@ func CommonRoutes(ctx gocontext.Context) *web.Route {
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/chef", func() {
r.Group("/api/v1", func() {
r.Get("/universe", chef.PackagesUniverse)
r.Get("/search", chef.EnumeratePackages)
r.Group("/cookbooks", func() {
r.Get("", chef.EnumeratePackages)
r.Post("", reqPackageAccess(perm.AccessModeWrite), chef.UploadPackage)
r.Group("/{name}", func() {
r.Get("", chef.PackageMetadata)
r.Group("/versions/{version}", func() {
r.Get("", chef.PackageVersionMetadata)
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackageVersion)
r.Get("/download", chef.DownloadPackage)
})
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackage)
})
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/composer", func() {
r.Get("/packages.json", composer.ServiceIndex)
r.Get("/search.json", composer.SearchPackages)

View file

@ -0,0 +1,270 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package chef
import (
"crypto"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"hash"
"math/big"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
user_model "code.gitea.io/gitea/models/user"
chef_module "code.gitea.io/gitea/modules/packages/chef"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/auth"
)
const (
maxTimeDifference = 10 * time.Minute
)
var (
algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`)
versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`)
authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`)
)
// Documentation:
// https://docs.chef.io/server/api_chef_server/#required-headers
// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md
// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb
type Auth struct{}
func (a *Auth) Name() string {
return "chef"
}
// Verify extracts the user from the signed request
// If the request is signed with the user private key the user is verified.
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
u, err := getUserFromRequest(req)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
pub, err := getUserPublicKey(u)
if err != nil {
return nil, err
}
if err := verifyTimestamp(req); err != nil {
return nil, err
}
version, err := getSignVersion(req)
if err != nil {
return nil, err
}
if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil {
return nil, err
}
return u, nil
}
func getUserFromRequest(req *http.Request) (*user_model.User, error) {
username := req.Header.Get("X-Ops-Userid")
if username == "" {
return nil, nil
}
return user_model.GetUserByName(req.Context(), username)
}
func getUserPublicKey(u *user_model.User) (crypto.PublicKey, error) {
pubKey, err := user_model.GetSetting(u.ID, chef_module.SettingPublicPem)
if err != nil {
return nil, err
}
pubPem, _ := pem.Decode([]byte(pubKey))
return x509.ParsePKIXPublicKey(pubPem.Bytes)
}
func verifyTimestamp(req *http.Request) error {
hdr := req.Header.Get("X-Ops-Timestamp")
if hdr == "" {
return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing")
}
ts, err := time.Parse(time.RFC3339, hdr)
if err != nil {
return err
}
diff := time.Now().UTC().Sub(ts)
if diff < 0 {
diff = -diff
}
if diff > maxTimeDifference {
return fmt.Errorf("time difference")
}
return nil
}
func getSignVersion(req *http.Request) (string, error) {
hdr := req.Header.Get("X-Ops-Sign")
if hdr == "" {
return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing")
}
m := versionPattern.FindStringSubmatch(hdr)
if len(m) != 2 {
return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header")
}
switch m[1] {
case "1.0", "1.1", "1.2", "1.3":
default:
return "", util.NewInvalidArgumentErrorf("unsupported version")
}
version := m[1]
m = algorithmPattern.FindStringSubmatch(hdr)
if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") {
return "", util.NewInvalidArgumentErrorf("unsupported algorithm")
}
return version, nil
}
func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error {
authorizationData, err := getAuthorizationData(req)
if err != nil {
return err
}
checkData := buildCheckData(req, version)
switch version {
case "1.3":
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256)
case "1.2":
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1)
default:
return verifyDataOld(authorizationData, checkData, pub)
}
}
func getAuthorizationData(req *http.Request) ([]byte, error) {
valueList := make(map[int]string)
for k, vs := range req.Header {
if m := authorizationPattern.FindStringSubmatch(k); m != nil {
index, _ := strconv.Atoi(m[1])
var v string
if len(vs) == 0 {
v = ""
} else {
v = vs[0]
}
valueList[index] = v
}
}
tmp := make([]string, len(valueList))
for k, v := range valueList {
if k > len(tmp) {
return nil, fmt.Errorf("invalid X-Ops-Authorization headers")
}
tmp[k-1] = v
}
return base64.StdEncoding.DecodeString(strings.Join(tmp, ""))
}
func buildCheckData(req *http.Request, version string) []byte {
username := req.Header.Get("X-Ops-Userid")
if version != "1.0" && version != "1.3" {
sum := sha1.Sum([]byte(username))
username = base64.StdEncoding.EncodeToString(sum[:])
}
var data string
if version == "1.3" {
data = fmt.Sprintf(
"Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s",
req.Method,
path.Clean(req.URL.Path),
req.Header.Get("X-Ops-Content-Hash"),
version,
req.Header.Get("X-Ops-Timestamp"),
username,
req.Header.Get("X-Ops-Server-Api-Version"),
)
} else {
sum := sha1.Sum([]byte(path.Clean(req.URL.Path)))
data = fmt.Sprintf(
"Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s",
req.Method,
base64.StdEncoding.EncodeToString(sum[:]),
req.Header.Get("X-Ops-Content-Hash"),
req.Header.Get("X-Ops-Timestamp"),
username,
)
}
return []byte(data)
}
func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error {
var h hash.Hash
if algo == crypto.SHA256 {
h = sha256.New()
} else {
h = sha1.New()
}
if _, err := h.Write(data); err != nil {
return err
}
return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature)
}
func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error {
c := new(big.Int)
m := new(big.Int)
m.SetBytes(signature)
e := big.NewInt(int64(pub.E))
c.Exp(m, e, pub.N)
out := c.Bytes()
skip := 0
for i := 2; i < len(out); i++ {
if i+1 >= len(out) {
break
}
if out[i] == 0xFF && out[i+1] == 0 {
skip = i + 2
break
}
}
if !util.SliceEqual(out[skip:], data) {
return fmt.Errorf("could not verify signature")
}
return nil
}

View file

@ -0,0 +1,404 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package chef
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"code.gitea.io/gitea/models/db"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/modules/context"
packages_module "code.gitea.io/gitea/modules/packages"
chef_module "code.gitea.io/gitea/modules/packages/chef"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/packages/helper"
packages_service "code.gitea.io/gitea/services/packages"
)
func apiError(ctx *context.Context, status int, obj interface{}) {
type Error struct {
ErrorMessages []string `json:"error_messages"`
}
helper.LogAndProcessError(ctx, status, obj, func(message string) {
ctx.JSON(status, Error{
ErrorMessages: []string{message},
})
})
}
func PackagesUniverse(ctx *context.Context) {
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeChef,
IsInternal: util.OptionalBoolFalse,
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
type VersionInfo struct {
LocationType string `json:"location_type"`
LocationPath string `json:"location_path"`
DownloadURL string `json:"download_url"`
Dependencies map[string]string `json:"dependencies"`
}
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1"
universe := make(map[string]map[string]*VersionInfo)
for _, pd := range pds {
if _, ok := universe[pd.Package.Name]; !ok {
universe[pd.Package.Name] = make(map[string]*VersionInfo)
}
universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{
LocationType: "opscode",
LocationPath: baseURL,
DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version),
Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies,
}
}
ctx.JSON(http.StatusOK, universe)
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb
func EnumeratePackages(ctx *context.Context) {
opts := &packages_model.PackageSearchOptions{
OwnerID: ctx.Package.Owner.ID,
Type: packages_model.TypeChef,
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
IsInternal: util.OptionalBoolFalse,
Paginator: db.NewAbsoluteListOptions(
ctx.FormInt("start"),
ctx.FormInt("items"),
),
}
switch strings.ToLower(ctx.FormTrim("order")) {
case "recently_updated", "recently_added":
opts.Sort = packages_model.SortCreatedDesc
default:
opts.Sort = packages_model.SortNameAsc
}
pvs, total, err := packages_model.SearchLatestVersions(ctx, opts)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
type Item struct {
CookbookName string `json:"cookbook_name"`
CookbookMaintainer string `json:"cookbook_maintainer"`
CookbookDescription string `json:"cookbook_description"`
Cookbook string `json:"cookbook"`
}
type Result struct {
Start int `json:"start"`
Total int `json:"total"`
Items []*Item `json:"items"`
}
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/"
items := make([]*Item, 0, len(pds))
for _, pd := range pds {
metadata := pd.Metadata.(*chef_module.Metadata)
items = append(items, &Item{
CookbookName: pd.Package.Name,
CookbookMaintainer: metadata.Author,
CookbookDescription: metadata.Description,
Cookbook: baseURL + url.PathEscape(pd.Package.Name),
})
}
skip, _ := opts.Paginator.GetSkipTake()
ctx.JSON(http.StatusOK, &Result{
Start: skip,
Total: int(total),
Items: items,
})
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
func PackageMetadata(ctx *context.Context) {
packageName := ctx.Params("name")
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, nil)
return
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
sort.Slice(pds, func(i, j int) bool {
return pds[i].SemVer.LessThan(pds[j].SemVer)
})
type Result struct {
Name string `json:"name"`
Maintainer string `json:"maintainer"`
Description string `json:"description"`
Category string `json:"category"`
LatestVersion string `json:"latest_version"`
SourceURL string `json:"source_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Deprecated bool `json:"deprecated"`
Versions []string `json:"versions"`
}
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName))
versions := make([]string, 0, len(pds))
for _, pd := range pds {
versions = append(versions, baseURL+pd.Version.Version)
}
latest := pds[len(pds)-1]
metadata := latest.Metadata.(*chef_module.Metadata)
ctx.JSON(http.StatusOK, &Result{
Name: latest.Package.Name,
Maintainer: metadata.Author,
Description: metadata.Description,
LatestVersion: baseURL + latest.Version.Version,
SourceURL: metadata.RepositoryURL,
CreatedAt: latest.Version.CreatedUnix.AsLocalTime(),
UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(),
Deprecated: false,
Versions: versions,
})
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
func PackageVersionMetadata(ctx *context.Context) {
packageName := ctx.Params("name")
packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?!
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion)
if err != nil {
if err == packages_model.ErrPackageNotExist {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
type Result struct {
Version string `json:"version"`
TarballFileSize int64 `json:"tarball_file_size"`
PublishedAt time.Time `json:"published_at"`
Cookbook string `json:"cookbook"`
File string `json:"file"`
License string `json:"license"`
Dependencies map[string]string `json:"dependencies"`
}
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name))
metadata := pd.Metadata.(*chef_module.Metadata)
ctx.JSON(http.StatusOK, &Result{
Version: pd.Version.Version,
TarballFileSize: pd.Files[0].Blob.Size,
PublishedAt: pd.Version.CreatedUnix.AsLocalTime(),
Cookbook: baseURL,
File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version),
License: metadata.License,
Dependencies: metadata.Dependencies,
})
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb
func UploadPackage(ctx *context.Context) {
file, _, err := ctx.Req.FormFile("tarball")
if err != nil {
apiError(ctx, http.StatusBadRequest, err)
return
}
defer file.Close()
buf, err := packages_module.CreateHashedBufferFromReader(file, 32*1024*1024)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer buf.Close()
pck, err := chef_module.ParsePackage(buf)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusBadRequest, err)
} else {
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
if _, err := buf.Seek(0, io.SeekStart); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
_, _, err = packages_service.CreatePackageAndAddFile(
&packages_service.PackageCreationInfo{
PackageInfo: packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeChef,
Name: pck.Name,
Version: pck.Version,
},
Creator: ctx.Doer,
SemverCompatible: true,
Metadata: pck.Metadata,
},
&packages_service.PackageFileCreationInfo{
PackageFileInfo: packages_service.PackageFileInfo{
Filename: strings.ToLower(pck.Version + ".tar.gz"),
},
Creator: ctx.Doer,
Data: buf,
IsLead: true,
},
)
if err != nil {
switch err {
case packages_model.ErrDuplicatePackageVersion:
apiError(ctx, http.StatusBadRequest, err)
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
apiError(ctx, http.StatusForbidden, err)
default:
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
ctx.JSON(http.StatusCreated, make(map[any]any))
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb
func DownloadPackage(ctx *context.Context) {
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version"))
if err != nil {
if err == packages_model.ErrPackageNotExist {
apiError(ctx, http.StatusNotFound, err)
return
}
apiError(ctx, http.StatusInternalServerError, err)
return
}
pd, err := packages_model.GetPackageDescriptor(ctx, pv)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
pf := pd.Files[0].File
s, _, err := packages_service.GetPackageFileStream(ctx, pf)
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
defer s.Close()
ctx.ServeContent(s, &context.ServeHeaderOptions{
Filename: pf.Name,
LastModified: pf.CreatedUnix.AsLocalTime(),
})
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
func DeletePackageVersion(ctx *context.Context) {
packageName := ctx.Params("name")
packageVersion := ctx.Params("version")
err := packages_service.RemovePackageVersionByNameAndVersion(
ctx.Doer,
&packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeChef,
Name: packageName,
Version: packageVersion,
},
)
if err != nil {
if err == packages_model.ErrPackageNotExist {
apiError(ctx, http.StatusNotFound, err)
} else {
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
ctx.Status(http.StatusOK)
}
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
func DeletePackage(ctx *context.Context) {
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"))
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if len(pvs) == 0 {
apiError(ctx, http.StatusNotFound, err)
return
}
for _, pv := range pvs {
if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
}
ctx.Status(http.StatusOK)
}

View file

@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) {
// in: query
// description: package type filter
// type: string
// enum: [cargo, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant]
// enum: [cargo, chef, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant]
// - name: q
// in: query
// description: name filter