diff --git a/modules/git/gitcmd/command.go b/modules/git/gitcmd/command.go index 67fb41f46d..7c6965d2c2 100644 --- a/modules/git/gitcmd/command.go +++ b/modules/git/gitcmd/command.go @@ -61,20 +61,26 @@ type Command struct { // operations (migration / mirror with a generated keypair). ssh runs // non-interactively (BatchMode) so the worker never hangs on an unknown host, // and the configured host-key policy is applied. -func managedSSHCommand() string { +func managedSSHCommand(identityFile string) string { + var cmd string mode := setting.Migrations.SSHHostKeyChecking if mode == "no" { - return "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull) - } - cmd := "ssh -o BatchMode=yes -o StrictHostKeyChecking=" + mode - // Persist accepted host keys in a Gitea-managed file so a later key change - // is detected (TOFU); fall back to ssh's default known_hosts if unset. - if setting.AppDataPath != "" { - knownHosts := filepath.Join(setting.AppDataPath, "home", ".ssh", "known_hosts") - if err := os.MkdirAll(filepath.Dir(knownHosts), 0o700); err == nil { - cmd += " -o UserKnownHostsFile=" + util.ShellEscape(knownHosts) + cmd = "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull) + } else { + cmd = "ssh -o BatchMode=yes -o StrictHostKeyChecking=" + mode + // Persist accepted host keys in a Gitea-managed file so a later key change + // is detected (TOFU); fall back to ssh's default known_hosts if unset. + if setting.AppDataPath != "" { + knownHosts := filepath.Join(setting.AppDataPath, "home", ".ssh", "known_hosts") + if err := os.MkdirAll(filepath.Dir(knownHosts), 0o700); err == nil { + cmd += " -o UserKnownHostsFile=" + util.ShellEscape(knownHosts) + } } } + // pin auth to the managed key so ssh ignores the OS user's $HOME/.ssh identities + if identityFile != "" { + cmd += " -o IdentitiesOnly=yes -i " + util.ShellEscape(identityFile) + } return cmd } @@ -242,6 +248,9 @@ type runOpts struct { // If provided, SSH_AUTH_SOCK environment variable will be set SSHAuthSock string + // SSHIdentityFile is the managed public key file used to pin ssh authentication + SSHIdentityFile string + PipelineFunc func(Context) error } @@ -305,6 +314,11 @@ func (c *Command) WithSSHAuthSock(sshAuthSock string) *Command { return c } +func (c *Command) WithSSHIdentityFile(identityFile string) *Command { + c.opts.SSHIdentityFile = identityFile + return c +} + func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) { pr, pw, err := os.Pipe() if err != nil { @@ -475,7 +489,7 @@ func (c *Command) Start(ctx context.Context) (retErr error) { if c.opts.SSHAuthSock != "" { c.cmd.Env = append(c.cmd.Env, "SSH_AUTH_SOCK="+c.opts.SSHAuthSock) - c.cmd.Env = append(c.cmd.Env, "GIT_SSH_COMMAND="+managedSSHCommand()) + c.cmd.Env = append(c.cmd.Env, "GIT_SSH_COMMAND="+managedSSHCommand(c.opts.SSHIdentityFile)) } c.cmd.Dir = c.opts.Dir diff --git a/modules/git/gitcmd/host_key_test.go b/modules/git/gitcmd/host_key_test.go index 13def89e77..680308b78a 100644 --- a/modules/git/gitcmd/host_key_test.go +++ b/modules/git/gitcmd/host_key_test.go @@ -30,7 +30,7 @@ func TestManagedSSHCommand(t *testing.T) { knownHosts := filepath.Join(dataPath, "home", ".ssh", "known_hosts") expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=" + util.ShellEscape(knownHosts) - assert.Equal(t, expected, managedSSHCommand()) + assert.Equal(t, expected, managedSSHCommand("")) // the known_hosts directory must be created so ssh can write to it info, err := os.Stat(filepath.Dir(knownHosts)) @@ -45,7 +45,7 @@ func TestManagedSSHCommand(t *testing.T) { knownHosts := filepath.Join(dataPath, "home", ".ssh", "known_hosts") expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=" + util.ShellEscape(knownHosts) - assert.Equal(t, expected, managedSSHCommand()) + assert.Equal(t, expected, managedSSHCommand("")) }) t.Run("no disables verification with /dev/null known_hosts", func(t *testing.T) { @@ -53,21 +53,28 @@ func TestManagedSSHCommand(t *testing.T) { setting.Migrations.SSHHostKeyChecking = "no" expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull) - assert.Equal(t, expected, managedSSHCommand()) + assert.Equal(t, expected, managedSSHCommand("")) }) t.Run("empty AppDataPath falls back without UserKnownHostsFile", func(t *testing.T) { setting.AppDataPath = "" setting.Migrations.SSHHostKeyChecking = "accept-new" - assert.Equal(t, "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", managedSSHCommand()) + assert.Equal(t, "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", managedSSHCommand("")) }) t.Run("BatchMode is always set so the worker never hangs", func(t *testing.T) { setting.AppDataPath = t.TempDir() for _, mode := range []string{"accept-new", "yes", "no"} { setting.Migrations.SSHHostKeyChecking = mode - assert.Contains(t, managedSSHCommand(), "ssh -o BatchMode=yes ", "mode %q must run ssh non-interactively", mode) + assert.Contains(t, managedSSHCommand(""), "ssh -o BatchMode=yes ", "mode %q must run ssh non-interactively", mode) } }) + + t.Run("identityFile pins authentication to the managed key", func(t *testing.T) { + setting.AppDataPath = t.TempDir() + setting.Migrations.SSHHostKeyChecking = "no" + keyFile := filepath.Join(t.TempDir(), "id.pub") + assert.Contains(t, managedSSHCommand(keyFile), "-o IdentitiesOnly=yes -i "+util.ShellEscape(keyFile)) + }) } diff --git a/modules/git/repo.go b/modules/git/repo.go index 606eeba292..84b551b69f 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -99,19 +99,20 @@ func (repo *Repository) IsEmpty() (bool, error) { // CloneRepoOptions options when clone a repository type CloneRepoOptions struct { - Timeout time.Duration - Mirror bool - Bare bool - Quiet bool - Branch string - Shared bool - NoCheckout bool - Depth int - Filter string - SkipTLSVerify bool - SSHAuthSock string - SingleBranch bool - Env []string + Timeout time.Duration + Mirror bool + Bare bool + Quiet bool + Branch string + Shared bool + NoCheckout bool + Depth int + Filter string + SkipTLSVerify bool + SSHAuthSock string + SSHIdentityFile string + SingleBranch bool + Env []string } // Clone clones original repository to target path. @@ -172,20 +173,22 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { WithTimeout(opts.Timeout). WithEnv(envs). WithSSHAuthSock(opts.SSHAuthSock). + WithSSHIdentityFile(opts.SSHIdentityFile). RunWithStderr(ctx) } // PushOptions options when push to remote type PushOptions struct { - Remote string - LocalRefName string - Branch string - Force bool - ForceWithLease string - Mirror bool - Env []string - Timeout time.Duration - SSHAuthSock string + Remote string + LocalRefName string + Branch string + Force bool + ForceWithLease string + Mirror bool + Env []string + Timeout time.Duration + SSHAuthSock string + SSHIdentityFile string } // Push pushs local commits to given remote branch. @@ -211,7 +214,7 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { } cmd.AddDashesAndList(remoteBranchArgs...) - stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).WithSSHAuthSock(opts.SSHAuthSock).RunStdString(ctx) + stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).WithSSHAuthSock(opts.SSHAuthSock).WithSSHIdentityFile(opts.SSHIdentityFile).RunStdString(ctx) if err != nil { if strings.Contains(stderr, "non-fast-forward") { return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err} diff --git a/modules/ssh/managed.go b/modules/ssh/managed.go index 11e3e9684d..26e30da8f8 100644 --- a/modules/ssh/managed.go +++ b/modules/ssh/managed.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "os" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" @@ -47,10 +48,10 @@ func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository // If sshKeyOwnerID is non-zero, the keypair of that owner is used instead of // the repository owner's (used when migrating to an org and the user wants // to authenticate with their personal managed key). -func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string, sshKeyOwnerID int64) (sshAuthSock string, cleanup func(), err error) { +func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string, sshKeyOwnerID int64) (sshAuthSock, sshIdentityFile string, cleanup func(), err error) { noop := func() {} if !IsSSHURL(remoteURL) { - return "", noop, nil + return "", "", noop, nil } ownerID := repo.OwnerID @@ -59,22 +60,53 @@ func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remo } keypair, err := GetOrCreateSSHKeypair(ctx, ownerID) if err != nil { - return "", noop, fmt.Errorf("failed to get SSH keypair for owner %d: %w", ownerID, err) + return "", "", noop, fmt.Errorf("failed to get SSH keypair for owner %d: %w", ownerID, err) } if keypair == nil { - return "", noop, nil + return "", "", noop, nil } privateKey, err := keypair.GetDecryptedPrivateKey() if err != nil { - return "", noop, fmt.Errorf("failed to decrypt SSH private key: %w", err) + return "", "", noop, fmt.Errorf("failed to decrypt SSH private key: %w", err) } socketPath, agentCleanup, err := CreateTemporaryAgent(privateKey) if err != nil { - return "", noop, fmt.Errorf("failed to create SSH agent: %w", err) + return "", "", noop, fmt.Errorf("failed to create SSH agent: %w", err) + } + + identityFile, keyCleanup, err := writeManagedPublicKey(keypair.PublicKey) + if err != nil { + agentCleanup() + return "", "", noop, fmt.Errorf("failed to write managed public key: %w", err) + } + + cleanup = func() { + keyCleanup() + agentCleanup() } log.Debug("SSH agent ready for %s (socket: %s)", repo.FullName(), socketPath) - return socketPath, agentCleanup, nil + return socketPath, identityFile, cleanup, nil +} + +// writeManagedPublicKey writes the managed public key to a temporary file so the +// git SSH command can pin authentication to it via "-i". The returned cleanup +// removes the file. +func writeManagedPublicKey(publicKey string) (path string, cleanup func(), err error) { + f, err := os.CreateTemp("", "gitea-managed-ssh-*.pub") + if err != nil { + return "", nil, err + } + if _, err = f.WriteString(publicKey); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, err + } + if err = f.Close(); err != nil { + os.Remove(f.Name()) + return "", nil, err + } + return f.Name(), func() { os.Remove(f.Name()) }, nil } diff --git a/services/mirror/mirror_pull.go b/services/mirror/mirror_pull.go index 16b7aa86e6..3b1f6cdfc4 100644 --- a/services/mirror/mirror_pull.go +++ b/services/mirror/mirror_pull.go @@ -119,7 +119,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second // Setup SSH authentication if needed - sshAuthSock, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String(), 0) + sshAuthSock, sshIdentityFile, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String(), 0) if sshErr != nil { log.Error("SyncMirrors [repo: %-v]: SSH setup error %v", m.Repo, sshErr) return nil, false @@ -132,7 +132,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu if m.EnablePrune { cmd.AddArguments("--prune") } - return cmd.AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock) + return cmd.AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock).WithSSHIdentityFile(sshIdentityFile) } var err error @@ -209,7 +209,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu cmdRemoteUpdatePrune := func() *gitcmd.Command { return gitcmd.NewCommand("remote", "update", "--prune"). - AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock) + AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock).WithSSHIdentityFile(sshIdentityFile) } if repo_service.HasWiki(ctx, m.Repo) { diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index d3843e4deb..b952e53285 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -168,13 +168,14 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { } // Setup SSH authentication - sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String(), 0) + sshAuthSock, sshIdentityFile, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String(), 0) if err != nil { log.Error("Failed to set up SSH agent for push mirror %s: %v", repo.FullName(), err) return util.SanitizeErrorCredentialURLs(err) } defer cleanup() pushOpts.SSHAuthSock = sshAuthSock + pushOpts.SSHIdentityFile = sshIdentityFile if err := gitrepo.PushToExternal(ctx, storageRepo, pushOpts); err != nil { log.Error("Error pushing %s mirror[%d] remote %s: %v", storageRepo.RelativePath(), m.ID, m.RemoteName, err) diff --git a/services/repository/migrate.go b/services/repository/migrate.go index 8112241759..68b6f42f89 100644 --- a/services/repository/migrate.go +++ b/services/repository/migrate.go @@ -29,13 +29,14 @@ import ( ) func cloneExternalRepoWithSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, storageRepo gitrepo.Repository, cloneOpts git.CloneRepoOptions, sshKeyOwnerID int64) error { - sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL, sshKeyOwnerID) + sshAuthSock, sshIdentityFile, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL, sshKeyOwnerID) if err != nil { return err } defer cleanup() cloneOpts.SSHAuthSock = sshAuthSock + cloneOpts.SSHIdentityFile = sshIdentityFile return gitrepo.CloneExternalRepo(ctx, remoteURL, storageRepo, cloneOpts) }