Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4c8e222
SSH Push/Pull Mirroring & Migrations
techknowlogick Jul 15, 2025
c2e9532
Merge remote-tracking branch 'origin/main' into test
techknowlogick Jul 15, 2025
fb5e880
add repomigration form to dom-ready
techknowlogick Jul 15, 2025
20e70e4
fix lint errors
techknowlogick Jul 15, 2025
41afc43
more lint fixes
techknowlogick Jul 15, 2025
5ca8e61
Merge branch 'main' into ssh-mirroring
techknowlogick Jul 15, 2025
2830c5f
Quick quit ssh://
techknowlogick Jul 15, 2025
21422e9
fmt and swagger
techknowlogick Jul 15, 2025
5f8c350
fix swagger
techknowlogick Jul 15, 2025
8347926
make fix
techknowlogick Jul 15, 2025
95af679
skip err check
techknowlogick Jul 15, 2025
cecb89d
fix test
techknowlogick Jul 15, 2025
795bd80
Merge branch 'main' into ssh-mirroring
techknowlogick Jul 15, 2025
a965cd5
Merge branch 'main' into ssh-mirroring
techknowlogick Jul 16, 2025
0d7e2d1
rename migration to 322
techknowlogick Aug 8, 2025
cee2ca0
Merge remote-tracking branch 'upstream/main' into ssh-mirroring
techknowlogick Aug 8, 2025
f97acdc
update to `UserSSHKeypair` per feedback
techknowlogick Aug 8, 2025
2f3f2d1
Merge branch 'ssh-mirroring' of https://github.com/techknowlogick/git…
techknowlogick Aug 8, 2025
3ed601d
switch to use user_settings table per feedback
techknowlogick Aug 8, 2025
df90dbb
Merge branch 'main' into ssh-mirroring
techknowlogick Sep 11, 2025
50662c8
Update models/migrations/migrations.go
techknowlogick Sep 11, 2025
5a2fc6e
Update models/repo/mirror_ssh_keypair.go
techknowlogick Sep 11, 2025
1d9e98c
Update routers/api/v1/api.go
techknowlogick Sep 11, 2025
e26e628
use withtx2 correctly
techknowlogick Sep 12, 2025
8579b08
fix testing context
techknowlogick Sep 12, 2025
48483b3
Merge branch 'ssh-mirroring' of github.com:techknowlogick/gitea into …
lunny Sep 25, 2025
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
3 changes: 2 additions & 1 deletion models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,9 @@ 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)
newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs),
newMigration(322, "Add Mirror SSH keypair table", v1_25.AddUserSSHKeypairTable),
}
return preparedMigrations
}
Expand Down
24 changes: 24 additions & 0 deletions models/migrations/v1_25/v322.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 AddUserSSHKeypairTable(x *xorm.Engine) error {
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"`
}

return x.Sync(new(UserSSHKeypair))
}
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.
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it's better to move this file to models/user package? Why it belongs to repo package?

Copy link
Member

@lunny lunny Sep 12, 2025

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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)
}
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 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)
}
})
}
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