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 14 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
4 changes: 4 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions models/migrations/v1_25/v321.go
Original file line number Diff line number Diff line change
@@ -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))
}
126 changes: 126 additions & 0 deletions models/repo/mirror_ssh_keypair.go
Original file line number Diff line number Diff line change
@@ -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)
}
148 changes: 148 additions & 0 deletions models/repo/mirror_ssh_keypair_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo_test

import (
"crypto/ed25519"
"testing"

"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMirrorSSHKeypair(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())

t.Run("CreateMirrorSSHKeypair", func(t *testing.T) {
// Test creating a new SSH keypair for a user
keypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 1)
require.NoError(t, err)
assert.NotNil(t, keypair)
assert.Equal(t, int64(1), keypair.OwnerID)
assert.NotEmpty(t, keypair.PublicKey)
assert.NotEmpty(t, keypair.PrivateKeyEncrypted)
assert.NotEmpty(t, keypair.Fingerprint)
assert.Positive(t, keypair.CreatedUnix)
assert.Positive(t, keypair.UpdatedUnix)

// Verify the public key is in SSH format
assert.Contains(t, keypair.PublicKey, "ssh-ed25519")

// Test creating a keypair for an organization
orgKeypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 2)
require.NoError(t, err)
assert.NotNil(t, orgKeypair)
assert.Equal(t, int64(2), orgKeypair.OwnerID)

// Ensure different owners get different keypairs
assert.NotEqual(t, keypair.PublicKey, orgKeypair.PublicKey)
assert.NotEqual(t, keypair.Fingerprint, orgKeypair.Fingerprint)
})

t.Run("GetMirrorSSHKeypairByOwner", func(t *testing.T) {
// Create a keypair first
created, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 3)
require.NoError(t, err)

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

// Test retrieving non-existent keypair
_, err = repo_model.GetMirrorSSHKeypairByOwner(db.DefaultContext, 999)
assert.ErrorIs(t, err, util.ErrNotExist)
})

t.Run("GetDecryptedPrivateKey", func(t *testing.T) {
// Ensure we have a valid SECRET_KEY for testing
if setting.SecretKey == "" {
setting.SecretKey = "test-secret-key-for-testing"
}

// Create a keypair
keypair, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 4)
require.NoError(t, err)

// Test decrypting the private key
privateKey, err := keypair.GetDecryptedPrivateKey()
require.NoError(t, err)
assert.IsType(t, ed25519.PrivateKey{}, privateKey)
assert.Len(t, privateKey, ed25519.PrivateKeySize)

// Verify the private key corresponds to the public key
publicKey := privateKey.Public().(ed25519.PublicKey)
assert.Len(t, publicKey, ed25519.PublicKeySize)
})

t.Run("DeleteMirrorSSHKeypair", func(t *testing.T) {
// Create a keypair
_, err := repo_model.CreateMirrorSSHKeypair(db.DefaultContext, 5)
require.NoError(t, err)

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

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

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

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

// Regenerate it
regenerated, err := repo_model.RegenerateMirrorSSHKeypair(db.DefaultContext, 6)
require.NoError(t, err)

// Verify it's different
assert.NotEqual(t, original.PublicKey, regenerated.PublicKey)
assert.NotEqual(t, original.PrivateKeyEncrypted, regenerated.PrivateKeyEncrypted)
assert.NotEqual(t, original.Fingerprint, regenerated.Fingerprint)
assert.Equal(t, original.OwnerID, regenerated.OwnerID)
})
}

func TestMirrorSSHKeypairConcurrency(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())

if setting.SecretKey == "" {
setting.SecretKey = "test-secret-key-for-testing"
}

// Test concurrent creation of keypairs to ensure no race conditions
t.Run("ConcurrentCreation", func(t *testing.T) {
ctx := t.Context()
results := make(chan error, 10)

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

// Check all creations succeeded
for range 10 {
err := <-results
assert.NoError(t, err)
}
})
}
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