diff options
| -rw-r--r-- | cmd/git-lfs-authenticate/main.go | 7 | ||||
| -rw-r--r-- | cmd/git-lfs-server/main.go | 206 |
2 files changed, 182 insertions, 31 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index f48fe5c..027a2f9 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
| @@ -100,8 +100,11 @@ type authenticateResponse struct { | |||
| 100 | Header map[string]string `json:"header"` | 100 | Header map[string]string `json:"header"` |
| 101 | // In seconds. | 101 | // In seconds. |
| 102 | ExpiresIn int64 `json:"expires_in,omitempty"` | 102 | ExpiresIn int64 `json:"expires_in,omitempty"` |
| 103 | // expires_at (RFC3339) could also be used, but we leave it out since we | 103 | // The expires_at (RFC3339) property could also be used, but we leave it |
| 104 | // don't use it. | 104 | // out since we don't use it. It is also possibleto specify the href |
| 105 | // property, making the Git LFS use this instead of the usual Service | ||
| 106 | // Discovery mechanism. See | ||
| 107 | // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/docs/api/server-discovery.md#ssh | ||
| 105 | } | 108 | } |
| 106 | 109 | ||
| 107 | func wipe(b []byte) { | 110 | func wipe(b []byte) { |
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index c76c5d9..934f2ea 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
| @@ -1,7 +1,10 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 4 | "context" | 5 | "context" |
| 6 | "crypto/ed25519" | ||
| 7 | "encoding/hex" | ||
| 5 | "encoding/json" | 8 | "encoding/json" |
| 6 | "errors" | 9 | "errors" |
| 7 | "fmt" | 10 | "fmt" |
| @@ -14,10 +17,12 @@ import ( | |||
| 14 | "path" | 17 | "path" |
| 15 | "regexp" | 18 | "regexp" |
| 16 | "slices" | 19 | "slices" |
| 20 | "strconv" | ||
| 17 | "strings" | 21 | "strings" |
| 18 | "time" | 22 | "time" |
| 19 | "unicode" | 23 | "unicode" |
| 20 | 24 | ||
| 25 | "github.com/golang-jwt/jwt/v5" | ||
| 21 | "github.com/minio/minio-go/v7" | 26 | "github.com/minio/minio-go/v7" |
| 22 | "github.com/minio/minio-go/v7/pkg/credentials" | 27 | "github.com/minio/minio-go/v7/pkg/credentials" |
| 23 | "github.com/rs/xid" | 28 | "github.com/rs/xid" |
| @@ -86,6 +91,7 @@ type handler struct { | |||
| 86 | bucket string | 91 | bucket string |
| 87 | anonUser string | 92 | anonUser string |
| 88 | gitolitePath string | 93 | gitolitePath string |
| 94 | publicKey ed25519.PublicKey | ||
| 89 | } | 95 | } |
| 90 | 96 | ||
| 91 | // Requires lowercase hash | 97 | // Requires lowercase hash |
| @@ -172,6 +178,36 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 172 | } | 178 | } |
| 173 | } | 179 | } |
| 174 | 180 | ||
| 181 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | ||
| 182 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
| 183 | expiresIn := time.Hour * 24 | ||
| 184 | |||
| 185 | presigned, err := h.mc.Presign(ctx, http.MethodPut, h.bucket, fullPath, expiresIn, url.Values{ | ||
| 186 | "x-amz-sdk-checksum-algorithm": {"sha256"}, | ||
| 187 | "x-amz-checksum-sha256": {obj.fullHash}, | ||
| 188 | "Content-Length": {strconv.FormatUint(obj.size, 10)}, | ||
| 189 | }) | ||
| 190 | if err != nil { | ||
| 191 | // TODO: consider not making this an object-specific, but rather a | ||
| 192 | // generic error such that the entire Batch API request fails. | ||
| 193 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | ||
| 194 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | ||
| 195 | } | ||
| 196 | |||
| 197 | authenticated := true | ||
| 198 | return batchResponseObject{ | ||
| 199 | OID: obj.fullHash, | ||
| 200 | Size: obj.size, | ||
| 201 | Authenticated: &authenticated, | ||
| 202 | Actions: map[operation]batchAction{ | ||
| 203 | operationUpload: { | ||
| 204 | HRef: presigned.String(), | ||
| 205 | ExpiresIn: int64(expiresIn.Seconds()), | ||
| 206 | }, | ||
| 207 | }, | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 175 | type parsedBatchObject struct { | 211 | type parsedBatchObject struct { |
| 176 | firstByte string | 212 | firstByte string |
| 177 | secondByte string | 213 | secondByte string |
| @@ -196,6 +232,103 @@ type requestID struct{} | |||
| 196 | 232 | ||
| 197 | var requestIDKey requestID | 233 | var requestIDKey requestID |
| 198 | 234 | ||
| 235 | // TODO: make a shared package for this | ||
| 236 | type gitolfs3Claims struct { | ||
| 237 | Repository string `json:"repository"` | ||
| 238 | Permission operation `json:"permission"` | ||
| 239 | } | ||
| 240 | |||
| 241 | type customClaims struct { | ||
| 242 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | ||
| 243 | *jwt.RegisteredClaims | ||
| 244 | } | ||
| 245 | |||
| 246 | // Request to perform <operation> in <repository> [on reference <refspec>] | ||
| 247 | type operationRequest struct { | ||
| 248 | operation operation | ||
| 249 | repository string | ||
| 250 | refspec *string | ||
| 251 | } | ||
| 252 | |||
| 253 | func getGitoliteAccess(repo, user, gitolitePerm string, refspec *string) (bool, error) { | ||
| 254 | // gitolite access -q: returns only exit code | ||
| 255 | gitoliteArgs := []string{"access", "-q", repo, user, gitolitePerm} | ||
| 256 | if refspec != nil { | ||
| 257 | gitoliteArgs = append(gitoliteArgs, *refspec) | ||
| 258 | } | ||
| 259 | cmd := exec.Command("gitolite", gitoliteArgs...) | ||
| 260 | err := cmd.Run() | ||
| 261 | if err != nil { | ||
| 262 | var exitErr *exec.ExitError | ||
| 263 | if !errors.As(err, &exitErr) { | ||
| 264 | return false, fmt.Errorf("(running %s): %w", cmd, err) | ||
| 265 | } | ||
| 266 | return false, nil | ||
| 267 | } | ||
| 268 | return true, nil | ||
| 269 | } | ||
| 270 | |||
| 271 | func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http.Request, or operationRequest) bool { | ||
| 272 | user := h.anonUser | ||
| 273 | |||
| 274 | if authz := r.Header.Get("Authorization"); authz != "" { | ||
| 275 | if !strings.HasPrefix(authz, "Bearer ") { | ||
| 276 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
| 277 | return false | ||
| 278 | } | ||
| 279 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
| 280 | |||
| 281 | var claims customClaims | ||
| 282 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
| 283 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
| 284 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
| 285 | } | ||
| 286 | return h.publicKey, nil | ||
| 287 | }) | ||
| 288 | if err != nil { | ||
| 289 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 290 | return false | ||
| 291 | } | ||
| 292 | |||
| 293 | if claims.Gitolfs3.Repository != or.repository { | ||
| 294 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 295 | return false | ||
| 296 | } | ||
| 297 | if claims.Gitolfs3.Permission == operationDownload && or.operation == operationUpload { | ||
| 298 | makeRespError(ctx, w, "Forbidden", http.StatusForbidden) | ||
| 299 | return false | ||
| 300 | } | ||
| 301 | |||
| 302 | user = claims.Subject | ||
| 303 | } | ||
| 304 | |||
| 305 | readAccess, err := getGitoliteAccess(or.repository, user, "R", or.refspec) | ||
| 306 | if err != nil { | ||
| 307 | reqlog(ctx, "Error checking access info: %s", err) | ||
| 308 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
| 309 | return false | ||
| 310 | } | ||
| 311 | if !readAccess { | ||
| 312 | makeRespError(ctx, w, "Repository not found", http.StatusNotFound) | ||
| 313 | return false | ||
| 314 | } | ||
| 315 | if or.operation == operationUpload { | ||
| 316 | writeAccess, err := getGitoliteAccess(or.repository, user, "W", or.refspec) | ||
| 317 | if err != nil { | ||
| 318 | reqlog(ctx, "Error checking access info: %s", err) | ||
| 319 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
| 320 | return false | ||
| 321 | } | ||
| 322 | // User has read access but no write access | ||
| 323 | if !writeAccess { | ||
| 324 | makeRespError(ctx, w, "Forbidden", http.StatusForbidden) | ||
| 325 | return false | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | return true | ||
| 330 | } | ||
| 331 | |||
| 199 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 332 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 200 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | 333 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) |
| 201 | 334 | ||
| @@ -229,42 +362,29 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 229 | makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest) | 362 | makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest) |
| 230 | return | 363 | return |
| 231 | } | 364 | } |
| 232 | 365 | if body.Operation != operationDownload && body.Operation != operationUpload { | |
| 233 | if body.HashAlgo != hashAlgoSHA256 { | 366 | makeRespError(ctx, w, "Invalid operation specified", http.StatusBadRequest) |
| 234 | makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict) | ||
| 235 | return | 367 | return |
| 236 | } | 368 | } |
| 237 | 369 | ||
| 238 | // TODO: handle authentication | 370 | or := operationRequest{ |
| 239 | // right now, we're just trying to make everything publically accessible | 371 | operation: body.Operation, |
| 240 | if body.Operation == operationUpload { | 372 | repository: repo, |
| 241 | makeRespError(ctx, w, "Upload operations are currently not supported", http.StatusForbidden) | ||
| 242 | return | ||
| 243 | } | 373 | } |
| 244 | 374 | if body.Ref != nil { | |
| 245 | if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { | 375 | or.refspec = &body.Ref.Name |
| 246 | makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) | 376 | } |
| 377 | if !h.authorize(ctx, w, r, or) { | ||
| 247 | return | 378 | return |
| 248 | } | 379 | } |
| 249 | 380 | ||
| 250 | gitoliteArgs := []string{"access", "-q", repo, h.anonUser, "R"} | 381 | if body.HashAlgo != hashAlgoSHA256 { |
| 251 | if body.Ref != nil && body.Ref.Name != "" { | 382 | makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict) |
| 252 | gitoliteArgs = append(gitoliteArgs, body.Ref.Name) | ||
| 253 | } | ||
| 254 | cmd := exec.Command(h.gitolitePath, gitoliteArgs...) | ||
| 255 | err := cmd.Run() | ||
| 256 | permGranted := err == nil | ||
| 257 | var exitErr *exec.ExitError | ||
| 258 | if err != nil && !errors.As(err, &exitErr) { | ||
| 259 | reqlog(ctx, "Error checking access info (running %s): %s", cmd, err) | ||
| 260 | makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError) | ||
| 261 | return | 383 | return |
| 262 | } | 384 | } |
| 263 | if !permGranted { | 385 | |
| 264 | // TODO: when handling authorization, make sure to return 403 Forbidden | 386 | if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { |
| 265 | // here when the user *does* have read permissions, but is not allowed | 387 | makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) |
| 266 | // to write when requesting an upload operation. | ||
| 267 | makeRespError(ctx, w, "Repository not found", http.StatusNotFound) | ||
| 268 | return | 388 | return |
| 269 | } | 389 | } |
| 270 | 390 | ||
| @@ -288,7 +408,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 288 | HashAlgo: hashAlgoSHA256, | 408 | HashAlgo: hashAlgoSHA256, |
| 289 | } | 409 | } |
| 290 | for _, obj := range objects { | 410 | for _, obj := range objects { |
| 291 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | 411 | switch body.Operation { |
| 412 | case operationDownload: | ||
| 413 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | ||
| 414 | case operationUpload: | ||
| 415 | resp.Objects = append(resp.Objects, h.handleUploadObject(ctx, repo, obj)) | ||
| 416 | } | ||
| 292 | } | 417 | } |
| 293 | 418 | ||
| 294 | w.Header().Set("Content-Type", lfsMIME) | 419 | w.Header().Set("Content-Type", lfsMIME) |
| @@ -316,6 +441,23 @@ func die(msg string, args ...any) { | |||
| 316 | os.Exit(1) | 441 | os.Exit(1) |
| 317 | } | 442 | } |
| 318 | 443 | ||
| 444 | func loadPublicKey(path string) ed25519.PublicKey { | ||
| 445 | raw, err := os.ReadFile(path) | ||
| 446 | if err != nil { | ||
| 447 | die("Failed to open specified public key: %s", err) | ||
| 448 | } | ||
| 449 | raw = bytes.TrimSpace(raw) | ||
| 450 | |||
| 451 | if hex.DecodedLen(len(raw)) != ed25519.PublicKeySize { | ||
| 452 | die("Specified public key file does not contain key of appropriate length") | ||
| 453 | } | ||
| 454 | decoded := make([]byte, hex.DecodedLen(len(raw))) | ||
| 455 | if _, err = hex.Decode(decoded, raw); err != nil { | ||
| 456 | die("Failed to decode specified public key: %s", err) | ||
| 457 | } | ||
| 458 | return decoded | ||
| 459 | } | ||
| 460 | |||
| 319 | func main() { | 461 | func main() { |
| 320 | log("Environment variables:") | 462 | log("Environment variables:") |
| 321 | for _, s := range os.Environ() { | 463 | for _, s := range os.Environ() { |
| @@ -323,6 +465,7 @@ func main() { | |||
| 323 | } | 465 | } |
| 324 | 466 | ||
| 325 | anonUser := os.Getenv("ANON_USER") | 467 | anonUser := os.Getenv("ANON_USER") |
| 468 | publicKeyPath := os.Getenv("GITOLFS3_PUBLIC_KEY_PATH") | ||
| 326 | endpoint := os.Getenv("S3_ENDPOINT") | 469 | endpoint := os.Getenv("S3_ENDPOINT") |
| 327 | bucket := os.Getenv("S3_BUCKET") | 470 | bucket := os.Getenv("S3_BUCKET") |
| 328 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") | 471 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") |
| @@ -336,6 +479,9 @@ func main() { | |||
| 336 | if anonUser == "" { | 479 | if anonUser == "" { |
| 337 | die("Fatal: expected environment variable ANON_USER to be set") | 480 | die("Fatal: expected environment variable ANON_USER to be set") |
| 338 | } | 481 | } |
| 482 | if publicKeyPath == "" { | ||
| 483 | die("Fatal: expected environment variable GITOLFS3_PUBLIC_KEY_PATH to be set") | ||
| 484 | } | ||
| 339 | if endpoint == "" { | 485 | if endpoint == "" { |
| 340 | die("Fatal: expected environment variable S3_ENDPOINT to be set") | 486 | die("Fatal: expected environment variable S3_ENDPOINT to be set") |
| 341 | } | 487 | } |
| @@ -359,6 +505,8 @@ func main() { | |||
| 359 | die("Fatal: failed to read secret access key from specified file: %s", err) | 505 | die("Fatal: failed to read secret access key from specified file: %s", err) |
| 360 | } | 506 | } |
| 361 | 507 | ||
| 508 | publicKey := loadPublicKey(publicKeyPath) | ||
| 509 | |||
| 362 | mc, err := minio.New(endpoint, &minio.Options{ | 510 | mc, err := minio.New(endpoint, &minio.Options{ |
| 363 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), | 511 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), |
| 364 | Secure: true, | 512 | Secure: true, |
| @@ -367,7 +515,7 @@ func main() { | |||
| 367 | die("Fatal: failed to create S3 client: %s", err) | 515 | die("Fatal: failed to create S3 client: %s", err) |
| 368 | } | 516 | } |
| 369 | 517 | ||
| 370 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath}); err != nil { | 518 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, publicKey}); err != nil { |
| 371 | die("Fatal: failed to serve CGI: %s", err) | 519 | die("Fatal: failed to serve CGI: %s", err) |
| 372 | } | 520 | } |
| 373 | } | 521 | } |