diff --git a/go.mod b/go.mod
index 4c2145a3afd93..90b38ea8cd935 100644
--- a/go.mod
+++ b/go.mod
@@ -100,6 +100,7 @@ require (
github.com/fxamacker/cbor/v2 v2.7.0
github.com/ghodss/yaml v1.0.0
github.com/gizak/termui/v3 v3.1.0
+ github.com/go-git/go-git/v5 v5.12.0
github.com/go-jose/go-jose/v3 v3.0.3
github.com/go-ldap/ldap/v3 v3.4.8
github.com/go-logr/logr v1.4.2
@@ -339,6 +340,8 @@ require (
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -400,6 +403,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -467,6 +471,7 @@ require (
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22 // indirect
github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243 // indirect
+ github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/xattr v0.4.10 // indirect
@@ -546,6 +551,7 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/component-helpers v0.31.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/metrics v0.31.1 // indirect
diff --git a/go.sum b/go.sum
index e0b7780b45836..5405cfcee1129 100644
--- a/go.sum
+++ b/go.sum
@@ -778,8 +778,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
-github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
-github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
+github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY+9ef8E=
@@ -1242,6 +1242,14 @@ github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
+github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
+github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1684,6 +1692,8 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -1968,6 +1978,8 @@ github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22 h1:2SOzvGvE8beiC1Y4g
github.com/pingcap/log v1.1.1-0.20230317032135-a0d097d16e22/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243 h1:B3pF5adXRpuEDfSKY/bV2Lw+pPKtWH4FOaAX3Jx3X54=
github.com/pingcap/tidb/pkg/parser v0.0.0-20240930120915-74034d4ac243/go.mod h1:dXcO3Ts6jUVE1VwBZp3wbVdGO4pi9MXY6IvL4L1z62g=
+github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -2076,8 +2088,8 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
-github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500/go.mod h1:+njLrG5wSeoG4Ds61rFgEzKvenR2UHbjMoDHsczxly0=
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIGMa7oSwp8SQPp7Hb4ZiI38K3RNBKD2LLeEM=
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc=
@@ -3128,6 +3140,8 @@ gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/tool/tsh/common/git.go b/tool/tsh/common/git.go
index 3f43578fb4132..53de1df70f1af 100644
--- a/tool/tsh/common/git.go
+++ b/tool/tsh/common/git.go
@@ -19,24 +19,119 @@
package common
import (
+ "bytes"
+ "io"
+ "os/exec"
+ "strings"
+
"github.com/alecthomas/kingpin/v2"
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
)
type gitCommands struct {
- list *gitListCommand
- login *gitLoginCommand
+ list *gitListCommand
+ login *gitLoginCommand
+ ssh *gitSSHCommand
+ config *gitConfigCommand
+ clone *gitCloneCommand
}
func newGitCommands(app *kingpin.Application) gitCommands {
git := app.Command("git", "Git server commands.")
cmds := gitCommands{
- login: newGitLoginCommand(git),
- list: newGitListCommand(git),
+ login: newGitLoginCommand(git),
+ list: newGitListCommand(git),
+ ssh: newGitSSHCommand(git),
+ config: newGitConfigCommand(git),
+ clone: newGitCloneCommand(git),
}
// TODO(greedy52) hide the commands until all basic features are implemented.
git.Hidden()
cmds.login.Hidden()
cmds.list.Hidden()
+ cmds.config.Hidden()
+ cmds.clone.Hidden()
return cmds
}
+
+type gitSSHURL struct {
+ *transport.Endpoint
+}
+
+func (g gitSSHURL) check() error {
+ switch {
+ case g.isGitHub():
+ if err := types.ValidateGitHubOrganizationName(g.owner()); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+ return nil
+}
+
+func (g gitSSHURL) isGitHub() bool {
+ return g.Host == "github.com"
+}
+
+// owner returns the first part of the path. If the path does not have an owner,
+// an empty string is returned.
+//
+// For GitHub, owner is either the user or the organization that owns the repo.
+func (g gitSSHURL) owner() string {
+ owner, _, ok := strings.Cut(strings.TrimPrefix(g.Path, "/"), "/")
+ if !ok {
+ return ""
+ }
+ return owner
+}
+
+// parseGitSSHURL parse a Git SSH URL.
+//
+// Git URL Spec:
+// - spec: https://git-scm.com/docs/git-clone#_git_urls
+// - example: ssh://example.org/path/to/repo.git
+//
+// GitHub (SCP-like) URL:
+// - spec: https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories
+// - example: git@github.com:gravitational/teleport.git
+func parseGitSSHURL(originalURL string) (*gitSSHURL, error) {
+ endpoint, err := transport.NewEndpoint(originalURL)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if endpoint.Protocol != "ssh" {
+ return nil, trace.BadParameter("unsupported git ssh URL %s", originalURL)
+ }
+ s := &gitSSHURL{
+ Endpoint: endpoint,
+ }
+
+ if err := s.check(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return s, nil
+}
+
+func execGitAndCaptureStdout(cf *CLIConf, args ...string) (string, error) {
+ var bufStd bytes.Buffer
+ if err := execGitWithStdoutAndStderr(cf, &bufStd, cf.Stderr(), args...); err != nil {
+ return "", trace.Wrap(err)
+ }
+ return strings.TrimSpace(bufStd.String()), nil
+}
+
+func execGit(cf *CLIConf, args ...string) error {
+ return trace.Wrap(execGitWithStdoutAndStderr(cf, cf.Stdout(), cf.Stderr(), args...))
+}
+
+func execGitWithStdoutAndStderr(cf *CLIConf, stdout, stderr io.Writer, args ...string) error {
+ log.Debugf("Executing 'git' with args: %v", args)
+ cmd := exec.CommandContext(cf.Context, "git", args...)
+ cmd.Stdin = cf.Stdin()
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ return trace.Wrap(cf.RunCommand(cmd))
+}
diff --git a/tool/tsh/common/git_clone.go b/tool/tsh/common/git_clone.go
new file mode 100644
index 0000000000000..e1450d2dc6b3d
--- /dev/null
+++ b/tool/tsh/common/git_clone.go
@@ -0,0 +1,71 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "fmt"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+)
+
+// gitCloneCommand implements `tsh git clone`.
+//
+// This command internally executes `git clone` while setting `core.sshcommand`.
+// You can generally assume the user has `git` binary installed (otherwise there
+// is no point using the `git` proxy feature). An alternative is to use the
+// `go-git` library.
+type gitCloneCommand struct {
+ *kingpin.CmdClause
+
+ repository string
+ directory string
+}
+
+func newGitCloneCommand(parent *kingpin.CmdClause) *gitCloneCommand {
+ cmd := &gitCloneCommand{
+ CmdClause: parent.Command("clone", "Clone a Git repository."),
+ }
+
+ cmd.Arg("repository", "Git URL of the repository to clone.").Required().StringVar(&cmd.repository)
+ cmd.Arg("directory", "The name of a new directory to clone into.").StringVar(&cmd.directory)
+ // TODO(greedy52) support passing extra args to git like --branch/--depth.
+ return cmd
+}
+
+func (c *gitCloneCommand) run(cf *CLIConf) error {
+ u, err := parseGitSSHURL(c.repository)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if !u.isGitHub() {
+ return trace.BadParameter("not a GitHub repository")
+ }
+
+ sshCommand := makeGitCoreSSHCommand(cf.executablePath, u.owner())
+ args := []string{
+ "clone",
+ "--config", fmt.Sprintf("%s=%s", gitCoreSSHCommand, sshCommand),
+ c.repository,
+ }
+ if c.directory != "" {
+ args = append(args, c.directory)
+ }
+ return trace.Wrap(execGit(cf, args...))
+}
diff --git a/tool/tsh/common/git_clone_test.go b/tool/tsh/common/git_clone_test.go
new file mode 100644
index 0000000000000..b2091e6de995f
--- /dev/null
+++ b/tool/tsh/common/git_clone_test.go
@@ -0,0 +1,114 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "context"
+ "os/exec"
+ "slices"
+ "testing"
+
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitCloneCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ cmd *gitCloneCommand
+ verifyCommand func(*exec.Cmd) error
+ checkError require.ErrorAssertionFunc
+ }{
+ {
+ name: "success",
+ cmd: &gitCloneCommand{
+ repository: "git@github.com:gravitational/teleport.git",
+ },
+ verifyCommand: func(cmd *exec.Cmd) error {
+ expect := []string{
+ "git", "clone",
+ "--config", "core.sshcommand=\"tsh\" git ssh --github-org gravitational",
+ "git@github.com:gravitational/teleport.git",
+ }
+ if !slices.Equal(expect, cmd.Args) {
+ return trace.CompareFailed("expect %v but got %v", expect, cmd.Args)
+ }
+ return nil
+ },
+ checkError: require.NoError,
+ },
+ {
+ name: "success with target dir",
+ cmd: &gitCloneCommand{
+ repository: "git@github.com:gravitational/teleport.git",
+ directory: "target_dir",
+ },
+ verifyCommand: func(cmd *exec.Cmd) error {
+ expect := []string{
+ "git", "clone",
+ "--config", "core.sshcommand=\"tsh\" git ssh --github-org gravitational",
+ "git@github.com:gravitational/teleport.git",
+ "target_dir",
+ }
+ if !slices.Equal(expect, cmd.Args) {
+ return trace.CompareFailed("expect %v but got %v", expect, cmd.Args)
+ }
+ return nil
+ },
+ checkError: require.NoError,
+ },
+ {
+ name: "invalid URL",
+ cmd: &gitCloneCommand{
+ repository: "not-a-git-ssh-url",
+ },
+ checkError: require.Error,
+ },
+ {
+ name: "unsupported Git service",
+ cmd: &gitCloneCommand{
+ repository: "git@gitlab.com:group/project.git",
+ },
+ checkError: require.Error,
+ },
+ {
+ name: "git fails",
+ cmd: &gitCloneCommand{
+ repository: "git@github.com:gravitational/teleport.git",
+ },
+ verifyCommand: func(cmd *exec.Cmd) error {
+ return trace.BadParameter("some git error")
+ },
+ checkError: func(t require.TestingT, err error, i ...interface{}) {
+ require.ErrorIs(t, err, trace.BadParameter("some git error"))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cf := &CLIConf{
+ Context: context.Background(),
+ executablePath: "tsh",
+ cmdRunner: tt.verifyCommand,
+ }
+ tt.checkError(t, tt.cmd.run(cf))
+ })
+ }
+}
diff --git a/tool/tsh/common/git_config.go b/tool/tsh/common/git_config.go
new file mode 100644
index 0000000000000..fb7c94eae2445
--- /dev/null
+++ b/tool/tsh/common/git_config.go
@@ -0,0 +1,178 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+)
+
+// gitConfigCommand implements `tsh git config`.
+//
+// This command internally executes `git` commands like `git config xxx`. You
+// can generally assume the user has `git` binary installed (otherwise there is
+// no point using the `git` proxy feature). An alternative is to use the
+// `go-git` library.
+type gitConfigCommand struct {
+ *kingpin.CmdClause
+
+ action string
+}
+
+const (
+ gitConfigActionDefault = ""
+ gitConfigActionUpdate = "update"
+ gitConfigActionReset = "reset"
+
+ // gitCoreSSHCommand is the Git config used for setting up alternative SSH
+ // command. For Git-proxying, the command should point to "tsh git ssh".
+ //
+ // https://git-scm.com/docs/git-config#Documentation/git-config.txt-coresshCommand
+ gitCoreSSHCommand = "core.sshcommand"
+)
+
+func newGitConfigCommand(parent *kingpin.CmdClause) *gitConfigCommand {
+ cmd := &gitConfigCommand{
+ CmdClause: parent.Command("config", "Check Teleport config on the working Git directory. Or provide an action ('update' or 'reset') to configure the Git repo."),
+ }
+
+ cmd.Arg("action", "Optional action to perform. 'update' to configure the Git repo to proxy Git commands through Teleport. 'reset' to clear Teleport configuration from the Git repo.").
+ EnumVar(&cmd.action, gitConfigActionUpdate, gitConfigActionReset)
+ return cmd
+}
+
+func (c *gitConfigCommand) run(cf *CLIConf) error {
+ // Make sure we are in a Git dir.
+ err := execGitWithStdoutAndStderr(cf, io.Discard, io.Discard, "rev-parse", "--is-inside-work-tree")
+ if err != nil {
+ // This error message is a slight alternation of the original error
+ // message from the above command.
+ return trace.BadParameter("the current directory is not a Git repository (or any of the parent directories)")
+ }
+
+ switch c.action {
+ case gitConfigActionDefault:
+ return trace.Wrap(c.doCheck(cf))
+ case gitConfigActionUpdate:
+ return trace.Wrap(c.doUpdate(cf))
+ case gitConfigActionReset:
+ return trace.Wrap(c.doReset(cf))
+ default:
+ return trace.BadParameter("unknown action '%v'", c.action)
+ }
+}
+
+func (c *gitConfigCommand) doCheck(cf *CLIConf) error {
+ sshCommand, err := c.getCoreSSHCommand(cf)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ wantPrefix := makeGitCoreSSHCommand(cf.executablePath, "")
+ if strings.HasPrefix(sshCommand, wantPrefix) {
+ _, org, _ := strings.Cut(sshCommand, wantPrefix)
+ fmt.Fprintf(cf.Stdout(), "The current Git directory is configured with Teleport for GitHub organization %q.\n", org)
+ return nil
+ }
+
+ c.printDirNotConfigured(cf.Stdout(), true, sshCommand)
+ return nil
+}
+
+func (c *gitConfigCommand) printDirNotConfigured(w io.Writer, withUpdate bool, existingSSHCommand string) {
+ fmt.Fprintln(w, "The current Git directory is not configured with Teleport.")
+ if withUpdate {
+ if existingSSHCommand != "" {
+ fmt.Fprintf(w, "%q currently has value %q.\n", gitCoreSSHCommand, existingSSHCommand)
+ fmt.Fprintf(w, "Run 'tsh git config update' to configure Git directory with Teleport but %q will be overwritten.\n", gitCoreSSHCommand)
+ } else {
+ fmt.Fprintln(w, "Run 'tsh git config update' to configure it.")
+ }
+ }
+}
+
+func (c *gitConfigCommand) doUpdate(cf *CLIConf) error {
+ urls, err := execGitAndCaptureStdout(cf, "ls-remote", "--get-url")
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ for _, url := range strings.Split(urls, "\n") {
+ u, err := parseGitSSHURL(url)
+ if err != nil {
+ log.Debugf("Skippig URL: %v", err)
+ continue
+ }
+ if !u.isGitHub() {
+ log.Debugf("Skippig non-GitHub host: %v", u.Host)
+ continue
+ }
+
+ log.Debugf("Configuring %s (org %s) to use tsh.", url, u.owner())
+ args := []string{
+ "config", "--local",
+ "--replace-all", gitCoreSSHCommand,
+ makeGitCoreSSHCommand(cf.executablePath, u.owner()),
+ }
+ if err := execGit(cf, args...); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Fprintln(cf.Stdout(), "Teleport configuration added.")
+ return trace.Wrap(c.doCheck(cf))
+ }
+ return trace.NotFound("no GitHub SSH URL found from 'git ls-remote --get-url'")
+}
+
+func (c *gitConfigCommand) doReset(cf *CLIConf) error {
+ sshCommand, err := c.getCoreSSHCommand(cf)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ wantPrefix := makeGitCoreSSHCommand(cf.executablePath, "")
+ if !strings.HasPrefix(sshCommand, wantPrefix) {
+ c.printDirNotConfigured(cf.Stdout(), false, sshCommand)
+ return nil
+ }
+
+ if err := execGit(cf, "config", "--local", "--unset-all", gitCoreSSHCommand); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Fprintln(cf.Stdout(), "Teleport configuration removed.")
+ return nil
+}
+
+func (c *gitConfigCommand) getCoreSSHCommand(cf *CLIConf) (string, error) {
+ return execGitAndCaptureStdout(cf,
+ "config", "--local",
+ // set default to empty to avoid non-zero exit when config is missing
+ "--default", "",
+ "--get", gitCoreSSHCommand,
+ )
+}
+
+// makeGitCoreSSHCommand generates the value for Git config "core.sshcommand".
+func makeGitCoreSSHCommand(tshBin, githubOrg string) string {
+ // Quote the path in case it has spaces
+ return fmt.Sprintf("\"%s\" git ssh --github-org %s",
+ tshBin,
+ githubOrg,
+ )
+}
diff --git a/tool/tsh/common/git_config_test.go b/tool/tsh/common/git_config_test.go
new file mode 100644
index 0000000000000..265dd68fbfd62
--- /dev/null
+++ b/tool/tsh/common/git_config_test.go
@@ -0,0 +1,183 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "slices"
+ "testing"
+
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/require"
+)
+
+func isGitDirCheck(cmd *exec.Cmd) bool {
+ return slices.Equal([]string{"git", "rev-parse", "--is-inside-work-tree"}, cmd.Args)
+}
+func isGitListRemoteURL(cmd *exec.Cmd) bool {
+ return slices.Equal([]string{"git", "ls-remote", "--get-url"}, cmd.Args)
+}
+func isGitConfigGetCoreSSHCommand(cmd *exec.Cmd) bool {
+ return slices.Equal([]string{"git", "config", "--local", "--default", "", "--get", "core.sshcommand"}, cmd.Args)
+}
+
+type fakeGitCommandRunner struct {
+ dirCheckError error
+ coreSSHCommand string
+ remoteURL string
+ verifyCommand func(cmd *exec.Cmd) error
+}
+
+func (f fakeGitCommandRunner) run(cmd *exec.Cmd) error {
+ switch {
+ case isGitDirCheck(cmd):
+ return f.dirCheckError
+ case isGitConfigGetCoreSSHCommand(cmd):
+ fmt.Fprintln(cmd.Stdout, f.coreSSHCommand)
+ return nil
+ case isGitListRemoteURL(cmd):
+ fmt.Fprintln(cmd.Stdout, f.remoteURL)
+ return nil
+ default:
+ if f.verifyCommand != nil {
+ return trace.Wrap(f.verifyCommand(cmd))
+ }
+ return trace.NotFound("unknown command")
+ }
+}
+
+func TestGitConfigCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ cmd *gitConfigCommand
+ fakeRunner fakeGitCommandRunner
+ checkError require.ErrorAssertionFunc
+ checkOutputContains string
+ }{
+ {
+ name: "not a git dir",
+ cmd: &gitConfigCommand{},
+ fakeRunner: fakeGitCommandRunner{
+ dirCheckError: trace.BadParameter("not a git dir"),
+ },
+ checkError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "the current directory is not a Git repository")
+ },
+ },
+ {
+ name: "check",
+ cmd: &gitConfigCommand{},
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: makeGitCoreSSHCommand("tsh", "org"),
+ },
+ checkError: require.NoError,
+ checkOutputContains: "is configured with Teleport for GitHub organization \"org\"",
+ },
+ {
+ name: "check not configured",
+ cmd: &gitConfigCommand{},
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: "",
+ },
+ checkError: require.NoError,
+ checkOutputContains: "is not configured",
+ },
+ {
+ name: "update success",
+ cmd: &gitConfigCommand{
+ action: gitConfigActionUpdate,
+ },
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: makeGitCoreSSHCommand("tsh", "org"),
+ remoteURL: "git@github.com:gravitational/teleport.git",
+ verifyCommand: func(cmd *exec.Cmd) error {
+ expect := []string{
+ "git", "config", "--local",
+ "--replace-all", "core.sshcommand",
+ "\"tsh\" git ssh --github-org gravitational",
+ }
+ if !slices.Equal(expect, cmd.Args) {
+ return trace.CompareFailed("expect %v but got %v", expect, cmd.Args)
+ }
+ return nil
+ },
+ },
+ checkError: require.NoError,
+ },
+ {
+ name: "update failed missing url",
+ cmd: &gitConfigCommand{
+ action: gitConfigActionUpdate,
+ },
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: makeGitCoreSSHCommand("tsh", "org"),
+ remoteURL: "",
+ },
+ checkError: require.Error,
+ },
+ {
+ name: "reset no-op",
+ cmd: &gitConfigCommand{
+ action: gitConfigActionReset,
+ },
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: "",
+ },
+ checkError: require.NoError,
+ },
+ {
+ name: "reset no-op",
+ cmd: &gitConfigCommand{
+ action: gitConfigActionReset,
+ },
+ fakeRunner: fakeGitCommandRunner{
+ coreSSHCommand: makeGitCoreSSHCommand("tsh", "org"),
+ verifyCommand: func(cmd *exec.Cmd) error {
+ expect := []string{
+ "git", "config", "--local",
+ "--unset-all", "core.sshcommand",
+ }
+ if !slices.Equal(expect, cmd.Args) {
+ return trace.CompareFailed("expect %v but got %v", expect, cmd.Args)
+ }
+ return nil
+ },
+ },
+ checkError: require.NoError,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ cf := &CLIConf{
+ Context: context.Background(),
+ OverrideStdout: &buf,
+ executablePath: "tsh",
+ cmdRunner: tt.fakeRunner.run,
+ }
+ tt.checkError(t, tt.cmd.run(cf))
+ require.Contains(t, buf.String(), tt.checkOutputContains)
+ })
+ }
+}
diff --git a/tool/tsh/common/git_ssh.go b/tool/tsh/common/git_ssh.go
new file mode 100644
index 0000000000000..7b38ffd32f84b
--- /dev/null
+++ b/tool/tsh/common/git_ssh.go
@@ -0,0 +1,86 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/client"
+)
+
+// gitSSHCommand implements `tsh git ssh`.
+//
+// Note that this is a hidden command as it is only meant for 'git` to call.
+// TODO(greedy52) support Git protocol v2.
+type gitSSHCommand struct {
+ *kingpin.CmdClause
+
+ gitHubOrg string
+ userHost string
+ command []string
+ options []string
+}
+
+func newGitSSHCommand(parent *kingpin.CmdClause) *gitSSHCommand {
+ cmd := &gitSSHCommand{
+ CmdClause: parent.Command("ssh", "Proxy Git commands using SSH").Hidden(),
+ }
+
+ cmd.Flag("github-org", "GitHub organization.").Required().StringVar(&cmd.gitHubOrg)
+ cmd.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cmd.userHost)
+ cmd.Arg("command", "Command to execute on a remote host").StringsVar(&cmd.command)
+ cmd.Flag("option", "OpenSSH options in the format used in the configuration file").Short('o').AllowDuplicate().StringsVar(&cmd.options)
+ return cmd
+}
+
+func (c *gitSSHCommand) run(cf *CLIConf) error {
+ _, host, ok := strings.Cut(c.userHost, "@")
+ if !ok || host != "github.com" {
+ return trace.BadParameter("user-host %q is not GitHub", c.userHost)
+ }
+
+ // TODO(greedy52) when git calls tsh, tsh cannot prompt for password (e.g.
+ // user session expired) using provided stdin pipe. `tc.Login` should try
+ // hijacking "/dev/tty" and replace `prompt.Stdin` temporarily.
+ identity, err := getGitHubIdentity(cf, c.gitHubOrg)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ log.Debugf("Proxying git command %v for GitHub user %s.", c.command, identity.Username)
+
+ cf.RemoteCommand = c.command
+ cf.Options = c.options
+ cf.UserHost = fmt.Sprintf("git@%s", types.MakeGitHubOrgServerDomain(c.gitHubOrg))
+
+ tc, err := makeClient(cf)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ tc.Stdin = os.Stdin
+ err = client.RetryWithRelogin(cf.Context, tc, func() error {
+ return tc.SSH(cf.Context, cf.RemoteCommand)
+ })
+ return trace.Wrap(convertSSHExitCode(tc, err))
+}
diff --git a/tool/tsh/common/git_test.go b/tool/tsh/common/git_test.go
new file mode 100644
index 0000000000000..0a83e0da4c0fe
--- /dev/null
+++ b/tool/tsh/common/git_test.go
@@ -0,0 +1,84 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "testing"
+
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_parseGitSSHURL(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantError bool
+ wantOut *gitSSHURL
+ }{
+ {
+ name: "github ssh format",
+ input: "org-1234567@github.com:some-org/some-repo.git",
+ wantOut: &gitSSHURL{
+ Endpoint: &transport.Endpoint{
+ Protocol: "ssh",
+ Host: "github.com",
+ User: "org-1234567",
+ Path: "some-org/some-repo.git",
+ Port: 22,
+ },
+ },
+ },
+ {
+ name: "github ssh format invalid path",
+ input: "org-1234567@github.com:missing-org",
+ wantError: true,
+ },
+ {
+ name: "ssh schema format",
+ input: "ssh://git@github.com/some-org/some-repo.git",
+ wantOut: &gitSSHURL{
+ Endpoint: &transport.Endpoint{
+ Protocol: "ssh",
+ Host: "github.com",
+ User: "git",
+ Path: "/some-org/some-repo.git",
+ },
+ },
+ },
+ {
+ name: "unsupported format",
+ input: "https://github.com/gravitational/teleport.git",
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ out, err := parseGitSSHURL(tt.input)
+ t.Log(out, err)
+ if tt.wantError {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tt.wantOut, out)
+ })
+ }
+}
diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go
index 582cce0fcd08d..d8701a668ebd4 100644
--- a/tool/tsh/common/tsh.go
+++ b/tool/tsh/common/tsh.go
@@ -1630,6 +1630,12 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = gitCmd.list.run(&cf)
case gitCmd.login.FullCommand():
err = gitCmd.login.run(&cf)
+ case gitCmd.ssh.FullCommand():
+ err = gitCmd.ssh.run(&cf)
+ case gitCmd.config.FullCommand():
+ err = gitCmd.config.run(&cf)
+ case gitCmd.clone.FullCommand():
+ err = gitCmd.clone.run(&cf)
default:
// Handle commands that might not be available.
switch {
@@ -3946,7 +3952,12 @@ func onSSH(cf *CLIConf) error {
accessRequestForSSH,
fmt.Sprintf("%s@%s", tc.HostLogin, tc.Host),
)
+
// Exit with the same exit status as the failed command.
+ return trace.Wrap(convertSSHExitCode(tc, err))
+}
+
+func convertSSHExitCode(tc *client.TeleportClient, err error) error {
if tc.ExitStatus != 0 {
var exitErr *common.ExitCodeError
if errors.As(err, &exitErr) {