diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 176372486e8f6..4bbc2c0713342 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_22" "code.gitea.io/gitea/models/migrations/v1_23" "code.gitea.io/gitea/models/migrations/v1_24" + "code.gitea.io/gitea/models/migrations/v1_25" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -382,6 +383,9 @@ func prepareMigrationTasks() []*migration { newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), + + // Gitea 1.24.0 ends at migration ID number 320 (database version 321) + newMigration(321, "Add Mirror SSH keypair table", v1_25.AddMirrorSSHKeypairTable), } return preparedMigrations } diff --git a/models/migrations/v1_25/v321.go b/models/migrations/v1_25/v321.go new file mode 100644 index 0000000000000..8860721cadb79 --- /dev/null +++ b/models/migrations/v1_25/v321.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddMirrorSSHKeypairTable(x *xorm.Engine) error { + type MirrorSSHKeypair struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + PublicKey string `xorm:"TEXT NOT NULL"` + Fingerprint string `xorm:"VARCHAR(255) UNIQUE NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(MirrorSSHKeypair)) +} diff --git a/models/repo/mirror_ssh_keypair.go b/models/repo/mirror_ssh_keypair.go new file mode 100644 index 0000000000000..4bfc83e054bcd --- /dev/null +++ b/models/repo/mirror_ssh_keypair.go @@ -0,0 +1,126 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" +) + +// MirrorSSHKeypair represents an SSH keypair for repository mirroring +type MirrorSSHKeypair struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + PublicKey string `xorm:"TEXT NOT NULL"` + Fingerprint string `xorm:"VARCHAR(255) UNIQUE NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(MirrorSSHKeypair)) +} + +// GetMirrorSSHKeypairByOwner gets the most recent SSH keypair for the given owner +func GetMirrorSSHKeypairByOwner(ctx context.Context, ownerID int64) (*MirrorSSHKeypair, error) { + keypair := &MirrorSSHKeypair{} + has, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID). + Desc("created_unix").Get(keypair) + if err != nil { + return nil, err + } + if !has { + return nil, util.NewNotExistErrorf("SSH keypair does not exist for owner %d", ownerID) + } + return keypair, nil +} + +// CreateMirrorSSHKeypair creates a new SSH keypair for mirroring +func CreateMirrorSSHKeypair(ctx context.Context, ownerID int64) (*MirrorSSHKeypair, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate Ed25519 keypair: %w", err) + } + + sshPublicKey, err := ssh.NewPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("failed to convert public key to SSH format: %w", err) + } + + publicKeyStr := string(ssh.MarshalAuthorizedKey(sshPublicKey)) + + fingerprint := sha256.Sum256(sshPublicKey.Marshal()) + fingerprintStr := hex.EncodeToString(fingerprint[:]) + + privateKeyEncrypted, err := secret.EncryptSecret(setting.SecretKey, string(privateKey)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt private key: %w", err) + } + + keypair := &MirrorSSHKeypair{ + OwnerID: ownerID, + PrivateKeyEncrypted: privateKeyEncrypted, + PublicKey: publicKeyStr, + Fingerprint: fingerprintStr, + } + + return keypair, db.Insert(ctx, keypair) +} + +// GetDecryptedPrivateKey returns the decrypted private key +func (k *MirrorSSHKeypair) GetDecryptedPrivateKey() (ed25519.PrivateKey, error) { + decrypted, err := secret.DecryptSecret(setting.SecretKey, k.PrivateKeyEncrypted) + if err != nil { + return nil, fmt.Errorf("failed to decrypt private key: %w", err) + } + return ed25519.PrivateKey(decrypted), nil +} + +// GetPublicKeyWithComment returns the public key with a descriptive comment (namespace-fingerprint@domain) +func (k *MirrorSSHKeypair) GetPublicKeyWithComment(ctx context.Context) (string, error) { + owner, err := user_model.GetUserByID(ctx, k.OwnerID) + if err != nil { + return k.PublicKey, nil + } + + domain := setting.Domain + if domain == "" { + domain = "gitea" + } + + keyID := k.Fingerprint + if len(keyID) > 8 { + keyID = keyID[:8] + } + + comment := fmt.Sprintf("%s-%s@%s", owner.Name, keyID, domain) + return strings.TrimSpace(k.PublicKey) + " " + comment, nil +} + +// DeleteMirrorSSHKeypair deletes an SSH keypair +func DeleteMirrorSSHKeypair(ctx context.Context, ownerID int64) error { + _, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Delete(&MirrorSSHKeypair{}) + return err +} + +// RegenerateMirrorSSHKeypair regenerates an SSH keypair for the given owner +func RegenerateMirrorSSHKeypair(ctx context.Context, ownerID int64) (*MirrorSSHKeypair, error) { + // TODO: This creates a new one old ones will be garbage collected later, as the user may accidentally regenerate + return CreateMirrorSSHKeypair(ctx, ownerID) +} diff --git a/models/repo/mirror_ssh_keypair_test.go b/models/repo/mirror_ssh_keypair_test.go new file mode 100644 index 0000000000000..5f540a6f74fbe --- /dev/null +++ b/models/repo/mirror_ssh_keypair_test.go @@ -0,0 +1,148 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo_test + +import ( + "crypto/ed25519" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMirrorSSHKeypair(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("CreateMirrorSSHKeypair", func(t *testing.T) { + // Test creating a new SSH keypair for a user + keypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 1) + require.NoError(t, err) + assert.NotNil(t, keypair) + assert.Equal(t, int64(1), keypair.OwnerID) + assert.NotEmpty(t, keypair.PublicKey) + assert.NotEmpty(t, keypair.PrivateKeyEncrypted) + assert.NotEmpty(t, keypair.Fingerprint) + assert.Positive(t, keypair.CreatedUnix) + assert.Positive(t, keypair.UpdatedUnix) + + // Verify the public key is in SSH format + assert.Contains(t, keypair.PublicKey, "ssh-ed25519") + + // Test creating a keypair for an organization + orgKeypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 2) + require.NoError(t, err) + assert.NotNil(t, orgKeypair) + assert.Equal(t, int64(2), orgKeypair.OwnerID) + + // Ensure different owners get different keypairs + assert.NotEqual(t, keypair.PublicKey, orgKeypair.PublicKey) + assert.NotEqual(t, keypair.Fingerprint, orgKeypair.Fingerprint) + }) + + t.Run("GetMirrorSSHKeypairByOwner", func(t *testing.T) { + // Create a keypair first + created, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 3) + require.NoError(t, err) + + // Test retrieving the keypair + retrieved, err := repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 3) + require.NoError(t, err) + assert.Equal(t, created.ID, retrieved.ID) + assert.Equal(t, created.PublicKey, retrieved.PublicKey) + assert.Equal(t, created.Fingerprint, retrieved.Fingerprint) + + // Test retrieving non-existent keypair + _, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 999) + assert.ErrorIs(t, err, util.ErrNotExist) + }) + + t.Run("GetDecryptedPrivateKey", func(t *testing.T) { + // Ensure we have a valid SECRET_KEY for testing + if setting.SecretKey == "" { + setting.SecretKey = "test-secret-key-for-testing" + } + + // Create a keypair + keypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 4) + require.NoError(t, err) + + // Test decrypting the private key + privateKey, err := keypair.GetDecryptedPrivateKey() + require.NoError(t, err) + assert.IsType(t, ed25519.PrivateKey{}, privateKey) + assert.Len(t, privateKey, ed25519.PrivateKeySize) + + // Verify the private key corresponds to the public key + publicKey := privateKey.Public().(ed25519.PublicKey) + assert.Len(t, publicKey, ed25519.PublicKeySize) + }) + + t.Run("DeleteMirrorSSHKeypair", func(t *testing.T) { + // Create a keypair + _, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 5) + require.NoError(t, err) + + // Verify it exists + _, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 5) + require.NoError(t, err) + + // Delete it + err = repo_model.DeleteMirrorSSHKeypair(db.DefaultContext, 5) + require.NoError(t, err) + + // Verify it's gone + _, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 5) + assert.ErrorIs(t, err, util.ErrNotExist) + }) + + t.Run("RegenerateMirrorSSHKeypair", func(t *testing.T) { + // Create initial keypair + original, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 6) + require.NoError(t, err) + + // Regenerate it + regenerated, err := repo_model.RegenerateMirrorSSHKeypair(db.DefaultContext, 6) + require.NoError(t, err) + + // Verify it's different + assert.NotEqual(t, original.PublicKey, regenerated.PublicKey) + assert.NotEqual(t, original.PrivateKeyEncrypted, regenerated.PrivateKeyEncrypted) + assert.NotEqual(t, original.Fingerprint, regenerated.Fingerprint) + assert.Equal(t, original.OwnerID, regenerated.OwnerID) + }) +} + +func TestMirrorSSHKeypairConcurrency(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + if setting.SecretKey == "" { + setting.SecretKey = "test-secret-key-for-testing" + } + + // Test concurrent creation of keypairs to ensure no race conditions + t.Run("ConcurrentCreation", func(t *testing.T) { + ctx := t.Context() + results := make(chan error, 10) + + // Start multiple goroutines creating keypairs for different owners + for i := range 10 { + go func(ownerID int64) { + _, err := repo_model.CreateMirrorSSHKeypair(ctx, ownerID+100) + results <- err + }(int64(i)) + } + + // Check all creations succeeded + for range 10 { + err := <-results + assert.NoError(t, err) + } + }) +} diff --git a/modules/git/command.go b/modules/git/command.go index 22f1d02339148..90ee7d8daf3be 100644 --- a/modules/git/command.go +++ b/modules/git/command.go @@ -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, } diff --git a/modules/git/remote.go b/modules/git/remote.go index 876c3d6acb81b..530a813f82507 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -5,6 +5,7 @@ package git import ( "context" + "errors" "fmt" "net/url" "strings" @@ -88,11 +89,69 @@ 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.HasPrefix(remoteAddr, "ssh://") { + return remoteAddr, nil + } + if strings.Contains(remoteAddr, "://") { + return remoteAddr, errors.New("remoteAddr has a scheme") + } + if strings.Contains(remoteAddr, "\\") { + return remoteAddr, errors.New("remoteAddr has Windows path slashes") + } + if strings.Contains(remoteAddr, ":/") { + return remoteAddr, errors.New("remoteAddr could be Windows drive with forward slash") + } + if remoteAddr != "" && (remoteAddr[0] == '/' || remoteAddr[0] == '\\') { + return remoteAddr, errors.New("remoteAddr is a local file path") + } + + // Parse SSH-SCP format: [user@]host:path + colonIndex := strings.Index(remoteAddr, ":") + if colonIndex == -1 { + return remoteAddr, errors.New("remoteAddr has no colon") + } + + if colonIndex == 1 && len(remoteAddr) > 2 { + return remoteAddr, errors.New("remoteAddr could be Windows drive letter check (C:, D:, etc.)") + } + + hostPart := remoteAddr[:colonIndex] + pathPart := remoteAddr[colonIndex+1:] + + if hostPart == "" || pathPart == "" { + return remoteAddr, errors.New("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, errors.New("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 +163,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 diff --git a/modules/git/remote_test.go b/modules/git/remote_test.go new file mode 100644 index 0000000000000..5e143e4a5705e --- /dev/null +++ b/modules/git/remote_test.go @@ -0,0 +1,109 @@ +// 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, _ := normalizeSSHURL(tt.input) + 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) + } + }) + } +} diff --git a/modules/git/repo.go b/modules/git/repo.go index f1f6902773af2..66a85309f2070 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -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} diff --git a/modules/ssh/agent.go b/modules/ssh/agent.go new file mode 100644 index 0000000000000..d6b6a9c6745ee --- /dev/null +++ b/modules/ssh/agent.go @@ -0,0 +1,264 @@ +// 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" +) + +// Agent represents a temporary SSH agent for repo mirroring +type Agent 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) (*Agent, 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 := &Agent{ + 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 *Agent) 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 { + if err := listener.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + log.Debug("Failed to set listener deadline: %v", err) + } + } + } + + 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 *Agent) 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 *Agent) GetSocketPath() string { + return sa.socketPath +} + +// Close stops the SSH agent and cleans up resources +func (sa *Agent) 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 +} + +// AgentManager manages temporary SSH agents for git operations +type AgentManager struct { + mu sync.Mutex + agents map[string]*Agent +} + +var globalAgentManager = &AgentManager{ + agents: make(map[string]*Agent), +} + +// 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) + } +} diff --git a/modules/ssh/mirror.go b/modules/ssh/mirror.go new file mode 100644 index 0000000000000..040459f694200 --- /dev/null +++ b/modules/ssh/mirror.go @@ -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) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c1a3d37037fe3..37352aa5540f2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1032,6 +1032,16 @@ visibility.limited_tooltip = Visible only to authenticated users visibility.private = Private visibility.private_tooltip = Visible only to members of organizations you have joined +mirror_ssh_title = Repository Mirror SSH Keys +mirror_ssh_description = SSH keys for repository mirroring allow you to authenticate with remote Git repositories using SSH. Each user and organization has their own SSH keypair stored securely. +mirror_ssh_current_key = Current SSH Public Key +mirror_ssh_fingerprint = Fingerprint +mirror_ssh_generate = Generate SSH Key +mirror_ssh_regenerate = Regenerate SSH Key +mirror_ssh_regenerated = SSH keypair has been regenerated successfully. +mirror_ssh_documentation = SSH keys are automatically used for SSH-based repository mirrors. Add the public key to your remote Git service (GitHub, GitLab, etc.) to enable authentication. +mirror_ssh_org_notice = "This SSH key is only for your personal repositories. For organization repositories, you need to configure SSH keys in the organization's settings." + [repo] new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? Migrate repository. owner = Owner @@ -1193,9 +1203,12 @@ migrate_items_merge_requests = Merge Requests migrate_items_releases = Releases migrate_repo = Migrate Repository migrate.clone_address = Migrate / Clone From URL -migrate.clone_address_desc = The HTTP(S) or Git 'clone' URL of an existing repository +migrate.clone_address_desc = The HTTP(S), Git, or SSH 'clone' URL of an existing repository migrate.github_token_desc = You can put one or more tokens here separated by commas to make migrating faster by circumventing the GitHub API rate limit. WARNING: Abusing this feature may violate the service provider's policy and may lead to getting your account(s) blocked. migrate.clone_local_path = or a local server path +migrate.ssh_helper_title = SSH URLs +migrate.ssh_helper_desc = Upload your SSH mirror keys to the remote SSH server for authentication. +migrate.ssh_helper_link = "View your SSH keys (if migrating to an organization, you may need to upload the organization's SSH keys)." migrate.permission_denied = You are not allowed to import local repositories. migrate.permission_denied_blocked = You cannot import from disallowed hosts, please ask the admin to check ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS/BLOCKED_DOMAINS settings. migrate.invalid_local_path = "The local path is invalid. It doesn't exist or is not a directory." @@ -2856,6 +2869,7 @@ settings.rename_desc = Changing the organization name will also change your orga settings.rename_success = Organization %[1]s have been renamed to %[2]s successfully. settings.rename_no_change = Organization name is no change. settings.rename_new_org_name = New Organization Name +settings.ssh_keys = SSH Mirror Keys settings.rename_failed = Rename Organization failed because of internal error settings.rename_notices_1 = This operation CANNOT be undone. settings.rename_notices_2 = The old name will redirect until it is claimed. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f412e8a06caca..c04659252442d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1161,6 +1161,11 @@ func Routes() *web.Router { m.Delete("", user.UnblockUser) }, context.UserAssignmentAPI(), checkTokenPublicOnly()) }) + + m.Group("/mirror-ssh-key", func() { + m.Get("", user.GetMirrorSSHKey) + m.Post("/regenerate", user.RegenerateMirrorSSHKey) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1687,6 +1692,11 @@ func Routes() *web.Router { m.Delete("", org.UnblockUser) }) }, reqToken(), reqOrgOwnership()) + + m.Group("/mirror-ssh-key", func() { + m.Get("", reqToken(), reqOrgMembership(), org.GetMirrorSSHKey) + m.Post("/regenerate", reqToken(), reqOrgOwnership(), org.RegenerateMirrorSSHKey) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(), org.GetTeam). diff --git a/routers/api/v1/org/mirror.go b/routers/api/v1/org/mirror.go new file mode 100644 index 0000000000000..4f5933f444191 --- /dev/null +++ b/routers/api/v1/org/mirror.go @@ -0,0 +1,94 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/services/context" + mirror_service "code.gitea.io/gitea/services/mirror" +) + +// GetMirrorSSHKey gets the SSH public key for organization mirroring +func GetMirrorSSHKey(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/mirror-ssh-key organization orgGetMirrorSSHKey + // --- + // summary: Get SSH public key for organization mirroring + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // description: SSH public key + // schema: + // type: object + // properties: + // public_key: + // type: string + // fingerprint: + // type: string + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + keypair, err := mirror_service.GetOrCreateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + if db.IsErrNotExist(err) { + ctx.APIError(http.StatusNotFound, "SSH keypair not found") + return + } + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "public_key": keypair.PublicKey, + "fingerprint": keypair.Fingerprint, + }) +} + +// RegenerateMirrorSSHKey regenerates the SSH keypair for organization mirroring +func RegenerateMirrorSSHKey(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/mirror-ssh-key/regenerate organization orgRegenerateMirrorSSHKey + // --- + // summary: Regenerate SSH keypair for organization mirroring + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // description: New SSH public key + // schema: + // type: object + // properties: + // public_key: + // type: string + // fingerprint: + // type: string + // "403": + // "$ref": "#/responses/forbidden" + + keypair, err := mirror_service.RegenerateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "public_key": keypair.PublicKey, + "fingerprint": keypair.Fingerprint, + }) +} diff --git a/routers/api/v1/user/mirror.go b/routers/api/v1/user/mirror.go new file mode 100644 index 0000000000000..6ddc4946a1c50 --- /dev/null +++ b/routers/api/v1/user/mirror.go @@ -0,0 +1,78 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/services/context" + mirror_service "code.gitea.io/gitea/services/mirror" +) + +// GetMirrorSSHKey gets the SSH public key for user mirroring +func GetMirrorSSHKey(ctx *context.APIContext) { + // swagger:operation GET /user/mirror-ssh-key user userGetMirrorSSHKey + // --- + // summary: Get SSH public key for user mirroring + // produces: + // - application/json + // responses: + // "200": + // description: SSH public key + // schema: + // type: object + // properties: + // public_key: + // type: string + // fingerprint: + // type: string + // "404": + // "$ref": "#/responses/notFound" + + keypair, err := mirror_service.GetOrCreateSSHKeypairForUser(ctx, ctx.Doer.ID) + if err != nil { + if db.IsErrNotExist(err) { + ctx.APIError(http.StatusNotFound, "SSH keypair not found") + return + } + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "public_key": keypair.PublicKey, + "fingerprint": keypair.Fingerprint, + }) +} + +// RegenerateMirrorSSHKey regenerates the SSH keypair for user mirroring +func RegenerateMirrorSSHKey(ctx *context.APIContext) { + // swagger:operation POST /user/mirror-ssh-key/regenerate user userRegenerateMirrorSSHKey + // --- + // summary: Regenerate SSH keypair for user mirroring + // produces: + // - application/json + // responses: + // "200": + // description: New SSH public key + // schema: + // type: object + // properties: + // public_key: + // type: string + // fingerprint: + // type: string + + keypair, err := mirror_service.RegenerateSSHKeypairForUser(ctx, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "public_key": keypair.PublicKey, + "fingerprint": keypair.Fingerprint, + }) +} diff --git a/routers/web/org/setting_ssh_keys.go b/routers/web/org/setting_ssh_keys.go new file mode 100644 index 0000000000000..6e03ec1aaa3a7 --- /dev/null +++ b/routers/web/org/setting_ssh_keys.go @@ -0,0 +1,51 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/modules/templates" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + mirror_service "code.gitea.io/gitea/services/mirror" +) + +const ( + tplSettingsSSHKeys templates.TplName = "org/settings/ssh_keys" +) + +// SSHKeys render organization SSH mirror keys page +func SSHKeys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings.ssh_keys") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsSSHKeys"] = true + + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + + keypair, err := mirror_service.GetOrCreateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("GetOrCreateSSHKeypairForOrg", err) + return + } + + ctx.Data["SSHKeypair"] = keypair + + ctx.HTML(http.StatusOK, tplSettingsSSHKeys) +} + +// RegenerateSSHKey regenerates the SSH keypair for organization mirror operations +func RegenerateSSHKey(ctx *context.Context) { + _, err := mirror_service.RegenerateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("RegenerateSSHKeypairForOrg", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.mirror_ssh_regenerated")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/ssh_keys") +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 6b5a7a2e2a0bd..a296c2434b68d 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -10,6 +10,7 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "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/setting" "code.gitea.io/gitea/modules/templates" @@ -17,6 +18,7 @@ import ( asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" + mirror_service "code.gitea.io/gitea/services/mirror" ) const ( @@ -342,4 +344,36 @@ func loadKeysData(ctx *context.Context) { ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg") ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh") ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) + + // Load SSH mirror keypair if it exists + mirrorKeypair, err := mirror_service.GetOrCreateSSHKeypairForUser(ctx, ctx.Doer.ID) + if err == nil { + ctx.Data["HasMirrorSSHKey"] = true + + // Create a struct with the public key including comment + publicKeyWithComment, _ := mirrorKeypair.GetPublicKeyWithComment(ctx) + mirrorKeyData := struct { + *repo_model.MirrorSSHKeypair + PublicKeyWithComment string + }{ + MirrorSSHKeypair: mirrorKeypair, + PublicKeyWithComment: publicKeyWithComment, + } + + ctx.Data["MirrorSSHKey"] = mirrorKeyData + } else { + ctx.Data["HasMirrorSSHKey"] = false + } +} + +// RegenerateMirrorSSHKeyPair regenerates the SSH keypair for repository mirroring +func RegenerateMirrorSSHKeyPair(ctx *context.Context) { + _, err := mirror_service.RegenerateSSHKeypairForUser(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("RegenerateSSHKeypairForUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.mirror_ssh_key_regenerated")) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") } diff --git a/routers/web/web.go b/routers/web/web.go index f8612db504dc5..7cb820cb593cc 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -641,6 +641,7 @@ func registerWebRoutes(m *web.Router) { m.Combo("/keys").Get(user_setting.Keys). Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) m.Post("/keys/delete", user_setting.DeleteKey) + m.Post("/keys/mirror-ssh/regenerate", user_setting.RegenerateMirrorSSHKeyPair) m.Group("/packages", func() { m.Get("", user_setting.Packages) m.Group("/rules", func() { @@ -962,6 +963,11 @@ func registerWebRoutes(m *web.Router) { m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels) }) + m.Group("/ssh_keys", func() { + m.Get("", org.SSHKeys) + m.Post("/regenerate", org.RegenerateSSHKey) + }) + m.Group("/actions", func() { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go index eba9c79df573a..31857ca33687a 100644 --- a/services/migrations/migrate.go +++ b/services/migrations/migrate.go @@ -71,7 +71,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error { return &git.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true} } - if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" { + if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" && u.Scheme != "ssh" { return &git.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true} } diff --git a/services/mirror/mirror_pull.go b/services/mirror/mirror_pull.go index cb90af5894db9..81c06b5ec3d2b 100644 --- a/services/mirror/mirror_pull.go +++ b/services/mirror/mirror_pull.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/proxy" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" + ssh_module "code.gitea.io/gitea/modules/ssh" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" @@ -254,6 +255,36 @@ func checkRecoverableSyncError(stderrMessage string) bool { } // runSync returns true if sync finished without error. +// setupSSHAuth sets up SSH authentication for git operations if needed +func setupSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, runOpts *git.RunOpts) (func(), error) { + if !IsSSHURL(remoteURL) { + return func() {}, nil + } + + keypair, err := GetSSHKeypairForURL(ctx, repo, remoteURL) + if err != nil { + return nil, fmt.Errorf("failed to get SSH keypair: %w", err) + } + if keypair == nil { + return func() {}, nil + } + + privateKey, err := keypair.GetDecryptedPrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to decrypt private key: %w", err) + } + + socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create SSH agent: %w", err) + } + + runOpts.SSHAuthSock = socketPath + + log.Debug("SSH agent created for repository %s with socket: %s", repo.FullName(), socketPath) + return cleanup, nil +} + func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bool) { repoPath := m.Repo.RepoPath() wikiPath := m.Repo.WikiPath() @@ -278,13 +309,23 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo stdoutBuilder := strings.Builder{} stderrBuilder := strings.Builder{} - if err := cmd.Run(ctx, &git.RunOpts{ + + runOpts := &git.RunOpts{ Timeout: timeout, Dir: repoPath, Env: envs, Stdout: &stdoutBuilder, Stderr: &stderrBuilder, - }); err != nil { + } + + cleanup, err := setupSSHAuth(ctx, m.Repo, remoteURL.String(), runOpts) + if err != nil { + log.Error("SyncMirrors [repo: %-v]: SSH setup error %v", m.Repo, err) + return nil, false + } + defer cleanup() + + if err := cmd.Run(ctx, runOpts); err != nil { stdout := stdoutBuilder.String() stderr := stderrBuilder.String() @@ -303,12 +344,21 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo // Successful prune - reattempt mirror stderrBuilder.Reset() stdoutBuilder.Reset() - if err = cmd.Run(ctx, &git.RunOpts{ + retryRunOpts := &git.RunOpts{ Timeout: timeout, Dir: repoPath, Stdout: &stdoutBuilder, Stderr: &stderrBuilder, - }); err != nil { + } + + retryCleanup, sshErr := setupSSHAuth(ctx, m.Repo, remoteURL.String(), retryRunOpts) + if sshErr != nil { + log.Error("SyncMirrors [repo: %-v]: SSH setup error on retry %v", m.Repo, sshErr) + return nil, false + } + defer retryCleanup() + + if err = cmd.Run(ctx, retryRunOpts); err != nil { stdout := stdoutBuilder.String() stderr := stderrBuilder.String() diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index 9b57427d98041..6a20a1ff92534 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" + ssh_module "code.gitea.io/gitea/modules/ssh" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" ) @@ -97,7 +98,10 @@ func SyncPushMirror(ctx context.Context, mirrorID int64) bool { return false } - _ = m.GetRepository(ctx) + if m.GetRepository(ctx) == nil { + log.Error("GetRepository [%d]: repository not found", mirrorID) + return false + } m.LastError = "" @@ -138,7 +142,7 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { return errors.New("Unexpected error") } - if setting.LFS.StartServer { + if setting.LFS.StartServer && !IsSSHURL(remoteURL.String()) { log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo) var gitRepo *git.Repository @@ -163,13 +167,48 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName) envs := proxy.EnvWithProxy(remoteURL.URL) - if err := git.Push(ctx, path, git.PushOptions{ + + pushOpts := git.PushOptions{ Remote: m.RemoteName, Force: true, Mirror: true, Timeout: timeout, Env: envs, - }); err != nil { + } + + // Setup SSH authentication + if IsSSHURL(remoteURL.String()) { + if repo.Owner == nil { + if err := repo.LoadOwner(ctx); err != nil { + log.Error("Failed to load repository owner for %s: %v", repo.FullName(), err) + return util.SanitizeErrorCredentialURLs(err) + } + } + keypair, err := GetSSHKeypairForRepository(ctx, repo) + if err != nil { + log.Error("Failed to get SSH keypair for repository %s: %v", repo.FullName(), err) + return util.SanitizeErrorCredentialURLs(err) + } + if keypair != nil { + privateKey, err := keypair.GetDecryptedPrivateKey() + if err != nil { + log.Error("Failed to decrypt private key for repository %s: %v", repo.FullName(), err) + return util.SanitizeErrorCredentialURLs(err) + } + + socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey) + if err != nil { + log.Error("Failed to create SSH agent for repository %s: %v", repo.FullName(), err) + return util.SanitizeErrorCredentialURLs(err) + } + defer cleanup() + + pushOpts.SSHAuthSock = socketPath + log.Debug("SSH agent created for push mirror %s with socket: %s", repo.FullName(), socketPath) + } + } + + if err := git.Push(ctx, path, pushOpts); err != nil { log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err) return util.SanitizeErrorCredentialURLs(err) diff --git a/services/mirror/ssh_keypair.go b/services/mirror/ssh_keypair.go new file mode 100644 index 0000000000000..91acd7ced8c13 --- /dev/null +++ b/services/mirror/ssh_keypair.go @@ -0,0 +1,52 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mirror + +import ( + "context" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + ssh_module "code.gitea.io/gitea/modules/ssh" +) + +// GetOrCreateSSHKeypairForUser gets or creates an SSH keypair for the given user +func GetOrCreateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.MirrorSSHKeypair, error) { + return ssh_module.GetOrCreateSSHKeypairForUser(ctx, userID) +} + +// GetOrCreateSSHKeypairForOrg gets or creates an SSH keypair for the given organization +func GetOrCreateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.MirrorSSHKeypair, error) { + return ssh_module.GetOrCreateSSHKeypairForOrg(ctx, orgID) +} + +// 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) { + return ssh_module.GetSSHKeypairForRepository(ctx, repo) +} + +// RegenerateSSHKeypairForUser regenerates the SSH keypair for a user +func RegenerateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.MirrorSSHKeypair, error) { + log.Info("Regenerating SSH keypair for user %d", userID) + return repo_model.RegenerateMirrorSSHKeypair(ctx, userID) +} + +// RegenerateSSHKeypairForOrg regenerates the SSH keypair for an organization +func RegenerateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.MirrorSSHKeypair, error) { + log.Info("Regenerating SSH keypair for organization %d", orgID) + return repo_model.RegenerateMirrorSSHKeypair(ctx, orgID) +} + +// IsSSHURL checks if a URL is an SSH URL +func IsSSHURL(url string) bool { + return ssh_module.IsSSHURL(url) +} + +// 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) { + return ssh_module.GetSSHKeypairForURL(ctx, repo, url) +} diff --git a/services/repository/migrate.go b/services/repository/migrate.go index 0a3dc45339fd8..220d18bb2ffc1 100644 --- a/services/repository/migrate.go +++ b/services/repository/migrate.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/migration" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" + ssh_module "code.gitea.io/gitea/modules/ssh" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" ) @@ -42,12 +43,42 @@ func cloneWiki(ctx context.Context, u *user_model.User, opts migration.MigrateOp log.Error("Failed to remove incomplete wiki dir %q, err: %v", wikiPath, err) } } - if err := git.Clone(ctx, wikiRemotePath, wikiPath, git.CloneRepoOptions{ + cloneOpts := git.CloneRepoOptions{ Mirror: true, Quiet: true, Timeout: migrateTimeout, SkipTLSVerify: setting.Migrations.SkipTLSVerify, - }); err != nil { + } + + if ssh_module.IsSSHURL(wikiRemotePath) { + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, u.Name, opts.RepoName) + if err != nil { + log.Error("Failed to get repository for wiki clone SSH auth: %v", err) + } else { + if repo.Owner == nil { + repo.Owner = u + } + keypair, err := ssh_module.GetSSHKeypairForRepository(ctx, repo) + if err != nil { + log.Error("Failed to get SSH keypair for wiki clone: %v", err) + } else if keypair != nil { + privateKey, err := keypair.GetDecryptedPrivateKey() + if err != nil { + log.Error("Failed to decrypt private key for wiki clone: %v", err) + } else { + socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey) + if err != nil { + log.Error("Failed to create SSH agent for wiki clone: %v", err) + } else { + cloneOpts.SSHAuthSock = socketPath + defer cleanup() + } + } + } + } + } + + if err := git.Clone(ctx, wikiRemotePath, wikiPath, cloneOpts); err != nil { log.Error("Clone wiki failed, err: %v", err) cleanIncompleteWikiPath() return "", err @@ -90,12 +121,37 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repoPath, err) } - if err := git.Clone(ctx, opts.CloneAddr, repoPath, git.CloneRepoOptions{ + cloneOpts := git.CloneRepoOptions{ Mirror: true, Quiet: true, Timeout: migrateTimeout, SkipTLSVerify: setting.Migrations.SkipTLSVerify, - }); err != nil { + } + + if ssh_module.IsSSHURL(opts.CloneAddr) { + if repo.Owner == nil { + repo.Owner = u + } + keypair, err := ssh_module.GetSSHKeypairForRepository(ctx, repo) + if err != nil { + return repo, fmt.Errorf("failed to get SSH keypair for repository: %w", err) + } + if keypair != nil { + privateKey, err := keypair.GetDecryptedPrivateKey() + if err != nil { + return repo, fmt.Errorf("failed to decrypt private key: %w", err) + } + + socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey) + if err != nil { + return repo, fmt.Errorf("failed to create SSH agent: %w", err) + } + cloneOpts.SSHAuthSock = socketPath + defer cleanup() + } + } + + if err := git.Clone(ctx, opts.CloneAddr, repoPath, cloneOpts); err != nil { if errors.Is(err, context.DeadlineExceeded) { return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err) } diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 58475de7e7a31..61a3505ce4ba5 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -12,6 +12,9 @@ {{ctx.Locale.Tr "repo.labels"}} + + {{ctx.Locale.Tr "org.settings.ssh_keys"}} + {{if .EnableOAuth2}} {{ctx.Locale.Tr "settings.applications"}} diff --git a/templates/org/settings/ssh_keys.tmpl b/templates/org/settings/ssh_keys.tmpl new file mode 100644 index 0000000000000..d8ab121a091d0 --- /dev/null +++ b/templates/org/settings/ssh_keys.tmpl @@ -0,0 +1,46 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings ssh-keys")}} + +
+

+ {{ctx.Locale.Tr "settings.mirror_ssh_title"}} +

+
+
+
+ +
+ {{if .SSHKeypair}} +
+ +
+ + +
+ {{ctx.Locale.Tr "settings.mirror_ssh_fingerprint"}}: {{.SSHKeypair.Fingerprint}} +
+ {{end}} +
+
+ {{.CsrfTokenHtml}} + +
+
+
+
+ + {{ctx.Locale.Tr "settings.mirror_ssh_documentation"}} +
+
+
+
+
+ +{{template "org/settings/layout_footer" .}} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index b61076ff4637e..70955e5f16c3f 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -123,7 +123,11 @@ {{if $.PullMirror}}
{{ctx.Locale.Tr "repo.mirror_from"}} - {{$.PullMirror.RemoteAddress}} + {{if not (StringUtils.HasPrefix $.PullMirror.RemoteAddress "ssh://")}} + {{$.PullMirror.RemoteAddress}} + {{else}} + {{$.PullMirror.RemoteAddress}} + {{end}} {{if $.PullMirror.UpdatedUnix}}{{ctx.Locale.Tr "repo.mirror_sync"}} {{DateUtils.TimeSince $.PullMirror.UpdatedUnix}}{{end}}
{{end}} diff --git a/templates/repo/migrate/git.tmpl b/templates/repo/migrate/git.tmpl index 41139d4fd67b5..b770686cce93e 100644 --- a/templates/repo/migrate/git.tmpl +++ b/templates/repo/migrate/git.tmpl @@ -18,6 +18,10 @@ {{ctx.Locale.Tr "repo.migrate.clone_address_desc"}}{{if .ContextUser.CanImportLocal}} {{ctx.Locale.Tr "repo.migrate.clone_local_path"}}{{end}} + +
{{ctx.Locale.Tr "repo.migrate.ssh_helper_title"}}: {{ctx.Locale.Tr "repo.migrate.ssh_helper_desc"}} + {{ctx.Locale.Tr "repo.migrate.ssh_helper_link"}} +
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 323e0d64ac567..0e8c310c12cd4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3311,6 +3311,89 @@ } } }, + "/orgs/{org}/mirror-ssh-key": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get SSH public key for organization mirroring", + "operationId": "orgGetMirrorSSHKey", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SSH public key", + "schema": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/mirror-ssh-key/regenerate": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Regenerate SSH keypair for organization mirroring", + "operationId": "orgRegenerateMirrorSSHKey", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "New SSH public key", + "schema": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + } + } + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -19627,6 +19710,65 @@ } } }, + "/user/mirror-ssh-key": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get SSH public key for user mirroring", + "operationId": "userGetMirrorSSHKey", + "responses": { + "200": { + "description": "SSH public key", + "schema": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + } + } + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/user/mirror-ssh-key/regenerate": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Regenerate SSH keypair for user mirroring", + "operationId": "userRegenerateMirrorSSHKey", + "responses": { + "200": { + "description": "New SSH public key", + "schema": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + } + } + } + } + } + }, "/user/orgs": { "get": { "produces": [ diff --git a/templates/user/settings/keys.tmpl b/templates/user/settings/keys.tmpl index e0f5e426ae45a..20597dd6f6a4b 100644 --- a/templates/user/settings/keys.tmpl +++ b/templates/user/settings/keys.tmpl @@ -7,5 +7,7 @@ {{if not ($.UserDisabledFeatures.Contains "manage_gpg_keys")}} {{template "user/settings/keys_gpg" .}} {{end}} +
+ {{template "user/settings/keys_mirror_ssh" .}}
{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/keys_mirror_ssh.tmpl b/templates/user/settings/keys_mirror_ssh.tmpl new file mode 100644 index 0000000000000..de787a3615f7d --- /dev/null +++ b/templates/user/settings/keys_mirror_ssh.tmpl @@ -0,0 +1,47 @@ +

+ {{ctx.Locale.Tr "settings.mirror_ssh_title"}} +

+
+
+
+ +
+ {{if .HasMirrorSSHKey}} +
+ +
+ + +
+ {{ctx.Locale.Tr "settings.mirror_ssh_fingerprint"}}: {{.MirrorSSHKey.Fingerprint}} +
+ {{end}} +
+
+ {{.CsrfTokenHtml}} + +
+
+
+
+ + {{ctx.Locale.Tr "settings.mirror_ssh_documentation"}} +
+
+
+
+ + {{ctx.Locale.Tr "settings.mirror_ssh_org_notice"}} +
+
+
+
+ diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts index 0788f83215c96..aae78a9741cbb 100644 --- a/web_src/js/features/repo-migrate.ts +++ b/web_src/js/features/repo-migrate.ts @@ -59,3 +59,44 @@ async function doMigrationRetry(e: DOMEvent) { await POST(e.target.getAttribute('data-migrating-task-retry-url')); window.location.reload(); } + +export function initRepoMigrationForm() { + const cloneAddrInput = document.querySelector('#clone_addr'); + const authUsernameInput = document.querySelector('#auth_username'); + const authPasswordInput = document.querySelector('#auth_password'); + const sshHelpText = document.querySelector('.help.ssh-help'); + + if (!cloneAddrInput || !authUsernameInput || !authPasswordInput || !sshHelpText) return; + + function isSSHURL(url: string): boolean { + return url.startsWith('ssh://') || + url.startsWith('git@') || + (url.includes('@') && url.includes(':') && !url.includes('://')); + } + + function updateAuthFields() { + const url = cloneAddrInput.value.trim(); + const isSSH = isSSHURL(url); + + if (isSSH) { + // Disable auth fields for SSH URLs + authUsernameInput.disabled = true; + authPasswordInput.disabled = true; + authUsernameInput.value = ''; + authPasswordInput.value = ''; + authUsernameInput.parentElement?.classList.add('disabled'); + authPasswordInput.parentElement?.classList.add('disabled'); + showElem(sshHelpText); + } else { + authUsernameInput.disabled = false; + authPasswordInput.disabled = false; + authUsernameInput.parentElement?.classList.remove('disabled'); + authPasswordInput.parentElement?.classList.remove('disabled'); + hideElem(sshHelpText); + } + } + + updateAuthFields(); + cloneAddrInput.addEventListener('input', updateAuthFields); + cloneAddrInput.addEventListener('blur', updateAuthFields); +} diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 770c7fc00c642..e513d5fe84000 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -30,7 +30,7 @@ import {initRepoCodeView} from './features/repo-code.ts'; import {initSshKeyFormParser} from './features/sshkey-helper.ts'; import {initUserSettings} from './features/user-settings.ts'; import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts'; -import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; +import {initRepoMigrationStatusChecker, initRepoMigrationForm} from './features/repo-migrate.ts'; import {initRepoDiffView} from './features/repo-diff.ts'; import {initOrgTeam} from './features/org-team.ts'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts'; @@ -135,6 +135,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoIssueSidebarDependency, initRepoMigration, initRepoMigrationStatusChecker, + initRepoMigrationForm, initRepoProject, initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview,