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