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) {