SSH Push/Pull Mirroring & Migrations

This commit is contained in:
techknowlogick
2025-07-15 15:46:17 -04:00
parent 9854df3e87
commit 4c8e222242
30 changed files with 1495 additions and 28 deletions
+10
View File
@@ -243,6 +243,10 @@ type RunOpts struct {
// In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture.
Stdin io.Reader
// SSHAuthSock is the path to an SSH agent socket for authentication
// If provided, SSH_AUTH_SOCK environment variable will be set
SSHAuthSock string
PipelineFunc func(context.Context, context.CancelFunc) error
}
@@ -342,6 +346,11 @@ func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error {
process.SetSysProcAttribute(cmd)
cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...)
if opts.SSHAuthSock != "" {
cmd.Env = append(cmd.Env, "SSH_AUTH_SOCK="+opts.SSHAuthSock)
}
cmd.Dir = opts.Dir
cmd.Stdout = opts.Stdout
cmd.Stderr = opts.Stderr
@@ -457,6 +466,7 @@ func (c *Command) runStdBytes(ctx context.Context, opts *RunOpts) (stdout, stder
Stdout: stdoutBuf,
Stderr: stderrBuf,
Stdin: opts.Stdin,
SSHAuthSock: opts.SSHAuthSock,
PipelineFunc: opts.PipelineFunc,
}
+67 -1
View File
@@ -88,11 +88,66 @@ func IsRemoteNotExistError(err error) bool {
return strings.HasPrefix(err.Error(), prefix1) || strings.HasPrefix(err.Error(), prefix2)
}
// normalizeSSHURL converts SSH-SCP format URLs to standard ssh:// format for security
func normalizeSSHURL(remoteAddr string) (string, error) {
if strings.Contains(remoteAddr, "://") {
return remoteAddr, fmt.Errorf("remoteAddr has a scheme")
}
if strings.Contains(remoteAddr, "\\") {
return remoteAddr, fmt.Errorf("remoteAddr has Windows path slashes")
}
if strings.Contains(remoteAddr, ":/") {
return remoteAddr, fmt.Errorf("remoteAddr could be Windows drive with forward slash")
}
if remoteAddr != "" && (remoteAddr[0] == '/' || remoteAddr[0] == '\\') {
return remoteAddr, fmt.Errorf("remoteAddr is a local file path")
}
// Parse SSH-SCP format: [user@]host:path
colonIndex := strings.Index(remoteAddr, ":")
if colonIndex == -1 {
return remoteAddr, fmt.Errorf("remoteAddr has no colon")
}
if colonIndex == 1 && len(remoteAddr) > 2 {
return remoteAddr, fmt.Errorf("remoteAddr could be Windows drive letter check (C:, D:, etc.)")
}
hostPart := remoteAddr[:colonIndex]
pathPart := remoteAddr[colonIndex+1:]
if hostPart == "" || pathPart == "" {
return remoteAddr, fmt.Errorf("remoteAddr has empty host or path")
}
var user, host string
if atIndex := strings.LastIndex(hostPart, "@"); atIndex != -1 {
user = hostPart[:atIndex+1] // Include the @
host = hostPart[atIndex+1:]
} else {
user = "git@"
host = hostPart
}
if host == "" {
return remoteAddr, fmt.Errorf("Must have SSH host")
}
return fmt.Sprintf("ssh://%s%s/%s", user, host, pathPart), nil
}
// ParseRemoteAddr checks if given remote address is valid,
// and returns composed URL with needed username and password.
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
remoteAddr = strings.TrimSpace(remoteAddr)
// Remote address can be HTTP/HTTPS/Git URL or local path.
// First, try to normalize SSH-SCP format URLs to ssh:// format for security
normalizedAddr, err := normalizeSSHURL(remoteAddr)
if err == nil {
remoteAddr = normalizedAddr
}
// Remote address can be HTTP/HTTPS/Git URL or SSH URL or local path.
if strings.HasPrefix(remoteAddr, "http://") ||
strings.HasPrefix(remoteAddr, "https://") ||
strings.HasPrefix(remoteAddr, "git://") {
@@ -104,6 +159,17 @@ func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, err
u.User = url.UserPassword(authUsername, authPassword)
}
remoteAddr = u.String()
} else if strings.HasPrefix(remoteAddr, "ssh://") {
// Handle ssh:// URLs (including normalized ones)
u, err := url.Parse(remoteAddr)
if err != nil {
return "", &ErrInvalidCloneAddr{IsURLError: true, Host: remoteAddr}
}
if len(authUsername)+len(authPassword) > 0 {
// SSH URLs don't support username/password auth, only key-based auth
return "", &ErrInvalidCloneAddr{IsURLError: true, Host: remoteAddr}
}
remoteAddr = u.String()
}
return remoteAddr, nil
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeSSHURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "SSH-SCP format with user",
input: "git@github.com:user/repo.git",
expected: "ssh://git@github.com/user/repo.git",
},
{
name: "SSH-SCP format without user",
input: "github.com:user/repo.git",
expected: "ssh://git@github.com/user/repo.git",
},
{
name: "Already ssh:// format",
input: "ssh://git@github.com/user/repo.git",
expected: "ssh://git@github.com/user/repo.git",
},
{
name: "HTTP URL unchanged",
input: "https://github.com/user/repo.git",
expected: "https://github.com/user/repo.git",
},
{
name: "Custom SSH user",
input: "myuser@example.com:path/to/repo.git",
expected: "ssh://myuser@example.com/path/to/repo.git",
},
{
name: "Complex path",
input: "git@gitlab.com:group/subgroup/project.git",
expected: "ssh://git@gitlab.com/group/subgroup/project.git",
},
{
name: "SSH with Port",
input: "ssh://git@example.com:2222/user/repo.git",
expected: "ssh://git@example.com:2222/user/repo.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := normalizeSSHURL(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseRemoteAddrSSH(t *testing.T) {
tests := []struct {
name string
remoteAddr string
authUser string
authPass string
expected string
shouldError bool
}{
{
name: "SSH-SCP format normalized",
remoteAddr: "git@github.com:user/repo.git",
authUser: "",
authPass: "",
expected: "ssh://git@github.com/user/repo.git",
shouldError: false,
},
{
name: "SSH URL with auth should error",
remoteAddr: "git@github.com:user/repo.git",
authUser: "user",
authPass: "pass",
expected: "",
shouldError: true,
},
{
name: "HTTPS URL with auth",
remoteAddr: "https://github.com/user/repo.git",
authUser: "user",
authPass: "pass",
expected: "https://user:pass@github.com/user/repo.git",
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseRemoteAddr(tt.remoteAddr, tt.authUser, tt.authPass)
if tt.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
+19 -11
View File
@@ -117,6 +117,7 @@ type CloneRepoOptions struct {
Depth int
Filter string
SkipTLSVerify bool
SSHAuthSock string
}
// Clone clones original repository to target path.
@@ -173,10 +174,11 @@ func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, op
stderr := new(bytes.Buffer)
if err = cmd.Run(ctx, &RunOpts{
Timeout: opts.Timeout,
Env: envs,
Stdout: io.Discard,
Stderr: stderr,
Timeout: opts.Timeout,
Env: envs,
Stdout: io.Discard,
Stderr: stderr,
SSHAuthSock: opts.SSHAuthSock,
}); err != nil {
return ConcatenateError(err, stderr.String())
}
@@ -185,12 +187,13 @@ func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, op
// PushOptions options when push to remote
type PushOptions struct {
Remote string
Branch string
Force bool
Mirror bool
Env []string
Timeout time.Duration
Remote string
Branch string
Force bool
Mirror bool
Env []string
Timeout time.Duration
SSHAuthSock string
}
// Push pushs local commits to given remote branch.
@@ -208,7 +211,12 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
}
cmd.AddDashesAndList(remoteBranchArgs...)
stdout, stderr, err := cmd.RunStdString(ctx, &RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
stdout, stderr, err := cmd.RunStdString(ctx, &RunOpts{
Env: opts.Env,
Timeout: opts.Timeout,
Dir: repoPath,
SSHAuthSock: opts.SSHAuthSock,
})
if err != nil {
if strings.Contains(stderr, "non-fast-forward") {
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}