Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to `bgit` are documented in this file.

This project follows semantic versioning.

## 1.3.5

Added

- Added `git-remote-bgit` remote-helper mode so the `bgit` binary can be
installed under that name and used by native Git for `bgit://` and
`bgit::...` remotes.

Fixed

- `bgit admin accept-broker-invite` now reports a missing SSH identity as an
invite acceptance problem instead of mislabeling it as broker version skew.

## 1.3.4

Fixed
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ git fetch
git push
```

When `bgit` is also installed as `git-remote-bgit`, native Git can use the
BucketGit remote helper for `bgit://` and `bgit::` remotes:

```bash
git clone bgit::https://broker.example.com/demo.git
git remote add origin bgit::demo.git
git fetch origin
git push origin main
```

`bgit::https://broker.example.com/demo.git` carries the broker URL explicitly.
`bgit::demo.git` and `bgit://demo.git` resolve through the current checkout's
BucketGit broker configuration.

## Custom Domains

BucketGit can discover brokers from DNS TXT records, so users can clone from a
Expand Down
2 changes: 1 addition & 1 deletion broker/aws/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2169,7 +2169,7 @@ Resources:
if (path === "/broker/users/invite/accept" && method === "POST") {
const users = await loadBrokerUsers();
const signed = await submittedSignedKey(event);
if (!signed) throw Object.assign(new Error("SSH signature required"), {statusCode: 403});
if (!signed) throw Object.assign(new Error("broker invite accept SSH signature required"), {statusCode: 403});
const tokenHash = ownershipTransferTokenHash(body.token);
const invites = users.data.invites || [];
const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || "") > Date.now());
Expand Down
2 changes: 1 addition & 1 deletion broker/gcp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1684,7 +1684,7 @@ exports.broker = async (req, res) => {
if (req.path === '/broker/users/invite/accept' && req.method === 'POST') {
const users = await loadBrokerUsers();
const signed = await submittedSignedKey(req);
if (!signed) throw Object.assign(new Error('SSH signature required'), {status: 403});
if (!signed) throw Object.assign(new Error('broker invite accept SSH signature required'), {status: 403});
const tokenHash = ownershipTransferTokenHash(body.token);
const invites = users.data.invites || [];
const invite = invites.find((item) => item.token_hash === tokenHash && Date.parse(item.expires_at || '') > Date.now());
Expand Down
11 changes: 11 additions & 0 deletions broker_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2069,12 +2069,23 @@ func brokerAcceptBrokerInviteCommand(args []string, stdout io.Writer) error {
}
var resp brokerOwnerTransferResponse
if err := brokerPost(payload.BrokerURL, "/broker/users/invite/accept", brokerRepoAdminRequest{User: payload.User, Token: payload.Token}, &resp); err != nil {
if brokerInviteAcceptNeedsSSHIdentity(err) {
return errors.New("accepting a broker invite requires an SSH signature; load a key with `ssh-add`, pass `--identity PATH`, or set BGIT_SSH_KEY")
}
return err
}
fmt.Fprintf(stdout, "accepted broker invite for %s as %s with key %s\n", resp.User, resp.Role, resp.Fingerprint)
return nil
}

func brokerInviteAcceptNeedsSSHIdentity(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "/broker/users/invite/accept") && strings.Contains(msg, "ssh signature required")
}

func brokerCancelBrokerInviteCommand(cfg config, args []string, stdout io.Writer) error {
brokerURL := ""
user := ""
Expand Down
7 changes: 7 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ type config struct {
}

func main() {
if filepath.Base(os.Args[0]) == "git-remote-bgit" {
if err := remoteHelperCommand(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
return
}
if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
Expand Down
10 changes: 10 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,16 @@ func TestBrokerHTTPErrorHintsAtIncompatibleBrokerOnSignatureFailure(t *testing.T
}
}

func TestBrokerInviteAcceptNeedsSSHIdentity(t *testing.T) {
err := brokerHTTPError("/broker/users/invite/accept", `{"error":"SSH signature required"}`)
if !brokerInviteAcceptNeedsSSHIdentity(err) {
t.Fatalf("expected broker invite accept identity hint for %v", err)
}
if brokerInviteAcceptNeedsSSHIdentity(errors.New(`broker /auth/status: {"error":"SSH signature required"}`)) {
t.Fatalf("did not expect broker invite accept identity hint for auth status")
}
}

func TestMergeConfigUsesRepoAuthUnlessExplicit(t *testing.T) {
local := config{auth: "adc", gcloudConfiguration: "test-profile"}
merged := mergeConfig(config{auth: "gcloud"}, local)
Expand Down
151 changes: 151 additions & 0 deletions remote_helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package main

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/url"
"strings"
)

func remoteHelperCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
address := remoteHelperAddress(args)
if address == "" {
return errors.New("usage: git-remote-bgit <repository> [<url>]")
}
br := bufio.NewReader(stdin)
bw := bufio.NewWriter(stdout)
for {
line, err := br.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && strings.TrimSpace(line) == "" {
return nil
}
return err
}
line = strings.TrimRight(line, "\r\n")
switch {
case line == "":
return nil
case line == "capabilities":
fmt.Fprintln(bw, "connect")
fmt.Fprintln(bw)
if err := bw.Flush(); err != nil {
return err
}
case strings.HasPrefix(line, "connect "):
service := strings.TrimSpace(strings.TrimPrefix(line, "connect "))
if service != gitUploadPackService && service != gitReceivePackService {
return fmt.Errorf("unsupported git remote helper service %q", service)
}
cfg, err := configForRemoteHelperAddress(address)
if err != nil {
return err
}
fmt.Fprintln(bw)
if err := bw.Flush(); err != nil {
return err
}
return serveGitServiceWithConfig(context.Background(), service, cfg, br, stdout)
case strings.HasPrefix(line, "option "):
fmt.Fprintln(bw, "unsupported")
if err := bw.Flush(); err != nil {
return err
}
default:
fmt.Fprintf(stderr, "unsupported git remote helper command %q\n", line)
return fmt.Errorf("unsupported git remote helper command %q", line)
}
}
}

func remoteHelperAddress(args []string) string {
if len(args) >= 2 {
return strings.TrimSpace(args[1])
}
if len(args) == 1 {
return strings.TrimSpace(args[0])
}
return ""
}

func configForRemoteHelperAddress(address string) (config, error) {
address = strings.TrimSpace(address)
address = strings.TrimPrefix(address, "bgit::")
if address == "" {
return config{}, errors.New("missing bgit remote helper URL")
}
if strings.HasPrefix(address, "bgit://") {
parsed, err := url.Parse(address)
if err != nil {
return config{}, err
}
repo := strings.Trim(strings.Trim(parsed.Host+"/"+strings.Trim(parsed.Path, "/"), "/"), "/")
if repo == "" {
return config{}, errors.New("bgit remote helper URL must include a repository name")
}
return configForRemoteHelperLogicalRepo(repo)
}
if strings.HasPrefix(address, "http://") || strings.HasPrefix(address, "https://") {
return configForRemoteHelperBrokerURL(address)
}
if strings.HasPrefix(address, "s3://") || strings.HasPrefix(address, "gs://") || strings.HasPrefix(address, "gcs://") {
cfg, _, err := parseRepoURI(address)
if err != nil {
return config{}, err
}
return mergeSSHRepoAuth(cfg), nil
}
return configForRemoteHelperLogicalRepo(address)
}

func configForRemoteHelperBrokerURL(raw string) (config, error) {
parsed, err := url.Parse(raw)
if err != nil {
return config{}, err
}
repoPart := strings.Trim(parsed.Path, "/")
if repoPart == "" {
return config{}, errors.New("bgit broker URL must include a repository name")
}
parts := strings.Split(repoPart, "/")
logical, err := normalizeLogicalRepoName(parts[len(parts)-1])
if err != nil {
return config{}, err
}
parsed.Path = strings.TrimSuffix(strings.TrimSuffix(parsed.Path, "/"), "/"+parts[len(parts)-1])
parsed.RawQuery = ""
parsed.Fragment = ""
brokerURL := strings.TrimRight(parsed.String(), "/")
cfg := config{
provider: "gcs",
brokerURL: brokerURL,
logicalRepo: logical,
prefix: logical,
branch: defaultBranch,
origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logical),
}
return mergeSSHRepoAuth(cfg), nil
}

func configForRemoteHelperLogicalRepo(repo string) (config, error) {
logical, err := normalizeLogicalRepoName(repo)
if err != nil {
return config{}, err
}
if localCfg, err := readLocalConfig("."); err == nil && strings.TrimSpace(localCfg.brokerURL) != "" {
localCfg.logicalRepo = logical
localCfg.prefix = logical
localCfg.origin = fmt.Sprintf("git@%s:%s", defaultSSHHost, logical)
return mergeSSHRepoAuth(localCfg), nil
}
return mergeSSHRepoAuth(config{
provider: "gcs",
logicalRepo: logical,
prefix: logical,
branch: defaultBranch,
origin: fmt.Sprintf("git@%s:%s", defaultSSHHost, logical),
}), nil
}
47 changes: 47 additions & 0 deletions remote_helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestRemoteHelperCapabilities(t *testing.T) {
var stdout, stderr bytes.Buffer
err := remoteHelperCommand([]string{"bgit://demo.git"}, strings.NewReader("capabilities\n\n"), &stdout, &stderr)
if err != nil {
t.Fatalf("remoteHelperCommand: %v\nstderr: %s", err, stderr.String())
}
if got := stdout.String(); got != "connect\n\n" {
t.Fatalf("capabilities output = %q", got)
}
}

func TestRemoteHelperAddress(t *testing.T) {
if got := remoteHelperAddress([]string{"origin", "bgit://demo.git"}); got != "bgit://demo.git" {
t.Fatalf("address with url = %q", got)
}
if got := remoteHelperAddress([]string{"bgit::demo.git"}); got != "bgit::demo.git" {
t.Fatalf("address without url = %q", got)
}
}

func TestRemoteHelperBrokerURLConfig(t *testing.T) {
cfg, err := configForRemoteHelperAddress("https://broker.example.com/demo.git")
if err != nil {
t.Fatal(err)
}
if cfg.brokerURL != "https://broker.example.com" || cfg.logicalRepo != "demo.git" || cfg.prefix != "demo.git" {
t.Fatalf("cfg = %#v", cfg)
}
}

func TestRemoteHelperLogicalURLConfig(t *testing.T) {
cfg, err := configForRemoteHelperAddress("bgit://demo.git")
if err != nil {
t.Fatal(err)
}
if cfg.logicalRepo != "demo.git" || cfg.prefix != "demo.git" {
t.Fatalf("cfg = %#v", cfg)
}
}
4 changes: 4 additions & 0 deletions ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func sshServeGitService(service, repo, host string, stdin io.Reader, stdout io.W
if err != nil {
return err
}
return serveGitServiceWithConfig(ctx, service, cfg, stdin, stdout)
}

func serveGitServiceWithConfig(ctx context.Context, service string, cfg config, stdin io.Reader, stdout io.Writer) error {
if err := authorizeSSHGitService(cfg, service); err != nil {
return err
}
Expand Down