diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/git-lfs-authenticate/main.go | 155 | ||||
| -rw-r--r-- | cmd/git-lfs-server/main.go | 32 |
2 files changed, 130 insertions, 57 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index 41e2dbc..56b9d78 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
| @@ -1,8 +1,18 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "encoding/hex" | ||
| 7 | "encoding/json" | ||
| 8 | "errors" | ||
| 4 | "fmt" | 9 | "fmt" |
| 5 | "os" | 10 | "os" |
| 11 | "os/exec" | ||
| 12 | "strings" | ||
| 13 | "time" | ||
| 14 | |||
| 15 | "github.com/golang-jwt/jwt/v5" | ||
| 6 | ) | 16 | ) |
| 7 | 17 | ||
| 8 | func die(msg string, args ...any) { | 18 | func die(msg string, args ...any) { |
| @@ -12,37 +22,118 @@ func die(msg string, args ...any) { | |||
| 12 | os.Exit(1) | 22 | os.Exit(1) |
| 13 | } | 23 | } |
| 14 | 24 | ||
| 25 | func getGitoliteAccess(path, user, gitolitePerm string) bool { | ||
| 26 | // gitolite access -q: returns only exit code | ||
| 27 | cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | ||
| 28 | err := cmd.Run() | ||
| 29 | permGranted := err == nil | ||
| 30 | var exitErr *exec.ExitError | ||
| 31 | if err != nil && !errors.As(err, &exitErr) { | ||
| 32 | die("failed to query access information") | ||
| 33 | } | ||
| 34 | return permGranted | ||
| 35 | } | ||
| 36 | |||
| 37 | type gitolfs3Claims struct { | ||
| 38 | Repository string `json:"repository"` | ||
| 39 | Permission string `json:"permission"` | ||
| 40 | } | ||
| 41 | |||
| 42 | type customClaims struct { | ||
| 43 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | ||
| 44 | *jwt.RegisteredClaims | ||
| 45 | } | ||
| 46 | |||
| 47 | type authenticateResponse struct { | ||
| 48 | Header map[string]string `json:"header"` | ||
| 49 | // In seconds. | ||
| 50 | ExpiresIn int64 `json:"expires_in,omitempty"` | ||
| 51 | // expires_at (RFC3339) could also be used, but we leave it out since we | ||
| 52 | // don't use it. | ||
| 53 | } | ||
| 54 | |||
| 55 | func wipe(b []byte) { | ||
| 56 | for i := range b { | ||
| 57 | b[i] = 0 | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 15 | func main() { | 61 | func main() { |
| 16 | // if len(os.Args) != 3 { | 62 | // Even though not explicitly described in the Git LFS documentation, the |
| 17 | // die("expected 2 arguments [path, operation], got %d", len(os.Args)-1) | 63 | // git-lfs-authenticate command is expected to either exit succesfully with |
| 18 | // } | 64 | // exit code 0 and to then print credentials in the prescribed JSON format |
| 19 | // | 65 | // to standard out. On errors, the command should exit with a non-zero exit |
| 20 | // path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") | 66 | // code and print the error message in plain text to standard error. See |
| 21 | // operation := os.Args[2] | 67 | // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117 |
| 22 | // | 68 | |
| 23 | // if operation != "download" && operation != "upload" { | 69 | if len(os.Args) != 3 { |
| 24 | // die("expected operation to be in {upload, download}, got %s", operation) | 70 | die("expected 2 arguments (path, operation), got %d", len(os.Args)-1) |
| 25 | // } | 71 | } |
| 26 | // | 72 | |
| 27 | // user := os.Getenv("GL_USER") | 73 | path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") |
| 28 | // | 74 | operation := os.Args[2] |
| 29 | // if user == "" { | 75 | if operation != "download" && operation != "upload" { |
| 30 | // die("expected Gitolite user env (GL_USER) to be set") | 76 | die("expected operation to be upload or download, got %s", operation) |
| 31 | // } | 77 | } |
| 32 | // | 78 | |
| 33 | // gitolitePerm := "R" | 79 | user := os.Getenv("GL_USER") |
| 34 | // | 80 | if user == "" { |
| 35 | // if operation == "upload" { | 81 | die("internal error") |
| 36 | // gitolitePerm = "W" | 82 | } |
| 37 | // } | 83 | keyPath := os.Getenv("GITOLFS3_KEY_PATH") |
| 38 | // | 84 | if keyPath == "" { |
| 39 | // // gitolite access -q: returns only exit code | 85 | die("internal error") |
| 40 | // cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | 86 | } |
| 41 | // err := cmd.Run() | 87 | keyStr, err := os.ReadFile(keyPath) |
| 42 | // permGranted := err == nil | 88 | if err != nil { |
| 43 | // var exitErr *exec.ExitError | 89 | die("internal error") |
| 44 | // | 90 | } |
| 45 | // if err != nil && !errors.As(err, &exitErr) { | 91 | keyStr = bytes.TrimSpace(keyStr) |
| 46 | // die("failed to query Gitolite access information") | 92 | defer wipe(keyStr) |
| 47 | // } | 93 | |
| 94 | if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { | ||
| 95 | die("internal error") | ||
| 96 | } | ||
| 97 | |||
| 98 | seed := make([]byte, ed25519.SeedSize) | ||
| 99 | defer wipe(seed) | ||
| 100 | if _, err = hex.Decode(seed, keyStr); err != nil { | ||
| 101 | die("internal error") | ||
| 102 | } | ||
| 103 | privateKey := ed25519.NewKeyFromSeed(seed) | ||
| 104 | |||
| 105 | if !getGitoliteAccess(path, user, "R") { | ||
| 106 | die("repository not found") | ||
| 107 | } | ||
| 108 | if operation == "upload" && !getGitoliteAccess(path, user, "W") { | ||
| 109 | // User has read access but not write access | ||
| 110 | die("forbidden") | ||
| 111 | } | ||
| 112 | |||
| 113 | expiresIn := time.Hour * 24 | ||
| 114 | claims := customClaims{ | ||
| 115 | Gitolfs3: gitolfs3Claims{ | ||
| 116 | Repository: path, | ||
| 117 | Permission: operation, | ||
| 118 | }, | ||
| 119 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
| 120 | Subject: user, | ||
| 121 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
| 122 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
| 123 | }, | ||
| 124 | } | ||
| 125 | |||
| 126 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) | ||
| 127 | ss, err := token.SignedString(privateKey) | ||
| 128 | if err != nil { | ||
| 129 | die("failed to generate token") | ||
| 130 | } | ||
| 131 | |||
| 132 | response := authenticateResponse{ | ||
| 133 | Header: map[string]string{ | ||
| 134 | "Authorization": "Bearer " + ss, | ||
| 135 | }, | ||
| 136 | ExpiresIn: int64(expiresIn.Seconds()), | ||
| 137 | } | ||
| 138 | json.NewEncoder(os.Stdout).Encode(response) | ||
| 48 | } | 139 | } |
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 5e99288..c76c5d9 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | "path" | 14 | "path" |
| 15 | "regexp" | 15 | "regexp" |
| 16 | "slices" | 16 | "slices" |
| 17 | "strconv" | ||
| 18 | "strings" | 17 | "strings" |
| 19 | "time" | 18 | "time" |
| 20 | "unicode" | 19 | "unicode" |
| @@ -54,29 +53,13 @@ type batchRequest struct { | |||
| 54 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` | 53 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` |
| 55 | } | 54 | } |
| 56 | 55 | ||
| 57 | type RFC3339SecondsTime time.Time | ||
| 58 | |||
| 59 | func (t RFC3339SecondsTime) MarshalJSON() ([]byte, error) { | ||
| 60 | b := make([]byte, 0, len(time.RFC3339)+len(`""`)) | ||
| 61 | b = append(b, '"') | ||
| 62 | b = time.Time(t).AppendFormat(b, time.RFC3339) | ||
| 63 | b = append(b, '"') | ||
| 64 | return b, nil | ||
| 65 | } | ||
| 66 | |||
| 67 | type SecondDuration time.Duration | ||
| 68 | |||
| 69 | func (d SecondDuration) MarshalJSON() ([]byte, error) { | ||
| 70 | var b []byte | ||
| 71 | b = strconv.AppendInt(b, int64(time.Duration(d).Seconds()), 10) | ||
| 72 | return b, nil | ||
| 73 | } | ||
| 74 | |||
| 75 | type batchAction struct { | 56 | type batchAction struct { |
| 76 | HRef string `json:"href"` | 57 | HRef string `json:"href"` |
| 77 | Header map[string]string `json:"header,omitempty"` | 58 | Header map[string]string `json:"header,omitempty"` |
| 78 | ExpiresIn *SecondDuration `json:"expires_in,omitempty"` | 59 | // In seconds. |
| 79 | ExpiresAt *RFC3339SecondsTime `json:"expires_at,omitempty"` | 60 | ExpiresIn int64 `json:"expires_in,omitempty"` |
| 61 | // expires_at (RFC3339) could also be used, but we leave it out since we | ||
| 62 | // don't use it. | ||
| 80 | } | 63 | } |
| 81 | 64 | ||
| 82 | type batchError struct { | 65 | type batchError struct { |
| @@ -148,7 +131,6 @@ func makeObjError(obj parsedBatchObject, message string, code int) batchResponse | |||
| 148 | func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | 131 | func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { |
| 149 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | 132 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) |
| 150 | expiresIn := time.Hour * 24 | 133 | expiresIn := time.Hour * 24 |
| 151 | expiresInSeconds := SecondDuration(expiresIn) | ||
| 152 | 134 | ||
| 153 | info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) | 135 | info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) |
| 154 | if err != nil { | 136 | if err != nil { |
| @@ -184,7 +166,7 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 184 | Actions: map[operation]batchAction{ | 166 | Actions: map[operation]batchAction{ |
| 185 | operationDownload: { | 167 | operationDownload: { |
| 186 | HRef: presigned.String(), | 168 | HRef: presigned.String(), |
| 187 | ExpiresIn: &expiresInSeconds, | 169 | ExpiresIn: int64(expiresIn.Seconds()), |
| 188 | }, | 170 | }, |
| 189 | }, | 171 | }, |
| 190 | } | 172 | } |