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}
+
+
+
+
+