From 2f8900e46354098e39aec7a30bb5eab81c289d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enver=20Bi=C5=A1evac?= Date: Mon, 3 Jun 2024 12:47:31 +0000 Subject: [PATCH] [code-1946] initial work on ssh server (#2075) --- .gitignore | 5 +- app/api/controller/repo/create.go | 1 + app/api/controller/repo/find.go | 1 + app/api/controller/repo/git_service_pack.go | 18 +- app/api/controller/repo/import.go | 1 + app/api/controller/repo/move.go | 1 + app/api/controller/repo/update.go | 1 + .../controller/repo/update_public_access.go | 1 + app/api/controller/space/list_repositories.go | 1 + app/api/handler/repo/git_service_pack.go | 9 +- app/services/webhook/types.go | 2 + app/url/provider.go | 36 +- app/url/wire.go | 2 + cli/operations/server/server.go | 13 + cli/operations/server/system.go | 13 +- cmd/gitness/wire.go | 4 + cmd/gitness/wire_gen.go | 6 +- git/api/{http.go => service_pack.go} | 43 +- git/interface.go | 2 +- git/{http.go => service_pack.go} | 31 +- ssh/server.go | 404 ++++++++++++++++++ ssh/wire.go | 48 +++ types/config.go | 23 + types/enum/git.go | 2 +- types/repo.go | 3 +- .../CloneButtonTooltip/CloneButtonTooltip.tsx | 13 +- .../pages/Repository/EmptyRepositoryInfo.tsx | 27 +- .../ContentHeader/ContentHeader.tsx | 7 +- web/src/services/code/index.tsx | 1 + 29 files changed, 651 insertions(+), 68 deletions(-) rename git/api/{http.go => service_pack.go} (69%) rename git/{http.go => service_pack.go} (75%) create mode 100644 ssh/server.go create mode 100644 ssh/wire.go diff --git a/.gitignore b/.gitignore index 3e838e8ba..c1f1509cf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ web/cypress/node_modules # ignore any executables we build /gitness -node_modules/ \ No newline at end of file +node_modules/ + +ssh/gitness.rsa +ssh/gitness.rsa.pub \ No newline at end of file diff --git a/app/api/controller/repo/create.go b/app/api/controller/repo/create.go index 955ee7c95..363b65d56 100644 --- a/app/api/controller/repo/create.go +++ b/app/api/controller/repo/create.go @@ -147,6 +147,7 @@ func (c *Controller) Create(ctx context.Context, session *auth.Session, in *Crea // backfil GitURL repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) repoOutput := &RepositoryOutput{ Repository: *repo, diff --git a/app/api/controller/repo/find.go b/app/api/controller/repo/find.go index 6a08a9346..036b089e7 100644 --- a/app/api/controller/repo/find.go +++ b/app/api/controller/repo/find.go @@ -36,6 +36,7 @@ func (c *Controller) Find(ctx context.Context, session *auth.Session, repoRef st // backfill clone url repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) return GetRepoOutput(ctx, c.publicAccess, repo) } diff --git a/app/api/controller/repo/git_service_pack.go b/app/api/controller/repo/git_service_pack.go index f4a436c48..08f164af4 100644 --- a/app/api/controller/repo/git_service_pack.go +++ b/app/api/controller/repo/git_service_pack.go @@ -17,11 +17,11 @@ package repo import ( "context" "fmt" - "io" "github.com/harness/gitness/app/api/controller" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/git" + "github.com/harness/gitness/git/api" "github.com/harness/gitness/types/enum" ) @@ -30,15 +30,12 @@ func (c *Controller) GitServicePack( ctx context.Context, session *auth.Session, repoRef string, - service enum.GitServiceType, - gitProtocol string, - r io.Reader, - w io.Writer, + options api.ServicePackOptions, ) error { isWriteOperation := false permission := enum.PermissionRepoView // receive-pack is the server receiving data - aka the client pushing data. - if service == enum.GitServiceTypeReceivePack { + if options.Service == enum.GitServiceTypeReceivePack { isWriteOperation = true permission = enum.PermissionRepoPush } @@ -50,10 +47,7 @@ func (c *Controller) GitServicePack( params := &git.ServicePackParams{ // TODO: git shouldn't take a random string here, but instead have accepted enum values. - Service: string(service), - Data: r, - Options: nil, - GitProtocol: gitProtocol, + ServicePackOptions: options, } // setup read/writeparams depending on whether it's a write operation @@ -69,8 +63,8 @@ func (c *Controller) GitServicePack( params.ReadParams = &readParams } - if err = c.git.ServicePack(ctx, w, params); err != nil { - return fmt.Errorf("failed service pack operation %q on git: %w", service, err) + if err = c.git.ServicePack(ctx, params); err != nil { + return fmt.Errorf("failed service pack operation %q on git: %w", options.Service, err) } return nil diff --git a/app/api/controller/repo/import.go b/app/api/controller/repo/import.go index b4f349378..0f988295c 100644 --- a/app/api/controller/repo/import.go +++ b/app/api/controller/repo/import.go @@ -98,6 +98,7 @@ func (c *Controller) Import(ctx context.Context, session *auth.Session, in *Impo } repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) err = c.auditService.Log(ctx, session.Principal, diff --git a/app/api/controller/repo/move.go b/app/api/controller/repo/move.go index 59702336b..1568551c4 100644 --- a/app/api/controller/repo/move.go +++ b/app/api/controller/repo/move.go @@ -131,6 +131,7 @@ func (c *Controller) Move(ctx context.Context, } repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) return GetRepoOutput(ctx, c.publicAccess, repo) } diff --git a/app/api/controller/repo/update.go b/app/api/controller/repo/update.go index c18b06691..a0bd84374 100644 --- a/app/api/controller/repo/update.go +++ b/app/api/controller/repo/update.go @@ -85,6 +85,7 @@ func (c *Controller) Update(ctx context.Context, // backfill repo url repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) return GetRepoOutput(ctx, c.publicAccess, repo) } diff --git a/app/api/controller/repo/update_public_access.go b/app/api/controller/repo/update_public_access.go index 74c5c7991..e2d60893c 100644 --- a/app/api/controller/repo/update_public_access.go +++ b/app/api/controller/repo/update_public_access.go @@ -75,6 +75,7 @@ func (c *Controller) UpdatePublicAccess(ctx context.Context, // backfill GitURL repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) err = c.auditService.Log(ctx, session.Principal, diff --git a/app/api/controller/space/list_repositories.go b/app/api/controller/space/list_repositories.go index 59ef4bcf0..9d4092e00 100644 --- a/app/api/controller/space/list_repositories.go +++ b/app/api/controller/space/list_repositories.go @@ -82,6 +82,7 @@ func (c *Controller) ListRepositoriesNoAuth( for _, repo := range repos { // backfill URLs repo.GitURL = c.urlProvider.GenerateGITCloneURL(repo.Path) + repo.GitSSHURL = c.urlProvider.GenerateGITCloneSSHURL(repo.Path) repoOut, err := repoCtrl.GetRepoOutput(ctx, c.publicAccess, repo) if err != nil { diff --git a/app/api/handler/repo/git_service_pack.go b/app/api/handler/repo/git_service_pack.go index 213f51fcc..ee4c37da2 100644 --- a/app/api/handler/repo/git_service_pack.go +++ b/app/api/handler/repo/git_service_pack.go @@ -26,6 +26,7 @@ import ( "github.com/harness/gitness/app/api/request" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/url" + "github.com/harness/gitness/git/api" "github.com/harness/gitness/types/enum" "github.com/rs/zerolog/log" @@ -71,7 +72,13 @@ func HandleGitServicePack( render.NoCache(w) w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) - err = repoCtrl.GitServicePack(ctx, session, repoRef, service, gitProtocol, dataReader, w) + err = repoCtrl.GitServicePack(ctx, session, repoRef, api.ServicePackOptions{ + Service: service, + StatelessRPC: true, + Stdout: w, + Stdin: dataReader, + Protocol: gitProtocol, + }) if errors.Is(err, apiauth.ErrNotAuthorized) && auth.IsAnonymousSession(session) { renderBasicAuth(w, urlProvider) return diff --git a/app/services/webhook/types.go b/app/services/webhook/types.go index 6306a0e15..30198f6a9 100644 --- a/app/services/webhook/types.go +++ b/app/services/webhook/types.go @@ -87,6 +87,7 @@ type RepositoryInfo struct { Identifier string `json:"identifier"` DefaultBranch string `json:"default_branch"` GitURL string `json:"git_url"` + GitSSHURL string `json:"git_ssh_url"` } // TODO [CODE-1363]: remove after identifier migration. @@ -110,6 +111,7 @@ func repositoryInfoFrom(repo *types.Repository, urlProvider url.Provider) Reposi Identifier: repo.Identifier, DefaultBranch: repo.DefaultBranch, GitURL: urlProvider.GenerateGITCloneURL(repo.Path), + GitSSHURL: urlProvider.GenerateGITCloneSSHURL(repo.Path), } } diff --git a/app/url/provider.go b/app/url/provider.go index f94bd2364..f19fce26b 100644 --- a/app/url/provider.go +++ b/app/url/provider.go @@ -48,6 +48,10 @@ type Provider interface { // NOTE: url is guaranteed to not have any trailing '/'. GenerateGITCloneURL(repoPath string) string + // GenerateGITCloneSSHURL generates the public git clone URL for the provided repo path. + // NOTE: url is guaranteed to not have any trailing '/'. + GenerateGITCloneSSHURL(repoPath string) string + // GenerateUIRepoURL returns the url for the UI screen of a repository. GenerateUIRepoURL(repoPath string) string @@ -87,6 +91,9 @@ type provider struct { // NOTE: we store it as url.URL so we can derive clone URLS without errors. gitURL *url.URL + SSHDefaultUser string + gitSSHURL *url.URL + // uiURL stores the raw URL to the ui endpoints. uiURL *url.URL } @@ -96,6 +103,8 @@ func NewProvider( containerURLRaw string, apiURLRaw string, gitURLRaw, + gitSSHURLRaw string, + sshDefaultUser string, uiURLRaw string, ) (Provider, error) { // remove trailing '/' to make usage easier @@ -103,6 +112,7 @@ func NewProvider( containerURLRaw = strings.TrimRight(containerURLRaw, "/") apiURLRaw = strings.TrimRight(apiURLRaw, "/") gitURLRaw = strings.TrimRight(gitURLRaw, "/") + gitSSHURLRaw = strings.TrimRight(gitSSHURLRaw, "/") uiURLRaw = strings.TrimRight(uiURLRaw, "/") internalURL, err := url.Parse(internalURLRaw) @@ -125,17 +135,24 @@ func NewProvider( return nil, fmt.Errorf("provided gitURLRaw '%s' is invalid: %w", gitURLRaw, err) } + gitSSHURL, err := url.Parse(gitSSHURLRaw) + if err != nil { + return nil, fmt.Errorf("provided gitSSHURLRaw '%s' is invalid: %w", gitSSHURLRaw, err) + } + uiURL, err := url.Parse(uiURLRaw) if err != nil { return nil, fmt.Errorf("provided uiURLRaw '%s' is invalid: %w", uiURLRaw, err) } return &provider{ - internalURL: internalURL, - containerURL: containerURL, - apiURL: apiURL, - gitURL: gitURL, - uiURL: uiURL, + internalURL: internalURL, + containerURL: containerURL, + apiURL: apiURL, + gitURL: gitURL, + gitSSHURL: gitSSHURL, + SSHDefaultUser: sshDefaultUser, + uiURL: uiURL, }, nil } @@ -161,6 +178,15 @@ func (p *provider) GenerateGITCloneURL(repoPath string) string { return p.gitURL.JoinPath(repoPath).String() } +func (p *provider) GenerateGITCloneSSHURL(repoPath string) string { + repoPath = path.Clean(repoPath) + if !strings.HasSuffix(repoPath, GITSuffix) { + repoPath += GITSuffix + } + + return fmt.Sprintf("%s@%s:%s", p.SSHDefaultUser, p.gitSSHURL.String(), repoPath) +} + func (p *provider) GenerateUIBuildURL(repoPath, pipelineIdentifier string, seqNumber int64) string { return p.uiURL.JoinPath(repoPath, "pipelines", pipelineIdentifier, "execution", strconv.Itoa(int(seqNumber))).String() diff --git a/app/url/wire.go b/app/url/wire.go index e08c0c19a..8722d1c24 100644 --- a/app/url/wire.go +++ b/app/url/wire.go @@ -29,6 +29,8 @@ func ProvideURLProvider(config *types.Config) (Provider, error) { config.URL.Container, config.URL.API, config.URL.Git, + config.URL.GitSSH, + config.SSH.DefaultUser, config.URL.UI, ) } diff --git a/cli/operations/server/server.go b/cli/operations/server/server.go index 8acaa4f96..f7f1df3ed 100644 --- a/cli/operations/server/server.go +++ b/cli/operations/server/server.go @@ -130,6 +130,13 @@ func (c *command) run(*kingpin.ParseContext) error { }) } + if config.SSH.Enable { + g.Go(func() error { + log.Err(system.sshServer.ListenAndServe()).Send() + return nil + }) + } + log.Info(). Int("port", config.Server.HTTP.Port). Str("revision", version.GitCommit). @@ -152,6 +159,12 @@ func (c *command) run(*kingpin.ParseContext) error { log.Err(sErr).Msg("failed to shutdown http server gracefully") } + if config.SSH.Enable { + if err := system.sshServer.Shutdown(shutdownCtx); err != nil { + log.Err(err).Msg("failed to shutdown ssh server gracefully") + } + } + system.services.JobScheduler.WaitJobsDone(shutdownCtx) log.Info().Msg("wait for subroutines to complete") diff --git a/cli/operations/server/system.go b/cli/operations/server/system.go index 3baf242ad..dbd69333d 100644 --- a/cli/operations/server/system.go +++ b/cli/operations/server/system.go @@ -19,6 +19,7 @@ import ( "github.com/harness/gitness/app/pipeline/resolver" "github.com/harness/gitness/app/server" "github.com/harness/gitness/app/services" + "github.com/harness/gitness/ssh" "github.com/drone/runner-go/poller" ) @@ -27,17 +28,25 @@ import ( type System struct { bootstrap bootstrap.Bootstrap server *server.Server + sshServer *ssh.Server resolverManager *resolver.Manager poller *poller.Poller services services.Services } // NewSystem returns a new system structure. -func NewSystem(bootstrap bootstrap.Bootstrap, server *server.Server, poller *poller.Poller, - resolverManager *resolver.Manager, services services.Services) *System { +func NewSystem( + bootstrap bootstrap.Bootstrap, + server *server.Server, + sshServer *ssh.Server, + poller *poller.Poller, + resolverManager *resolver.Manager, + services services.Services, +) *System { return &System{ bootstrap: bootstrap, server: server, + sshServer: sshServer, poller: poller, resolverManager: resolverManager, services: services, diff --git a/cmd/gitness/wire.go b/cmd/gitness/wire.go index 6e8058ac9..47f1d5734 100644 --- a/cmd/gitness/wire.go +++ b/cmd/gitness/wire.go @@ -64,6 +64,7 @@ import ( "github.com/harness/gitness/app/services/notification/mailer" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/services/publicaccess" + "github.com/harness/gitness/app/services/publickey" pullreqservice "github.com/harness/gitness/app/services/pullreq" reposervice "github.com/harness/gitness/app/services/repo" "github.com/harness/gitness/app/services/settings" @@ -88,6 +89,7 @@ import ( "github.com/harness/gitness/livelog" "github.com/harness/gitness/lock" "github.com/harness/gitness/pubsub" + "github.com/harness/gitness/ssh" "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" "github.com/harness/gitness/types/check" @@ -193,6 +195,8 @@ func initSystem(ctx context.Context, config *types.Config) (*cliserver.System, e openapi.WireSet, repo.ProvideRepoCheck, audit.WireSet, + ssh.WireSet, + publickey.WireSet, ) return &cliserver.System{}, nil } diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index bdb38ab66..39c28e564 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -63,6 +63,7 @@ import ( "github.com/harness/gitness/app/services/notification/mailer" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/services/publicaccess" + "github.com/harness/gitness/app/services/publickey" "github.com/harness/gitness/app/services/pullreq" repo2 "github.com/harness/gitness/app/services/repo" "github.com/harness/gitness/app/services/settings" @@ -87,6 +88,7 @@ import ( "github.com/harness/gitness/livelog" "github.com/harness/gitness/lock" "github.com/harness/gitness/pubsub" + "github.com/harness/gitness/ssh" "github.com/harness/gitness/store/database/dbtx" "github.com/harness/gitness/types" "github.com/harness/gitness/types/check" @@ -309,6 +311,8 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro webHandler := router.ProvideWebHandler(config, openapiService) routerRouter := router.ProvideRouter(apiHandler, gitHandler, webHandler, provider) serverServer := server2.ProvideServer(config, routerRouter) + publickeyService := publickey.ProvidePublicKey(publicKeyStore, principalInfoCache) + sshServer := ssh.ProvideServer(config, publickeyService, repoController) executionManager := manager.ProvideExecutionManager(config, executionStore, pipelineStore, provider, streamer, fileService, converterService, logStore, logStream, checkStore, repoStore, schedulerScheduler, secretStore, stageStore, stepStore, principalStore, publicaccessService) client := manager.ProvideExecutionClient(executionManager, provider, config) resolverManager := resolver.ProvideResolver(config, pluginStore, templateStore, executionStore, repoStore) @@ -356,6 +360,6 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro return nil, err } servicesServices := services.ProvideServices(webhookService, pullreqService, triggerService, jobScheduler, collector, sizeCalculator, repoService, cleanupService, notificationService, keywordsearchService) - serverSystem := server.NewSystem(bootstrapBootstrap, serverServer, poller, resolverManager, servicesServices) + serverSystem := server.NewSystem(bootstrapBootstrap, serverServer, sshServer, poller, resolverManager, servicesServices) return serverSystem, nil } diff --git a/git/api/http.go b/git/api/service_pack.go similarity index 69% rename from git/api/http.go rename to git/api/service_pack.go index 0b70165de..068f93f7a 100644 --- a/git/api/http.go +++ b/git/api/service_pack.go @@ -18,15 +18,19 @@ import ( "bytes" "context" "io" + "regexp" "strconv" "strings" "github.com/harness/gitness/errors" "github.com/harness/gitness/git/command" + "github.com/harness/gitness/types/enum" "github.com/rs/zerolog/log" ) +var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) + func (g *Git) InfoRefs( ctx context.Context, repoPath string, @@ -61,27 +65,44 @@ func (g *Git) InfoRefs( return nil } +type ServicePackOptions struct { + Service enum.GitServiceType + Timeout int // seconds + StatelessRPC bool + Stdout io.Writer + Stdin io.Reader + Stderr io.Writer + Env []string + Protocol string +} + func (g *Git) ServicePack( ctx context.Context, repoPath string, - service string, - stdin io.Reader, - stdout io.Writer, - env ...string, + options ServicePackOptions, ) error { - cmd := command.New(service, - command.WithFlag("--stateless-rpc"), + cmd := command.New(string(options.Service), command.WithArg(repoPath), - command.WithEnv("SSH_ORIGINAL_COMMAND", service), + command.WithEnv("SSH_ORIGINAL_COMMAND", string(options.Service)), ) + + if options.StatelessRPC { + cmd.Add(command.WithFlag("--stateless-rpc")) + } + + if options.Protocol != "" && safeGitProtocolHeader.MatchString(options.Protocol) { + cmd.Add(command.WithEnv("GIT_PROTOCOL", options.Protocol)) + } + err := cmd.Run(ctx, command.WithDir(repoPath), - command.WithStdout(stdout), - command.WithStdin(stdin), - command.WithEnvs(env...), + command.WithStdout(options.Stdout), + command.WithStdin(options.Stdin), + command.WithStderr(options.Stderr), + command.WithEnvs(options.Env...), ) if err != nil && err.Error() != "signal: killed" { - log.Ctx(ctx).Err(err).Msgf("Fail to serve RPC(%s) in %s: %v", service, repoPath, err) + log.Ctx(ctx).Err(err).Msgf("Fail to serve RPC(%s) in %s: %v", options.Service, repoPath, err) } return err } diff --git a/git/interface.go b/git/interface.go index 396bd189e..7626507ee 100644 --- a/git/interface.go +++ b/git/interface.go @@ -70,7 +70,7 @@ type Interface interface { * Git Cli Service */ GetInfoRefs(ctx context.Context, w io.Writer, params *InfoRefsParams) error - ServicePack(ctx context.Context, w io.Writer, params *ServicePackParams) error + ServicePack(ctx context.Context, params *ServicePackParams) error /* * Diff services diff --git a/git/http.go b/git/service_pack.go similarity index 75% rename from git/http.go rename to git/service_pack.go index 482f3bbc7..4a52c7616 100644 --- a/git/http.go +++ b/git/service_pack.go @@ -18,13 +18,12 @@ import ( "context" "fmt" "io" - "regexp" "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/api" + "github.com/harness/gitness/types/enum" ) -var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) - type InfoRefsParams struct { ReadParams Service string @@ -53,10 +52,7 @@ func (s *Service) GetInfoRefs(ctx context.Context, w io.Writer, params *InfoRefs type ServicePackParams struct { *ReadParams *WriteParams - Service string - GitProtocol string - Data io.Reader - Options []string // (key, value) pair + api.ServicePackOptions } func (p *ServicePackParams) Validate() error { @@ -66,37 +62,28 @@ func (p *ServicePackParams) Validate() error { return nil } -func (s *Service) ServicePack(ctx context.Context, w io.Writer, params *ServicePackParams) error { +func (s *Service) ServicePack(ctx context.Context, params *ServicePackParams) error { if err := params.Validate(); err != nil { return err } - - var ( - repoPath string - env []string - ) - + var repoPath string switch params.Service { - case "upload-pack": + case enum.GitServiceTypeUploadPack: if err := params.ReadParams.Validate(); err != nil { return errors.InvalidArgument("upload-pack requires ReadParams") } repoPath = getFullPathForRepo(s.reposRoot, params.ReadParams.RepoUID) - case "receive-pack": + case enum.GitServiceTypeReceivePack: if err := params.WriteParams.Validate(); err != nil { return errors.InvalidArgument("receive-pack requires WriteParams") } - env = CreateEnvironmentForPush(ctx, *params.WriteParams) + params.Env = append(params.Env, CreateEnvironmentForPush(ctx, *params.WriteParams)...) repoPath = getFullPathForRepo(s.reposRoot, params.WriteParams.RepoUID) default: return errors.InvalidArgument("unsupported service provided: %s", params.Service) } - if params.GitProtocol != "" && safeGitProtocolHeader.MatchString(params.GitProtocol) { - env = append(env, "GIT_PROTOCOL="+params.GitProtocol) - } - - err := s.git.ServicePack(ctx, repoPath, params.Service, params.Data, w, env...) + err := s.git.ServicePack(ctx, repoPath, params.ServicePackOptions) if err != nil { return fmt.Errorf("failed to execute git %s: %w", params.Service, err) } diff --git a/ssh/server.go b/ssh/server.go new file mode 100644 index 000000000..173b15d71 --- /dev/null +++ b/ssh/server.go @@ -0,0 +1,404 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/harness/gitness/app/api/controller/repo" + "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/app/services/publickey" + "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/api" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/gliderlabs/ssh" + "github.com/rs/zerolog/log" + gossh "golang.org/x/crypto/ssh" + "golang.org/x/exp/slices" +) + +type contextKey string + +const principalKey = contextKey("principalKey") + +var ( + allowedCommands = []string{ + "git-upload-pack", + "git-receive-pack", + } + defaultCiphers = []string{ + "chacha20-poly1305@openssh.com", + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + } + defaultKeyExchanges = []string{ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + } + defaultMACs = []string{ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + } + defaultServerKeys = []string{"ssh/gitness.rsa"} + KeepAliveMsg = "keepalive@openssh.com" +) + +var ( + ErrHostKeysAreRequired = errors.New("host keys are required") +) + +type Server struct { + internal *ssh.Server + + Host string + Port int + DefaultUser string + + TrustedUserCAKeys []string + TrustedUserCAKeysParsed []gossh.PublicKey + Ciphers []string + KeyExchanges []string + MACs []string + HostKeys []string + KeepAliveInterval int + + Verifier publickey.Service + RepoCtrl *repo.Controller +} + +func (s *Server) sanitize() error { + if s.Port == 0 { + s.Port = 22 + } + + if len(s.Ciphers) == 0 { + s.Ciphers = defaultCiphers + } + + if len(s.KeyExchanges) == 0 { + s.KeyExchanges = defaultKeyExchanges + } + + if len(s.MACs) == 0 { + s.MACs = defaultMACs + } + + if len(s.HostKeys) == 0 { + s.HostKeys = defaultServerKeys + } + + if s.KeepAliveInterval == 0 { + s.KeepAliveInterval = 5000 + } + + if s.RepoCtrl == nil { + return errors.InvalidArgument("repository controller is needed to run git service pack commands") + } + return nil +} + +func (s *Server) ListenAndServe() error { + err := s.sanitize() + if err != nil { + return fmt.Errorf("failed to sanitize server defaults: %w", err) + } + s.internal = &ssh.Server{ + Addr: net.JoinHostPort(s.Host, strconv.Itoa(s.Port)), + Handler: s.sessionHandler, + PublicKeyHandler: s.publicKeyHandler, + PtyCallback: func(ssh.Context, ssh.Pty) bool { + return false + }, + ConnectionFailedCallback: sshConnectionFailed, + ServerConfigCallback: func(ssh.Context) *gossh.ServerConfig { + config := &gossh.ServerConfig{} + config.KeyExchanges = s.KeyExchanges + config.MACs = s.MACs + config.Ciphers = s.Ciphers + return config + }, + } + + err = s.setupHostKeys() + if err != nil { + return fmt.Errorf("failed to setup host keys: %w", err) + } + + log.Debug().Msgf("starting ssh service....: %v", s.internal.Addr) + err = s.internal.ListenAndServe() + if err != nil { + return fmt.Errorf("ssh service not running: %w", err) + } + return nil +} + +func (s *Server) setupHostKeys() error { + if len(s.HostKeys) == 0 { + return ErrHostKeysAreRequired + } + keys := make([]string, 0, len(s.HostKeys)) + // check if file exists and append to slice keys + for _, key := range s.HostKeys { + _, err := os.Stat(key) + if err != nil { + log.Err(err).Msgf("unable to check if %s exists", key) + continue + } + keys = append(keys, key) + } + + // if there is no keys found then create one from HostKeys field + if len(keys) == 0 { + fullpath := s.HostKeys[0] + filePath := filepath.Dir(fullpath) + + if err := os.MkdirAll(filePath, os.ModePerm); err != nil { + return fmt.Errorf("failed to create dir %s: %w", filePath, err) + } + + err := GenerateKeyPair(fullpath) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + keys = append(keys, fullpath) + } + + // set keys to internal ssh server + for _, key := range keys { + err := s.internal.SetOption(ssh.HostKeyFile(key)) + if err != nil { + log.Err(err).Msg("failed to set host key to ssh server") + } + } + return nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + log.Debug().Msgf("stopping ssh service: %v", s.internal.Addr) + err := s.internal.Shutdown(ctx) + if err != nil { + return fmt.Errorf("failed to stop ssh service: %w", err) + } + return nil +} + +func (s *Server) sessionHandler(session ssh.Session) { + command := session.RawCommand() + + principal, ok := session.Context().Value(principalKey).(*types.PrincipalInfo) + if !ok { + _, _ = fmt.Fprintf(session.Stderr(), "principal not found or empty") + return + } + + parts := strings.Fields(command) + if len(parts) < 2 { + _, _ = fmt.Fprintf(session.Stderr(), "command %q must have an argument\n", command) + return + } + + // first part is git service pack command: git-upload-pack, git-receive-pack + gitCommand := parts[0] + if !slices.Contains(allowedCommands, gitCommand) { + _, _ = fmt.Fprintf(session.Stderr(), "command not supported: %q\n", command) + return + } + + gitServicePack := strings.TrimPrefix(gitCommand, "git-") + service, err := enum.ParseGitServiceType(gitServicePack) + if err != nil { + _, _ = fmt.Fprintf(session.Stderr(), "failed to parse service pack: %q\n", gitServicePack) + return + } + + // git command args + gitArgs := parts[1:] + + // first git service pack cmd arg is path: 'space/repository.git' so we need to remove + // single quotes. + repoRef := strings.Trim(gitArgs[0], "'") + // remove .git suffix + repoRef = strings.TrimSuffix(repoRef, ".git") + + gitProtocol := "" + for _, key := range session.Environ() { + if strings.HasPrefix(key, "GIT_PROTOCOL=") { + gitProtocol = key[len("GIT_PROTOCOL="):] + } + } + + ctx, cancel := context.WithCancel(session.Context()) + defer cancel() + + // set keep alive connection + if s.KeepAliveInterval > 0 { + go s.sendKeepAliveMsg(ctx, session) + } + + err = s.RepoCtrl.GitServicePack( + ctx, + &auth.Session{ + Principal: types.Principal{ + ID: principal.ID, + UID: principal.UID, + Email: principal.Email, + Type: principal.Type, + DisplayName: principal.DisplayName, + Created: principal.Created, + Updated: principal.Updated, + }, + }, + repoRef, + api.ServicePackOptions{ + Service: service, + Stdout: session, + Stdin: session, + Stderr: session.Stderr(), + Protocol: gitProtocol, + }, + ) + if err != nil { + log.Error().Err(err).Msg("git service pack failed") + _, err = io.Copy(session.Stderr(), strings.NewReader(err.Error())) + if err != nil { + log.Error().Err(err).Msg("error writing to session stderr") + } + } +} + +func (s *Server) sendKeepAliveMsg(ctx context.Context, session ssh.Session) { + ticker := time.NewTicker(time.Duration(s.KeepAliveInterval)) + defer ticker.Stop() + log.Ctx(ctx).Debug().Str("remote_addr", session.RemoteAddr().String()).Msgf("sendKeepAliveMsg") + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + log.Ctx(ctx).Debug().Msg("connection: sendKeepAliveMsg: send keepalive message to a client") + _, _ = session.SendRequest(KeepAliveMsg, true, nil) + } + } +} + +func (s *Server) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { + if slices.Contains(publickey.DisallowedTypes, key.Type()) { + log.Warn().Msgf("public key type not supported: %s", key.Type()) + return false + } + + if s.DefaultUser != "" && ctx.User() != s.DefaultUser { + log.Warn().Msgf("invalid SSH username %s - must use %s for all git operations via ssh", + ctx.User(), s.DefaultUser) + log.Warn().Msgf("failed authentication attempt from %s", ctx.RemoteAddr()) + return false + } + + principal, err := s.Verifier.ValidateKey(ctx, key, enum.PublicKeyUsageAuth) + if err != nil { + log.Error().Err(err).Msg("failed to validate public key") + return false + } + + // check if we have a certificate + if cert, ok := key.(*gossh.Certificate); ok { + if len(s.TrustedUserCAKeys) == 0 { + log.Warn().Msg("Certificate Rejected: No trusted certificate authorities for this server") + log.Warn().Msgf("Failed authentication attempt from %s", ctx.RemoteAddr()) + return false + } + + if cert.CertType != gossh.UserCert { + log.Warn().Msg("Certificate Rejected: Not a user certificate") + log.Warn().Msgf("Failed authentication attempt from %s", ctx.RemoteAddr()) + return false + } + + certChecker := &gossh.CertChecker{} + if err := certChecker.CheckCert(principal.UID, cert); err != nil { + return false + } + } + + ctx.SetValue(principalKey, principal) + return true +} + +func sshConnectionFailed(conn net.Conn, err error) { + log.Err(err).Msgf("failed connection from %s with error: %v", conn.RemoteAddr(), err) +} + +// GenerateKeyPair make a pair of public and private keys for SSH access. +func GenerateKeyPair(keyPath string) error { + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return err + } + + privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + + if err := pem.Encode(f, privateKeyPEM); err != nil { + return err + } + + // generate public key + pub, err := gossh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return err + } + + public := gossh.MarshalAuthorizedKey(pub) + p, err := os.OpenFile(keyPath+".pub", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer p.Close() + + _, err = p.Write(public) + if err != nil { + return fmt.Errorf("failed to write to public key: %w", err) + } + return nil +} diff --git a/ssh/wire.go b/ssh/wire.go new file mode 100644 index 000000000..8a368ffb5 --- /dev/null +++ b/ssh/wire.go @@ -0,0 +1,48 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh + +import ( + "github.com/harness/gitness/app/api/controller/repo" + "github.com/harness/gitness/app/services/publickey" + "github.com/harness/gitness/types" + + "github.com/google/wire" +) + +var WireSet = wire.NewSet( + ProvideServer, +) + +func ProvideServer( + config *types.Config, + vierifier publickey.Service, + repoctrl *repo.Controller, +) *Server { + return &Server{ + Host: config.SSH.Host, + Port: config.SSH.Port, + DefaultUser: config.SSH.DefaultUser, + Ciphers: config.SSH.Ciphers, + KeyExchanges: config.SSH.KeyExchanges, + MACs: config.SSH.MACs, + HostKeys: config.SSH.ServerHostKeys, + TrustedUserCAKeys: config.SSH.TrustedUserCAKeys, + TrustedUserCAKeysParsed: config.SSH.TrustedUserCAKeysParsed, + KeepAliveInterval: config.SSH.KeepAliveInterval, + Verifier: vierifier, + RepoCtrl: repoctrl, + } +} diff --git a/types/config.go b/types/config.go index 3cc760174..7230aa416 100644 --- a/types/config.go +++ b/types/config.go @@ -22,6 +22,8 @@ import ( gitenum "github.com/harness/gitness/git/enum" "github.com/harness/gitness/lock" "github.com/harness/gitness/pubsub" + + gossh "golang.org/x/crypto/ssh" ) // Config stores the system configuration. @@ -63,6 +65,9 @@ type Config struct { // Value is derived from Base unless explicitly specified (e.g. http://localhost:3000/git). Git string `envconfig:"GITNESS_URL_GIT"` + // GitSSH defines the external URL via which the GIT SSH server is reachable. + GitSSH string `envconfig:"GITNESS_URL_GIT_SSH" default:"localhost"` + // API defines the external URL via which the rest API is reachable. // NOTE: for routing to work properly, the request path reaching gitness has to end with `/api` // (this could be after proxy path rewrite). @@ -132,6 +137,24 @@ type Config struct { } } + SSH struct { + Enable bool `envconfig:"GITNESS_SSH_ENABLE" default:"true"` + Host string `envconfig:"GITNESS_SSH_HOST"` + Port int `envconfig:"GITNESS_SSH_PORT" default:"22"` + // DefaultUser holds value for generating urls {user}@host:path and force check + // no other user can authenticate unless it is empty then any username is allowed + DefaultUser string `envconfig:"GITNESS_SSH_DEFAULT_USER" default:"git"` + Ciphers []string `envconfig:"GITNESS_SSH_CIPHERS"` + KeyExchanges []string `envconfig:"GITNESS_SSH_KEY_EXCHANGES"` + MACs []string `envconfig:"GITNESS_SSH_MACS"` + ServerHostKeys []string `envconfig:"GITNESS_SSH_HOST_KEYS"` + KeygenPath string `envconfig:"GITNESS_SSH_KEYGEN_PATH"` + TrustedUserCAKeys []string `envconfig:"GITNESS_SSH_TRUSTED_USER_CA_KEYS"` + TrustedUserCAKeysFile string `envconfig:"GITNESS_SSH_TRUSTED_USER_CA_KEYS_FILENAME"` + TrustedUserCAKeysParsed []gossh.PublicKey + KeepAliveInterval int `envconfig:"GITNESS_SSH_KEEP_ALIVE_INTERVAL" default:"5000"` + } + // CI defines configuration related to build executions. CI struct { ParallelWorkers int `envconfig:"GITNESS_CI_PARALLEL_WORKERS" default:"2"` diff --git a/types/enum/git.go b/types/enum/git.go index f93a1c685..d608a7e9a 100644 --- a/types/enum/git.go +++ b/types/enum/git.go @@ -111,6 +111,6 @@ func ParseGitServiceType(s string) (GitServiceType, error) { case string(GitServiceTypeUploadPack): return GitServiceTypeUploadPack, nil default: - return GitServiceType(""), fmt.Errorf("unknown git service type provided: %q", s) + return "", fmt.Errorf("unknown git service type provided: %q", s) } } diff --git a/types/repo.go b/types/repo.go index 6dfac5061..74f00c4f9 100644 --- a/types/repo.go +++ b/types/repo.go @@ -52,7 +52,8 @@ type Repository struct { IsEmpty bool `json:"is_empty,omitempty" yaml:"is_empty"` // git urls - GitURL string `json:"git_url" yaml:"-"` + GitURL string `json:"git_url" yaml:"-"` + GitSSHURL string `json:"git_ssh_url" yaml:"-"` } // Clone makes deep copy of repository object. diff --git a/web/src/components/CloneButtonTooltip/CloneButtonTooltip.tsx b/web/src/components/CloneButtonTooltip/CloneButtonTooltip.tsx index 07245d8d4..d45d6e4fc 100644 --- a/web/src/components/CloneButtonTooltip/CloneButtonTooltip.tsx +++ b/web/src/components/CloneButtonTooltip/CloneButtonTooltip.tsx @@ -30,9 +30,10 @@ import css from './CloneButtonTooltip.module.scss' interface CloneButtonTooltipProps { httpsURL: string + sshURL: string } -export function CloneButtonTooltip({ httpsURL }: CloneButtonTooltipProps) { +export function CloneButtonTooltip({ httpsURL, sshURL }: CloneButtonTooltipProps) { const { getString } = useStrings() const [flag, setFlag] = useState(false) const { isCurrentSessionPublic } = useAppContext() @@ -54,6 +55,7 @@ export function CloneButtonTooltip({ httpsURL }: CloneButtonTooltipProps) { {getString('cloneHTTPS')} + HTTP {httpsURL} @@ -61,6 +63,15 @@ export function CloneButtonTooltip({ httpsURL }: CloneButtonTooltipProps) { + + SSH + + {sshURL} + + + + + + } + tooltip={ + + } tooltipProps={{ interactionKind: 'click', minimal: true, diff --git a/web/src/services/code/index.tsx b/web/src/services/code/index.tsx index 80bec5a5a..19a90f6b5 100644 --- a/web/src/services/code/index.tsx +++ b/web/src/services/code/index.tsx @@ -894,6 +894,7 @@ export interface TypesRepository { description?: string fork_id?: number git_url?: string + git_ssh_url?: string id?: number importing?: boolean is_public?: boolean