diff options
| author | Nick Thomas <nick@gitlab.com> | 2019-10-17 12:04:52 +0100 |
|---|---|---|
| committer | Nick Thomas <nick@gitlab.com> | 2019-10-18 11:47:25 +0100 |
| commit | 83d11f4deeb20b852a0af3433190a0f7250a0027 (patch) | |
| tree | 1a9df18d6f9f59712c6f5c98e995a4918eb94a11 /internal/command | |
| parent | 7d5229db263a62661653431881bef8b46984d0de (diff) | |
| download | gitlab-shell-83d11f4deeb20b852a0af3433190a0f7250a0027.tar.gz | |
Move go code up one level
Diffstat (limited to 'internal/command')
38 files changed, 2532 insertions, 0 deletions
diff --git a/internal/command/authorizedkeys/authorized_keys.go b/internal/command/authorizedkeys/authorized_keys.go new file mode 100644 index 0000000..d5837b0 --- /dev/null +++ b/internal/command/authorizedkeys/authorized_keys.go @@ -0,0 +1,61 @@ +package authorizedkeys + +import ( + "fmt" + "strconv" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/authorizedkeys" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/keyline" +) + +type Command struct { + Config *config.Config + Args *commandargs.AuthorizedKeys + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + // Do and return nothing when the expected and actual user don't match. + // This can happen when the user in sshd_config doesn't match the user + // trying to login. When nothing is printed, the user will be denied access. + if c.Args.ExpectedUser != c.Args.ActualUser { + // TODO: Log this event once we have a consistent way to log in Go. + // See https://gitlab.com/gitlab-org/gitlab-shell/issues/192 for more info. + return nil + } + + if err := c.printKeyLine(); err != nil { + return err + } + + return nil +} + +func (c *Command) printKeyLine() error { + response, err := c.getAuthorizedKey() + if err != nil { + fmt.Fprintln(c.ReadWriter.Out, fmt.Sprintf("# No key was found for %s", c.Args.Key)) + return nil + } + + keyLine, err := keyline.NewPublicKeyLine(strconv.FormatInt(response.Id, 10), response.Key, c.Config.RootDir) + if err != nil { + return err + } + + fmt.Fprintln(c.ReadWriter.Out, keyLine.ToString()) + + return nil +} + +func (c *Command) getAuthorizedKey() (*authorizedkeys.Response, error) { + client, err := authorizedkeys.NewClient(c.Config) + if err != nil { + return nil, err + } + + return client.GetByKey(c.Args.Key) +} diff --git a/internal/command/authorizedkeys/authorized_keys_test.go b/internal/command/authorizedkeys/authorized_keys_test.go new file mode 100644 index 0000000..5cde366 --- /dev/null +++ b/internal/command/authorizedkeys/authorized_keys_test.go @@ -0,0 +1,90 @@ +package authorizedkeys + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +var ( + requests = []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/authorized_keys", + Handler: func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("key") == "key" { + body := map[string]interface{}{ + "id": 1, + "key": "public-key", + } + json.NewEncoder(w).Encode(body) + } else if r.URL.Query().Get("key") == "broken-message" { + body := map[string]string{ + "message": "Forbidden!", + } + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(body) + } else if r.URL.Query().Get("key") == "broken" { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusNotFound) + } + }, + }, + } +) + +func TestExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.AuthorizedKeys + expectedOutput string + }{ + { + desc: "With matching username and key", + arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "key"}, + expectedOutput: "command=\"/tmp/bin/gitlab-shell key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty public-key\n", + }, + { + desc: "When key doesn't match any existing key", + arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "not-found"}, + expectedOutput: "# No key was found for not-found\n", + }, + { + desc: "When the API returns an error", + arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "broken-message"}, + expectedOutput: "# No key was found for broken-message\n", + }, + { + desc: "When the API fails", + arguments: &commandargs.AuthorizedKeys{ExpectedUser: "user", ActualUser: "user", Key: "broken"}, + expectedOutput: "# No key was found for broken\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{RootDir: "/tmp", GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, buffer.String()) + }) + } +} diff --git a/internal/command/authorizedprincipals/authorized_principals.go b/internal/command/authorizedprincipals/authorized_principals.go new file mode 100644 index 0000000..b04e5a4 --- /dev/null +++ b/internal/command/authorizedprincipals/authorized_principals.go @@ -0,0 +1,47 @@ +package authorizedprincipals + +import ( + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/keyline" +) + +type Command struct { + Config *config.Config + Args *commandargs.AuthorizedPrincipals + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + if err := c.printPrincipalLines(); err != nil { + return err + } + + return nil +} + +func (c *Command) printPrincipalLines() error { + principals := c.Args.Principals + + for _, principal := range principals { + if err := c.printPrincipalLine(principal); err != nil { + return err + } + } + + return nil +} + +func (c *Command) printPrincipalLine(principal string) error { + principalKeyLine, err := keyline.NewPrincipalKeyLine(c.Args.KeyId, principal, c.Config.RootDir) + if err != nil { + return err + } + + fmt.Fprintln(c.ReadWriter.Out, principalKeyLine.ToString()) + + return nil +} diff --git a/internal/command/authorizedprincipals/authorized_principals_test.go b/internal/command/authorizedprincipals/authorized_principals_test.go new file mode 100644 index 0000000..2db0d41 --- /dev/null +++ b/internal/command/authorizedprincipals/authorized_principals_test.go @@ -0,0 +1,47 @@ +package authorizedprincipals + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" +) + +func TestExecute(t *testing.T) { + testCases := []struct { + desc string + arguments *commandargs.AuthorizedPrincipals + expectedOutput string + }{ + { + desc: "With single principal", + arguments: &commandargs.AuthorizedPrincipals{KeyId: "key", Principals: []string{"principal"}}, + expectedOutput: "command=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal\n", + }, + { + desc: "With multiple principals", + arguments: &commandargs.AuthorizedPrincipals{KeyId: "key", Principals: []string{"principal-1", "principal-2"}}, + expectedOutput: "command=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal-1\ncommand=\"/tmp/bin/gitlab-shell username-key\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty principal-2\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{RootDir: "/tmp"}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, buffer.String()) + }) + } +} diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000..52393df --- /dev/null +++ b/internal/command/command.go @@ -0,0 +1,81 @@ +package command + +import ( + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/authorizedkeys" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/authorizedprincipals" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/discover" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/healthcheck" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/receivepack" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/twofactorrecover" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/uploadarchive" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/uploadpack" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/executable" +) + +type Command interface { + Execute() error +} + +func New(e *executable.Executable, arguments []string, config *config.Config, readWriter *readwriter.ReadWriter) (Command, error) { + args, err := commandargs.Parse(e, arguments) + if err != nil { + return nil, err + } + + if cmd := buildCommand(e, args, config, readWriter); cmd != nil { + return cmd, nil + } + + return nil, disallowedcommand.Error +} + +func buildCommand(e *executable.Executable, args commandargs.CommandArgs, config *config.Config, readWriter *readwriter.ReadWriter) Command { + switch e.Name { + case executable.GitlabShell: + return buildShellCommand(args.(*commandargs.Shell), config, readWriter) + case executable.AuthorizedKeysCheck: + return buildAuthorizedKeysCommand(args.(*commandargs.AuthorizedKeys), config, readWriter) + case executable.AuthorizedPrincipalsCheck: + return buildAuthorizedPrincipalsCommand(args.(*commandargs.AuthorizedPrincipals), config, readWriter) + case executable.Healthcheck: + return buildHealthcheckCommand(config, readWriter) + } + + return nil +} + +func buildShellCommand(args *commandargs.Shell, config *config.Config, readWriter *readwriter.ReadWriter) Command { + switch args.CommandType { + case commandargs.Discover: + return &discover.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.TwoFactorRecover: + return &twofactorrecover.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.LfsAuthenticate: + return &lfsauthenticate.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.ReceivePack: + return &receivepack.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.UploadPack: + return &uploadpack.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.UploadArchive: + return &uploadarchive.Command{Config: config, Args: args, ReadWriter: readWriter} + } + + return nil +} + +func buildAuthorizedKeysCommand(args *commandargs.AuthorizedKeys, config *config.Config, readWriter *readwriter.ReadWriter) Command { + return &authorizedkeys.Command{Config: config, Args: args, ReadWriter: readWriter} +} + +func buildAuthorizedPrincipalsCommand(args *commandargs.AuthorizedPrincipals, config *config.Config, readWriter *readwriter.ReadWriter) Command { + return &authorizedprincipals.Command{Config: config, Args: args, ReadWriter: readWriter} +} + +func buildHealthcheckCommand(config *config.Config, readWriter *readwriter.ReadWriter) Command { + return &healthcheck.Command{Config: config, ReadWriter: readWriter} +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go new file mode 100644 index 0000000..cd3ac9b --- /dev/null +++ b/internal/command/command_test.go @@ -0,0 +1,146 @@ +package command + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/authorizedkeys" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/authorizedprincipals" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/discover" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/healthcheck" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/receivepack" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/twofactorrecover" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/uploadarchive" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/uploadpack" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/executable" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper" +) + +var ( + authorizedKeysExec = &executable.Executable{Name: executable.AuthorizedKeysCheck} + authorizedPrincipalsExec = &executable.Executable{Name: executable.AuthorizedPrincipalsCheck} + checkExec = &executable.Executable{Name: executable.Healthcheck} + gitlabShellExec = &executable.Executable{Name: executable.GitlabShell} + + basicConfig = &config.Config{GitlabUrl: "http+unix://gitlab.socket"} +) + +func buildEnv(command string) map[string]string { + return map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": command, + } +} + +func TestNew(t *testing.T) { + testCases := []struct { + desc string + executable *executable.Executable + environment map[string]string + arguments []string + expectedType interface{} + }{ + { + desc: "it returns a Discover command", + executable: gitlabShellExec, + environment: buildEnv(""), + expectedType: &discover.Command{}, + }, + { + desc: "it returns a TwoFactorRecover command", + executable: gitlabShellExec, + environment: buildEnv("2fa_recovery_codes"), + expectedType: &twofactorrecover.Command{}, + }, + { + desc: "it returns an LfsAuthenticate command", + executable: gitlabShellExec, + environment: buildEnv("git-lfs-authenticate"), + expectedType: &lfsauthenticate.Command{}, + }, + { + desc: "it returns a ReceivePack command", + executable: gitlabShellExec, + environment: buildEnv("git-receive-pack"), + expectedType: &receivepack.Command{}, + }, + { + desc: "it returns an UploadPack command", + executable: gitlabShellExec, + environment: buildEnv("git-upload-pack"), + expectedType: &uploadpack.Command{}, + }, + { + desc: "it returns an UploadArchive command", + executable: gitlabShellExec, + environment: buildEnv("git-upload-archive"), + expectedType: &uploadarchive.Command{}, + }, + { + desc: "it returns a Healthcheck command", + executable: checkExec, + expectedType: &healthcheck.Command{}, + }, + { + desc: "it returns a AuthorizedKeys command", + executable: authorizedKeysExec, + arguments: []string{"git", "git", "key"}, + expectedType: &authorizedkeys.Command{}, + }, + { + desc: "it returns a AuthorizedPrincipals command", + executable: authorizedPrincipalsExec, + arguments: []string{"key", "principal"}, + expectedType: &authorizedprincipals.Command{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + restoreEnv := testhelper.TempEnv(tc.environment) + defer restoreEnv() + + command, err := New(tc.executable, tc.arguments, basicConfig, nil) + + require.NoError(t, err) + require.IsType(t, tc.expectedType, command) + }) + } +} + +func TestFailingNew(t *testing.T) { + testCases := []struct { + desc string + executable *executable.Executable + environment map[string]string + expectedError error + }{ + { + desc: "Parsing environment failed", + executable: gitlabShellExec, + expectedError: errors.New("Only SSH allowed"), + }, + { + desc: "Unknown command given", + executable: gitlabShellExec, + environment: buildEnv("unknown"), + expectedError: disallowedcommand.Error, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + restoreEnv := testhelper.TempEnv(tc.environment) + defer restoreEnv() + + command, err := New(tc.executable, []string{}, basicConfig, nil) + require.Nil(t, command) + require.Equal(t, tc.expectedError, err) + }) + } +} diff --git a/internal/command/commandargs/authorized_keys.go b/internal/command/commandargs/authorized_keys.go new file mode 100644 index 0000000..2733954 --- /dev/null +++ b/internal/command/commandargs/authorized_keys.go @@ -0,0 +1,51 @@ +package commandargs + +import ( + "errors" + "fmt" +) + +type AuthorizedKeys struct { + Arguments []string + ExpectedUser string + ActualUser string + Key string +} + +func (ak *AuthorizedKeys) Parse() error { + if err := ak.validate(); err != nil { + return err + } + + ak.ExpectedUser = ak.Arguments[0] + ak.ActualUser = ak.Arguments[1] + ak.Key = ak.Arguments[2] + + return nil +} + +func (ak *AuthorizedKeys) GetArguments() []string { + return ak.Arguments +} + +func (ak *AuthorizedKeys) validate() error { + argsSize := len(ak.Arguments) + + if argsSize != 3 { + return errors.New(fmt.Sprintf("# Insufficient arguments. %d. Usage\n#\tgitlab-shell-authorized-keys-check <expected-username> <actual-username> <key>", argsSize)) + } + + expectedUsername := ak.Arguments[0] + actualUsername := ak.Arguments[1] + key := ak.Arguments[2] + + if expectedUsername == "" || actualUsername == "" { + return errors.New("# No username provided") + } + + if key == "" { + return errors.New("# No key provided") + } + + return nil +} diff --git a/internal/command/commandargs/authorized_principals.go b/internal/command/commandargs/authorized_principals.go new file mode 100644 index 0000000..746ae3f --- /dev/null +++ b/internal/command/commandargs/authorized_principals.go @@ -0,0 +1,50 @@ +package commandargs + +import ( + "errors" + "fmt" +) + +type AuthorizedPrincipals struct { + Arguments []string + KeyId string + Principals []string +} + +func (ap *AuthorizedPrincipals) Parse() error { + if err := ap.validate(); err != nil { + return err + } + + ap.KeyId = ap.Arguments[0] + ap.Principals = ap.Arguments[1:] + + return nil +} + +func (ap *AuthorizedPrincipals) GetArguments() []string { + return ap.Arguments +} + +func (ap *AuthorizedPrincipals) validate() error { + argsSize := len(ap.Arguments) + + if argsSize < 2 { + return errors.New(fmt.Sprintf("# Insufficient arguments. %d. Usage\n#\tgitlab-shell-authorized-principals-check <key-id> <principal1> [<principal2>...]", argsSize)) + } + + keyId := ap.Arguments[0] + principals := ap.Arguments[1:] + + if keyId == "" { + return errors.New("# No key_id provided") + } + + for _, principal := range principals { + if principal == "" { + return errors.New("# An invalid principal was provided") + } + } + + return nil +} diff --git a/internal/command/commandargs/command_args.go b/internal/command/commandargs/command_args.go new file mode 100644 index 0000000..4831134 --- /dev/null +++ b/internal/command/commandargs/command_args.go @@ -0,0 +1,31 @@ +package commandargs + +import ( + "gitlab.com/gitlab-org/gitlab-shell/go/internal/executable" +) + +type CommandType string + +type CommandArgs interface { + Parse() error + GetArguments() []string +} + +func Parse(e *executable.Executable, arguments []string) (CommandArgs, error) { + var args CommandArgs = &GenericArgs{Arguments: arguments} + + switch e.Name { + case executable.GitlabShell: + args = &Shell{Arguments: arguments} + case executable.AuthorizedKeysCheck: + args = &AuthorizedKeys{Arguments: arguments} + case executable.AuthorizedPrincipalsCheck: + args = &AuthorizedPrincipals{Arguments: arguments} + } + + if err := args.Parse(); err != nil { + return nil, err + } + + return args, nil +} diff --git a/internal/command/commandargs/command_args_test.go b/internal/command/commandargs/command_args_test.go new file mode 100644 index 0000000..9f1575d --- /dev/null +++ b/internal/command/commandargs/command_args_test.go @@ -0,0 +1,231 @@ +package commandargs + +import ( + "testing" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/executable" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper" + + "github.com/stretchr/testify/require" +) + +func TestParseSuccess(t *testing.T) { + testCases := []struct { + desc string + executable *executable.Executable + environment map[string]string + arguments []string + expectedArgs CommandArgs + }{ + // Setting the used env variables for every case to ensure we're + // not using anything set in the original env. + { + desc: "It sets discover as the command when the command string was empty", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{}, CommandType: Discover}, + }, + { + desc: "It finds the key id in any passed arguments", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "", + }, + arguments: []string{"hello", "key-123"}, + expectedArgs: &Shell{Arguments: []string{"hello", "key-123"}, SshArgs: []string{}, CommandType: Discover, GitlabKeyId: "123"}, + }, { + desc: "It finds the username in any passed arguments", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "", + }, + arguments: []string{"hello", "username-jane-doe"}, + expectedArgs: &Shell{Arguments: []string{"hello", "username-jane-doe"}, SshArgs: []string{}, CommandType: Discover, GitlabUsername: "jane-doe"}, + }, { + desc: "It parses 2fa_recovery_codes command", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "2fa_recovery_codes", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"2fa_recovery_codes"}, CommandType: TwoFactorRecover}, + }, { + desc: "It parses git-receive-pack command", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git-receive-pack group/repo", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-receive-pack", "group/repo"}, CommandType: ReceivePack}, + }, { + desc: "It parses git-receive-pack command and a project with single quotes", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git receive-pack 'group/repo'", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-receive-pack", "group/repo"}, CommandType: ReceivePack}, + }, { + desc: `It parses "git receive-pack" command`, + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": `git receive-pack "group/repo"`, + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-receive-pack", "group/repo"}, CommandType: ReceivePack}, + }, { + desc: `It parses a command followed by control characters`, + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": `git-receive-pack group/repo; any command`, + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-receive-pack", "group/repo"}, CommandType: ReceivePack}, + }, { + desc: "It parses git-upload-pack command", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": `git upload-pack "group/repo"`, + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-upload-pack", "group/repo"}, CommandType: UploadPack}, + }, { + desc: "It parses git-upload-archive command", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git-upload-archive 'group/repo'", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-upload-archive", "group/repo"}, CommandType: UploadArchive}, + }, { + desc: "It parses git-lfs-authenticate command", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git-lfs-authenticate 'group/repo' download", + }, + arguments: []string{}, + expectedArgs: &Shell{Arguments: []string{}, SshArgs: []string{"git-lfs-authenticate", "group/repo", "download"}, CommandType: LfsAuthenticate}, + }, { + desc: "It parses authorized-keys command", + executable: &executable.Executable{Name: executable.AuthorizedKeysCheck}, + arguments: []string{"git", "git", "key"}, + expectedArgs: &AuthorizedKeys{Arguments: []string{"git", "git", "key"}, ExpectedUser: "git", ActualUser: "git", Key: "key"}, + }, { + desc: "It parses authorized-principals command", + executable: &executable.Executable{Name: executable.AuthorizedPrincipalsCheck}, + arguments: []string{"key", "principal-1", "principal-2"}, + expectedArgs: &AuthorizedPrincipals{Arguments: []string{"key", "principal-1", "principal-2"}, KeyId: "key", Principals: []string{"principal-1", "principal-2"}}, + }, { + desc: "Unknown executable", + executable: &executable.Executable{Name: "unknown"}, + arguments: []string{}, + expectedArgs: &GenericArgs{Arguments: []string{}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + restoreEnv := testhelper.TempEnv(tc.environment) + defer restoreEnv() + + result, err := Parse(tc.executable, tc.arguments) + + require.NoError(t, err) + require.Equal(t, tc.expectedArgs, result) + }) + } +} + +func TestParseFailure(t *testing.T) { + testCases := []struct { + desc string + executable *executable.Executable + environment map[string]string + arguments []string + expectedError string + }{ + { + desc: "It fails if SSH connection is not set", + executable: &executable.Executable{Name: executable.GitlabShell}, + arguments: []string{}, + expectedError: "Only SSH allowed", + }, + { + desc: "It fails if SSH command is invalid", + executable: &executable.Executable{Name: executable.GitlabShell}, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": `git receive-pack "`, + }, + arguments: []string{}, + expectedError: "Invalid SSH command", + }, + { + desc: "With not enough arguments for the AuthorizedKeysCheck", + executable: &executable.Executable{Name: executable.AuthorizedKeysCheck}, + arguments: []string{"user"}, + expectedError: "# Insufficient arguments. 1. Usage\n#\tgitlab-shell-authorized-keys-check <expected-username> <actual-username> <key>", + }, + { + desc: "With too many arguments for the AuthorizedKeysCheck", + executable: &executable.Executable{Name: executable.AuthorizedKeysCheck}, + arguments: []string{"user", "user", "key", "something-else"}, + expectedError: "# Insufficient arguments. 4. Usage\n#\tgitlab-shell-authorized-keys-check <expected-username> <actual-username> <key>", + }, + { + desc: "With missing username for the AuthorizedKeysCheck", + executable: &executable.Executable{Name: executable.AuthorizedKeysCheck}, + arguments: []string{"user", "", "key"}, + expectedError: "# No username provided", + }, + { + desc: "With missing key for the AuthorizedKeysCheck", + executable: &executable.Executable{Name: executable.AuthorizedKeysCheck}, + arguments: []string{"user", "user", ""}, + expectedError: "# No key provided", + }, + { + desc: "With not enough arguments for the AuthorizedPrincipalsCheck", + executable: &executable.Executable{Name: executable.AuthorizedPrincipalsCheck}, + arguments: []string{"key"}, + expectedError: "# Insufficient arguments. 1. Usage\n#\tgitlab-shell-authorized-principals-check <key-id> <principal1> [<principal2>...]", + }, + { + desc: "With missing key_id for the AuthorizedPrincipalsCheck", + executable: &executable.Executable{Name: executable.AuthorizedPrincipalsCheck}, + arguments: []string{"", "principal"}, + expectedError: "# No key_id provided", + }, + { + desc: "With blank principal for the AuthorizedPrincipalsCheck", + executable: &executable.Executable{Name: executable.AuthorizedPrincipalsCheck}, + arguments: []string{"key", "principal", ""}, + expectedError: "# An invalid principal was provided", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + restoreEnv := testhelper.TempEnv(tc.environment) + defer restoreEnv() + + _, err := Parse(tc.executable, tc.arguments) + + require.EqualError(t, err, tc.expectedError) + }) + } +} diff --git a/internal/command/commandargs/generic_args.go b/internal/command/commandargs/generic_args.go new file mode 100644 index 0000000..96bed99 --- /dev/null +++ b/internal/command/commandargs/generic_args.go @@ -0,0 +1,14 @@ +package commandargs + +type GenericArgs struct { + Arguments []string +} + +func (b *GenericArgs) Parse() error { + // Do nothing + return nil +} + +func (b *GenericArgs) GetArguments() []string { + return b.Arguments +} diff --git a/internal/command/commandargs/shell.go b/internal/command/commandargs/shell.go new file mode 100644 index 0000000..7e2b72e --- /dev/null +++ b/internal/command/commandargs/shell.go @@ -0,0 +1,131 @@ +package commandargs + +import ( + "errors" + "os" + "regexp" + + "github.com/mattn/go-shellwords" +) + +const ( + Discover CommandType = "discover" + TwoFactorRecover CommandType = "2fa_recovery_codes" + LfsAuthenticate CommandType = "git-lfs-authenticate" + ReceivePack CommandType = "git-receive-pack" + UploadPack CommandType = "git-upload-pack" + UploadArchive CommandType = "git-upload-archive" +) + +var ( + whoKeyRegex = regexp.MustCompile(`\bkey-(?P<keyid>\d+)\b`) + whoUsernameRegex = regexp.MustCompile(`\busername-(?P<username>\S+)\b`) +) + +type Shell struct { + Arguments []string + GitlabUsername string + GitlabKeyId string + SshArgs []string + CommandType CommandType +} + +func (s *Shell) Parse() error { + if err := s.validate(); err != nil { + return err + } + + s.parseWho() + s.defineCommandType() + + return nil +} + +func (s *Shell) GetArguments() []string { + return s.Arguments +} + +func (s *Shell) validate() error { + if !s.isSshConnection() { + return errors.New("Only SSH allowed") + } + + if !s.isValidSshCommand() { + return errors.New("Invalid SSH command") + } + + return nil +} + +func (s *Shell) isSshConnection() bool { + ok := os.Getenv("SSH_CONNECTION") + return ok != "" +} + +func (s *Shell) isValidSshCommand() bool { + err := s.parseCommand(os.Getenv("SSH_ORIGINAL_COMMAND")) + return err == nil +} + +func (s *Shell) parseWho() { + for _, argument := range s.Arguments { + if keyId := tryParseKeyId(argument); keyId != "" { + s.GitlabKeyId = keyId + break + } + + if username := tryParseUsername(argument); username != "" { + s.GitlabUsername = username + break + } + } +} + +func tryParseKeyId(argument string) string { + matchInfo := whoKeyRegex.FindStringSubmatch(argument) + if len(matchInfo) == 2 { + // The first element is the full matched string + // The second element is the named `keyid` + return matchInfo[1] + } + + return "" +} + +func tryParseUsername(argument string) string { + matchInfo := whoUsernameRegex.FindStringSubmatch(argument) + if len(matchInfo) == 2 { + // The first element is the full matched string + // The second element is the named `username` + return matchInfo[1] + } + + return "" +} + +func (s *Shell) parseCommand(commandString string) error { + args, err := shellwords.Parse(commandString) + if err != nil { + return err + } + + // Handle Git for Windows 2.14 using "git upload-pack" instead of git-upload-pack + if len(args) > 1 && args[0] == "git" { + command := args[0] + "-" + args[1] + commandArgs := args[2:] + + args = append([]string{command}, commandArgs...) + } + + s.SshArgs = args + + return nil +} + +func (s *Shell) defineCommandType() { + if len(s.SshArgs) == 0 { + s.CommandType = Discover + } else { + s.CommandType = CommandType(s.SshArgs[0]) + } +} diff --git a/internal/command/discover/discover.go b/internal/command/discover/discover.go new file mode 100644 index 0000000..de94b56 --- /dev/null +++ b/internal/command/discover/discover.go @@ -0,0 +1,40 @@ +package discover + +import ( + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/discover" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + response, err := c.getUserInfo() + if err != nil { + return fmt.Errorf("Failed to get username: %v", err) + } + + if response.IsAnonymous() { + fmt.Fprintf(c.ReadWriter.Out, "Welcome to GitLab, Anonymous!\n") + } else { + fmt.Fprintf(c.ReadWriter.Out, "Welcome to GitLab, @%s!\n", response.Username) + } + + return nil +} + +func (c *Command) getUserInfo() (*discover.Response, error) { + client, err := discover.NewClient(c.Config) + if err != nil { + return nil, err + } + + return client.GetByCommandArgs(c.Args) +} diff --git a/internal/command/discover/discover_test.go b/internal/command/discover/discover_test.go new file mode 100644 index 0000000..7e052f7 --- /dev/null +++ b/internal/command/discover/discover_test.go @@ -0,0 +1,135 @@ +package discover + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +var ( + requests = []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/discover", + Handler: func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("key_id") == "1" || r.URL.Query().Get("username") == "alex-doe" { + body := map[string]interface{}{ + "id": 2, + "username": "alex-doe", + "name": "Alex Doe", + } + json.NewEncoder(w).Encode(body) + } else if r.URL.Query().Get("username") == "broken_message" { + body := map[string]string{ + "message": "Forbidden!", + } + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(body) + } else if r.URL.Query().Get("username") == "broken" { + w.WriteHeader(http.StatusInternalServerError) + } else { + fmt.Fprint(w, "null") + } + }, + }, + } +) + +func TestExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.Shell + expectedOutput string + }{ + { + desc: "With a known username", + arguments: &commandargs.Shell{GitlabUsername: "alex-doe"}, + expectedOutput: "Welcome to GitLab, @alex-doe!\n", + }, + { + desc: "With a known key id", + arguments: &commandargs.Shell{GitlabKeyId: "1"}, + expectedOutput: "Welcome to GitLab, @alex-doe!\n", + }, + { + desc: "With an unknown key", + arguments: &commandargs.Shell{GitlabKeyId: "-1"}, + expectedOutput: "Welcome to GitLab, Anonymous!\n", + }, + { + desc: "With an unknown username", + arguments: &commandargs.Shell{GitlabUsername: "unknown"}, + expectedOutput: "Welcome to GitLab, Anonymous!\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, buffer.String()) + }) + } +} + +func TestFailingExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.Shell + expectedError string + }{ + { + desc: "With missing arguments", + arguments: &commandargs.Shell{}, + expectedError: "Failed to get username: who='' is invalid", + }, + { + desc: "When the API returns an error", + arguments: &commandargs.Shell{GitlabUsername: "broken_message"}, + expectedError: "Failed to get username: Forbidden!", + }, + { + desc: "When the API fails", + arguments: &commandargs.Shell{GitlabUsername: "broken"}, + expectedError: "Failed to get username: Internal API error (500)", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + + require.Empty(t, buffer.String()) + require.EqualError(t, err, tc.expectedError) + }) + } +} diff --git a/internal/command/healthcheck/healthcheck.go b/internal/command/healthcheck/healthcheck.go new file mode 100644 index 0000000..fef981c --- /dev/null +++ b/internal/command/healthcheck/healthcheck.go @@ -0,0 +1,49 @@ +package healthcheck + +import ( + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/healthcheck" +) + +var ( + apiMessage = "Internal API available" + redisMessage = "Redis available via internal API" +) + +type Command struct { + Config *config.Config + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + response, err := c.runCheck() + if err != nil { + return fmt.Errorf("%v: FAILED - %v", apiMessage, err) + } + + fmt.Fprintf(c.ReadWriter.Out, "%v: OK\n", apiMessage) + + if !response.Redis { + return fmt.Errorf("%v: FAILED", redisMessage) + } + + fmt.Fprintf(c.ReadWriter.Out, "%v: OK\n", redisMessage) + return nil +} + +func (c *Command) runCheck() (*healthcheck.Response, error) { + client, err := healthcheck.NewClient(c.Config) + if err != nil { + return nil, err + } + + response, err := client.Check() + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/internal/command/healthcheck/healthcheck_test.go b/internal/command/healthcheck/healthcheck_test.go new file mode 100644 index 0000000..6c92ebc --- /dev/null +++ b/internal/command/healthcheck/healthcheck_test.go @@ -0,0 +1,90 @@ +package healthcheck + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/healthcheck" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +var ( + okResponse = &healthcheck.Response{ + APIVersion: "v4", + GitlabVersion: "v12.0.0-ee", + GitlabRevision: "3b13818e8330f68625d80d9bf5d8049c41fbe197", + Redis: true, + } + + badRedisResponse = &healthcheck.Response{Redis: false} + + okHandlers = buildTestHandlers(200, okResponse) + badRedisHandlers = buildTestHandlers(200, badRedisResponse) + brokenHandlers = buildTestHandlers(500, nil) +) + +func buildTestHandlers(code int, rsp *healthcheck.Response) []testserver.TestRequestHandler { + return []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/check", + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + if rsp != nil { + json.NewEncoder(w).Encode(rsp) + } + }, + }, + } +} + +func TestExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, okHandlers) + defer cleanup() + + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + + require.NoError(t, err) + require.Equal(t, "Internal API available: OK\nRedis available via internal API: OK\n", buffer.String()) +} + +func TestFailingRedisExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, badRedisHandlers) + defer cleanup() + + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + require.Error(t, err, "Redis available via internal API: FAILED") + require.Equal(t, "Internal API available: OK\n", buffer.String()) +} + +func TestFailingAPIExecute(t *testing.T) { + url, cleanup := testserver.StartSocketHttpServer(t, brokenHandlers) + defer cleanup() + + buffer := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + ReadWriter: &readwriter.ReadWriter{Out: buffer}, + } + + err := cmd.Execute() + require.Empty(t, buffer.String()) + require.EqualError(t, err, "Internal API available: FAILED - Internal API error (500)") +} diff --git a/internal/command/lfsauthenticate/lfsauthenticate.go b/internal/command/lfsauthenticate/lfsauthenticate.go new file mode 100644 index 0000000..bff5e7f --- /dev/null +++ b/internal/command/lfsauthenticate/lfsauthenticate.go @@ -0,0 +1,104 @@ +package lfsauthenticate + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/lfsauthenticate" +) + +const ( + downloadAction = "download" + uploadAction = "upload" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +type PayloadHeader struct { + Auth string `json:"Authorization"` +} + +type Payload struct { + Header PayloadHeader `json:"header"` + Href string `json:"href"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +func (c *Command) Execute() error { + args := c.Args.SshArgs + if len(args) < 3 { + return disallowedcommand.Error + } + + repo := args[1] + action, err := actionToCommandType(args[2]) + if err != nil { + return err + } + + accessResponse, err := c.verifyAccess(action, repo) + if err != nil { + return err + } + + payload, err := c.authenticate(action, repo, accessResponse.UserId) + if err != nil { + // return nothing just like Ruby's GitlabShell#lfs_authenticate does + return nil + } + + fmt.Fprintf(c.ReadWriter.Out, "%s\n", payload) + + return nil +} + +func actionToCommandType(action string) (commandargs.CommandType, error) { + var accessAction commandargs.CommandType + switch action { + case downloadAction: + accessAction = commandargs.UploadPack + case uploadAction: + accessAction = commandargs.ReceivePack + default: + return "", disallowedcommand.Error + } + + return accessAction, nil +} + +func (c *Command) verifyAccess(action commandargs.CommandType, repo string) (*accessverifier.Response, error) { + cmd := accessverifier.Command{c.Config, c.Args, c.ReadWriter} + + return cmd.Verify(action, repo) +} + +func (c *Command) authenticate(action commandargs.CommandType, repo, userId string) ([]byte, error) { + client, err := lfsauthenticate.NewClient(c.Config, c.Args) + if err != nil { + return nil, err + } + + response, err := client.Authenticate(action, repo, userId) + if err != nil { + return nil, err + } + + basicAuth := base64.StdEncoding.EncodeToString([]byte(response.Username + ":" + response.LfsToken)) + payload := &Payload{ + Header: PayloadHeader{Auth: "Basic " + basicAuth}, + Href: response.RepoPath + "/info/lfs", + ExpiresIn: response.ExpiresIn, + } + + return json.Marshal(payload) +} diff --git a/internal/command/lfsauthenticate/lfsauthenticate_test.go b/internal/command/lfsauthenticate/lfsauthenticate_test.go new file mode 100644 index 0000000..a6836a8 --- /dev/null +++ b/internal/command/lfsauthenticate/lfsauthenticate_test.go @@ -0,0 +1,153 @@ +package lfsauthenticate + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestFailedRequests(t *testing.T) { + requests := requesthandlers.BuildDisallowedByApiHandlers(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.Shell + expectedOutput string + }{ + { + desc: "With missing arguments", + arguments: &commandargs.Shell{}, + expectedOutput: "> GitLab: Disallowed command", + }, + { + desc: "With disallowed command", + arguments: &commandargs.Shell{GitlabKeyId: "1", SshArgs: []string{"git-lfs-authenticate", "group/repo", "unknown"}}, + expectedOutput: "> GitLab: Disallowed command", + }, + { + desc: "With disallowed user", + arguments: &commandargs.Shell{GitlabKeyId: "disallowed", SshArgs: []string{"git-lfs-authenticate", "group/repo", "download"}}, + expectedOutput: "Disallowed by API call", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.Error(t, err) + + require.Equal(t, tc.expectedOutput, err.Error()) + }) + } +} + +func TestLfsAuthenticateRequests(t *testing.T) { + userId := "123" + + requests := []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/lfs_authenticate", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(t, err) + + var request *lfsauthenticate.Request + require.NoError(t, json.Unmarshal(b, &request)) + + if request.UserId == userId { + body := map[string]interface{}{ + "username": "john", + "lfs_token": "sometoken", + "repository_http_path": "https://gitlab.com/repo/path", + "expires_in": 1800, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + } else { + w.WriteHeader(http.StatusForbidden) + } + }, + }, + { + Path: "/api/v4/internal/allowed", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(t, err) + + var request *accessverifier.Request + require.NoError(t, json.Unmarshal(b, &request)) + + var glId string + if request.Username == "somename" { + glId = userId + } else { + glId = "100" + } + + body := map[string]interface{}{ + "gl_id": glId, + "status": true, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + }, + }, + } + + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + username string + expectedOutput string + }{ + { + desc: "With successful response from API", + username: "somename", + expectedOutput: "{\"header\":{\"Authorization\":\"Basic am9objpzb21ldG9rZW4=\"},\"href\":\"https://gitlab.com/repo/path/info/lfs\",\"expires_in\":1800}\n", + }, + { + desc: "With forbidden response from API", + username: "anothername", + expectedOutput: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabUsername: tc.username, SshArgs: []string{"git-lfs-authenticate", "group/repo", "upload"}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.NoError(t, err) + + require.Equal(t, tc.expectedOutput, output.String()) + }) + } +} diff --git a/internal/command/readwriter/readwriter.go b/internal/command/readwriter/readwriter.go new file mode 100644 index 0000000..da18d30 --- /dev/null +++ b/internal/command/readwriter/readwriter.go @@ -0,0 +1,9 @@ +package readwriter + +import "io" + +type ReadWriter struct { + Out io.Writer + In io.Reader + ErrOut io.Writer +} diff --git a/internal/command/receivepack/customaction.go b/internal/command/receivepack/customaction.go new file mode 100644 index 0000000..8623437 --- /dev/null +++ b/internal/command/receivepack/customaction.go @@ -0,0 +1,99 @@ +package receivepack + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" +) + +type Request struct { + SecretToken []byte `json:"secret_token"` + Data accessverifier.CustomPayloadData `json:"data"` + Output []byte `json:"output"` +} + +type Response struct { + Result []byte `json:"result"` + Message string `json:"message"` +} + +func (c *Command) processCustomAction(response *accessverifier.Response) error { + data := response.Payload.Data + apiEndpoints := data.ApiEndpoints + + if len(apiEndpoints) == 0 { + return errors.New("Custom action error: Empty API endpoints") + } + + c.displayInfoMessage(data.InfoMessage) + + return c.processApiEndpoints(response) +} + +func (c *Command) displayInfoMessage(infoMessage string) { + messages := strings.Split(infoMessage, "\n") + + for _, msg := range messages { + fmt.Fprintf(c.ReadWriter.ErrOut, "> GitLab: %v\n", msg) + } +} + +func (c *Command) processApiEndpoints(response *accessverifier.Response) error { + client, err := gitlabnet.GetClient(c.Config) + + if err != nil { + return err + } + + data := response.Payload.Data + request := &Request{Data: data} + request.Data.UserId = response.Who + + for _, endpoint := range data.ApiEndpoints { + response, err := c.performRequest(client, endpoint, request) + if err != nil { + return err + } + + if err = c.displayResult(response.Result); err != nil { + return err + } + + // In the context of the git push sequence of events, it's necessary to read + // stdin in order to capture output to pass onto subsequent commands + output, err := ioutil.ReadAll(c.ReadWriter.In) + if err != nil { + return err + } + request.Output = output + } + + return nil +} + +func (c *Command) performRequest(client *gitlabnet.GitlabClient, endpoint string, request *Request) (*Response, error) { + response, err := client.DoRequest(http.MethodPost, endpoint, request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + cr := &Response{} + if err := gitlabnet.ParseJSON(response, cr); err != nil { + return nil, err + } + + return cr, nil +} + +func (c *Command) displayResult(result []byte) error { + _, err := io.Copy(c.ReadWriter.Out, bytes.NewReader(result)) + return err +} diff --git a/internal/command/receivepack/customaction_test.go b/internal/command/receivepack/customaction_test.go new file mode 100644 index 0000000..bd4991d --- /dev/null +++ b/internal/command/receivepack/customaction_test.go @@ -0,0 +1,105 @@ +package receivepack + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +func TestCustomReceivePack(t *testing.T) { + repo := "group/repo" + keyId := "1" + + requests := []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/allowed", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + + var request *accessverifier.Request + require.NoError(t, json.Unmarshal(b, &request)) + + require.Equal(t, "1", request.KeyId) + + body := map[string]interface{}{ + "status": true, + "gl_id": "1", + "payload": map[string]interface{}{ + "action": "geo_proxy_to_primary", + "data": map[string]interface{}{ + "api_endpoints": []string{"/geo/proxy_git_push_ssh/info_refs", "/geo/proxy_git_push_ssh/push"}, + "gl_username": "custom", + "primary_repo": "https://repo/path", + "info_message": "info_message\none more message", + }, + }, + } + w.WriteHeader(http.StatusMultipleChoices) + require.NoError(t, json.NewEncoder(w).Encode(body)) + }, + }, + { + Path: "/geo/proxy_git_push_ssh/info_refs", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + + var request *Request + require.NoError(t, json.Unmarshal(b, &request)) + + require.Equal(t, request.Data.UserId, "key-"+keyId) + require.Empty(t, request.Output) + + err = json.NewEncoder(w).Encode(Response{Result: []byte("custom")}) + require.NoError(t, err) + }, + }, + { + Path: "/geo/proxy_git_push_ssh/push", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + + var request *Request + require.NoError(t, json.Unmarshal(b, &request)) + + require.Equal(t, request.Data.UserId, "key-"+keyId) + require.Equal(t, "input", string(request.Output)) + + err = json.NewEncoder(w).Encode(Response{Result: []byte("output")}) + require.NoError(t, err) + }, + }, + } + + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + input := bytes.NewBufferString("input") + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: keyId, CommandType: commandargs.ReceivePack, SshArgs: []string{"git-receive-pack", repo}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: errBuf, Out: outBuf, In: input}, + } + + require.NoError(t, cmd.Execute()) + + // expect printing of info message, "custom" string from the first request + // and "output" string from the second request + require.Equal(t, "> GitLab: info_message\n> GitLab: one more message\n", errBuf.String()) + require.Equal(t, "customoutput", outBuf.String()) +} diff --git a/internal/command/receivepack/gitalycall.go b/internal/command/receivepack/gitalycall.go new file mode 100644 index 0000000..d735f17 --- /dev/null +++ b/internal/command/receivepack/gitalycall.go @@ -0,0 +1,39 @@ +package receivepack + +import ( + "context" + + "google.golang.org/grpc" + + "gitlab.com/gitlab-org/gitaly/client" + pb "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/handler" +) + +func (c *Command) performGitalyCall(response *accessverifier.Response) error { + gc := &handler.GitalyCommand{ + Config: c.Config, + ServiceName: string(commandargs.ReceivePack), + Address: response.Gitaly.Address, + Token: response.Gitaly.Token, + } + + request := &pb.SSHReceivePackRequest{ + Repository: &response.Gitaly.Repo, + GlId: response.UserId, + GlRepository: response.Repo, + GlUsername: response.Username, + GitProtocol: response.GitProtocol, + GitConfigOptions: response.GitConfigOptions, + } + + return gc.RunGitalyCommand(func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + rw := c.ReadWriter + return client.ReceivePack(ctx, conn, rw.In, rw.Out, rw.ErrOut, request) + }) +} diff --git a/internal/command/receivepack/gitalycall_test.go b/internal/command/receivepack/gitalycall_test.go new file mode 100644 index 0000000..eac9218 --- /dev/null +++ b/internal/command/receivepack/gitalycall_test.go @@ -0,0 +1,40 @@ +package receivepack + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestReceivePack(t *testing.T) { + gitalyAddress, cleanup := testserver.StartGitalyServer(t) + defer cleanup() + + requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + input := &bytes.Buffer{} + + userId := "1" + repo := "group/repo" + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: userId, CommandType: commandargs.ReceivePack, SshArgs: []string{"git-receive-pack", repo}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, + } + + err := cmd.Execute() + require.NoError(t, err) + + require.Equal(t, "ReceivePack: "+userId+" "+repo, output.String()) +} diff --git a/internal/command/receivepack/receivepack.go b/internal/command/receivepack/receivepack.go new file mode 100644 index 0000000..eb0b2fe --- /dev/null +++ b/internal/command/receivepack/receivepack.go @@ -0,0 +1,40 @@ +package receivepack + +import ( + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + args := c.Args.SshArgs + if len(args) != 2 { + return disallowedcommand.Error + } + + repo := args[1] + response, err := c.verifyAccess(repo) + if err != nil { + return err + } + + if response.IsCustomAction() { + return c.processCustomAction(response) + } + + return c.performGitalyCall(response) +} + +func (c *Command) verifyAccess(repo string) (*accessverifier.Response, error) { + cmd := accessverifier.Command{c.Config, c.Args, c.ReadWriter} + + return cmd.Verify(c.Args.CommandType, repo) +} diff --git a/internal/command/receivepack/receivepack_test.go b/internal/command/receivepack/receivepack_test.go new file mode 100644 index 0000000..a45d054 --- /dev/null +++ b/internal/command/receivepack/receivepack_test.go @@ -0,0 +1,32 @@ +package receivepack + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestForbiddenAccess(t *testing.T) { + requests := requesthandlers.BuildDisallowedByApiHandlers(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + input := bytes.NewBufferString("input") + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: "disallowed", SshArgs: []string{"git-receive-pack", "group/repo"}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, + } + + err := cmd.Execute() + require.Equal(t, "Disallowed by API call", err.Error()) +} diff --git a/internal/command/shared/accessverifier/accessverifier.go b/internal/command/shared/accessverifier/accessverifier.go new file mode 100644 index 0000000..fc6fa17 --- /dev/null +++ b/internal/command/shared/accessverifier/accessverifier.go @@ -0,0 +1,45 @@ +package accessverifier + +import ( + "errors" + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" +) + +type Response = accessverifier.Response + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Verify(action commandargs.CommandType, repo string) (*Response, error) { + client, err := accessverifier.NewClient(c.Config) + if err != nil { + return nil, err + } + + response, err := client.Verify(c.Args, action, repo) + if err != nil { + return nil, err + } + + c.displayConsoleMessages(response.ConsoleMessages) + + if !response.Success { + return nil, errors.New(response.Message) + } + + return response, nil +} + +func (c *Command) displayConsoleMessages(messages []string) { + for _, msg := range messages { + fmt.Fprintf(c.ReadWriter.ErrOut, "> GitLab: %v\n", msg) + } +} diff --git a/internal/command/shared/accessverifier/accessverifier_test.go b/internal/command/shared/accessverifier/accessverifier_test.go new file mode 100644 index 0000000..c19ed37 --- /dev/null +++ b/internal/command/shared/accessverifier/accessverifier_test.go @@ -0,0 +1,82 @@ +package accessverifier + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +var ( + repo = "group/repo" + action = commandargs.ReceivePack +) + +func setup(t *testing.T) (*Command, *bytes.Buffer, *bytes.Buffer, func()) { + requests := []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/allowed", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + + var requestBody *accessverifier.Request + err = json.Unmarshal(b, &requestBody) + require.NoError(t, err) + + if requestBody.KeyId == "1" { + body := map[string]interface{}{ + "gl_console_messages": []string{"console", "message"}, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + } else { + body := map[string]interface{}{ + "status": false, + "message": "missing user", + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + } + }, + }, + } + + url, cleanup := testserver.StartSocketHttpServer(t, requests) + + errBuf := &bytes.Buffer{} + outBuf := &bytes.Buffer{} + + readWriter := &readwriter.ReadWriter{Out: outBuf, ErrOut: errBuf} + cmd := &Command{Config: &config.Config{GitlabUrl: url}, ReadWriter: readWriter} + + return cmd, errBuf, outBuf, cleanup +} + +func TestMissingUser(t *testing.T) { + cmd, _, _, cleanup := setup(t) + defer cleanup() + + cmd.Args = &commandargs.Shell{GitlabKeyId: "2"} + _, err := cmd.Verify(action, repo) + + require.Equal(t, "missing user", err.Error()) +} + +func TestConsoleMessages(t *testing.T) { + cmd, errBuf, outBuf, cleanup := setup(t) + defer cleanup() + + cmd.Args = &commandargs.Shell{GitlabKeyId: "1"} + cmd.Verify(action, repo) + + require.Equal(t, "> GitLab: console\n> GitLab: message\n", errBuf.String()) + require.Empty(t, outBuf.String()) +} diff --git a/internal/command/shared/disallowedcommand/disallowedcommand.go b/internal/command/shared/disallowedcommand/disallowedcommand.go new file mode 100644 index 0000000..3c98bcc --- /dev/null +++ b/internal/command/shared/disallowedcommand/disallowedcommand.go @@ -0,0 +1,7 @@ +package disallowedcommand + +import "errors" + +var ( + Error = errors.New("> GitLab: Disallowed command") +) diff --git a/internal/command/twofactorrecover/twofactorrecover.go b/internal/command/twofactorrecover/twofactorrecover.go new file mode 100644 index 0000000..c68080a --- /dev/null +++ b/internal/command/twofactorrecover/twofactorrecover.go @@ -0,0 +1,65 @@ +package twofactorrecover + +import ( + "fmt" + "strings" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/twofactorrecover" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + if c.canContinue() { + c.displayRecoveryCodes() + } else { + fmt.Fprintln(c.ReadWriter.Out, "\nNew recovery codes have *not* been generated. Existing codes will remain valid.") + } + + return nil +} + +func (c *Command) canContinue() bool { + question := + "Are you sure you want to generate new two-factor recovery codes?\n" + + "Any existing recovery codes you saved will be invalidated. (yes/no)" + fmt.Fprintln(c.ReadWriter.Out, question) + + var answer string + fmt.Fscanln(c.ReadWriter.In, &answer) + + return answer == "yes" +} + +func (c *Command) displayRecoveryCodes() { + codes, err := c.getRecoveryCodes() + + if err == nil { + messageWithCodes := + "\nYour two-factor authentication recovery codes are:\n\n" + + strings.Join(codes, "\n") + + "\n\nDuring sign in, use one of the codes above when prompted for\n" + + "your two-factor code. Then, visit your Profile Settings and add\n" + + "a new device so you do not lose access to your account again.\n" + fmt.Fprint(c.ReadWriter.Out, messageWithCodes) + } else { + fmt.Fprintf(c.ReadWriter.Out, "\nAn error occurred while trying to generate new recovery codes.\n%v\n", err) + } +} + +func (c *Command) getRecoveryCodes() ([]string, error) { + client, err := twofactorrecover.NewClient(c.Config) + + if err != nil { + return nil, err + } + + return client.GetRecoveryCodes(c.Args) +} diff --git a/internal/command/twofactorrecover/twofactorrecover_test.go b/internal/command/twofactorrecover/twofactorrecover_test.go new file mode 100644 index 0000000..291d499 --- /dev/null +++ b/internal/command/twofactorrecover/twofactorrecover_test.go @@ -0,0 +1,136 @@ +package twofactorrecover + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/twofactorrecover" +) + +var ( + requests []testserver.TestRequestHandler +) + +func setup(t *testing.T) { + requests = []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/two_factor_recovery_codes", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + + require.NoError(t, err) + + var requestBody *twofactorrecover.RequestBody + json.Unmarshal(b, &requestBody) + + switch requestBody.KeyId { + case "1": + body := map[string]interface{}{ + "success": true, + "recovery_codes": [2]string{"recovery", "codes"}, + } + json.NewEncoder(w).Encode(body) + case "forbidden": + body := map[string]interface{}{ + "success": false, + "message": "Forbidden!", + } + json.NewEncoder(w).Encode(body) + case "broken": + w.WriteHeader(http.StatusInternalServerError) + } + }, + }, + } +} + +const ( + question = "Are you sure you want to generate new two-factor recovery codes?\n" + + "Any existing recovery codes you saved will be invalidated. (yes/no)\n\n" + errorHeader = "An error occurred while trying to generate new recovery codes.\n" +) + +func TestExecute(t *testing.T) { + setup(t) + + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.Shell + answer string + expectedOutput string + }{ + { + desc: "With a known key id", + arguments: &commandargs.Shell{GitlabKeyId: "1"}, + answer: "yes\n", + expectedOutput: question + + "Your two-factor authentication recovery codes are:\n\nrecovery\ncodes\n\n" + + "During sign in, use one of the codes above when prompted for\n" + + "your two-factor code. Then, visit your Profile Settings and add\n" + + "a new device so you do not lose access to your account again.\n", + }, + { + desc: "With bad response", + arguments: &commandargs.Shell{GitlabKeyId: "-1"}, + answer: "yes\n", + expectedOutput: question + errorHeader + "Parsing failed\n", + }, + { + desc: "With API returns an error", + arguments: &commandargs.Shell{GitlabKeyId: "forbidden"}, + answer: "yes\n", + expectedOutput: question + errorHeader + "Forbidden!\n", + }, + { + desc: "With API fails", + arguments: &commandargs.Shell{GitlabKeyId: "broken"}, + answer: "yes\n", + expectedOutput: question + errorHeader + "Internal API error (500)\n", + }, + { + desc: "With missing arguments", + arguments: &commandargs.Shell{}, + answer: "yes\n", + expectedOutput: question + errorHeader + "who='' is invalid\n", + }, + { + desc: "With negative answer", + arguments: &commandargs.Shell{}, + answer: "no\n", + expectedOutput: question + + "New recovery codes have *not* been generated. Existing codes will remain valid.\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + input := bytes.NewBufferString(tc.answer) + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: output, In: input}, + } + + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, tc.expectedOutput, output.String()) + }) + } +} diff --git a/internal/command/uploadarchive/gitalycall.go b/internal/command/uploadarchive/gitalycall.go new file mode 100644 index 0000000..e810ba3 --- /dev/null +++ b/internal/command/uploadarchive/gitalycall.go @@ -0,0 +1,32 @@ +package uploadarchive + +import ( + "context" + + "google.golang.org/grpc" + + "gitlab.com/gitlab-org/gitaly/client" + pb "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/handler" +) + +func (c *Command) performGitalyCall(response *accessverifier.Response) error { + gc := &handler.GitalyCommand{ + Config: c.Config, + ServiceName: string(commandargs.UploadArchive), + Address: response.Gitaly.Address, + Token: response.Gitaly.Token, + } + + request := &pb.SSHUploadArchiveRequest{Repository: &response.Gitaly.Repo} + + return gc.RunGitalyCommand(func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + rw := c.ReadWriter + return client.UploadArchive(ctx, conn, rw.In, rw.Out, rw.ErrOut, request) + }) +} diff --git a/internal/command/uploadarchive/gitalycall_test.go b/internal/command/uploadarchive/gitalycall_test.go new file mode 100644 index 0000000..5eb2eae --- /dev/null +++ b/internal/command/uploadarchive/gitalycall_test.go @@ -0,0 +1,40 @@ +package uploadarchive + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestUploadPack(t *testing.T) { + gitalyAddress, cleanup := testserver.StartGitalyServer(t) + defer cleanup() + + requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + input := &bytes.Buffer{} + + userId := "1" + repo := "group/repo" + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: userId, CommandType: commandargs.UploadArchive, SshArgs: []string{"git-upload-archive", repo}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, + } + + err := cmd.Execute() + require.NoError(t, err) + + require.Equal(t, "UploadArchive: "+repo, output.String()) +} diff --git a/internal/command/uploadarchive/uploadarchive.go b/internal/command/uploadarchive/uploadarchive.go new file mode 100644 index 0000000..2846455 --- /dev/null +++ b/internal/command/uploadarchive/uploadarchive.go @@ -0,0 +1,36 @@ +package uploadarchive + +import ( + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + args := c.Args.SshArgs + if len(args) != 2 { + return disallowedcommand.Error + } + + repo := args[1] + response, err := c.verifyAccess(repo) + if err != nil { + return err + } + + return c.performGitalyCall(response) +} + +func (c *Command) verifyAccess(repo string) (*accessverifier.Response, error) { + cmd := accessverifier.Command{c.Config, c.Args, c.ReadWriter} + + return cmd.Verify(c.Args.CommandType, repo) +} diff --git a/internal/command/uploadarchive/uploadarchive_test.go b/internal/command/uploadarchive/uploadarchive_test.go new file mode 100644 index 0000000..4cd6832 --- /dev/null +++ b/internal/command/uploadarchive/uploadarchive_test.go @@ -0,0 +1,31 @@ +package uploadarchive + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestForbiddenAccess(t *testing.T) { + requests := requesthandlers.BuildDisallowedByApiHandlers(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: "disallowed", SshArgs: []string{"git-upload-archive", "group/repo"}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.Equal(t, "Disallowed by API call", err.Error()) +} diff --git a/internal/command/uploadpack/gitalycall.go b/internal/command/uploadpack/gitalycall.go new file mode 100644 index 0000000..5dff24a --- /dev/null +++ b/internal/command/uploadpack/gitalycall.go @@ -0,0 +1,36 @@ +package uploadpack + +import ( + "context" + + "google.golang.org/grpc" + + "gitlab.com/gitlab-org/gitaly/client" + pb "gitlab.com/gitlab-org/gitaly/proto/go/gitalypb" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/handler" +) + +func (c *Command) performGitalyCall(response *accessverifier.Response) error { + gc := &handler.GitalyCommand{ + Config: c.Config, + ServiceName: string(commandargs.UploadPack), + Address: response.Gitaly.Address, + Token: response.Gitaly.Token, + } + + request := &pb.SSHUploadPackRequest{ + Repository: &response.Gitaly.Repo, + GitProtocol: response.GitProtocol, + GitConfigOptions: response.GitConfigOptions, + } + + return gc.RunGitalyCommand(func(ctx context.Context, conn *grpc.ClientConn) (int32, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + rw := c.ReadWriter + return client.UploadPack(ctx, conn, rw.In, rw.Out, rw.ErrOut, request) + }) +} diff --git a/internal/command/uploadpack/gitalycall_test.go b/internal/command/uploadpack/gitalycall_test.go new file mode 100644 index 0000000..eb18aa8 --- /dev/null +++ b/internal/command/uploadpack/gitalycall_test.go @@ -0,0 +1,40 @@ +package uploadpack + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestUploadPack(t *testing.T) { + gitalyAddress, cleanup := testserver.StartGitalyServer(t) + defer cleanup() + + requests := requesthandlers.BuildAllowedWithGitalyHandlers(t, gitalyAddress) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + input := &bytes.Buffer{} + + userId := "1" + repo := "group/repo" + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: userId, CommandType: commandargs.UploadPack, SshArgs: []string{"git-upload-pack", repo}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output, In: input}, + } + + err := cmd.Execute() + require.NoError(t, err) + + require.Equal(t, "UploadPack: "+repo, output.String()) +} diff --git a/internal/command/uploadpack/uploadpack.go b/internal/command/uploadpack/uploadpack.go new file mode 100644 index 0000000..4b08bf2 --- /dev/null +++ b/internal/command/uploadpack/uploadpack.go @@ -0,0 +1,36 @@ +package uploadpack + +import ( + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter +} + +func (c *Command) Execute() error { + args := c.Args.SshArgs + if len(args) != 2 { + return disallowedcommand.Error + } + + repo := args[1] + response, err := c.verifyAccess(repo) + if err != nil { + return err + } + + return c.performGitalyCall(response) +} + +func (c *Command) verifyAccess(repo string) (*accessverifier.Response, error) { + cmd := accessverifier.Command{c.Config, c.Args, c.ReadWriter} + + return cmd.Verify(c.Args.CommandType, repo) +} diff --git a/internal/command/uploadpack/uploadpack_test.go b/internal/command/uploadpack/uploadpack_test.go new file mode 100644 index 0000000..27a0786 --- /dev/null +++ b/internal/command/uploadpack/uploadpack_test.go @@ -0,0 +1,31 @@ +package uploadpack + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestForbiddenAccess(t *testing.T) { + requests := requesthandlers.BuildDisallowedByApiHandlers(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + output := &bytes.Buffer{} + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.Shell{GitlabKeyId: "disallowed", SshArgs: []string{"git-upload-pack", "group/repo"}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.Equal(t, "Disallowed by API call", err.Error()) +} |
