mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-02 11:27:06 +02:00
SSH Push/Pull Mirroring & Migrations
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// SSHAgent represents a temporary SSH agent for repo mirroring
|
||||
type SSHAgent struct {
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
agent agent.Agent
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSSHAgent creates a new SSH agent with the given private key
|
||||
func NewSSHAgent(privateKey ed25519.PrivateKey) (*SSHAgent, error) {
|
||||
var listener net.Listener
|
||||
var socketPath string
|
||||
var tempDir string
|
||||
var err error
|
||||
|
||||
// Setup cleanup function for early returns
|
||||
var cleanup func()
|
||||
defer func() {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows, use named pipes
|
||||
agentID, err := util.CryptoRandomString(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate agent ID: %w", err)
|
||||
}
|
||||
socketPath = `\\.\pipe\gitea-ssh-agent-` + agentID
|
||||
listener, err = net.Listen("pipe", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create named pipe: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
listener.Close()
|
||||
}
|
||||
} else {
|
||||
tempDir, err = os.MkdirTemp("", "gitea-ssh-agent-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tempDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("failed to set temporary directory permissions: %w", err)
|
||||
}
|
||||
|
||||
socketPath = filepath.Join(tempDir, "agent.sock")
|
||||
listener, err = net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Unix socket: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
listener.Close()
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketPath, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sshAgent := agent.NewKeyring()
|
||||
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("invalid Ed25519 private key size: expected %d, got %d", ed25519.PrivateKeySize, len(privateKey))
|
||||
}
|
||||
|
||||
_, err = ssh.NewSignerFromKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH signer: %w", err)
|
||||
}
|
||||
|
||||
err = sshAgent.Add(agent.AddedKey{
|
||||
PrivateKey: privateKey,
|
||||
Comment: "gitea-mirror-key",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add key to agent: %w", err)
|
||||
}
|
||||
|
||||
// Create our SSH agent wrapper
|
||||
sa := &SSHAgent{
|
||||
socketPath: socketPath,
|
||||
listener: listener,
|
||||
agent: sshAgent,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start serving
|
||||
sa.wg.Add(1)
|
||||
go sa.serve()
|
||||
|
||||
// Clear cleanup since we're returning successfully
|
||||
cleanup = nil
|
||||
|
||||
return sa, nil
|
||||
}
|
||||
|
||||
// serve handles incoming connections to the SSH agent
|
||||
func (sa *SSHAgent) serve() {
|
||||
defer sa.wg.Done()
|
||||
defer sa.cleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sa.stop:
|
||||
return
|
||||
default:
|
||||
// Set a timeout for Accept to avoid blocking indefinitely
|
||||
if runtime.GOOS != "windows" {
|
||||
// On Windows, named pipes don't support SetDeadline in the same way
|
||||
if listener, ok := sa.listener.(*net.UnixListener); ok {
|
||||
listener.SetDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := sa.listener.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-sa.stop:
|
||||
return
|
||||
default:
|
||||
log.Error("SSH agent failed to accept connection: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
sa.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer sa.wg.Done()
|
||||
defer c.Close()
|
||||
|
||||
err := agent.ServeAgent(sa.agent, c)
|
||||
if err != nil {
|
||||
log.Debug("SSH agent connection ended: %v", err)
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes the socket file and temporary directory
|
||||
func (sa *SSHAgent) cleanup() {
|
||||
if sa.socketPath != "" {
|
||||
if runtime.GOOS != "windows" {
|
||||
// On Windows, named pipes are automatically cleaned up when closed
|
||||
// On Unix-like systems, remove the temporary directory
|
||||
tempDir := filepath.Dir(sa.socketPath)
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSocketPath returns the path to the SSH agent socket
|
||||
func (sa *SSHAgent) GetSocketPath() string {
|
||||
return sa.socketPath
|
||||
}
|
||||
|
||||
// Close stops the SSH agent and cleans up resources
|
||||
func (sa *SSHAgent) Close() error {
|
||||
sa.mu.Lock()
|
||||
defer sa.mu.Unlock()
|
||||
|
||||
if sa.closed {
|
||||
return nil
|
||||
}
|
||||
sa.closed = true
|
||||
|
||||
close(sa.stop)
|
||||
|
||||
if sa.listener != nil {
|
||||
sa.listener.Close()
|
||||
}
|
||||
|
||||
sa.wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHAgentManager manages temporary SSH agents for git operations
|
||||
type SSHAgentManager struct {
|
||||
mu sync.Mutex
|
||||
agents map[string]*SSHAgent
|
||||
}
|
||||
|
||||
var globalAgentManager = &SSHAgentManager{
|
||||
agents: make(map[string]*SSHAgent),
|
||||
}
|
||||
|
||||
// CreateTemporaryAgent creates a temporary SSH agent with the given private key
|
||||
// Returns the socket path for use with SSH_AUTH_SOCK
|
||||
func CreateTemporaryAgent(privateKey ed25519.PrivateKey) (string, func(), error) {
|
||||
agent, err := NewSSHAgent(privateKey)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
agentID, err := util.CryptoRandomString(16)
|
||||
if err != nil {
|
||||
agent.Close()
|
||||
return "", nil, fmt.Errorf("failed to generate agent ID: %w", err)
|
||||
}
|
||||
|
||||
globalAgentManager.mu.Lock()
|
||||
globalAgentManager.agents[agentID] = agent
|
||||
globalAgentManager.mu.Unlock()
|
||||
|
||||
cleanup := func() {
|
||||
globalAgentManager.mu.Lock()
|
||||
defer globalAgentManager.mu.Unlock()
|
||||
|
||||
if agent, exists := globalAgentManager.agents[agentID]; exists {
|
||||
agent.Close()
|
||||
delete(globalAgentManager.agents, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
return agent.GetSocketPath(), cleanup, nil
|
||||
}
|
||||
|
||||
// CleanupAllAgents closes all active SSH agents (should be called on shutdown)
|
||||
func CleanupAllAgents() {
|
||||
globalAgentManager.mu.Lock()
|
||||
defer globalAgentManager.mu.Unlock()
|
||||
|
||||
for id, agent := range globalAgentManager.agents {
|
||||
agent.Close()
|
||||
delete(globalAgentManager.agents, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// IsSSHURL checks if a URL is an SSH URL
|
||||
func IsSSHURL(url string) bool {
|
||||
return strings.HasPrefix(url, "ssh://")
|
||||
}
|
||||
|
||||
// GetOrCreateSSHKeypairForUser gets or creates an SSH keypair for the given user
|
||||
func GetOrCreateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.MirrorSSHKeypair, error) {
|
||||
keypair, err := repo_model.GetMirrorSSHKeypairByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
log.Debug("Creating new SSH keypair for user %d", userID)
|
||||
return repo_model.CreateMirrorSSHKeypair(ctx, userID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get SSH keypair for user %d: %w", userID, err)
|
||||
}
|
||||
return keypair, nil
|
||||
}
|
||||
|
||||
// GetOrCreateSSHKeypairForOrg gets or creates an SSH keypair for the given organization
|
||||
func GetOrCreateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.MirrorSSHKeypair, error) {
|
||||
keypair, err := repo_model.GetMirrorSSHKeypairByOwner(ctx, orgID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
log.Debug("Creating new SSH keypair for organization %d", orgID)
|
||||
return repo_model.CreateMirrorSSHKeypair(ctx, orgID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get SSH keypair for organization %d: %w", orgID, err)
|
||||
}
|
||||
return keypair, nil
|
||||
}
|
||||
|
||||
// GetSSHKeypairForRepository gets the appropriate SSH keypair for a repository
|
||||
// If the repository belongs to an organization, it uses the org's keypair,
|
||||
// otherwise it uses the user's keypair
|
||||
func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository) (*repo_model.MirrorSSHKeypair, error) {
|
||||
if repo.Owner == nil {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository owner: %w", err)
|
||||
}
|
||||
repo.Owner = owner
|
||||
}
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
return GetOrCreateSSHKeypairForOrg(ctx, repo.OwnerID)
|
||||
}
|
||||
return GetOrCreateSSHKeypairForUser(ctx, repo.OwnerID)
|
||||
}
|
||||
|
||||
// GetSSHKeypairForURL gets the appropriate SSH keypair for a given repository and URL
|
||||
// Returns nil if the URL is not an SSH URL
|
||||
func GetSSHKeypairForURL(ctx context.Context, repo *repo_model.Repository, url string) (*repo_model.MirrorSSHKeypair, error) {
|
||||
if !IsSSHURL(url) {
|
||||
return nil, nil
|
||||
}
|
||||
return GetSSHKeypairForRepository(ctx, repo)
|
||||
}
|
||||
Reference in New Issue
Block a user