mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-08 12:06:01 +02:00
Merge branch 'ssh-mirroring' of github.com:techknowlogick/gitea into techknowlogick-ssh-mirroring
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// 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) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*UserSSHKeypair, error) {
|
||||
_ = DeleteUserSSHKeypair(ctx, ownerID)
|
||||
|
||||
newKeypair, err := CreateUserSSHKeypair(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newKeypair, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
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(t.Context(), 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(t.Context(), 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(t.Context(), 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test retrieving the keypair
|
||||
retrieved, err := repo_model.GetUserSSHKeypairByOwner(t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
_, err = repo_model.GetUserSSHKeypairByOwner(t.Context(), 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete it
|
||||
err = repo_model.DeleteUserSSHKeypair(t.Context(), 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
_, err = repo_model.GetUserSSHKeypairByOwner(t.Context(), 5)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
})
|
||||
|
||||
t.Run("RegenerateUserSSHKeypair", func(t *testing.T) {
|
||||
// Create initial keypair
|
||||
original, err := repo_model.CreateUserSSHKeypair(t.Context(), 6)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Regenerate it
|
||||
regenerated, err := repo_model.RegenerateUserSSHKeypair(t.Context(), 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -220,6 +220,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
|
||||
}
|
||||
|
||||
@@ -319,6 +323,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
|
||||
@@ -434,6 +443,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,
|
||||
}
|
||||
|
||||
|
||||
+71
-1
@@ -5,6 +5,7 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -79,11 +80,69 @@ func IsRemoteNotExistError(err error) bool {
|
||||
return strings.HasPrefix(err.Error(), prefix1) || strings.HasPrefix(err.Error(), prefix2)
|
||||
}
|
||||
|
||||
// normalizeSSHURL converts SSH-SCP format URLs to standard ssh:// format for security
|
||||
func normalizeSSHURL(remoteAddr string) (string, error) {
|
||||
if strings.HasPrefix(remoteAddr, "ssh://") {
|
||||
return remoteAddr, nil
|
||||
}
|
||||
if strings.Contains(remoteAddr, "://") {
|
||||
return remoteAddr, errors.New("remoteAddr has a scheme")
|
||||
}
|
||||
if strings.Contains(remoteAddr, "\\") {
|
||||
return remoteAddr, errors.New("remoteAddr has Windows path slashes")
|
||||
}
|
||||
if strings.Contains(remoteAddr, ":/") {
|
||||
return remoteAddr, errors.New("remoteAddr could be Windows drive with forward slash")
|
||||
}
|
||||
if remoteAddr != "" && (remoteAddr[0] == '/' || remoteAddr[0] == '\\') {
|
||||
return remoteAddr, errors.New("remoteAddr is a local file path")
|
||||
}
|
||||
|
||||
// Parse SSH-SCP format: [user@]host:path
|
||||
colonIndex := strings.Index(remoteAddr, ":")
|
||||
if colonIndex == -1 {
|
||||
return remoteAddr, errors.New("remoteAddr has no colon")
|
||||
}
|
||||
|
||||
if colonIndex == 1 && len(remoteAddr) > 2 {
|
||||
return remoteAddr, errors.New("remoteAddr could be Windows drive letter check (C:, D:, etc.)")
|
||||
}
|
||||
|
||||
hostPart := remoteAddr[:colonIndex]
|
||||
pathPart := remoteAddr[colonIndex+1:]
|
||||
|
||||
if hostPart == "" || pathPart == "" {
|
||||
return remoteAddr, errors.New("remoteAddr has empty host or path")
|
||||
}
|
||||
|
||||
var user, host string
|
||||
if atIndex := strings.LastIndex(hostPart, "@"); atIndex != -1 {
|
||||
user = hostPart[:atIndex+1] // Include the @
|
||||
host = hostPart[atIndex+1:]
|
||||
} else {
|
||||
user = "git@"
|
||||
host = hostPart
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return remoteAddr, errors.New("Must have SSH host")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ssh://%s%s/%s", user, host, pathPart), nil
|
||||
}
|
||||
|
||||
// ParseRemoteAddr checks if given remote address is valid,
|
||||
// and returns composed URL with needed username and password.
|
||||
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
|
||||
remoteAddr = strings.TrimSpace(remoteAddr)
|
||||
// Remote address can be HTTP/HTTPS/Git URL or local path.
|
||||
|
||||
// First, try to normalize SSH-SCP format URLs to ssh:// format for security
|
||||
normalizedAddr, err := normalizeSSHURL(remoteAddr)
|
||||
if err == nil {
|
||||
remoteAddr = normalizedAddr
|
||||
}
|
||||
|
||||
// Remote address can be HTTP/HTTPS/Git URL or SSH URL or local path.
|
||||
if strings.HasPrefix(remoteAddr, "http://") ||
|
||||
strings.HasPrefix(remoteAddr, "https://") ||
|
||||
strings.HasPrefix(remoteAddr, "git://") {
|
||||
@@ -95,6 +154,17 @@ func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, err
|
||||
u.User = url.UserPassword(authUsername, authPassword)
|
||||
}
|
||||
remoteAddr = u.String()
|
||||
} else if strings.HasPrefix(remoteAddr, "ssh://") {
|
||||
// Handle ssh:// URLs (including normalized ones)
|
||||
u, err := url.Parse(remoteAddr)
|
||||
if err != nil {
|
||||
return "", &ErrInvalidCloneAddr{IsURLError: true, Host: remoteAddr}
|
||||
}
|
||||
if len(authUsername)+len(authPassword) > 0 {
|
||||
// SSH URLs don't support username/password auth, only key-based auth
|
||||
return "", &ErrInvalidCloneAddr{IsURLError: true, Host: remoteAddr}
|
||||
}
|
||||
remoteAddr = u.String()
|
||||
}
|
||||
|
||||
return remoteAddr, nil
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeSSHURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "SSH-SCP format with user",
|
||||
input: "git@github.com:user/repo.git",
|
||||
expected: "ssh://git@github.com/user/repo.git",
|
||||
},
|
||||
{
|
||||
name: "SSH-SCP format without user",
|
||||
input: "github.com:user/repo.git",
|
||||
expected: "ssh://git@github.com/user/repo.git",
|
||||
},
|
||||
{
|
||||
name: "Already ssh:// format",
|
||||
input: "ssh://git@github.com/user/repo.git",
|
||||
expected: "ssh://git@github.com/user/repo.git",
|
||||
},
|
||||
{
|
||||
name: "HTTP URL unchanged",
|
||||
input: "https://github.com/user/repo.git",
|
||||
expected: "https://github.com/user/repo.git",
|
||||
},
|
||||
{
|
||||
name: "Custom SSH user",
|
||||
input: "myuser@example.com:path/to/repo.git",
|
||||
expected: "ssh://myuser@example.com/path/to/repo.git",
|
||||
},
|
||||
{
|
||||
name: "Complex path",
|
||||
input: "git@gitlab.com:group/subgroup/project.git",
|
||||
expected: "ssh://git@gitlab.com/group/subgroup/project.git",
|
||||
},
|
||||
{
|
||||
name: "SSH with Port",
|
||||
input: "ssh://git@example.com:2222/user/repo.git",
|
||||
expected: "ssh://git@example.com:2222/user/repo.git",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, _ := normalizeSSHURL(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteAddrSSH(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
authUser string
|
||||
authPass string
|
||||
expected string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "SSH-SCP format normalized",
|
||||
remoteAddr: "git@github.com:user/repo.git",
|
||||
authUser: "",
|
||||
authPass: "",
|
||||
expected: "ssh://git@github.com/user/repo.git",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "SSH URL with auth should error",
|
||||
remoteAddr: "git@github.com:user/repo.git",
|
||||
authUser: "user",
|
||||
authPass: "pass",
|
||||
expected: "",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with auth",
|
||||
remoteAddr: "https://github.com/user/repo.git",
|
||||
authUser: "user",
|
||||
authPass: "pass",
|
||||
expected: "https://user:pass@github.com/user/repo.git",
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseRemoteAddr(tt.remoteAddr, tt.authUser, tt.authPass)
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -129,6 +129,7 @@ type CloneRepoOptions struct {
|
||||
Depth int
|
||||
Filter string
|
||||
SkipTLSVerify bool
|
||||
SSHAuthSock string
|
||||
}
|
||||
|
||||
// Clone clones original repository to target path.
|
||||
@@ -180,10 +181,11 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
|
||||
stderr := new(bytes.Buffer)
|
||||
if err = cmd.Run(ctx, &gitcmd.RunOpts{
|
||||
Timeout: opts.Timeout,
|
||||
Env: envs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: stderr,
|
||||
Timeout: opts.Timeout,
|
||||
Env: envs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: stderr,
|
||||
SSHAuthSock: opts.SSHAuthSock,
|
||||
}); err != nil {
|
||||
return gitcmd.ConcatenateError(err, stderr.String())
|
||||
}
|
||||
@@ -192,12 +194,13 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
|
||||
// PushOptions options when push to remote
|
||||
type PushOptions struct {
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
Mirror bool
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
Mirror bool
|
||||
Env []string
|
||||
Timeout time.Duration
|
||||
SSHAuthSock string
|
||||
}
|
||||
|
||||
// Push pushs local commits to given remote branch.
|
||||
@@ -215,7 +218,12 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
|
||||
}
|
||||
cmd.AddDashesAndList(remoteBranchArgs...)
|
||||
|
||||
stdout, stderr, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Env: opts.Env, Timeout: opts.Timeout, Dir: repoPath})
|
||||
stdout, stderr, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{
|
||||
Env: opts.Env,
|
||||
Timeout: opts.Timeout,
|
||||
Dir: repoPath,
|
||||
SSHAuthSock: opts.SSHAuthSock,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(stderr, "non-fast-forward") {
|
||||
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// Agent represents a temporary SSH agent for repo mirroring
|
||||
type Agent struct {
|
||||
socketPath string
|
||||
listener net.Listener
|
||||
agent agent.Agent
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSSHAgent creates a new SSH agent with the given private key
|
||||
func NewSSHAgent(privateKey ed25519.PrivateKey) (*Agent, error) {
|
||||
var listener net.Listener
|
||||
var socketPath string
|
||||
var tempDir string
|
||||
var err error
|
||||
|
||||
// Setup cleanup function for early returns
|
||||
var cleanup func()
|
||||
defer func() {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows, use named pipes
|
||||
agentID, err := util.CryptoRandomString(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate agent ID: %w", err)
|
||||
}
|
||||
socketPath = `\\.\pipe\gitea-ssh-agent-` + agentID
|
||||
listener, err = net.Listen("pipe", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create named pipe: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
listener.Close()
|
||||
}
|
||||
} else {
|
||||
tempDir, err = os.MkdirTemp("", "gitea-ssh-agent-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tempDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("failed to set temporary directory permissions: %w", err)
|
||||
}
|
||||
|
||||
socketPath = filepath.Join(tempDir, "agent.sock")
|
||||
listener, err = net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Unix socket: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
listener.Close()
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketPath, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("failed to set socket permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sshAgent := agent.NewKeyring()
|
||||
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("invalid Ed25519 private key size: expected %d, got %d", ed25519.PrivateKeySize, len(privateKey))
|
||||
}
|
||||
|
||||
_, err = ssh.NewSignerFromKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH signer: %w", err)
|
||||
}
|
||||
|
||||
err = sshAgent.Add(agent.AddedKey{
|
||||
PrivateKey: privateKey,
|
||||
Comment: "gitea-mirror-key",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add key to agent: %w", err)
|
||||
}
|
||||
|
||||
// Create our SSH agent wrapper
|
||||
sa := &Agent{
|
||||
socketPath: socketPath,
|
||||
listener: listener,
|
||||
agent: sshAgent,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start serving
|
||||
sa.wg.Add(1)
|
||||
go sa.serve()
|
||||
|
||||
// Clear cleanup since we're returning successfully
|
||||
cleanup = nil
|
||||
|
||||
return sa, nil
|
||||
}
|
||||
|
||||
// serve handles incoming connections to the SSH agent
|
||||
func (sa *Agent) serve() {
|
||||
defer sa.wg.Done()
|
||||
defer sa.cleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sa.stop:
|
||||
return
|
||||
default:
|
||||
// Set a timeout for Accept to avoid blocking indefinitely
|
||||
if runtime.GOOS != "windows" {
|
||||
// On Windows, named pipes don't support SetDeadline in the same way
|
||||
if listener, ok := sa.listener.(*net.UnixListener); ok {
|
||||
if err := listener.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
log.Debug("Failed to set listener deadline: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := sa.listener.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-sa.stop:
|
||||
return
|
||||
default:
|
||||
log.Error("SSH agent failed to accept connection: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
sa.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer sa.wg.Done()
|
||||
defer c.Close()
|
||||
|
||||
err := agent.ServeAgent(sa.agent, c)
|
||||
if err != nil {
|
||||
log.Debug("SSH agent connection ended: %v", err)
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes the socket file and temporary directory
|
||||
func (sa *Agent) cleanup() {
|
||||
if sa.socketPath != "" {
|
||||
if runtime.GOOS != "windows" {
|
||||
// On Windows, named pipes are automatically cleaned up when closed
|
||||
// On Unix-like systems, remove the temporary directory
|
||||
tempDir := filepath.Dir(sa.socketPath)
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSocketPath returns the path to the SSH agent socket
|
||||
func (sa *Agent) GetSocketPath() string {
|
||||
return sa.socketPath
|
||||
}
|
||||
|
||||
// Close stops the SSH agent and cleans up resources
|
||||
func (sa *Agent) Close() error {
|
||||
sa.mu.Lock()
|
||||
defer sa.mu.Unlock()
|
||||
|
||||
if sa.closed {
|
||||
return nil
|
||||
}
|
||||
sa.closed = true
|
||||
|
||||
close(sa.stop)
|
||||
|
||||
if sa.listener != nil {
|
||||
sa.listener.Close()
|
||||
}
|
||||
|
||||
sa.wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentManager manages temporary SSH agents for git operations
|
||||
type AgentManager struct {
|
||||
mu sync.Mutex
|
||||
agents map[string]*Agent
|
||||
}
|
||||
|
||||
var globalAgentManager = &AgentManager{
|
||||
agents: make(map[string]*Agent),
|
||||
}
|
||||
|
||||
// CreateTemporaryAgent creates a temporary SSH agent with the given private key
|
||||
// Returns the socket path for use with SSH_AUTH_SOCK
|
||||
func CreateTemporaryAgent(privateKey ed25519.PrivateKey) (string, func(), error) {
|
||||
agent, err := NewSSHAgent(privateKey)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
agentID, err := util.CryptoRandomString(16)
|
||||
if err != nil {
|
||||
agent.Close()
|
||||
return "", nil, fmt.Errorf("failed to generate agent ID: %w", err)
|
||||
}
|
||||
|
||||
globalAgentManager.mu.Lock()
|
||||
globalAgentManager.agents[agentID] = agent
|
||||
globalAgentManager.mu.Unlock()
|
||||
|
||||
cleanup := func() {
|
||||
globalAgentManager.mu.Lock()
|
||||
defer globalAgentManager.mu.Unlock()
|
||||
|
||||
if agent, exists := globalAgentManager.agents[agentID]; exists {
|
||||
agent.Close()
|
||||
delete(globalAgentManager.agents, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
return agent.GetSocketPath(), cleanup, nil
|
||||
}
|
||||
|
||||
// CleanupAllAgents closes all active SSH agents (should be called on shutdown)
|
||||
func CleanupAllAgents() {
|
||||
globalAgentManager.mu.Lock()
|
||||
defer globalAgentManager.mu.Unlock()
|
||||
|
||||
for id, agent := range globalAgentManager.agents {
|
||||
agent.Close()
|
||||
delete(globalAgentManager.agents, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// IsSSHURL checks if a URL is an SSH URL
|
||||
func IsSSHURL(url string) bool {
|
||||
return strings.HasPrefix(url, "ssh://")
|
||||
}
|
||||
|
||||
// GetOrCreateSSHKeypairForUser gets or creates an SSH keypair for the given user
|
||||
func GetOrCreateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
keypair, err := repo_model.GetUserSSHKeypairByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
log.Debug("Creating new SSH keypair for user %d", userID)
|
||||
return repo_model.CreateUserSSHKeypair(ctx, userID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get SSH keypair for user %d: %w", userID, err)
|
||||
}
|
||||
return keypair, nil
|
||||
}
|
||||
|
||||
// GetOrCreateSSHKeypairForOrg gets or creates an SSH keypair for the given organization
|
||||
func GetOrCreateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
keypair, err := repo_model.GetUserSSHKeypairByOwner(ctx, orgID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
log.Debug("Creating new SSH keypair for organization %d", orgID)
|
||||
return repo_model.CreateUserSSHKeypair(ctx, orgID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get SSH keypair for organization %d: %w", orgID, err)
|
||||
}
|
||||
return keypair, nil
|
||||
}
|
||||
|
||||
// GetSSHKeypairForRepository gets the appropriate SSH keypair for a repository
|
||||
// If the repository belongs to an organization, it uses the org's keypair,
|
||||
// otherwise it uses the user's keypair
|
||||
func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository) (*repo_model.UserSSHKeypair, error) {
|
||||
if repo.Owner == nil {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository owner: %w", err)
|
||||
}
|
||||
repo.Owner = owner
|
||||
}
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
return GetOrCreateSSHKeypairForOrg(ctx, repo.OwnerID)
|
||||
}
|
||||
return GetOrCreateSSHKeypairForUser(ctx, repo.OwnerID)
|
||||
}
|
||||
|
||||
// GetSSHKeypairForURL gets the appropriate SSH keypair for a given repository and URL
|
||||
// Returns nil if the URL is not an SSH URL
|
||||
func GetSSHKeypairForURL(ctx context.Context, repo *repo_model.Repository, url string) (*repo_model.UserSSHKeypair, error) {
|
||||
if !IsSSHURL(url) {
|
||||
return nil, nil
|
||||
}
|
||||
return GetSSHKeypairForRepository(ctx, repo)
|
||||
}
|
||||
@@ -1041,6 +1041,16 @@ visibility.limited_tooltip = Visible only to authenticated users
|
||||
visibility.private = Private
|
||||
visibility.private_tooltip = Visible only to members of organizations you have joined
|
||||
|
||||
mirror_ssh_title = Repository Mirror SSH Keys
|
||||
mirror_ssh_description = SSH keys for repository mirroring allow you to authenticate with remote Git repositories using SSH. Each user and organization has their own SSH keypair stored securely.
|
||||
mirror_ssh_current_key = Current SSH Public Key
|
||||
mirror_ssh_fingerprint = Fingerprint
|
||||
mirror_ssh_generate = Generate SSH Key
|
||||
mirror_ssh_regenerate = Regenerate SSH Key
|
||||
mirror_ssh_regenerated = SSH keypair has been regenerated successfully.
|
||||
mirror_ssh_documentation = SSH keys are automatically used for SSH-based repository mirrors. Add the public key to your remote Git service (GitHub, GitLab, etc.) to enable authentication.
|
||||
mirror_ssh_org_notice = "This SSH key is only for your personal repositories. For organization repositories, you need to configure SSH keys in the organization's settings."
|
||||
|
||||
[repo]
|
||||
new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? <a href="%s">Migrate repository.</a>
|
||||
owner = Owner
|
||||
@@ -1202,9 +1212,12 @@ migrate_items_merge_requests = Merge Requests
|
||||
migrate_items_releases = Releases
|
||||
migrate_repo = Migrate Repository
|
||||
migrate.clone_address = Migrate / Clone From URL
|
||||
migrate.clone_address_desc = The HTTP(S) or Git 'clone' URL of an existing repository
|
||||
migrate.clone_address_desc = The HTTP(S), Git, or SSH 'clone' URL of an existing repository
|
||||
migrate.github_token_desc = You can put one or more tokens here, separated by commas, to make migrating faster by circumventing the GitHub API rate limit. WARNING: Abusing this feature may violate the service provider's policy and may lead to getting your account(s) blocked.
|
||||
migrate.clone_local_path = or a local server path
|
||||
migrate.ssh_helper_title = SSH URLs
|
||||
migrate.ssh_helper_desc = Upload your SSH mirror keys to the remote SSH server for authentication.
|
||||
migrate.ssh_helper_link = "View your SSH keys (if migrating to an organization, you may need to upload the organization's SSH keys)."
|
||||
migrate.permission_denied = You are not allowed to import local repositories.
|
||||
migrate.permission_denied_blocked = You cannot import from disallowed hosts. Please ask the admin to check ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS/BLOCKED_DOMAINS settings.
|
||||
migrate.invalid_local_path = "The local path is invalid. It doesn't exist or is not a directory."
|
||||
@@ -2871,6 +2884,7 @@ settings.rename_desc = Changing the organization name will also change your orga
|
||||
settings.rename_success = Organization %[1]s has been renamed to %[2]s successfully.
|
||||
settings.rename_no_change = Organization name is not changed.
|
||||
settings.rename_new_org_name = New Organization Name
|
||||
settings.ssh_keys = SSH Mirror Keys
|
||||
settings.rename_failed = Renaming organization failed because of an internal error
|
||||
settings.rename_notices_1 = This operation <strong>CANNOT</strong> be undone.
|
||||
settings.rename_notices_2 = The old name will redirect until it is claimed.
|
||||
|
||||
@@ -1161,6 +1161,11 @@ func Routes() *web.Router {
|
||||
m.Delete("", user.UnblockUser)
|
||||
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
|
||||
})
|
||||
|
||||
m.Group("/mirror-ssh-key", func() {
|
||||
m.Get("", user.GetMirrorSSHKey)
|
||||
m.Post("/regenerate", user.RegenerateMirrorSSHKey)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
|
||||
|
||||
// Repositories (requires repo scope, org scope)
|
||||
@@ -1687,6 +1692,11 @@ func Routes() *web.Router {
|
||||
m.Delete("", org.UnblockUser)
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/mirror-ssh-key", func() {
|
||||
m.Get("", reqToken(), reqOrgOwnership(), org.GetMirrorSSHKey)
|
||||
m.Post("/regenerate", reqToken(), reqOrgOwnership(), org.RegenerateMirrorSSHKey)
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||
)
|
||||
|
||||
// GetMirrorSSHKey gets the SSH public key for organization mirroring
|
||||
func GetMirrorSSHKey(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/mirror-ssh-key organization orgGetMirrorSSHKey
|
||||
// ---
|
||||
// summary: Get SSH public key for organization mirroring
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: SSH public key
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// public_key:
|
||||
// type: string
|
||||
// fingerprint:
|
||||
// type: string
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
keypair, err := mirror_service.GetOrCreateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "SSH keypair not found")
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"public_key": keypair.PublicKey,
|
||||
"fingerprint": keypair.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateMirrorSSHKey regenerates the SSH keypair for organization mirroring
|
||||
func RegenerateMirrorSSHKey(ctx *context.APIContext) {
|
||||
// swagger:operation POST /orgs/{org}/mirror-ssh-key/regenerate organization orgRegenerateMirrorSSHKey
|
||||
// ---
|
||||
// summary: Regenerate SSH keypair for organization mirroring
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// description: New SSH public key
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// public_key:
|
||||
// type: string
|
||||
// fingerprint:
|
||||
// type: string
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
keypair, err := mirror_service.RegenerateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"public_key": keypair.PublicKey,
|
||||
"fingerprint": keypair.Fingerprint,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||
)
|
||||
|
||||
// GetMirrorSSHKey gets the SSH public key for user mirroring
|
||||
func GetMirrorSSHKey(ctx *context.APIContext) {
|
||||
// swagger:operation GET /user/mirror-ssh-key user userGetMirrorSSHKey
|
||||
// ---
|
||||
// summary: Get SSH public key for user mirroring
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// description: SSH public key
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// public_key:
|
||||
// type: string
|
||||
// fingerprint:
|
||||
// type: string
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
keypair, err := mirror_service.GetOrCreateSSHKeypairForUser(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, "SSH keypair not found")
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"public_key": keypair.PublicKey,
|
||||
"fingerprint": keypair.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateMirrorSSHKey regenerates the SSH keypair for user mirroring
|
||||
func RegenerateMirrorSSHKey(ctx *context.APIContext) {
|
||||
// swagger:operation POST /user/mirror-ssh-key/regenerate user userRegenerateMirrorSSHKey
|
||||
// ---
|
||||
// summary: Regenerate SSH keypair for user mirroring
|
||||
// produces:
|
||||
// - application/json
|
||||
// responses:
|
||||
// "200":
|
||||
// description: New SSH public key
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// public_key:
|
||||
// type: string
|
||||
// fingerprint:
|
||||
// type: string
|
||||
|
||||
keypair, err := mirror_service.RegenerateSSHKeypairForUser(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{
|
||||
"public_key": keypair.PublicKey,
|
||||
"fingerprint": keypair.Fingerprint,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSettingsSSHKeys templates.TplName = "org/settings/ssh_keys"
|
||||
)
|
||||
|
||||
// SSHKeys render organization SSH mirror keys page
|
||||
func SSHKeys(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("org.settings.ssh_keys")
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsOrgSettingsSSHKeys"] = true
|
||||
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return
|
||||
}
|
||||
|
||||
keypair, err := mirror_service.GetOrCreateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOrCreateSSHKeypairForOrg", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["SSHKeypair"] = keypair
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsSSHKeys)
|
||||
}
|
||||
|
||||
// RegenerateSSHKey regenerates the SSH keypair for organization mirror operations
|
||||
func RegenerateSSHKey(ctx *context.Context) {
|
||||
_, err := mirror_service.RegenerateSSHKeypairForOrg(ctx, ctx.Org.Organization.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("RegenerateSSHKeypairForOrg", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.mirror_ssh_regenerated"))
|
||||
ctx.Redirect(ctx.Org.OrgLink + "/settings/ssh_keys")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -342,4 +344,36 @@ func loadKeysData(ctx *context.Context) {
|
||||
ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg")
|
||||
ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh")
|
||||
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
|
||||
|
||||
// Load SSH mirror keypair if it exists
|
||||
mirrorKeypair, err := mirror_service.GetOrCreateSSHKeypairForUser(ctx, ctx.Doer.ID)
|
||||
if err == nil {
|
||||
ctx.Data["HasMirrorSSHKey"] = true
|
||||
|
||||
// Create a struct with the public key including comment
|
||||
publicKeyWithComment, _ := mirrorKeypair.GetPublicKeyWithComment(ctx)
|
||||
mirrorKeyData := struct {
|
||||
*repo_model.UserSSHKeypair
|
||||
PublicKeyWithComment string
|
||||
}{
|
||||
UserSSHKeypair: mirrorKeypair,
|
||||
PublicKeyWithComment: publicKeyWithComment,
|
||||
}
|
||||
|
||||
ctx.Data["MirrorSSHKey"] = mirrorKeyData
|
||||
} else {
|
||||
ctx.Data["HasMirrorSSHKey"] = false
|
||||
}
|
||||
}
|
||||
|
||||
// RegenerateUserSSHKeypair regenerates the SSH keypair for repository mirroring
|
||||
func RegenerateUserSSHKeypair(ctx *context.Context) {
|
||||
_, err := mirror_service.RegenerateSSHKeypairForUser(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("RegenerateSSHKeypairForUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.mirror_ssh_key_regenerated"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||
}
|
||||
|
||||
@@ -641,6 +641,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Combo("/keys").Get(user_setting.Keys).
|
||||
Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost)
|
||||
m.Post("/keys/delete", user_setting.DeleteKey)
|
||||
m.Post("/keys/mirror-ssh/regenerate", user_setting.RegenerateUserSSHKeypair)
|
||||
m.Group("/packages", func() {
|
||||
m.Get("", user_setting.Packages)
|
||||
m.Group("/rules", func() {
|
||||
@@ -962,6 +963,11 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels)
|
||||
})
|
||||
|
||||
m.Group("/ssh_keys", func() {
|
||||
m.Get("", org.SSHKeys)
|
||||
m.Post("/regenerate", org.RegenerateSSHKey)
|
||||
})
|
||||
|
||||
m.Group("/actions", func() {
|
||||
m.Get("", org_setting.RedirectToDefaultSetting)
|
||||
addSettingsRunnersRoutes()
|
||||
|
||||
@@ -71,7 +71,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error {
|
||||
return &git.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true}
|
||||
}
|
||||
|
||||
if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" {
|
||||
if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" && u.Scheme != "ssh" {
|
||||
return &git.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/proxy"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
ssh_module "code.gitea.io/gitea/modules/ssh"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
@@ -248,6 +249,36 @@ func checkRecoverableSyncError(stderrMessage string) bool {
|
||||
}
|
||||
|
||||
// runSync returns true if sync finished without error.
|
||||
// setupSSHAuth sets up SSH authentication for git operations if needed
|
||||
func setupSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, runOpts *gitcmd.RunOpts) (func(), error) {
|
||||
if !IsSSHURL(remoteURL) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
keypair, err := GetSSHKeypairForURL(ctx, repo, remoteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get SSH keypair: %w", err)
|
||||
}
|
||||
if keypair == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
privateKey, err := keypair.GetDecryptedPrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH agent: %w", err)
|
||||
}
|
||||
|
||||
runOpts.SSHAuthSock = socketPath
|
||||
|
||||
log.Debug("SSH agent created for repository %s with socket: %s", repo.FullName(), socketPath)
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bool) {
|
||||
repoPath := m.Repo.RepoPath()
|
||||
wikiPath := m.Repo.WikiPath()
|
||||
@@ -272,13 +303,23 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo
|
||||
|
||||
stdoutBuilder := strings.Builder{}
|
||||
stderrBuilder := strings.Builder{}
|
||||
if err := cmd.Run(ctx, &gitcmd.RunOpts{
|
||||
|
||||
runOpts := &gitcmd.RunOpts{
|
||||
Timeout: timeout,
|
||||
Dir: repoPath,
|
||||
Env: envs,
|
||||
Stdout: &stdoutBuilder,
|
||||
Stderr: &stderrBuilder,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
cleanup, err := setupSSHAuth(ctx, m.Repo, remoteURL.String(), runOpts)
|
||||
if err != nil {
|
||||
log.Error("SyncMirrors [repo: %-v]: SSH setup error %v", m.Repo, err)
|
||||
return nil, false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err := cmd.Run(ctx, runOpts); err != nil {
|
||||
stdout := stdoutBuilder.String()
|
||||
stderr := stderrBuilder.String()
|
||||
|
||||
@@ -297,12 +338,21 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo
|
||||
// Successful prune - reattempt mirror
|
||||
stderrBuilder.Reset()
|
||||
stdoutBuilder.Reset()
|
||||
if err = cmd.Run(ctx, &gitcmd.RunOpts{
|
||||
retryRunOpts := &gitcmd.RunOpts{
|
||||
Timeout: timeout,
|
||||
Dir: repoPath,
|
||||
Stdout: &stdoutBuilder,
|
||||
Stderr: &stderrBuilder,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
retryCleanup, sshErr := setupSSHAuth(ctx, m.Repo, remoteURL.String(), retryRunOpts)
|
||||
if sshErr != nil {
|
||||
log.Error("SyncMirrors [repo: %-v]: SSH setup error on retry %v", m.Repo, sshErr)
|
||||
return nil, false
|
||||
}
|
||||
defer retryCleanup()
|
||||
|
||||
if err = cmd.Run(ctx, retryRunOpts); err != nil {
|
||||
stdout := stdoutBuilder.String()
|
||||
stderr := stderrBuilder.String()
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/proxy"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
ssh_module "code.gitea.io/gitea/modules/ssh"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
@@ -92,7 +93,10 @@ func SyncPushMirror(ctx context.Context, mirrorID int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
_ = m.GetRepository(ctx)
|
||||
if m.GetRepository(ctx) == nil {
|
||||
log.Error("GetRepository [%d]: repository not found", mirrorID)
|
||||
return false
|
||||
}
|
||||
|
||||
m.LastError = ""
|
||||
|
||||
@@ -135,7 +139,7 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
|
||||
return errors.New("Unexpected error")
|
||||
}
|
||||
|
||||
if setting.LFS.StartServer {
|
||||
if setting.LFS.StartServer && !IsSSHURL(remoteURL.String()) {
|
||||
log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo)
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, storageRepo)
|
||||
@@ -155,13 +159,48 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
|
||||
log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName)
|
||||
|
||||
envs := proxy.EnvWithProxy(remoteURL.URL)
|
||||
if err := git.Push(ctx, path, git.PushOptions{
|
||||
|
||||
pushOpts := git.PushOptions{
|
||||
Remote: m.RemoteName,
|
||||
Force: true,
|
||||
Mirror: true,
|
||||
Timeout: timeout,
|
||||
Env: envs,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
// Setup SSH authentication
|
||||
if IsSSHURL(remoteURL.String()) {
|
||||
if repo.Owner == nil {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
log.Error("Failed to load repository owner for %s: %v", repo.FullName(), err)
|
||||
return util.SanitizeErrorCredentialURLs(err)
|
||||
}
|
||||
}
|
||||
keypair, err := GetSSHKeypairForRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Failed to get SSH keypair for repository %s: %v", repo.FullName(), err)
|
||||
return util.SanitizeErrorCredentialURLs(err)
|
||||
}
|
||||
if keypair != nil {
|
||||
privateKey, err := keypair.GetDecryptedPrivateKey()
|
||||
if err != nil {
|
||||
log.Error("Failed to decrypt private key for repository %s: %v", repo.FullName(), err)
|
||||
return util.SanitizeErrorCredentialURLs(err)
|
||||
}
|
||||
|
||||
socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey)
|
||||
if err != nil {
|
||||
log.Error("Failed to create SSH agent for repository %s: %v", repo.FullName(), err)
|
||||
return util.SanitizeErrorCredentialURLs(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
pushOpts.SSHAuthSock = socketPath
|
||||
log.Debug("SSH agent created for push mirror %s with socket: %s", repo.FullName(), socketPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := git.Push(ctx, path, pushOpts); err != nil {
|
||||
log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err)
|
||||
|
||||
return util.SanitizeErrorCredentialURLs(err)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
ssh_module "code.gitea.io/gitea/modules/ssh"
|
||||
)
|
||||
|
||||
// GetOrCreateSSHKeypairForUser gets or creates an SSH keypair for the given user
|
||||
func GetOrCreateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
return ssh_module.GetOrCreateSSHKeypairForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// GetOrCreateSSHKeypairForOrg gets or creates an SSH keypair for the given organization
|
||||
func GetOrCreateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
return ssh_module.GetOrCreateSSHKeypairForOrg(ctx, orgID)
|
||||
}
|
||||
|
||||
// GetSSHKeypairForRepository gets the appropriate SSH keypair for a repository
|
||||
// If the repository belongs to an organization, it uses the org's keypair,
|
||||
// otherwise it uses the user's keypair
|
||||
func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository) (*repo_model.UserSSHKeypair, error) {
|
||||
return ssh_module.GetSSHKeypairForRepository(ctx, repo)
|
||||
}
|
||||
|
||||
// RegenerateSSHKeypairForUser regenerates the SSH keypair for a user
|
||||
func RegenerateSSHKeypairForUser(ctx context.Context, userID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
log.Info("Regenerating SSH keypair for user %d", userID)
|
||||
return repo_model.RegenerateUserSSHKeypair(ctx, userID)
|
||||
}
|
||||
|
||||
// RegenerateSSHKeypairForOrg regenerates the SSH keypair for an organization
|
||||
func RegenerateSSHKeypairForOrg(ctx context.Context, orgID int64) (*repo_model.UserSSHKeypair, error) {
|
||||
log.Info("Regenerating SSH keypair for organization %d", orgID)
|
||||
return repo_model.RegenerateUserSSHKeypair(ctx, orgID)
|
||||
}
|
||||
|
||||
// IsSSHURL checks if a URL is an SSH URL
|
||||
func IsSSHURL(url string) bool {
|
||||
return ssh_module.IsSSHURL(url)
|
||||
}
|
||||
|
||||
// GetSSHKeypairForURL gets the appropriate SSH keypair for a given repository and URL
|
||||
// Returns nil if the URL is not an SSH URL
|
||||
func GetSSHKeypairForURL(ctx context.Context, repo *repo_model.Repository, url string) (*repo_model.UserSSHKeypair, error) {
|
||||
return ssh_module.GetSSHKeypairForURL(ctx, repo, url)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/migration"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
ssh_module "code.gitea.io/gitea/modules/ssh"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
@@ -43,12 +44,42 @@ func cloneWiki(ctx context.Context, u *user_model.User, opts migration.MigrateOp
|
||||
log.Error("Failed to remove incomplete wiki dir %q, err: %v", wikiPath, err)
|
||||
}
|
||||
}
|
||||
if err := git.Clone(ctx, wikiRemotePath, wikiPath, git.CloneRepoOptions{
|
||||
cloneOpts := git.CloneRepoOptions{
|
||||
Mirror: true,
|
||||
Quiet: true,
|
||||
Timeout: migrateTimeout,
|
||||
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if ssh_module.IsSSHURL(wikiRemotePath) {
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, u.Name, opts.RepoName)
|
||||
if err != nil {
|
||||
log.Error("Failed to get repository for wiki clone SSH auth: %v", err)
|
||||
} else {
|
||||
if repo.Owner == nil {
|
||||
repo.Owner = u
|
||||
}
|
||||
keypair, err := ssh_module.GetSSHKeypairForRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Failed to get SSH keypair for wiki clone: %v", err)
|
||||
} else if keypair != nil {
|
||||
privateKey, err := keypair.GetDecryptedPrivateKey()
|
||||
if err != nil {
|
||||
log.Error("Failed to decrypt private key for wiki clone: %v", err)
|
||||
} else {
|
||||
socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey)
|
||||
if err != nil {
|
||||
log.Error("Failed to create SSH agent for wiki clone: %v", err)
|
||||
} else {
|
||||
cloneOpts.SSHAuthSock = socketPath
|
||||
defer cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := git.Clone(ctx, wikiRemotePath, wikiPath, cloneOpts); err != nil {
|
||||
log.Error("Clone wiki failed, err: %v", err)
|
||||
cleanIncompleteWikiPath()
|
||||
return "", err
|
||||
@@ -91,12 +122,37 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
||||
return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repoPath, err)
|
||||
}
|
||||
|
||||
if err := git.Clone(ctx, opts.CloneAddr, repoPath, git.CloneRepoOptions{
|
||||
cloneOpts := git.CloneRepoOptions{
|
||||
Mirror: true,
|
||||
Quiet: true,
|
||||
Timeout: migrateTimeout,
|
||||
SkipTLSVerify: setting.Migrations.SkipTLSVerify,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if ssh_module.IsSSHURL(opts.CloneAddr) {
|
||||
if repo.Owner == nil {
|
||||
repo.Owner = u
|
||||
}
|
||||
keypair, err := ssh_module.GetSSHKeypairForRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("failed to get SSH keypair for repository: %w", err)
|
||||
}
|
||||
if keypair != nil {
|
||||
privateKey, err := keypair.GetDecryptedPrivateKey()
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
socketPath, cleanup, err := ssh_module.CreateTemporaryAgent(privateKey)
|
||||
if err != nil {
|
||||
return repo, fmt.Errorf("failed to create SSH agent: %w", err)
|
||||
}
|
||||
cloneOpts.SSHAuthSock = socketPath
|
||||
defer cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if err := git.Clone(ctx, opts.CloneAddr, repoPath, cloneOpts); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<a class="{{if .PageIsOrgSettingsLabels}}active {{end}}item" href="{{.OrgLink}}/settings/labels">
|
||||
{{ctx.Locale.Tr "repo.labels"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsOrgSettingsSSHKeys}}active {{end}}item" href="{{.OrgLink}}/settings/ssh_keys">
|
||||
{{ctx.Locale.Tr "org.settings.ssh_keys"}}
|
||||
</a>
|
||||
{{if .EnableOAuth2}}
|
||||
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{.OrgLink}}/settings/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings ssh-keys")}}
|
||||
|
||||
<div class="ui segments org-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_title"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "settings.mirror_ssh_description"}}</label>
|
||||
</div>
|
||||
{{if .SSHKeypair}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "settings.mirror_ssh_current_key"}}</label>
|
||||
<div class="ui action input">
|
||||
<input id="ssh-public-key-content" class="js-ssh-key-content" value="{{.SSHKeypair.PublicKeyWithComment}}" readonly>
|
||||
<button class="ui compact button" data-clipboard-target="#ssh-public-key-content" data-tooltip-content="{{ctx.Locale.Tr "copy"}}">
|
||||
{{svg "octicon-copy" 14}}
|
||||
</button>
|
||||
</div>
|
||||
<small class="text grey">{{ctx.Locale.Tr "settings.mirror_ssh_fingerprint"}}: {{.SSHKeypair.Fingerprint}}</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field">
|
||||
<form action="{{.OrgLink}}/settings/ssh_keys/regenerate" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui primary button" type="submit">
|
||||
{{if .SSHKeypair}}
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_regenerate"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_generate"}}
|
||||
{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui info message">
|
||||
<i class="info icon"></i>
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_documentation"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
@@ -123,7 +123,11 @@
|
||||
{{if $.PullMirror}}
|
||||
<div class="fork-flag">
|
||||
{{ctx.Locale.Tr "repo.mirror_from"}}
|
||||
<a target="_blank" rel="noopener noreferrer" href="{{$.PullMirror.RemoteAddress}}">{{$.PullMirror.RemoteAddress}}</a>
|
||||
{{if not (StringUtils.HasPrefix $.PullMirror.RemoteAddress "ssh://")}}
|
||||
<a target="_blank" rel="noopener noreferrer" href="{{$.PullMirror.RemoteAddress}}">{{$.PullMirror.RemoteAddress}}</a>
|
||||
{{else}}
|
||||
<span>{{$.PullMirror.RemoteAddress}}</span>
|
||||
{{end}}
|
||||
{{if $.PullMirror.UpdatedUnix}}{{ctx.Locale.Tr "repo.mirror_sync"}} {{DateUtils.TimeSince $.PullMirror.UpdatedUnix}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
<span class="help">
|
||||
{{ctx.Locale.Tr "repo.migrate.clone_address_desc"}}{{if .ContextUser.CanImportLocal}} {{ctx.Locale.Tr "repo.migrate.clone_local_path"}}{{end}}
|
||||
</span>
|
||||
<span class="help ssh-help tw-hidden">
|
||||
<br><strong>{{ctx.Locale.Tr "repo.migrate.ssh_helper_title"}}:</strong> {{ctx.Locale.Tr "repo.migrate.ssh_helper_desc"}}
|
||||
<a href="/user/settings/keys" target="_blank">{{ctx.Locale.Tr "repo.migrate.ssh_helper_link"}}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="inline field {{if .Err_Auth}}error{{end}}">
|
||||
<label for="auth_username">{{ctx.Locale.Tr "username"}}</label>
|
||||
|
||||
Generated
+142
@@ -3311,6 +3311,89 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{org}/mirror-ssh-key": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organization"
|
||||
],
|
||||
"summary": "Get SSH public key for organization mirroring",
|
||||
"operationId": "orgGetMirrorSSHKey",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the organization",
|
||||
"name": "org",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSH public key",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{org}/mirror-ssh-key/regenerate": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organization"
|
||||
],
|
||||
"summary": "Regenerate SSH keypair for organization mirroring",
|
||||
"operationId": "orgRegenerateMirrorSSHKey",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the organization",
|
||||
"name": "org",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "New SSH public key",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{org}/public_members": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -19627,6 +19710,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/mirror-ssh-key": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"summary": "Get SSH public key for user mirroring",
|
||||
"operationId": "userGetMirrorSSHKey",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSH public key",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/mirror-ssh-key/regenerate": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"summary": "Regenerate SSH keypair for user mirroring",
|
||||
"operationId": "userRegenerateMirrorSSHKey",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "New SSH public key",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/orgs": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
{{if not ($.UserDisabledFeatures.Contains "manage_gpg_keys")}}
|
||||
{{template "user/settings/keys_gpg" .}}
|
||||
{{end}}
|
||||
<div class="ui divider"></div>
|
||||
{{template "user/settings/keys_mirror_ssh" .}}
|
||||
</div>
|
||||
{{template "user/settings/layout_footer" .}}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_title"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "settings.mirror_ssh_description"}}</label>
|
||||
</div>
|
||||
{{if .HasMirrorSSHKey}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "settings.mirror_ssh_current_key"}}</label>
|
||||
<div class="ui action input">
|
||||
<input id="ssh-public-key-content" class="js-ssh-key-content" value="{{.MirrorSSHKey.PublicKeyWithComment}}" readonly>
|
||||
<button class="ui compact button" data-clipboard-target="#ssh-public-key-content" data-tooltip-content="{{ctx.Locale.Tr "copy"}}">
|
||||
{{svg "octicon-copy" 14}}
|
||||
</button>
|
||||
</div>
|
||||
<small class="text grey">{{ctx.Locale.Tr "settings.mirror_ssh_fingerprint"}}: {{.MirrorSSHKey.Fingerprint}}</small>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="field">
|
||||
<form action="{{.Link}}/mirror-ssh/regenerate" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui primary button" type="submit">
|
||||
{{if .HasMirrorSSHKey}}
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_regenerate"}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_generate"}}
|
||||
{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui info message">
|
||||
<i class="info icon"></i>
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_documentation"}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui warning message">
|
||||
<i class="warning icon"></i>
|
||||
{{ctx.Locale.Tr "settings.mirror_ssh_org_notice"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,3 +59,44 @@ async function doMigrationRetry(e: DOMEvent<MouseEvent>) {
|
||||
await POST(e.target.getAttribute('data-migrating-task-retry-url'));
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function initRepoMigrationForm() {
|
||||
const cloneAddrInput = document.querySelector<HTMLInputElement>('#clone_addr');
|
||||
const authUsernameInput = document.querySelector<HTMLInputElement>('#auth_username');
|
||||
const authPasswordInput = document.querySelector<HTMLInputElement>('#auth_password');
|
||||
const sshHelpText = document.querySelector('.help.ssh-help');
|
||||
|
||||
if (!cloneAddrInput || !authUsernameInput || !authPasswordInput || !sshHelpText) return;
|
||||
|
||||
function isSSHURL(url: string): boolean {
|
||||
return url.startsWith('ssh://') ||
|
||||
url.startsWith('git@') ||
|
||||
(url.includes('@') && url.includes(':') && !url.includes('://'));
|
||||
}
|
||||
|
||||
function updateAuthFields() {
|
||||
const url = cloneAddrInput.value.trim();
|
||||
const isSSH = isSSHURL(url);
|
||||
|
||||
if (isSSH) {
|
||||
// Disable auth fields for SSH URLs
|
||||
authUsernameInput.disabled = true;
|
||||
authPasswordInput.disabled = true;
|
||||
authUsernameInput.value = '';
|
||||
authPasswordInput.value = '';
|
||||
authUsernameInput.parentElement?.classList.add('disabled');
|
||||
authPasswordInput.parentElement?.classList.add('disabled');
|
||||
showElem(sshHelpText);
|
||||
} else {
|
||||
authUsernameInput.disabled = false;
|
||||
authPasswordInput.disabled = false;
|
||||
authUsernameInput.parentElement?.classList.remove('disabled');
|
||||
authPasswordInput.parentElement?.classList.remove('disabled');
|
||||
hideElem(sshHelpText);
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthFields();
|
||||
cloneAddrInput.addEventListener('input', updateAuthFields);
|
||||
cloneAddrInput.addEventListener('blur', updateAuthFields);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {initRepoCodeView} from './features/repo-code.ts';
|
||||
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
||||
import {initUserSettings} from './features/user-settings.ts';
|
||||
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
|
||||
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||
import {initRepoMigrationStatusChecker, initRepoMigrationForm} from './features/repo-migrate.ts';
|
||||
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||
import {initOrgTeam} from './features/org-team.ts';
|
||||
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||
@@ -134,6 +134,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoIssueSidebarDependency,
|
||||
initRepoMigration,
|
||||
initRepoMigrationStatusChecker,
|
||||
initRepoMigrationForm,
|
||||
initRepoProject,
|
||||
initRepoPullRequestAllowMaintainerEdit,
|
||||
initRepoPullRequestReview,
|
||||
|
||||
Reference in New Issue
Block a user