Skip to content

SSH Push/Pull Mirroring & Migrations #35089

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ func prepareMigrationTasks() []*migration {
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 database version 321
// Gitea 1.24.0 ends at migration ID number 320 (database version 321)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated change now.

newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs),
}
return preparedMigrations
Expand Down
172 changes: 172 additions & 0 deletions models/repo/mirror_ssh_keypair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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/util"

"golang.org/x/crypto/ssh"
)

// UserSSHKeypair represents an SSH keypair for repository mirroring
type UserSSHKeypair struct {
OwnerID int64
PrivateKeyEncrypted string
PublicKey string
Fingerprint string
}

// GetUserSSHKeypairByOwner gets the SSH keypair for the given owner
func GetUserSSHKeypairByOwner(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) {
settings, err := user_model.GetSettings(ctx, ownerID, []string{
user_model.UserSSHMirrorPrivPem,
user_model.UserSSHMirrorPubPem,
user_model.UserSSHMirrorFingerprint,
})
if err != nil {
return nil, err
}
if len(settings) == 0 {
return nil, util.NewNotExistErrorf("SSH keypair does not exist for owner %d", ownerID)
}

keypair := &UserSSHKeypair{
OwnerID: ownerID,
}

if privSetting, exists := settings[user_model.UserSSHMirrorPrivPem]; exists {
keypair.PrivateKeyEncrypted = privSetting.SettingValue
}
if pubSetting, exists := settings[user_model.UserSSHMirrorPubPem]; exists {
keypair.PublicKey = pubSetting.SettingValue
}
if fpSetting, exists := settings[user_model.UserSSHMirrorFingerprint]; exists {
keypair.Fingerprint = fpSetting.SettingValue
}

if keypair.PrivateKeyEncrypted == "" || keypair.PublicKey == "" || keypair.Fingerprint == "" {
return nil, util.NewNotExistErrorf("SSH keypair incomplete for owner %d", ownerID)
}

return keypair, nil
}

// CreateUserSSHKeypair creates a new SSH keypair for mirroring
func CreateUserSSHKeypair(ctx context.Context, ownerID int64) (*UserSSHKeypair, 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)
}

err = db.WithTx(ctx, func(ctx context.Context) error {
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorPrivPem, privateKeyEncrypted); err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorPubPem, publicKeyStr); err != nil {
return fmt.Errorf("failed to save public key: %w", err)
}
if err := user_model.SetUserSetting(ctx, ownerID, user_model.UserSSHMirrorFingerprint, fingerprintStr); err != nil {
return fmt.Errorf("failed to save fingerprint: %w", err)
}
return nil
})
if err != nil {
return nil, err
}

keypair := &UserSSHKeypair{
OwnerID: ownerID,
PrivateKeyEncrypted: privateKeyEncrypted,
PublicKey: publicKeyStr,
Fingerprint: fingerprintStr,
}

return keypair, nil
}

// GetDecryptedPrivateKey returns the decrypted private key
func (k *UserSSHKeypair) 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 *UserSSHKeypair) 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
}

// DeleteUserSSHKeypair deletes an SSH keypair
func DeleteUserSSHKeypair(ctx context.Context, ownerID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorPrivPem); err != nil {
return err
}
if err := user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorPubPem); err != nil {
return err
}
return user_model.DeleteUserSetting(ctx, ownerID, user_model.UserSSHMirrorFingerprint)
})
}

// RegenerateUserSSHKeypair regenerates an SSH keypair for the given owner
func RegenerateUserSSHKeypair(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) {
var keypair *UserSSHKeypair
err := db.WithTx(ctx, func(ctx context.Context) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use db.WithTx2 instead now.

_ = DeleteUserSSHKeypair(ctx, ownerID)

newKeypair, err := CreateUserSSHKeypair(ctx, ownerID)
if err != nil {
return err
}
keypair = newKeypair
return nil
})
return keypair, err
}
146 changes: 146 additions & 0 deletions models/repo/mirror_ssh_keypair_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// 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 TestUserSSHKeypair(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())

t.Run("CreateUserSSHKeypair", func(t *testing.T) {
// Test creating a new SSH keypair for a user
keypair, err := repo_model.CreateUserSSHKeypair(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)

// 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.CreateUserSSHKeypair(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("GetUserSSHKeypairByOwner", func(t *testing.T) {
// Create a keypair first
created, err := repo_model.CreateUserSSHKeypair(db.DefaultContext, 3)
require.NoError(t, err)

// Test retrieving the keypair
retrieved, err := repo_model.GetUserSSHKeypairByOwner(db.DefaultContext, 3)
require.NoError(t, err)
assert.Equal(t, created.OwnerID, retrieved.OwnerID)
assert.Equal(t, created.PublicKey, retrieved.PublicKey)
assert.Equal(t, created.Fingerprint, retrieved.Fingerprint)

// Test retrieving non-existent keypair
_, err = repo_model.GetUserSSHKeypairByOwner(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.CreateUserSSHKeypair(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("DeleteUserSSHKeypair", func(t *testing.T) {
// Create a keypair
_, err := repo_model.CreateUserSSHKeypair(db.DefaultContext, 5)
require.NoError(t, err)

// Verify it exists
_, err = repo_model.GetUserSSHKeypairByOwner(db.DefaultContext, 5)
require.NoError(t, err)

// Delete it
err = repo_model.DeleteUserSSHKeypair(db.DefaultContext, 5)
require.NoError(t, err)

// Verify it's gone
_, err = repo_model.GetUserSSHKeypairByOwner(db.DefaultContext, 5)
assert.ErrorIs(t, err, util.ErrNotExist)
})

t.Run("RegenerateUserSSHKeypair", func(t *testing.T) {
// Create initial keypair
original, err := repo_model.CreateUserSSHKeypair(db.DefaultContext, 6)
require.NoError(t, err)

// Regenerate it
regenerated, err := repo_model.RegenerateUserSSHKeypair(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 TestUserSSHKeypairConcurrency(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 := db.DefaultContext
results := make(chan error, 10)

// Start multiple goroutines creating keypairs for different owners
for i := range 10 {
go func(ownerID int64) {
_, err := repo_model.CreateUserSSHKeypair(ctx, ownerID+100)
results <- err
}(int64(i))
}

// Check all creations succeeded
for range 10 {
err := <-results
assert.NoError(t, err)
}
})
}
4 changes: 4 additions & 0 deletions models/user/setting_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ const (
SettingEmailNotificationGiteaActionsAll = "all"
SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
SettingEmailNotificationGiteaActionsDisabled = "disabled"

UserSSHMirrorPrivPem = "ssh_mirror.priv_pem"
UserSSHMirrorPubPem = "ssh_mirror.pub_pem"
UserSSHMirrorFingerprint = "ssh_mirror.fingerprint"
)
10 changes: 10 additions & 0 deletions modules/git/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down
Loading