From 4c8e222242cd84f6d8db71aca9497a4a0c07cf9e Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 15:46:17 -0400 Subject: [PATCH 01/10] SSH Push/Pull Mirroring & Migrations --- models/migrations/migrations.go | 4 + models/migrations/v1_25/v321.go | 24 ++ models/repo/mirror_ssh_keypair.go | 126 +++++++++ models/repo/mirror_ssh_keypair_test.go | 148 +++++++++++ modules/git/command.go | 10 + modules/git/remote.go | 68 ++++- modules/git/remote_test.go | 110 ++++++++ modules/git/repo.go | 30 ++- modules/ssh/agent.go | 262 +++++++++++++++++++ modules/ssh/mirror.go | 73 ++++++ options/locale/locale_en-US.ini | 16 +- routers/api/v1/api.go | 10 + routers/api/v1/org/mirror.go | 96 +++++++ routers/api/v1/user/mirror.go | 80 ++++++ routers/web/org/setting_ssh_keys.go | 51 ++++ routers/web/user/setting/keys.go | 34 +++ routers/web/web.go | 6 + services/migrations/migrate.go | 2 +- services/mirror/mirror_pull.go | 58 +++- services/mirror/mirror_push.go | 47 +++- services/mirror/ssh_keypair.go | 52 ++++ services/repository/migrate.go | 64 ++++- templates/org/settings/navbar.tmpl | 3 + templates/org/settings/ssh_keys.tmpl | 46 ++++ templates/repo/header.tmpl | 6 +- templates/repo/migrate/git.tmpl | 4 + templates/user/settings/keys.tmpl | 2 + templates/user/settings/keys_mirror_ssh.tmpl | 47 ++++ web_src/js/features/repo-migrate.ts | 41 +++ web_src/js/index.ts | 3 +- 30 files changed, 1495 insertions(+), 28 deletions(-) create mode 100644 models/migrations/v1_25/v321.go create mode 100644 models/repo/mirror_ssh_keypair.go create mode 100644 models/repo/mirror_ssh_keypair_test.go create mode 100644 modules/git/remote_test.go create mode 100644 modules/ssh/agent.go create mode 100644 modules/ssh/mirror.go create mode 100644 routers/api/v1/org/mirror.go create mode 100644 routers/api/v1/user/mirror.go create mode 100644 routers/web/org/setting_ssh_keys.go create mode 100644 services/mirror/ssh_keypair.go create mode 100644 templates/org/settings/ssh_keys.tmpl create mode 100644 templates/user/settings/keys_mirror_ssh.tmpl 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..383550948ed96 --- /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 ( + "context" + "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" + + "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.True(t, keypair.CreatedUnix > 0) + assert.True(t, keypair.UpdatedUnix > 0) + + // 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.True(t, db.IsErrNotExist(err)) + }) + + 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.Equal(t, ed25519.PrivateKeySize, len(privateKey)) + + // Verify the private key corresponds to the public key + publicKey := privateKey.Public().(ed25519.PublicKey) + assert.Equal(t, ed25519.PublicKeySize, len(publicKey)) + }) + + 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.True(t, db.IsErrNotExist(err)) + }) + + 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 := context.Background() + results := make(chan error, 10) + + // Start multiple goroutines creating keypairs for different owners + for i := 0; i < 10; i++ { + go func(ownerID int64) { + _, err := repo_model.CreateMirrorSSHKeypair(ctx, ownerID+100) + results <- err + }(int64(i)) + } + + // Check all creations succeeded + for i := 0; i < 10; i++ { + 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..5db3db71a581f 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -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 diff --git a/modules/git/remote_test.go b/modules/git/remote_test.go new file mode 100644 index 0000000000000..5ce886c22108e --- /dev/null +++ b/modules/git/remote_test.go @@ -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) + } + }) + } +} 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..0fd772501113a --- /dev/null +++ b/modules/ssh/agent.go @@ -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) + } +} 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 0d82f9e92066e..2aa92394ffce9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1030,6 +1030,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 @@ -1191,9 +1201,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 with comma separated here to make migrating faster because of GitHub API rate limit. WARN: Abusing this feature may violate the service provider's policy and lead to account blocking. 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." @@ -2850,6 +2863,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 4a4bf12657a0e..e0bb7556e0c1c 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..fa6b2385dfe2f --- /dev/null +++ b/routers/api/v1/org/mirror.go @@ -0,0 +1,96 @@ +// 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" + // "500": + // "$ref": "#/responses/internalServerError" + + 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..61f11271522df --- /dev/null +++ b/routers/api/v1/user/mirror.go @@ -0,0 +1,80 @@ +// 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 + // "500": + // "$ref": "#/responses/internalServerError" + + 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 66a5a9be5e81a..b057283cba5e3 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -636,6 +636,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() { @@ -957,6 +958,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 15458e761cbdf..1a5a8e2a2fff8 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..461cb39851a54 --- /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" .}} \ No newline at end of file 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..4dd2aa0d9269d 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/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.ts b/web_src/js/index.ts index 7e84773bc18fa..06bc06da6e123 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -29,7 +29,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 @@ onDomReady(() => { initRepoIssueSidebarDependency, initRepoMigration, initRepoMigrationStatusChecker, + initRepoMigrationForm, initRepoProject, initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, From fb5e88076ad5e67f417297300e6911b87171703c Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 15:50:24 -0400 Subject: [PATCH 02/10] add repomigration form to dom-ready --- web_src/js/index-domready.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, From 20e70e4b2ec9ddbd97fc853286339c14ebf5c6d1 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 16:03:17 -0400 Subject: [PATCH 03/10] fix lint errors --- models/repo/mirror_ssh_keypair_test.go | 11 +++++----- modules/git/remote.go | 17 ++++++++------- modules/ssh/agent.go | 30 ++++++++++++++------------ 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/models/repo/mirror_ssh_keypair_test.go b/models/repo/mirror_ssh_keypair_test.go index 383550948ed96..f09b8b5d1b90e 100644 --- a/models/repo/mirror_ssh_keypair_test.go +++ b/models/repo/mirror_ssh_keypair_test.go @@ -4,7 +4,6 @@ package repo_test import ( - "context" "crypto/ed25519" "testing" @@ -29,8 +28,8 @@ func TestMirrorSSHKeypair(t *testing.T) { assert.NotEmpty(t, keypair.PublicKey) assert.NotEmpty(t, keypair.PrivateKeyEncrypted) assert.NotEmpty(t, keypair.Fingerprint) - assert.True(t, keypair.CreatedUnix > 0) - assert.True(t, keypair.UpdatedUnix > 0) + 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") @@ -77,11 +76,11 @@ func TestMirrorSSHKeypair(t *testing.T) { privateKey, err := keypair.GetDecryptedPrivateKey() require.NoError(t, err) assert.IsType(t, ed25519.PrivateKey{}, privateKey) - assert.Equal(t, ed25519.PrivateKeySize, len(privateKey)) + assert.Len(t, privateKey, ed25519.PrivateKeySize) // Verify the private key corresponds to the public key publicKey := privateKey.Public().(ed25519.PublicKey) - assert.Equal(t, ed25519.PublicKeySize, len(publicKey)) + assert.Len(t, publicKey, ed25519.PublicKeySize) }) t.Run("DeleteMirrorSSHKeypair", func(t *testing.T) { @@ -128,7 +127,7 @@ func TestMirrorSSHKeypairConcurrency(t *testing.T) { // Test concurrent creation of keypairs to ensure no race conditions t.Run("ConcurrentCreation", func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() results := make(chan error, 10) // Start multiple goroutines creating keypairs for different owners diff --git a/modules/git/remote.go b/modules/git/remote.go index 5db3db71a581f..a36b106827cd6 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -5,6 +5,7 @@ package git import ( "context" + "errors" "fmt" "net/url" "strings" @@ -91,33 +92,33 @@ func IsRemoteNotExistError(err error) bool { // 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") + return remoteAddr, errors.New("remoteAddr has a scheme") } if strings.Contains(remoteAddr, "\\") { - return remoteAddr, fmt.Errorf("remoteAddr has Windows path slashes") + return remoteAddr, errors.New("remoteAddr has Windows path slashes") } if strings.Contains(remoteAddr, ":/") { - return remoteAddr, fmt.Errorf("remoteAddr could be Windows drive with forward slash") + return remoteAddr, errors.New("remoteAddr could be Windows drive with forward slash") } if remoteAddr != "" && (remoteAddr[0] == '/' || remoteAddr[0] == '\\') { - return remoteAddr, fmt.Errorf("remoteAddr is a local file path") + 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, fmt.Errorf("remoteAddr has no colon") + return remoteAddr, errors.New("remoteAddr has no colon") } if colonIndex == 1 && len(remoteAddr) > 2 { - return remoteAddr, fmt.Errorf("remoteAddr could be Windows drive letter check (C:, D:, etc.)") + 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, fmt.Errorf("remoteAddr has empty host or path") + return remoteAddr, errors.New("remoteAddr has empty host or path") } var user, host string @@ -130,7 +131,7 @@ func normalizeSSHURL(remoteAddr string) (string, error) { } if host == "" { - return remoteAddr, fmt.Errorf("Must have SSH host") + return remoteAddr, errors.New("Must have SSH host") } return fmt.Sprintf("ssh://%s%s/%s", user, host, pathPart), nil diff --git a/modules/ssh/agent.go b/modules/ssh/agent.go index 0fd772501113a..d6b6a9c6745ee 100644 --- a/modules/ssh/agent.go +++ b/modules/ssh/agent.go @@ -20,8 +20,8 @@ import ( "golang.org/x/crypto/ssh/agent" ) -// SSHAgent represents a temporary SSH agent for repo mirroring -type SSHAgent struct { +// Agent represents a temporary SSH agent for repo mirroring +type Agent struct { socketPath string listener net.Listener agent agent.Agent @@ -32,7 +32,7 @@ type SSHAgent struct { } // NewSSHAgent creates a new SSH agent with the given private key -func NewSSHAgent(privateKey ed25519.PrivateKey) (*SSHAgent, error) { +func NewSSHAgent(privateKey ed25519.PrivateKey) (*Agent, error) { var listener net.Listener var socketPath string var tempDir string @@ -108,7 +108,7 @@ func NewSSHAgent(privateKey ed25519.PrivateKey) (*SSHAgent, error) { } // Create our SSH agent wrapper - sa := &SSHAgent{ + sa := &Agent{ socketPath: socketPath, listener: listener, agent: sshAgent, @@ -126,7 +126,7 @@ func NewSSHAgent(privateKey ed25519.PrivateKey) (*SSHAgent, error) { } // serve handles incoming connections to the SSH agent -func (sa *SSHAgent) serve() { +func (sa *Agent) serve() { defer sa.wg.Done() defer sa.cleanup() @@ -139,7 +139,9 @@ func (sa *SSHAgent) serve() { 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)) + if err := listener.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + log.Debug("Failed to set listener deadline: %v", err) + } } } @@ -172,7 +174,7 @@ func (sa *SSHAgent) serve() { } // cleanup removes the socket file and temporary directory -func (sa *SSHAgent) cleanup() { +func (sa *Agent) cleanup() { if sa.socketPath != "" { if runtime.GOOS != "windows" { // On Windows, named pipes are automatically cleaned up when closed @@ -184,12 +186,12 @@ func (sa *SSHAgent) cleanup() { } // GetSocketPath returns the path to the SSH agent socket -func (sa *SSHAgent) GetSocketPath() string { +func (sa *Agent) GetSocketPath() string { return sa.socketPath } // Close stops the SSH agent and cleans up resources -func (sa *SSHAgent) Close() error { +func (sa *Agent) Close() error { sa.mu.Lock() defer sa.mu.Unlock() @@ -209,14 +211,14 @@ func (sa *SSHAgent) Close() error { return nil } -// SSHAgentManager manages temporary SSH agents for git operations -type SSHAgentManager struct { +// AgentManager manages temporary SSH agents for git operations +type AgentManager struct { mu sync.Mutex - agents map[string]*SSHAgent + agents map[string]*Agent } -var globalAgentManager = &SSHAgentManager{ - agents: make(map[string]*SSHAgent), +var globalAgentManager = &AgentManager{ + agents: make(map[string]*Agent), } // CreateTemporaryAgent creates a temporary SSH agent with the given private key From 41afc4377c3dc13889fc8222e233abc9f9a66612 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 16:16:51 -0400 Subject: [PATCH 04/10] more lint fixes --- templates/org/settings/ssh_keys.tmpl | 2 +- templates/repo/migrate/git.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/org/settings/ssh_keys.tmpl b/templates/org/settings/ssh_keys.tmpl index 461cb39851a54..d8ab121a091d0 100644 --- a/templates/org/settings/ssh_keys.tmpl +++ b/templates/org/settings/ssh_keys.tmpl @@ -43,4 +43,4 @@ -{{template "org/settings/layout_footer" .}} \ No newline at end of file +{{template "org/settings/layout_footer" .}} diff --git a/templates/repo/migrate/git.tmpl b/templates/repo/migrate/git.tmpl index 4dd2aa0d9269d..b770686cce93e 100644 --- a/templates/repo/migrate/git.tmpl +++ b/templates/repo/migrate/git.tmpl @@ -19,7 +19,7 @@ {{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_title"}}: {{ctx.Locale.Tr "repo.migrate.ssh_helper_desc"}} {{ctx.Locale.Tr "repo.migrate.ssh_helper_link"}}
From 2830c5f89714c48b4ac5a5f51663e8e8784968cd Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 17:00:08 -0400 Subject: [PATCH 05/10] Quick quit ssh:// --- modules/git/remote.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/git/remote.go b/modules/git/remote.go index a36b106827cd6..d7c49a44015e2 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -91,6 +91,9 @@ func IsRemoteNotExistError(err error) bool { // 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") } From 21422e9af6156d79d1fe87ed41434e77c2ac5e48 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 17:42:20 -0400 Subject: [PATCH 06/10] fmt and swagger --- modules/git/remote.go | 6 +- templates/swagger/v1_json.tmpl | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/modules/git/remote.go b/modules/git/remote.go index d7c49a44015e2..530a813f82507 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -91,9 +91,9 @@ func IsRemoteNotExistError(err error) bool { // 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.HasPrefix(remoteAddr, "ssh://") { + return remoteAddr, nil + } if strings.Contains(remoteAddr, "://") { return remoteAddr, errors.New("remoteAddr has a scheme") } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 323e0d64ac567..8230d564d7085 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3311,6 +3311,92 @@ } } }, + "/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" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -19627,6 +19713,68 @@ } } }, + "/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" + } + } + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/user/orgs": { "get": { "produces": [ From 5f8c350a3164082fe0056d6c828a88556fa0c034 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 17:45:48 -0400 Subject: [PATCH 07/10] fix swagger --- routers/api/v1/org/mirror.go | 2 -- routers/api/v1/user/mirror.go | 2 -- templates/swagger/v1_json.tmpl | 6 ------ 3 files changed, 10 deletions(-) diff --git a/routers/api/v1/org/mirror.go b/routers/api/v1/org/mirror.go index fa6b2385dfe2f..4f5933f444191 100644 --- a/routers/api/v1/org/mirror.go +++ b/routers/api/v1/org/mirror.go @@ -80,8 +80,6 @@ func RegenerateMirrorSSHKey(ctx *context.APIContext) { // type: string // "403": // "$ref": "#/responses/forbidden" - // "500": - // "$ref": "#/responses/internalServerError" keypair, err := mirror_service.RegenerateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID) if err != nil { diff --git a/routers/api/v1/user/mirror.go b/routers/api/v1/user/mirror.go index 61f11271522df..6ddc4946a1c50 100644 --- a/routers/api/v1/user/mirror.go +++ b/routers/api/v1/user/mirror.go @@ -64,8 +64,6 @@ func RegenerateMirrorSSHKey(ctx *context.APIContext) { // type: string // fingerprint: // type: string - // "500": - // "$ref": "#/responses/internalServerError" keypair, err := mirror_service.RegenerateSSHKeypairForUser(ctx, ctx.Doer.ID) if err != nil { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8230d564d7085..0e8c310c12cd4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3390,9 +3390,6 @@ }, "403": { "$ref": "#/responses/forbidden" - }, - "500": { - "$ref": "#/responses/internalServerError" } } } @@ -19768,9 +19765,6 @@ } } } - }, - "500": { - "$ref": "#/responses/internalServerError" } } } From 8347926610c771f4d6125ddf4a3453665e41b9f5 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 17:58:04 -0400 Subject: [PATCH 08/10] make fix --- models/repo/mirror_ssh_keypair_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/repo/mirror_ssh_keypair_test.go b/models/repo/mirror_ssh_keypair_test.go index f09b8b5d1b90e..d98ba767b862d 100644 --- a/models/repo/mirror_ssh_keypair_test.go +++ b/models/repo/mirror_ssh_keypair_test.go @@ -131,7 +131,7 @@ func TestMirrorSSHKeypairConcurrency(t *testing.T) { results := make(chan error, 10) // Start multiple goroutines creating keypairs for different owners - for i := 0; i < 10; i++ { + for i := range 10 { go func(ownerID int64) { _, err := repo_model.CreateMirrorSSHKeypair(ctx, ownerID+100) results <- err @@ -139,7 +139,7 @@ func TestMirrorSSHKeypairConcurrency(t *testing.T) { } // Check all creations succeeded - for i := 0; i < 10; i++ { + for range 10 { err := <-results assert.NoError(t, err) } From 95af6795fa5bac0453b0c096ad486c6541ab19af Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 18:14:06 -0400 Subject: [PATCH 09/10] skip err check --- modules/git/remote_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/git/remote_test.go b/modules/git/remote_test.go index 5ce886c22108e..5e143e4a5705e 100644 --- a/modules/git/remote_test.go +++ b/modules/git/remote_test.go @@ -54,8 +54,7 @@ func TestNormalizeSSHURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := normalizeSSHURL(tt.input) - assert.NoError(t, err) + result, _ := normalizeSSHURL(tt.input) assert.Equal(t, tt.expected, result) }) } From cecb89d23f27dedb8a806fb3a78fc4b7636956e5 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 15 Jul 2025 18:50:26 -0400 Subject: [PATCH 10/10] fix test --- models/repo/mirror_ssh_keypair_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/models/repo/mirror_ssh_keypair_test.go b/models/repo/mirror_ssh_keypair_test.go index d98ba767b862d..5f540a6f74fbe 100644 --- a/models/repo/mirror_ssh_keypair_test.go +++ b/models/repo/mirror_ssh_keypair_test.go @@ -11,6 +11,7 @@ import ( 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" @@ -59,7 +60,7 @@ func TestMirrorSSHKeypair(t *testing.T) { // Test retrieving non-existent keypair _, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 999) - assert.True(t, db.IsErrNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) }) t.Run("GetDecryptedPrivateKey", func(t *testing.T) { @@ -98,7 +99,7 @@ func TestMirrorSSHKeypair(t *testing.T) { // Verify it's gone _, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 5) - assert.True(t, db.IsErrNotExist(err)) + assert.ErrorIs(t, err, util.ErrNotExist) }) t.Run("RegenerateMirrorSSHKeypair", func(t *testing.T) {