diff options
author | Nick Thomas <nick@gitlab.com> | 2021-01-18 18:36:26 +0000 |
---|---|---|
committer | Nick Thomas <nick@gitlab.com> | 2021-01-18 18:36:26 +0000 |
commit | 524a57effc04fdabf5bcc8fcf2b353e5ff7ae4c7 (patch) | |
tree | 6ccc8b187438d1a218ee0ec833c339fa8faa8dd1 | |
parent | 0e5660917f0316a2197ffc5add7a8f01d3e428be (diff) | |
parent | 2a410f31b633ec5a994ecf1ff39dc8ffb9c6f828 (diff) | |
download | gitlab-shell-524a57effc04fdabf5bcc8fcf2b353e5ff7ae4c7.tar.gz |
Merge branch 'master' into 'main'
RFC: Simple built-in SSH server
Closes #165
See merge request gitlab-org/gitlab-shell!394
-rw-r--r-- | cmd/check/main.go | 2 | ||||
-rw-r--r-- | cmd/gitlab-shell-authorized-keys-check/main.go | 2 | ||||
-rw-r--r-- | cmd/gitlab-shell-authorized-principals-check/main.go | 2 | ||||
-rw-r--r-- | cmd/gitlab-shell/main.go | 2 | ||||
-rw-r--r-- | cmd/gitlab-sshd/Dockerfile | 3 | ||||
-rw-r--r-- | cmd/gitlab-sshd/main.go | 57 | ||||
-rw-r--r-- | config.yml.example | 12 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 6 | ||||
-rw-r--r-- | internal/command/command.go | 4 | ||||
-rw-r--r-- | internal/command/commandargs/shell.go | 12 | ||||
-rw-r--r-- | internal/command/receivepack/gitalycall.go | 5 | ||||
-rw-r--r-- | internal/command/receivepack/receivepack.go | 10 | ||||
-rw-r--r-- | internal/command/uploadpack/gitalycall.go | 5 | ||||
-rw-r--r-- | internal/command/uploadpack/uploadpack.go | 10 | ||||
-rw-r--r-- | internal/config/config.go | 138 | ||||
-rw-r--r-- | internal/config/config_test.go | 120 | ||||
-rw-r--r-- | internal/gitlabnet/accessverifier/client.go | 6 | ||||
-rw-r--r-- | internal/logger/logger.go | 105 | ||||
-rw-r--r-- | internal/logger/logger_test.go | 52 | ||||
-rw-r--r-- | internal/sshd/sshd.go | 214 |
21 files changed, 460 insertions, 309 deletions
diff --git a/cmd/check/main.go b/cmd/check/main.go index 28634f4..b87e87e 100644 --- a/cmd/check/main.go +++ b/cmd/check/main.go @@ -24,7 +24,7 @@ func main() { os.Exit(1) } - config, err := config.NewFromDir(executable.RootDir) + config, err := config.NewFromDirExternal(executable.RootDir) if err != nil { fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting") os.Exit(1) diff --git a/cmd/gitlab-shell-authorized-keys-check/main.go b/cmd/gitlab-shell-authorized-keys-check/main.go index 3a7dcbb..ba8ddd8 100644 --- a/cmd/gitlab-shell-authorized-keys-check/main.go +++ b/cmd/gitlab-shell-authorized-keys-check/main.go @@ -25,7 +25,7 @@ func main() { os.Exit(1) } - config, err := config.NewFromDir(executable.RootDir) + config, err := config.NewFromDirExternal(executable.RootDir) if err != nil { fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting") os.Exit(1) diff --git a/cmd/gitlab-shell-authorized-principals-check/main.go b/cmd/gitlab-shell-authorized-principals-check/main.go index ea8d140..412447d 100644 --- a/cmd/gitlab-shell-authorized-principals-check/main.go +++ b/cmd/gitlab-shell-authorized-principals-check/main.go @@ -25,7 +25,7 @@ func main() { os.Exit(1) } - config, err := config.NewFromDir(executable.RootDir) + config, err := config.NewFromDirExternal(executable.RootDir) if err != nil { fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting") os.Exit(1) diff --git a/cmd/gitlab-shell/main.go b/cmd/gitlab-shell/main.go index ff3a354..4db54f9 100644 --- a/cmd/gitlab-shell/main.go +++ b/cmd/gitlab-shell/main.go @@ -39,7 +39,7 @@ func main() { os.Exit(1) } - config, err := config.NewFromDir(executable.RootDir) + config, err := config.NewFromDirExternal(executable.RootDir) if err != nil { fmt.Fprintln(readWriter.ErrOut, "Failed to read config, exiting") os.Exit(1) diff --git a/cmd/gitlab-sshd/Dockerfile b/cmd/gitlab-sshd/Dockerfile new file mode 100644 index 0000000..ba1f7f5 --- /dev/null +++ b/cmd/gitlab-sshd/Dockerfile @@ -0,0 +1,3 @@ +FROM gcr.io/distroless/static-debian10 +COPY gitlab-sshd /gitlab-sshd +CMD ["/gitlab-sshd"]
\ No newline at end of file diff --git a/cmd/gitlab-sshd/main.go b/cmd/gitlab-sshd/main.go new file mode 100644 index 0000000..b9ea67a --- /dev/null +++ b/cmd/gitlab-sshd/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "os" + + log "github.com/sirupsen/logrus" + + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/logger" + "gitlab.com/gitlab-org/gitlab-shell/internal/sshd" +) + +var ( + configDir = flag.String("config-dir", "", "The directory the config is in") +) + +func overrideConfigFromEnvironment(cfg *config.Config) { + if gitlabUrl := os.Getenv("GITLAB_URL"); gitlabUrl != "" { + cfg.GitlabUrl = gitlabUrl + } + if gitlabTracing := os.Getenv("GITLAB_TRACING"); gitlabTracing != "" { + cfg.GitlabTracing = gitlabTracing + } + if gitlabShellSecret := os.Getenv("GITLAB_SHELL_SECRET"); gitlabShellSecret != "" { + cfg.Secret = gitlabShellSecret + } + if gitlabLogFormat := os.Getenv("GITLAB_LOG_FORMAT"); gitlabLogFormat != "" { + cfg.LogFormat = gitlabLogFormat + } + return +} + +func main() { + flag.Parse() + cfg := new(config.Config) + if *configDir != "" { + var err error + cfg, err = config.NewFromDir(*configDir) + if err != nil { + log.Fatalf("failed to load configuration from specified directory: %v", err) + } + } + overrideConfigFromEnvironment(cfg) + cfg.ApplyServerDefaults() + if err := cfg.IsSane(); err != nil { + if *configDir == "" { + log.Warn("note: no config-dir provided, using only environment variables") + } + log.Fatalf("configuration error: %v", err) + } + logger.ConfigureStandalone(cfg) + + if err := sshd.Run(cfg); err != nil { + log.Fatalf("Failed to start GitLab built-in sshd: %v", err) + } +} diff --git a/config.yml.example b/config.yml.example index 645cb88..6977ef2 100644 --- a/config.yml.example +++ b/config.yml.example @@ -61,3 +61,15 @@ audit_usernames: false # Distributed Tracing. GitLab-Shell has distributed tracing instrumentation. # For more details, visit https://docs.gitlab.com/ee/development/distributed_tracing.html # gitlab_tracing: opentracing://driver + +# This section configures the built-in SSH server. Ignored when running on OpenSSH. +sshd: + # Address which the SSH server listens on. Defaults to [::]:22. + listen: "[::]:22" + # Maximum number of concurrent sessions allowed on a single SSH connection. Defaults to 10. + concurrent_sessions_limit: 10 + # SSH host key files. + host_key_files: + - /run/secrets/ssh-hostkeys/ssh_host_rsa_key + - /run/secrets/ssh-hostkeys/ssh_host_ecdsa_key + - /run/secrets/ssh-hostkeys/ssh_host_ed25519_key
\ No newline at end of file @@ -9,6 +9,8 @@ require ( github.com/stretchr/testify v1.4.0 gitlab.com/gitlab-org/gitaly v1.68.0 gitlab.com/gitlab-org/labkit v0.0.0-20200908084045-45895e129029 + golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e google.golang.org/grpc v1.24.0 gopkg.in/yaml.v2 v2.2.8 ) @@ -337,6 +337,10 @@ golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f h1:aZp0e2vLN4MToVqnjNEYEtrEA8RH8U8FN1CU7JgqsPU= +golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -410,9 +414,11 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1 h1:gZpLHxUX5BdYLA08Lj4YCJNN/jk7KtquiArPoeX0WvA= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/command/command.go b/internal/command/command.go index 5062d15..c0f9090 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -70,7 +70,7 @@ func ContextWithCorrelationID() (context.Context, func()) { 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) + return BuildShellCommand(args.(*commandargs.Shell), config, readWriter) case executable.AuthorizedKeysCheck: return buildAuthorizedKeysCommand(args.(*commandargs.AuthorizedKeys), config, readWriter) case executable.AuthorizedPrincipalsCheck: @@ -82,7 +82,7 @@ func buildCommand(e *executable.Executable, args commandargs.CommandArgs, config return nil } -func buildShellCommand(args *commandargs.Shell, config *config.Config, readWriter *readwriter.ReadWriter) Command { +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} diff --git a/internal/command/commandargs/shell.go b/internal/command/commandargs/shell.go index 1535ccb..62fc8fa 100644 --- a/internal/command/commandargs/shell.go +++ b/internal/command/commandargs/shell.go @@ -2,6 +2,7 @@ package commandargs import ( "errors" + "net" "os" "regexp" @@ -32,6 +33,10 @@ type Shell struct { GitlabKeyId string SshArgs []string CommandType CommandType + + // Only set when running standalone + RemoteAddr *net.TCPAddr + GitProtocolVersion string } func (s *Shell) Parse() error { @@ -40,7 +45,6 @@ func (s *Shell) Parse() error { } s.parseWho() - s.defineCommandType() return nil } @@ -67,7 +71,7 @@ func (s *Shell) isSshConnection() bool { } func (s *Shell) isValidSshCommand() bool { - err := s.parseCommand(os.Getenv("SSH_ORIGINAL_COMMAND")) + err := s.ParseCommand(os.Getenv("SSH_ORIGINAL_COMMAND")) return err == nil } @@ -107,7 +111,7 @@ func tryParseUsername(argument string) string { return "" } -func (s *Shell) parseCommand(commandString string) error { +func (s *Shell) ParseCommand(commandString string) error { args, err := shellwords.Parse(commandString) if err != nil { return err @@ -123,6 +127,8 @@ func (s *Shell) parseCommand(commandString string) error { s.SshArgs = args + s.defineCommandType() + return nil } diff --git a/internal/command/receivepack/gitalycall.go b/internal/command/receivepack/gitalycall.go index a983c1a..b27b75a 100644 --- a/internal/command/receivepack/gitalycall.go +++ b/internal/command/receivepack/gitalycall.go @@ -2,7 +2,6 @@ package receivepack import ( "context" - "os" "google.golang.org/grpc" @@ -13,7 +12,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/internal/handler" ) -func (c *Command) performGitalyCall(response *accessverifier.Response) error { +func (c *Command) performGitalyCall(response *accessverifier.Response, gitProtocolVersion string) error { gc := &handler.GitalyCommand{ Config: c.Config, ServiceName: string(commandargs.ReceivePack), @@ -27,7 +26,7 @@ func (c *Command) performGitalyCall(response *accessverifier.Response) error { GlId: response.Who, GlRepository: response.Repo, GlUsername: response.Username, - GitProtocol: os.Getenv(commandargs.GitProtocolEnv), + GitProtocol: gitProtocolVersion, GitConfigOptions: response.GitConfigOptions, } diff --git a/internal/command/receivepack/receivepack.go b/internal/command/receivepack/receivepack.go index 4d5c686..5a67c5a 100644 --- a/internal/command/receivepack/receivepack.go +++ b/internal/command/receivepack/receivepack.go @@ -2,6 +2,7 @@ package receivepack import ( "context" + "os" "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" @@ -38,7 +39,14 @@ func (c *Command) Execute(ctx context.Context) error { return customAction.Execute(ctx, response) } - return c.performGitalyCall(response) + var gitProtocolVersion string + if c.Args.RemoteAddr != nil { + gitProtocolVersion = c.Args.GitProtocolVersion + } else { + gitProtocolVersion = os.Getenv(commandargs.GitProtocolEnv) + } + + return c.performGitalyCall(response, gitProtocolVersion) } func (c *Command) verifyAccess(ctx context.Context, repo string) (*accessverifier.Response, error) { diff --git a/internal/command/uploadpack/gitalycall.go b/internal/command/uploadpack/gitalycall.go index ba0fef2..3ebc8b3 100644 --- a/internal/command/uploadpack/gitalycall.go +++ b/internal/command/uploadpack/gitalycall.go @@ -2,7 +2,6 @@ package uploadpack import ( "context" - "os" "google.golang.org/grpc" @@ -13,7 +12,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/internal/handler" ) -func (c *Command) performGitalyCall(response *accessverifier.Response) error { +func (c *Command) performGitalyCall(response *accessverifier.Response, gitProtocolVersion string) error { gc := &handler.GitalyCommand{ Config: c.Config, ServiceName: string(commandargs.UploadPack), @@ -24,7 +23,7 @@ func (c *Command) performGitalyCall(response *accessverifier.Response) error { request := &pb.SSHUploadPackRequest{ Repository: &response.Gitaly.Repo, - GitProtocol: os.Getenv(commandargs.GitProtocolEnv), + GitProtocol: gitProtocolVersion, GitConfigOptions: response.GitConfigOptions, } diff --git a/internal/command/uploadpack/uploadpack.go b/internal/command/uploadpack/uploadpack.go index fca3823..bf5db2c 100644 --- a/internal/command/uploadpack/uploadpack.go +++ b/internal/command/uploadpack/uploadpack.go @@ -2,6 +2,7 @@ package uploadpack import ( "context" + "os" "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" @@ -38,7 +39,14 @@ func (c *Command) Execute(ctx context.Context) error { return customAction.Execute(ctx, response) } - return c.performGitalyCall(response) + var gitProtocolVersion string + if c.Args.RemoteAddr != nil { + gitProtocolVersion = c.Args.GitProtocolVersion + } else { + gitProtocolVersion = os.Getenv(commandargs.GitProtocolEnv) + } + + return c.performGitalyCall(response, gitProtocolVersion) } func (c *Command) verifyAccess(ctx context.Context, repo string) (*accessverifier.Response, error) { diff --git a/internal/config/config.go b/internal/config/config.go index 79c2a36..ac5c985 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,9 @@ package config import ( + "errors" "io/ioutil" "net/url" - "os" "path" "path/filepath" @@ -17,6 +17,12 @@ const ( defaultSecretFileName = ".gitlab_shell_secret" ) +type ServerConfig struct { + Listen string `yaml:"listen"` + ConcurrentSessionsLimit int64 `yaml:"concurrent_sessions_limit"` + HostKeyFiles []string `yaml:"host_key_files"` +} + type HttpSettingsConfig struct { User string `yaml:"user"` Password string `yaml:"password"` @@ -27,17 +33,20 @@ type HttpSettingsConfig struct { } type Config struct { + User string `yaml:"user"` RootDir string - LogFile string `yaml:"log_file"` - LogFormat string `yaml:"log_format"` - GitlabUrl string `yaml:"gitlab_url"` - GitlabRelativeURLRoot string `yaml:"gitlab_relative_url_root"` - GitlabTracing string `yaml:"gitlab_tracing"` - SecretFilePath string `yaml:"secret_file"` - Secret string `yaml:"secret"` - SslCertDir string `yaml:"ssl_cert_dir"` - HttpSettings HttpSettingsConfig `yaml:"http_settings"` - HttpClient *client.HttpClient `-` + LogFile string `yaml:"log_file"` + LogFormat string `yaml:"log_format"` + GitlabUrl string `yaml:"gitlab_url"` + GitlabRelativeURLRoot string `yaml:"gitlab_relative_url_root"` + GitlabTracing string `yaml:"gitlab_tracing"` + // SecretFilePath is only for parsing. Application code should always use Secret. + SecretFilePath string `yaml:"secret_file"` + Secret string `yaml:"secret"` + SslCertDir string `yaml:"ssl_cert_dir"` + HttpSettings HttpSettingsConfig `yaml:"http_settings"` + Server ServerConfig `yaml:"sshd"` + HttpClient *client.HttpClient `-` } func (c *Config) GetHttpClient() *client.HttpClient { @@ -58,66 +67,52 @@ func (c *Config) GetHttpClient() *client.HttpClient { return client } -func New() (*Config, error) { - dir, err := os.Getwd() +// NewFromDirExternal returns a new config from a given root dir. It also applies defaults appropriate for +// gitlab-shell running in an external SSH server. +func NewFromDirExternal(dir string) (*Config, error) { + cfg, err := newFromFile(filepath.Join(dir, configFile)) if err != nil { return nil, err } - - return NewFromDir(dir) + cfg.ApplyExternalDefaults() + return cfg, nil } +// NewFromDir returns a new config given a root directory. It looks for the config file name in the +// given directory and reads the config from it. It doesn't apply any defaults. New code should prefer +// this over NewFromDirIntegrated and apply the right default via one of the Apply... functions. func NewFromDir(dir string) (*Config, error) { - return newFromFile(path.Join(dir, configFile)) + return newFromFile(filepath.Join(dir, configFile)) } -func newFromFile(filename string) (*Config, error) { - cfg := &Config{RootDir: path.Dir(filename)} +// newFromFile reads a new Config instance from the given file path. It doesn't apply any defaults. +func newFromFile(path string) (*Config, error) { + cfg := &Config{RootDir: filepath.Dir(path)} - configBytes, err := ioutil.ReadFile(filename) + configBytes, err := ioutil.ReadFile(path) if err != nil { return nil, err } - if err := parseConfig(configBytes, cfg); err != nil { - return nil, err - } - - return cfg, nil -} - -// parseConfig expects YAML data in configBytes and a Config instance with RootDir set. -func parseConfig(configBytes []byte, cfg *Config) error { if err := yaml.Unmarshal(configBytes, cfg); err != nil { - return err - } - - if cfg.LogFile == "" { - cfg.LogFile = logFile - } - - if len(cfg.LogFile) > 0 && cfg.LogFile[0] != '/' { - cfg.LogFile = path.Join(cfg.RootDir, cfg.LogFile) - } - - if cfg.LogFormat == "" { - cfg.LogFormat = "text" + return nil, err } if cfg.GitlabUrl != "" { + // This is only done for historic reasons, don't implement it for new config sources. unescapedUrl, err := url.PathUnescape(cfg.GitlabUrl) if err != nil { - return err + return nil, err } cfg.GitlabUrl = unescapedUrl } if err := parseSecret(cfg); err != nil { - return err + return nil, err } - return nil + return cfg, nil } func parseSecret(cfg *Config) error { @@ -142,3 +137,58 @@ func parseSecret(cfg *Config) error { return nil } + +// ApplyServerDefaults applies defaults running inside an external SSH server. +func (cfg *Config) ApplyExternalDefaults() { + // Set default LogFile to a file since with an external SSH server stdout is not a possibility. + if cfg.LogFile == "" { + cfg.LogFile = logFile + } + cfg.applyGenericDefaults() +} + +// applyGenericDefaults applies defaults common to all operating modes. +func (cfg *Config) applyGenericDefaults() { + if cfg.LogFormat == "" { + cfg.LogFormat = "text" + } + // Currently only used by the built-in SSH server, but not specific to it, so let's to it here. + if cfg.User == "" { + cfg.User = "git" + } + if len(cfg.LogFile) > 0 && cfg.LogFile[0] != '/' && cfg.RootDir != "" { + cfg.LogFile = filepath.Join(cfg.RootDir, cfg.LogFile) + } +} + +// ApplyServerDefaults applies defaults for the built-in SSH server. +func (cfg *Config) ApplyServerDefaults() { + if cfg.Server.ConcurrentSessionsLimit == 0 { + cfg.Server.ConcurrentSessionsLimit = 10 + } + if cfg.Server.Listen == "" { + cfg.Server.Listen = "[::]:22" + } + if len(cfg.Server.HostKeyFiles) == 0 { + cfg.Server.HostKeyFiles = []string{ + "/run/secrets/ssh-hostkeys/ssh_host_rsa_key", + "/run/secrets/ssh-hostkeys/ssh_host_ecdsa_key", + "/run/secrets/ssh-hostkeys/ssh_host_ed25519_key", + } + } + cfg.applyGenericDefaults() +} + +// IsSane checks if the given config fulfills the minimum requirements to be able to run. +// Any error returned by this function should be a startup error. On the other hand +// if this function returns nil, this doesn't guarantee the config will work, but it's +// at least worth a try. +func (cfg *Config) IsSane() error { + if cfg.GitlabUrl == "" { + return errors.New("gitlab_url is required") + } + if cfg.Secret == "" { + return errors.New("secret or secret_file_path is required") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index f90e73c..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package config - -import ( - "fmt" - "path" - "testing" - - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitlab-shell/internal/testhelper" -) - -const ( - customSecret = "custom/my-contents-is-secret" -) - -var ( - testRoot = testhelper.TestRoot -) - -func TestParseConfig(t *testing.T) { - cleanup, err := testhelper.PrepareTestRootDir() - require.NoError(t, err) - defer cleanup() - - testCases := []struct { - yaml string - path string - format string - gitlabUrl string - secret string - sslCertDir string - httpSettings HttpSettingsConfig - }{ - { - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "default-secret-content", - }, - { - yaml: "log_file: my-log.log", - path: path.Join(testRoot, "my-log.log"), - format: "text", - secret: "default-secret-content", - }, - { - yaml: "log_file: /qux/my-log.log", - path: "/qux/my-log.log", - format: "text", - secret: "default-secret-content", - }, - { - yaml: "log_format: json", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "json", - secret: "default-secret-content", - }, - { - yaml: "gitlab_url: http+unix://%2Fpath%2Fto%2Fgitlab%2Fgitlab.socket", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - gitlabUrl: "http+unix:///path/to/gitlab/gitlab.socket", - secret: "default-secret-content", - }, - { - yaml: fmt.Sprintf("secret_file: %s", customSecret), - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "custom-secret-content", - }, - { - yaml: fmt.Sprintf("secret_file: %s", path.Join(testRoot, customSecret)), - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "custom-secret-content", - }, - { - yaml: "secret: an inline secret", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "an inline secret", - }, - { - yaml: "ssl_cert_dir: /tmp/certs", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "default-secret-content", - sslCertDir: "/tmp/certs", - }, - { - yaml: "http_settings:\n user: user_basic_auth\n password: password_basic_auth\n read_timeout: 500", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "default-secret-content", - httpSettings: HttpSettingsConfig{User: "user_basic_auth", Password: "password_basic_auth", ReadTimeoutSeconds: 500}, - }, - { - yaml: "http_settings:\n ca_file: /etc/ssl/cert.pem\n ca_path: /etc/pki/tls/certs\n self_signed_cert: true", - path: path.Join(testRoot, "gitlab-shell.log"), - format: "text", - secret: "default-secret-content", - httpSettings: HttpSettingsConfig{CaFile: "/etc/ssl/cert.pem", CaPath: "/etc/pki/tls/certs", SelfSignedCert: true}, - }, - } - - for _, tc := range testCases { - t.Run(fmt.Sprintf("yaml input: %q", tc.yaml), func(t *testing.T) { - cfg := Config{RootDir: testRoot} - - err := parseConfig([]byte(tc.yaml), &cfg) - require.NoError(t, err) - - require.Equal(t, tc.path, cfg.LogFile) - require.Equal(t, tc.format, cfg.LogFormat) - require.Equal(t, tc.gitlabUrl, cfg.GitlabUrl) - require.Equal(t, tc.secret, cfg.Secret) - require.Equal(t, tc.sslCertDir, cfg.SslCertDir) - require.Equal(t, tc.httpSettings, cfg.HttpSettings) - }) - } -} diff --git a/internal/gitlabnet/accessverifier/client.go b/internal/gitlabnet/accessverifier/client.go index 7e120e0..4a33d5b 100644 --- a/internal/gitlabnet/accessverifier/client.go +++ b/internal/gitlabnet/accessverifier/client.go @@ -87,7 +87,11 @@ func (c *Client) Verify(ctx context.Context, args *commandargs.Shell, action com request.KeyId = args.GitlabKeyId } - request.CheckIp = sshenv.LocalAddr() + if args.RemoteAddr != nil { + request.CheckIp = args.RemoteAddr.IP.String() + } else { + request.CheckIp = sshenv.LocalAddr() + } response, err := c.client.Post(ctx, "/allowed", request) if err != nil { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4d40d24..f836555 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,100 +1,53 @@ package logger import ( - "fmt" - "io" "io/ioutil" - golog "log" "log/syslog" - "math" "os" - "sync" - "time" - - "gitlab.com/gitlab-org/gitlab-shell/internal/config" log "github.com/sirupsen/logrus" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" ) -var ( - logWriter io.Writer - bootstrapLogger *golog.Logger - pid int - mutex sync.Mutex - ProgName string -) - -func Configure(cfg *config.Config) error { - mutex.Lock() - defer mutex.Unlock() - - pid = os.Getpid() - ProgName, _ = os.Executable() - - // Avoid leaking output if we can't set up the logging output - log.SetOutput(ioutil.Discard) - - output, err := os.OpenFile(cfg.LogFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) - if err != nil { - setupBootstrapLogger() - logPrint("Unable to configure logging", err) - return err - } - - logWriter = output - log.SetOutput(logWriter) +func configureLogFormat(cfg *config.Config) { if cfg.LogFormat == "json" { log.SetFormatter(&log.JSONFormatter{}) } - - return nil } -// If our log file is not available we want to log somewhere else, but -// not to standard error because that leaks information to the user. This -// function attempts to log to syslog. -func logPrint(msg string, err error) { - if logWriter == nil { - if bootstrapLogger != nil { - bootstrapLogger.Print(ProgName+":", msg+":", err) - } - return - } +// Configure configures the logging singleton for operation inside a remote TTY (like SSH). In this +// mode an empty LogFile is not accepted and syslog is used as a fallback when LogFile could not be +// opened for writing. +func Configure(cfg *config.Config) { + logFile, err := os.OpenFile(cfg.LogFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + if err != nil { + progName, _ := os.Executable() + syslogLogger, err := syslog.NewLogger(syslog.LOG_ERR|syslog.LOG_USER, 0) + syslogLogger.Print(progName + ": Unable to configure logging: " + err.Error()) - log.WithError(err).WithFields(log.Fields{ - "pid": pid, - }).Error(msg) -} + // Discard logs since a log file was specified but couldn't be opened + log.SetOutput(ioutil.Discard) + } -func Fatal(msg string, err error) { - mutex.Lock() - defer mutex.Unlock() - setupBootstrapLogger() + log.SetOutput(logFile) - logPrint(msg, err) - // We don't show the error to the end user because it can leak - // information that is private to the GitLab server. - fmt.Fprintf(os.Stderr, "%s: fatal: %s\n", ProgName, msg) - os.Exit(1) + configureLogFormat(cfg) } -// We assume the logging mutex is already locked. -func setupBootstrapLogger() { - if bootstrapLogger == nil { - bootstrapLogger, _ = syslog.NewLogger(syslog.LOG_ERR|syslog.LOG_USER, 0) +// ConfigureStandalone configures the logging singleton for standalone operation. In this mode an +// empty LogFile is treated as logging to standard output and standard output is used as a fallback +// when LogFile could not be opened for writing. +func ConfigureStandalone(cfg *config.Config) { + if cfg.LogFile == "" { + return } -} -func ElapsedTimeMs(start time.Time, end time.Time) float64 { - // Later versions of Go support Milliseconds directly: - // https://go-review.googlesource.com/c/go/+/167387/ - return roundFloat(end.Sub(start).Seconds() * 1e3) -} - -func roundFloat(x float64) float64 { - return round(x, 1000) -} + logFile, err := os.OpenFile(cfg.LogFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + if err != nil { + log.Printf("Unable to configure logging, falling back to stdout: %v", err) + return + } + log.SetOutput(logFile) -func round(x, unit float64) float64 { - return math.Round(x*unit) / unit + configureLogFormat(cfg) } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 7316dbf..8b28b37 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -1,12 +1,10 @@ package logger import ( - "fmt" "io/ioutil" "os" "strings" "testing" - "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" @@ -23,10 +21,7 @@ func TestConfigure(t *testing.T) { LogFormat: "json", } - err = Configure(&config) - - require.NoError(t, err) - + Configure(&config) log.Info("this is a test") tmpFile.Close() @@ -35,48 +30,3 @@ func TestConfigure(t *testing.T) { require.NoError(t, err) require.True(t, strings.Contains(string(data), `msg":"this is a test"`)) } - -func TestElapsedTimeMs(t *testing.T) { - testCases := []struct { - delta float64 - expected float64 - }{ - { - delta: 123.0, - expected: 123.0, - }, - { - delta: 123.4, - expected: 123.4, - }, - { - delta: 123.45, - expected: 123.45, - }, - { - delta: 123.456, - expected: 123.456, - }, - - { - delta: 123.4567, - expected: 123.457, - }, - { - delta: 123.4564, - expected: 123.456, - }, - } - - for _, tc := range testCases { - duration := fmt.Sprintf("%fms", tc.delta) - - t.Run(duration, func(t *testing.T) { - delta, _ := time.ParseDuration(duration) - start := time.Now() - end := start.Add(delta) - require.Equal(t, tc.expected, ElapsedTimeMs(start, end)) - require.InDelta(t, tc.expected, ElapsedTimeMs(start, end), 0.001) - }) - } -} diff --git a/internal/sshd/sshd.go b/internal/sshd/sshd.go new file mode 100644 index 0000000..648e29b --- /dev/null +++ b/internal/sshd/sshd.go @@ -0,0 +1,214 @@ +package sshd + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "net" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + "gitlab.com/gitlab-org/gitlab-shell/internal/command" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/authorizedkeys" + "golang.org/x/crypto/ssh" + "golang.org/x/sync/semaphore" +) + +func Run(cfg *config.Config) error { + authorizedKeysClient, err := authorizedkeys.NewClient(cfg) + if err != nil { + return fmt.Errorf("failed to initialize GitLab client: %w", err) + } + + sshListener, err := net.Listen("tcp", cfg.Server.Listen) + if err != nil { + return fmt.Errorf("failed to listen for connection: %w", err) + } + + config := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if conn.User() != cfg.User { + return nil, errors.New("unknown user") + } + if key.Type() == ssh.KeyAlgoDSA { + return nil, errors.New("DSA is prohibited") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + res, err := authorizedKeysClient.GetByKey(ctx, base64.RawStdEncoding.EncodeToString(key.Marshal())) + if err != nil { + return nil, err + } + + return &ssh.Permissions{ + // Record the public key used for authentication. + Extensions: map[string]string{ + "key-id": strconv.FormatInt(res.Id, 10), + }, + }, nil + }, + } + + var loadedHostKeys uint + for _, filename := range cfg.Server.HostKeyFiles { + keyRaw, err := ioutil.ReadFile(filename) + if err != nil { + log.Warnf("Failed to read host key %v: %v", filename, err) + continue + } + key, err := ssh.ParsePrivateKey(keyRaw) + if err != nil { + log.Warnf("Failed to parse host key %v: %v", filename, err) + continue + } + loadedHostKeys++ + config.AddHostKey(key) + } + if loadedHostKeys == 0 { + return fmt.Errorf("No host keys could be loaded, aborting") + } + + for { + nconn, err := sshListener.Accept() + if err != nil { + log.Warnf("Failed to accept connection: %v\n", err) + continue + } + + go handleConn(nconn, config, cfg) + } +} + +type execRequest struct { + Command string +} + +type exitStatusReq struct { + ExitStatus uint32 +} + +type envRequest struct { + Name string + Value string +} + +func exitSession(ch ssh.Channel, exitStatus uint32) { + exitStatusReq := exitStatusReq{ + ExitStatus: exitStatus, + } + ch.CloseWrite() + ch.SendRequest("exit-status", false, ssh.Marshal(exitStatusReq)) + ch.Close() +} + +func handleConn(nconn net.Conn, sshCfg *ssh.ServerConfig, cfg *config.Config) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer nconn.Close() + conn, chans, reqs, err := ssh.NewServerConn(nconn, sshCfg) + if err != nil { + log.Infof("Failed to initialize SSH connection: %v", err) + return + } + + concurrentSessions := semaphore.NewWeighted(cfg.Server.ConcurrentSessionsLimit) + + go ssh.DiscardRequests(reqs) + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + if !concurrentSessions.TryAcquire(1) { + newChannel.Reject(ssh.ResourceShortage, "too many concurrent sessions") + continue + } + ch, requests, err := newChannel.Accept() + if err != nil { + log.Infof("Could not accept channel: %v", err) + concurrentSessions.Release(1) + continue + } + + go handleSession(ctx, concurrentSessions, ch, requests, conn, nconn, cfg) + } +} + +func handleSession(ctx context.Context, concurrentSessions *semaphore.Weighted, ch ssh.Channel, requests <-chan *ssh.Request, conn *ssh.ServerConn, nconn net.Conn, cfg *config.Config) { + defer concurrentSessions.Release(1) + + rw := &readwriter.ReadWriter{ + Out: ch, + In: ch, + ErrOut: ch.Stderr(), + } + var gitProtocolVersion string + + for req := range requests { + var execCmd string + switch req.Type { + case "env": + var envRequest envRequest + if err := ssh.Unmarshal(req.Payload, &envRequest); err != nil { + ch.Close() + return + } + var accepted bool + if envRequest.Name == commandargs.GitProtocolEnv { + gitProtocolVersion = envRequest.Value + accepted = true + } + if req.WantReply { + req.Reply(accepted, []byte{}) + } + + case "exec": + var execRequest execRequest + if err := ssh.Unmarshal(req.Payload, &execRequest); err != nil { + ch.Close() + return + } + execCmd = execRequest.Command + fallthrough + case "shell": + if req.WantReply { + req.Reply(true, []byte{}) + } + args := &commandargs.Shell{ + GitlabKeyId: conn.Permissions.Extensions["key-id"], + RemoteAddr: nconn.RemoteAddr().(*net.TCPAddr), + GitProtocolVersion: gitProtocolVersion, + } + + if err := args.ParseCommand(execCmd); err != nil { + fmt.Fprintf(ch.Stderr(), "Failed to parse command: %v\n", err.Error()) + exitSession(ch, 128) + return + } + + cmd := command.BuildShellCommand(args, cfg, rw) + if cmd == nil { + fmt.Fprintf(ch.Stderr(), "Unknown command: %v\n", args.CommandType) + exitSession(ch, 128) + return + } + if err := cmd.Execute(ctx); err != nil { + fmt.Fprintf(ch.Stderr(), "remote: ERROR: %v\n", err.Error()) + exitSession(ch, 1) + return + } + exitSession(ch, 0) + return + default: + if req.WantReply { + req.Reply(false, []byte{}) + } + } + } +} |