mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-23 02:28:34 +02:00
SSH Push/Pull Mirroring & Migrations
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user