diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d08887..8677cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c9c5188..2768704 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/broker/aws/template.yaml b/broker/aws/template.yaml index 166c7c2..5b82bf4 100644 --- a/broker/aws/template.yaml +++ b/broker/aws/template.yaml @@ -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()); diff --git a/broker/gcp/index.js b/broker/gcp/index.js index 8bb36c6..a281c45 100644 --- a/broker/gcp/index.js +++ b/broker/gcp/index.js @@ -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()); diff --git a/broker_commands.go b/broker_commands.go index 8cd78cd..8547f90 100644 --- a/broker_commands.go +++ b/broker_commands.go @@ -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 := "" diff --git a/main.go b/main.go index 8831fd7..d8e5aab 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/main_test.go b/main_test.go index 4883b9e..479059b 100644 --- a/main_test.go +++ b/main_test.go @@ -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) diff --git a/remote_helper.go b/remote_helper.go new file mode 100644 index 0000000..d6b23ed --- /dev/null +++ b/remote_helper.go @@ -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 []") + } + 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 +} diff --git a/remote_helper_test.go b/remote_helper_test.go new file mode 100644 index 0000000..2dcd6d9 --- /dev/null +++ b/remote_helper_test.go @@ -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) + } +} diff --git a/ssh.go b/ssh.go index 9962e55..6e1aaad 100644 --- a/ssh.go +++ b/ssh.go @@ -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 }