diff --git a/modules/git/git.go b/modules/git/git.go index d1e841eeb8..851b563e88 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -38,6 +38,8 @@ var ( InvertedGitFlushEnv bool // 2.43.1 SupportCheckAttrOnBare bool // >= 2.40 + HasSSHExecutable bool + gitVersion *version.Version ) @@ -203,6 +205,10 @@ func InitFull(ctx context.Context) (err error) { globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=") } + // Detect the presence of the ssh executable in $PATH. + _, err = exec.LookPath("ssh") + HasSSHExecutable = err == nil + return syncGitConfig() } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 0495dffd88..a74446b7f0 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1105,6 +1105,7 @@ mirror_interval_invalid = The mirror interval is not valid. mirror_public_key = Public SSH key mirror_use_ssh.text = Use SSH authentication mirror_use_ssh.helper = Forgejo will mirror the repository via Git over SSH and create a keypair for you when you select this option. You must ensure that the generated public key is authorized to push to the destination repository. You cannot use password-based authorization when selecting this. +mirror_use_ssh.not_available = SSH authentication isn't available. mirror_denied_combination = Cannot use public key and password based authentication in combination. mirror_sync = synced mirror_sync_on_commit = Sync when commits are pushed diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go index 9ccf6f05ac..ae727fdbae 100644 --- a/routers/api/v1/repo/mirror.go +++ b/routers/api/v1/repo/mirror.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -350,6 +351,11 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro return } + if mirrorOption.UseSSH && !git.HasSSHExecutable { + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "SSH authentication not available.") + return + } + if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") { ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'") return diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 76539b9fa2..32e1d99e24 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -92,6 +92,7 @@ func SettingsCtxData(ctx *context.Context) { return } ctx.Data["PushMirrors"] = pushMirrors + ctx.Data["CanUseSSHMirroring"] = git.HasSSHExecutable } // Units show a repositorys unit settings page @@ -643,6 +644,11 @@ func SettingsPost(ctx *context.Context) { return } + if form.PushMirrorUseSSH && !git.HasSSHExecutable { + ctx.RenderWithErr(ctx.Tr("repo.mirror_use_ssh.not_available"), tplSettingsOptions, &form) + return + } + address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) if err == nil { err = migrations.IsMigrateURLAllowed(address, ctx.Doer) diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index a18a584c56..09f80cac84 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -300,6 +300,7 @@ + {{if .CanUseSSHMirroring}}
@@ -307,6 +308,7 @@ {{ctx.Locale.Tr "repo.mirror_use_ssh.helper"}}
+ {{end}}
diff --git a/tests/integration/api_push_mirror_test.go b/tests/integration/api_push_mirror_test.go index fb78e1bfaa..f2135cec62 100644 --- a/tests/integration/api_push_mirror_test.go +++ b/tests/integration/api_push_mirror_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "strconv" "testing" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -141,6 +143,11 @@ func testAPIPushMirror(t *testing.T, u *url.URL) { } func TestAPIPushMirrorSSH(t *testing.T) { + _, err := exec.LookPath("ssh") + if err != nil { + t.Skip("SSH executable not present") + } + onGiteaRun(t, func(t *testing.T, _ *url.URL) { defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() defer test.MockVariableValue(&setting.Mirror.Enabled, true)() @@ -178,6 +185,22 @@ func TestAPIPushMirrorSSH(t *testing.T) { assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message) }) + t.Run("SSH not available", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&git.HasSSHExecutable, false)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ + RemoteAddress: sshURL, + Interval: "8h", + UseSSH: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusBadRequest) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.EqualValues(t, "SSH authentication not available.", apiError.Message) + }) + t.Run("Normal", func(t *testing.T) { var pushMirror *repo_model.PushMirror t.Run("Adding", func(t *testing.T) { diff --git a/tests/integration/mirror_push_test.go b/tests/integration/mirror_push_test.go index c2a0d5a45b..2dda4d6836 100644 --- a/tests/integration/mirror_push_test.go +++ b/tests/integration/mirror_push_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "strconv" "testing" @@ -157,6 +158,11 @@ func doRemovePushMirror(ctx APITestContext, address, username, password string, } func TestSSHPushMirror(t *testing.T) { + _, err := exec.LookPath("ssh") + if err != nil { + t.Skip("SSH executable not present") + } + onGiteaRun(t, func(t *testing.T, _ *url.URL) { defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() defer test.MockVariableValue(&setting.Mirror.Enabled, true)() @@ -194,6 +200,36 @@ func TestSSHPushMirror(t *testing.T) { assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.") }) + inputSelector := `input[id="push_mirror_use_ssh"]` + + t.Run("SSH not available", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&git.HasSSHExecutable, false)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-add", + "push_mirror_address": sshURL, + "push_mirror_use_ssh": "true", + "push_mirror_interval": "0", + }) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + errMsg := htmlDoc.Find(".ui.negative.message").Text() + assert.Contains(t, errMsg, "SSH authentication isn't available.") + + htmlDoc.AssertElement(t, inputSelector, false) + }) + + t.Run("SSH available", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName())) + resp := sess.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, inputSelector, true) + }) + t.Run("Normal", func(t *testing.T) { var pushMirror *repo_model.PushMirror t.Run("Adding", func(t *testing.T) {