-
-
Notifications
You must be signed in to change notification settings - Fork 6.1k
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
base: main
Are you sure you want to change the base?
Changes from 18 commits
4c8e222
c2e9532
fb5e880
20e70e4
41afc43
5ca8e61
2830c5f
21422e9
5f8c350
8347926
95af679
cecb89d
795bd80
a965cd5
0d7e2d1
cee2ca0
f97acdc
2f3f2d1
3ed601d
df90dbb
50662c8
5a2fc6e
1d9e98c
e26e628
8579b08
48483b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 AddUserSSHKeypairTable(x *xorm.Engine) error { | ||
type UserSSHKeypair struct { | ||
techknowlogick marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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(UserSSHKeypair)) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
// Copyright 2025 The Gitea Authors. All rights reserved. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it's better to move this file to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And since it could be used for migrating, the file name is inaccurate. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. other mirror/migrate code is in here so that is why it is in here. most other mirror/migrate code is referred to as mirror (except for some specific logic) so I kept the naming the same. |
||
// 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" | ||
) | ||
|
||
// UserSSHKeypair represents an SSH keypair for repository mirroring | ||
type UserSSHKeypair 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(UserSSHKeypair)) | ||
} | ||
|
||
// GetUserSSHKeypairByOwner gets the most recent SSH keypair for the given owner | ||
func GetUserSSHKeypairByOwner(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) { | ||
keypair := &UserSSHKeypair{} | ||
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 | ||
} | ||
|
||
// 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) | ||
} | ||
|
||
keypair := &UserSSHKeypair{ | ||
OwnerID: ownerID, | ||
PrivateKeyEncrypted: privateKeyEncrypted, | ||
PublicKey: publicKeyStr, | ||
Fingerprint: fingerprintStr, | ||
} | ||
|
||
return keypair, db.Insert(ctx, keypair) | ||
} | ||
|
||
// 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 { | ||
_, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Delete(&UserSSHKeypair{}) | ||
return err | ||
} | ||
|
||
// RegenerateUserSSHKeypair regenerates an SSH keypair for the given owner | ||
func RegenerateUserSSHKeypair(ctx context.Context, ownerID int64) (*UserSSHKeypair, error) { | ||
// TODO: This creates a new one old ones will be garbage collected later, as the user may accidentally regenerate | ||
return CreateUserSSHKeypair(ctx, ownerID) | ||
} |
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 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) | ||
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.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.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.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 := 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.CreateUserSSHKeypair(ctx, ownerID+100) | ||
results <- err | ||
}(int64(i)) | ||
} | ||
|
||
// Check all creations succeeded | ||
for range 10 { | ||
err := <-results | ||
assert.NoError(t, err) | ||
} | ||
}) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.