mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-11-04 00:11:04 +00:00 
			
		
		
		
	Merge pull request 'Allow pushmirror to use publickey authentication' (#4819) from ironmagma/forgejo:publickey-auth-push-mirror into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4819 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
		
				commit
				
					
						5dbacb70f4
					
				
			
		
					 24 changed files with 648 additions and 66 deletions
				
			
		| 
						 | 
					@ -170,11 +170,6 @@ code.gitea.io/gitea/modules/json
 | 
				
			||||||
	StdJSON.NewDecoder
 | 
						StdJSON.NewDecoder
 | 
				
			||||||
	StdJSON.Indent
 | 
						StdJSON.Indent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
code.gitea.io/gitea/modules/keying
 | 
					 | 
				
			||||||
	DeriveKey
 | 
					 | 
				
			||||||
	Key.Encrypt
 | 
					 | 
				
			||||||
	Key.Decrypt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
code.gitea.io/gitea/modules/markup
 | 
					code.gitea.io/gitea/modules/markup
 | 
				
			||||||
	GetRendererByType
 | 
						GetRendererByType
 | 
				
			||||||
	RenderString
 | 
						RenderString
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -78,6 +78,8 @@ var migrations = []*Migration{
 | 
				
			||||||
	NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
 | 
						NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
 | 
				
			||||||
	// v20 -> v21
 | 
						// v20 -> v21
 | 
				
			||||||
	NewMigration("Creating Quota-related tables", CreateQuotaTables),
 | 
						NewMigration("Creating Quota-related tables", CreateQuotaTables),
 | 
				
			||||||
 | 
						// v21 -> v22
 | 
				
			||||||
 | 
						NewMigration("Add SSH keypair to `pull_mirror` table", AddSSHKeypairToPushMirror),
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GetCurrentDBVersion returns the current Forgejo database version.
 | 
					// GetCurrentDBVersion returns the current Forgejo database version.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										16
									
								
								models/forgejo_migrations/v21.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								models/forgejo_migrations/v21.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					package forgejo_migrations //nolint:revive
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import "xorm.io/xorm"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func AddSSHKeypairToPushMirror(x *xorm.Engine) error {
 | 
				
			||||||
 | 
						type PushMirror struct {
 | 
				
			||||||
 | 
							ID         int64  `xorm:"pk autoincr"`
 | 
				
			||||||
 | 
							PublicKey  string `xorm:"VARCHAR(100)"`
 | 
				
			||||||
 | 
							PrivateKey []byte `xorm:"BLOB"`
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return x.Sync(&PushMirror{})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -13,6 +13,7 @@ import (
 | 
				
			||||||
	"code.gitea.io/gitea/models/db"
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/git"
 | 
						"code.gitea.io/gitea/modules/git"
 | 
				
			||||||
	giturl "code.gitea.io/gitea/modules/git/url"
 | 
						giturl "code.gitea.io/gitea/modules/git/url"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/keying"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/log"
 | 
						"code.gitea.io/gitea/modules/log"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/timeutil"
 | 
						"code.gitea.io/gitea/modules/timeutil"
 | 
				
			||||||
| 
						 | 
					@ -32,6 +33,10 @@ type PushMirror struct {
 | 
				
			||||||
	RemoteName    string
 | 
						RemoteName    string
 | 
				
			||||||
	RemoteAddress string `xorm:"VARCHAR(2048)"`
 | 
						RemoteAddress string `xorm:"VARCHAR(2048)"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// A keypair formatted in OpenSSH format.
 | 
				
			||||||
 | 
						PublicKey  string `xorm:"VARCHAR(100)"`
 | 
				
			||||||
 | 
						PrivateKey []byte `xorm:"BLOB"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	SyncOnCommit   bool `xorm:"NOT NULL DEFAULT true"`
 | 
						SyncOnCommit   bool `xorm:"NOT NULL DEFAULT true"`
 | 
				
			||||||
	Interval       time.Duration
 | 
						Interval       time.Duration
 | 
				
			||||||
	CreatedUnix    timeutil.TimeStamp `xorm:"created"`
 | 
						CreatedUnix    timeutil.TimeStamp `xorm:"created"`
 | 
				
			||||||
| 
						 | 
					@ -82,6 +87,29 @@ func (m *PushMirror) GetRemoteName() string {
 | 
				
			||||||
	return m.RemoteName
 | 
						return m.RemoteName
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// GetPublicKey returns a sanitized version of the public key.
 | 
				
			||||||
 | 
					// This should only be used when displaying the public key to the user, not for actual code.
 | 
				
			||||||
 | 
					func (m *PushMirror) GetPublicKey() string {
 | 
				
			||||||
 | 
						return strings.TrimSuffix(m.PublicKey, "\n")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// SetPrivatekey encrypts the given private key and store it in the database.
 | 
				
			||||||
 | 
					// The ID of the push mirror must be known, so this should be done after the
 | 
				
			||||||
 | 
					// push mirror is inserted.
 | 
				
			||||||
 | 
					func (m *PushMirror) SetPrivatekey(ctx context.Context, privateKey []byte) error {
 | 
				
			||||||
 | 
						key := keying.DeriveKey(keying.ContextPushMirror)
 | 
				
			||||||
 | 
						m.PrivateKey = key.Encrypt(privateKey, keying.ColumnAndID("private_key", m.ID))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						_, err := db.GetEngine(ctx).ID(m.ID).Cols("private_key").Update(m)
 | 
				
			||||||
 | 
						return err
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Privatekey retrieves the encrypted private key and decrypts it.
 | 
				
			||||||
 | 
					func (m *PushMirror) Privatekey() ([]byte, error) {
 | 
				
			||||||
 | 
						key := keying.DeriveKey(keying.ContextPushMirror)
 | 
				
			||||||
 | 
						return key.Decrypt(m.PrivateKey, keying.ColumnAndID("private_key", m.ID))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// UpdatePushMirror updates the push-mirror
 | 
					// UpdatePushMirror updates the push-mirror
 | 
				
			||||||
func UpdatePushMirror(ctx context.Context, m *PushMirror) error {
 | 
					func UpdatePushMirror(ctx context.Context, m *PushMirror) error {
 | 
				
			||||||
	_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
 | 
						_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -50,3 +50,30 @@ func TestPushMirrorsIterate(t *testing.T) {
 | 
				
			||||||
		return nil
 | 
							return nil
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestPushMirrorPrivatekey(t *testing.T) {
 | 
				
			||||||
 | 
						require.NoError(t, unittest.PrepareTestDatabase())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						m := &repo_model.PushMirror{
 | 
				
			||||||
 | 
							RemoteName: "test-privatekey",
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						require.NoError(t, db.Insert(db.DefaultContext, m))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						privateKey := []byte{0x00, 0x01, 0x02, 0x04, 0x08, 0x10}
 | 
				
			||||||
 | 
						t.Run("Set privatekey", func(t *testing.T) {
 | 
				
			||||||
 | 
							require.NoError(t, m.SetPrivatekey(db.DefaultContext, privateKey))
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						t.Run("Normal retrieval", func(t *testing.T) {
 | 
				
			||||||
 | 
							actualPrivateKey, err := m.Privatekey()
 | 
				
			||||||
 | 
							require.NoError(t, err)
 | 
				
			||||||
 | 
							assert.EqualValues(t, privateKey, actualPrivateKey)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						t.Run("Incorrect retrieval", func(t *testing.T) {
 | 
				
			||||||
 | 
							m.ID++
 | 
				
			||||||
 | 
							actualPrivateKey, err := m.Privatekey()
 | 
				
			||||||
 | 
							require.Error(t, err)
 | 
				
			||||||
 | 
							assert.Empty(t, actualPrivateKey)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
// Copyright 2015 The Gogs Authors. All rights reserved.
 | 
					// Copyright 2015 The Gogs Authors. All rights reserved.
 | 
				
			||||||
// Copyright 2017 The Gitea Authors. All rights reserved.
 | 
					// Copyright 2017 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
				
			||||||
// SPDX-License-Identifier: MIT
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
package git
 | 
					package git
 | 
				
			||||||
| 
						 | 
					@ -18,6 +19,7 @@ import (
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/modules/proxy"
 | 
						"code.gitea.io/gitea/modules/proxy"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/util"
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -190,17 +192,39 @@ func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, op
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// PushOptions options when push to remote
 | 
					// PushOptions options when push to remote
 | 
				
			||||||
type PushOptions struct {
 | 
					type PushOptions struct {
 | 
				
			||||||
	Remote  string
 | 
						Remote         string
 | 
				
			||||||
	Branch  string
 | 
						Branch         string
 | 
				
			||||||
	Force   bool
 | 
						Force          bool
 | 
				
			||||||
	Mirror  bool
 | 
						Mirror         bool
 | 
				
			||||||
	Env     []string
 | 
						Env            []string
 | 
				
			||||||
	Timeout time.Duration
 | 
						Timeout        time.Duration
 | 
				
			||||||
 | 
						PrivateKeyPath string
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Push pushs local commits to given remote branch.
 | 
					// Push pushs local commits to given remote branch.
 | 
				
			||||||
func Push(ctx context.Context, repoPath string, opts PushOptions) error {
 | 
					func Push(ctx context.Context, repoPath string, opts PushOptions) error {
 | 
				
			||||||
	cmd := NewCommand(ctx, "push")
 | 
						cmd := NewCommand(ctx, "push")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if opts.PrivateKeyPath != "" {
 | 
				
			||||||
 | 
							// Preserve the behavior that existing environments are used if no
 | 
				
			||||||
 | 
							// environments are passed.
 | 
				
			||||||
 | 
							if len(opts.Env) == 0 {
 | 
				
			||||||
 | 
								opts.Env = os.Environ()
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Use environment because it takes precedence over using -c core.sshcommand
 | 
				
			||||||
 | 
							// and it's possible that a system might have an existing GIT_SSH_COMMAND
 | 
				
			||||||
 | 
							// environment set.
 | 
				
			||||||
 | 
							opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
 | 
				
			||||||
 | 
								fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
 | 
				
			||||||
 | 
								" -o IdentitiesOnly=yes"+
 | 
				
			||||||
 | 
								// This will store new SSH host keys and verify connections to existing
 | 
				
			||||||
 | 
								// host keys, but it doesn't allow replacement of existing host keys. This
 | 
				
			||||||
 | 
								// means TOFU is used for Git over SSH pushes.
 | 
				
			||||||
 | 
								" -o StrictHostKeyChecking=accept-new"+
 | 
				
			||||||
 | 
								" -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if opts.Force {
 | 
						if opts.Force {
 | 
				
			||||||
		cmd.AddArguments("-f")
 | 
							cmd.AddArguments("-f")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,6 +18,7 @@ package keying
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"crypto/rand"
 | 
						"crypto/rand"
 | 
				
			||||||
	"crypto/sha256"
 | 
						"crypto/sha256"
 | 
				
			||||||
 | 
						"encoding/binary"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"golang.org/x/crypto/chacha20poly1305"
 | 
						"golang.org/x/crypto/chacha20poly1305"
 | 
				
			||||||
	"golang.org/x/crypto/hkdf"
 | 
						"golang.org/x/crypto/hkdf"
 | 
				
			||||||
| 
						 | 
					@ -44,6 +45,9 @@ func Init(ikm []byte) {
 | 
				
			||||||
// This must be a hardcoded string and must not be arbitrarily constructed.
 | 
					// This must be a hardcoded string and must not be arbitrarily constructed.
 | 
				
			||||||
type Context string
 | 
					type Context string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Used for the `push_mirror` table.
 | 
				
			||||||
 | 
					var ContextPushMirror Context = "pushmirror"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Derive *the* key for a given context, this is a determistic function. The
 | 
					// Derive *the* key for a given context, this is a determistic function. The
 | 
				
			||||||
// same key will be provided for the same context.
 | 
					// same key will be provided for the same context.
 | 
				
			||||||
func DeriveKey(context Context) *Key {
 | 
					func DeriveKey(context Context) *Key {
 | 
				
			||||||
| 
						 | 
					@ -109,3 +113,13 @@ func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return e.Open(nil, nonce, ciphertext, additionalData)
 | 
						return e.Open(nil, nonce, ciphertext, additionalData)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ColumnAndID generates a context that can be used as additional context for
 | 
				
			||||||
 | 
					// encrypting and decrypting data. It requires the column name and the row ID
 | 
				
			||||||
 | 
					// (this requires to be known beforehand). Be careful when using this, as the
 | 
				
			||||||
 | 
					// table name isn't part of this context. This means it's not bound to a
 | 
				
			||||||
 | 
					// particular table. The table should be part of the context that the key was
 | 
				
			||||||
 | 
					// derived for, in which case it binds through that.
 | 
				
			||||||
 | 
					func ColumnAndID(column string, id int64) []byte {
 | 
				
			||||||
 | 
						return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@
 | 
				
			||||||
package keying_test
 | 
					package keying_test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"math"
 | 
				
			||||||
	"testing"
 | 
						"testing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/modules/keying"
 | 
						"code.gitea.io/gitea/modules/keying"
 | 
				
			||||||
| 
						 | 
					@ -94,3 +95,17 @@ func TestKeying(t *testing.T) {
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestKeyingColumnAndID(t *testing.T) {
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", math.MinInt64))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", -1))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", 0))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table", 1))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", math.MaxInt64))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", math.MinInt64))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", -1))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", 0))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
 | 
				
			||||||
 | 
						assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -60,6 +60,10 @@ func endpointFromURL(rawurl string) *url.URL {
 | 
				
			||||||
	case "git":
 | 
						case "git":
 | 
				
			||||||
		u.Scheme = "https"
 | 
							u.Scheme = "https"
 | 
				
			||||||
		return u
 | 
							return u
 | 
				
			||||||
 | 
						case "ssh":
 | 
				
			||||||
 | 
							u.Scheme = "https"
 | 
				
			||||||
 | 
							u.User = nil
 | 
				
			||||||
 | 
							return u
 | 
				
			||||||
	case "file":
 | 
						case "file":
 | 
				
			||||||
		return u
 | 
							return u
 | 
				
			||||||
	default:
 | 
						default:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,6 +12,7 @@ type CreatePushMirrorOption struct {
 | 
				
			||||||
	RemotePassword string `json:"remote_password"`
 | 
						RemotePassword string `json:"remote_password"`
 | 
				
			||||||
	Interval       string `json:"interval"`
 | 
						Interval       string `json:"interval"`
 | 
				
			||||||
	SyncOnCommit   bool   `json:"sync_on_commit"`
 | 
						SyncOnCommit   bool   `json:"sync_on_commit"`
 | 
				
			||||||
 | 
						UseSSH         bool   `json:"use_ssh"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// PushMirror represents information of a push mirror
 | 
					// PushMirror represents information of a push mirror
 | 
				
			||||||
| 
						 | 
					@ -27,4 +28,5 @@ type PushMirror struct {
 | 
				
			||||||
	LastError      string     `json:"last_error"`
 | 
						LastError      string     `json:"last_error"`
 | 
				
			||||||
	Interval       string     `json:"interval"`
 | 
						Interval       string     `json:"interval"`
 | 
				
			||||||
	SyncOnCommit   bool       `json:"sync_on_commit"`
 | 
						SyncOnCommit   bool       `json:"sync_on_commit"`
 | 
				
			||||||
 | 
						PublicKey      string     `json:"public_key"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,14 @@
 | 
				
			||||||
// Copyright 2017 The Gitea Authors. All rights reserved.
 | 
					// Copyright 2017 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
				
			||||||
// SPDX-License-Identifier: MIT
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
package util
 | 
					package util
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"bytes"
 | 
						"bytes"
 | 
				
			||||||
 | 
						"crypto/ed25519"
 | 
				
			||||||
	"crypto/rand"
 | 
						"crypto/rand"
 | 
				
			||||||
 | 
						"encoding/pem"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"math/big"
 | 
						"math/big"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
| 
						 | 
					@ -13,6 +16,7 @@ import (
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/modules/optional"
 | 
						"code.gitea.io/gitea/modules/optional"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"golang.org/x/crypto/ssh"
 | 
				
			||||||
	"golang.org/x/text/cases"
 | 
						"golang.org/x/text/cases"
 | 
				
			||||||
	"golang.org/x/text/language"
 | 
						"golang.org/x/text/language"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
| 
						 | 
					@ -229,3 +233,23 @@ func ReserveLineBreakForTextarea(input string) string {
 | 
				
			||||||
	// Other than this, we should respect the original content, even leading or trailing spaces.
 | 
						// Other than this, we should respect the original content, even leading or trailing spaces.
 | 
				
			||||||
	return strings.ReplaceAll(input, "\r\n", "\n")
 | 
						return strings.ReplaceAll(input, "\r\n", "\n")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// GenerateSSHKeypair generates a ed25519 SSH-compatible keypair.
 | 
				
			||||||
 | 
					func GenerateSSHKeypair() (publicKey, privateKey []byte, err error) {
 | 
				
			||||||
 | 
						public, private, err := ed25519.GenerateKey(nil)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, fmt.Errorf("ed25519.GenerateKey: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						privPEM, err := ssh.MarshalPrivateKey(private, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, fmt.Errorf("ssh.MarshalPrivateKey: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						sshPublicKey, err := ssh.NewPublicKey(public)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, nil, fmt.Errorf("ssh.NewPublicKey: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,19 @@
 | 
				
			||||||
// Copyright 2018 The Gitea Authors. All rights reserved.
 | 
					// Copyright 2018 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
				
			||||||
// SPDX-License-Identifier: MIT
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
package util
 | 
					package util_test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"bytes"
 | 
				
			||||||
 | 
						"crypto/rand"
 | 
				
			||||||
	"regexp"
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"testing"
 | 
						"testing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/modules/optional"
 | 
						"code.gitea.io/gitea/modules/optional"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/test"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/stretchr/testify/assert"
 | 
						"github.com/stretchr/testify/assert"
 | 
				
			||||||
	"github.com/stretchr/testify/require"
 | 
						"github.com/stretchr/testify/require"
 | 
				
			||||||
| 
						 | 
					@ -43,7 +48,7 @@ func TestURLJoin(t *testing.T) {
 | 
				
			||||||
		newTest("/a/b/c#hash",
 | 
							newTest("/a/b/c#hash",
 | 
				
			||||||
			"/a", "b/c#hash"),
 | 
								"/a", "b/c#hash"),
 | 
				
			||||||
	} {
 | 
						} {
 | 
				
			||||||
		assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...))
 | 
							assert.Equal(t, test.Expected, util.URLJoin(test.Base, test.Elements...))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -59,7 +64,7 @@ func TestIsEmptyString(t *testing.T) {
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for _, v := range cases {
 | 
						for _, v := range cases {
 | 
				
			||||||
		assert.Equal(t, v.expected, IsEmptyString(v.s))
 | 
							assert.Equal(t, v.expected, util.IsEmptyString(v.s))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -100,42 +105,42 @@ func Test_NormalizeEOL(t *testing.T) {
 | 
				
			||||||
	unix := buildEOLData(data1, "\n")
 | 
						unix := buildEOLData(data1, "\n")
 | 
				
			||||||
	mac := buildEOLData(data1, "\r")
 | 
						mac := buildEOLData(data1, "\r")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(dos))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(dos))
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(mac))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(mac))
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(unix))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(unix))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	dos = buildEOLData(data2, "\r\n")
 | 
						dos = buildEOLData(data2, "\r\n")
 | 
				
			||||||
	unix = buildEOLData(data2, "\n")
 | 
						unix = buildEOLData(data2, "\n")
 | 
				
			||||||
	mac = buildEOLData(data2, "\r")
 | 
						mac = buildEOLData(data2, "\r")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(dos))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(dos))
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(mac))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(mac))
 | 
				
			||||||
	assert.Equal(t, unix, NormalizeEOL(unix))
 | 
						assert.Equal(t, unix, util.NormalizeEOL(unix))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, []byte("one liner"), NormalizeEOL([]byte("one liner")))
 | 
						assert.Equal(t, []byte("one liner"), util.NormalizeEOL([]byte("one liner")))
 | 
				
			||||||
	assert.Equal(t, []byte("\n"), NormalizeEOL([]byte("\n")))
 | 
						assert.Equal(t, []byte("\n"), util.NormalizeEOL([]byte("\n")))
 | 
				
			||||||
	assert.Equal(t, []byte("\ntwo liner"), NormalizeEOL([]byte("\ntwo liner")))
 | 
						assert.Equal(t, []byte("\ntwo liner"), util.NormalizeEOL([]byte("\ntwo liner")))
 | 
				
			||||||
	assert.Equal(t, []byte("two liner\n"), NormalizeEOL([]byte("two liner\n")))
 | 
						assert.Equal(t, []byte("two liner\n"), util.NormalizeEOL([]byte("two liner\n")))
 | 
				
			||||||
	assert.Equal(t, []byte{}, NormalizeEOL([]byte{}))
 | 
						assert.Equal(t, []byte{}, util.NormalizeEOL([]byte{}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, []byte("mix\nand\nmatch\n."), NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
 | 
						assert.Equal(t, []byte("mix\nand\nmatch\n."), util.NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func Test_RandomInt(t *testing.T) {
 | 
					func Test_RandomInt(t *testing.T) {
 | 
				
			||||||
	randInt, err := CryptoRandomInt(255)
 | 
						randInt, err := util.CryptoRandomInt(255)
 | 
				
			||||||
	assert.GreaterOrEqual(t, randInt, int64(0))
 | 
						assert.GreaterOrEqual(t, randInt, int64(0))
 | 
				
			||||||
	assert.LessOrEqual(t, randInt, int64(255))
 | 
						assert.LessOrEqual(t, randInt, int64(255))
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func Test_RandomString(t *testing.T) {
 | 
					func Test_RandomString(t *testing.T) {
 | 
				
			||||||
	str1, err := CryptoRandomString(32)
 | 
						str1, err := util.CryptoRandomString(32)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
 | 
						matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	assert.True(t, matches)
 | 
						assert.True(t, matches)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	str2, err := CryptoRandomString(32)
 | 
						str2, err := util.CryptoRandomString(32)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
 | 
						matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
| 
						 | 
					@ -143,13 +148,13 @@ func Test_RandomString(t *testing.T) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.NotEqual(t, str1, str2)
 | 
						assert.NotEqual(t, str1, str2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	str3, err := CryptoRandomString(256)
 | 
						str3, err := util.CryptoRandomString(256)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
 | 
						matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	assert.True(t, matches)
 | 
						assert.True(t, matches)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	str4, err := CryptoRandomString(256)
 | 
						str4, err := util.CryptoRandomString(256)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
	matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
 | 
						matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
| 
						 | 
					@ -159,34 +164,34 @@ func Test_RandomString(t *testing.T) {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func Test_RandomBytes(t *testing.T) {
 | 
					func Test_RandomBytes(t *testing.T) {
 | 
				
			||||||
	bytes1, err := CryptoRandomBytes(32)
 | 
						bytes1, err := util.CryptoRandomBytes(32)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	bytes2, err := CryptoRandomBytes(32)
 | 
						bytes2, err := util.CryptoRandomBytes(32)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.NotEqual(t, bytes1, bytes2)
 | 
						assert.NotEqual(t, bytes1, bytes2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	bytes3, err := CryptoRandomBytes(256)
 | 
						bytes3, err := util.CryptoRandomBytes(256)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	bytes4, err := CryptoRandomBytes(256)
 | 
						bytes4, err := util.CryptoRandomBytes(256)
 | 
				
			||||||
	require.NoError(t, err)
 | 
						require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.NotEqual(t, bytes3, bytes4)
 | 
						assert.NotEqual(t, bytes3, bytes4)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestOptionalBoolParse(t *testing.T) {
 | 
					func TestOptionalBoolParse(t *testing.T) {
 | 
				
			||||||
	assert.Equal(t, optional.None[bool](), OptionalBoolParse(""))
 | 
						assert.Equal(t, optional.None[bool](), util.OptionalBoolParse(""))
 | 
				
			||||||
	assert.Equal(t, optional.None[bool](), OptionalBoolParse("x"))
 | 
						assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("x"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, optional.Some(false), OptionalBoolParse("0"))
 | 
						assert.Equal(t, optional.Some(false), util.OptionalBoolParse("0"))
 | 
				
			||||||
	assert.Equal(t, optional.Some(false), OptionalBoolParse("f"))
 | 
						assert.Equal(t, optional.Some(false), util.OptionalBoolParse("f"))
 | 
				
			||||||
	assert.Equal(t, optional.Some(false), OptionalBoolParse("False"))
 | 
						assert.Equal(t, optional.Some(false), util.OptionalBoolParse("False"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	assert.Equal(t, optional.Some(true), OptionalBoolParse("1"))
 | 
						assert.Equal(t, optional.Some(true), util.OptionalBoolParse("1"))
 | 
				
			||||||
	assert.Equal(t, optional.Some(true), OptionalBoolParse("t"))
 | 
						assert.Equal(t, optional.Some(true), util.OptionalBoolParse("t"))
 | 
				
			||||||
	assert.Equal(t, optional.Some(true), OptionalBoolParse("True"))
 | 
						assert.Equal(t, optional.Some(true), util.OptionalBoolParse("True"))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Test case for any function which accepts and returns a single string.
 | 
					// Test case for any function which accepts and returns a single string.
 | 
				
			||||||
| 
						 | 
					@ -209,7 +214,7 @@ var upperTests = []StringTest{
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestToUpperASCII(t *testing.T) {
 | 
					func TestToUpperASCII(t *testing.T) {
 | 
				
			||||||
	for _, tc := range upperTests {
 | 
						for _, tc := range upperTests {
 | 
				
			||||||
		assert.Equal(t, ToUpperASCII(tc.in), tc.out)
 | 
							assert.Equal(t, util.ToUpperASCII(tc.in), tc.out)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -217,27 +222,56 @@ func BenchmarkToUpper(b *testing.B) {
 | 
				
			||||||
	for _, tc := range upperTests {
 | 
						for _, tc := range upperTests {
 | 
				
			||||||
		b.Run(tc.in, func(b *testing.B) {
 | 
							b.Run(tc.in, func(b *testing.B) {
 | 
				
			||||||
			for i := 0; i < b.N; i++ {
 | 
								for i := 0; i < b.N; i++ {
 | 
				
			||||||
				ToUpperASCII(tc.in)
 | 
									util.ToUpperASCII(tc.in)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestToTitleCase(t *testing.T) {
 | 
					func TestToTitleCase(t *testing.T) {
 | 
				
			||||||
	assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`foo bar baz`))
 | 
						assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`foo bar baz`))
 | 
				
			||||||
	assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
 | 
						assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`FOO BAR BAZ`))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestToPointer(t *testing.T) {
 | 
					func TestToPointer(t *testing.T) {
 | 
				
			||||||
	assert.Equal(t, "abc", *ToPointer("abc"))
 | 
						assert.Equal(t, "abc", *util.ToPointer("abc"))
 | 
				
			||||||
	assert.Equal(t, 123, *ToPointer(123))
 | 
						assert.Equal(t, 123, *util.ToPointer(123))
 | 
				
			||||||
	abc := "abc"
 | 
						abc := "abc"
 | 
				
			||||||
	assert.NotSame(t, &abc, ToPointer(abc))
 | 
						assert.NotSame(t, &abc, util.ToPointer(abc))
 | 
				
			||||||
	val123 := 123
 | 
						val123 := 123
 | 
				
			||||||
	assert.NotSame(t, &val123, ToPointer(val123))
 | 
						assert.NotSame(t, &val123, util.ToPointer(val123))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestReserveLineBreakForTextarea(t *testing.T) {
 | 
					func TestReserveLineBreakForTextarea(t *testing.T) {
 | 
				
			||||||
	assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata"))
 | 
						assert.Equal(t, "test\ndata", util.ReserveLineBreakForTextarea("test\r\ndata"))
 | 
				
			||||||
	assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n"))
 | 
						assert.Equal(t, "test\ndata\n", util.ReserveLineBreakForTextarea("test\r\ndata\r\n"))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						testPublicKey  = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4\n"
 | 
				
			||||||
 | 
						testPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
 | 
				
			||||||
 | 
					b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
 | 
				
			||||||
 | 
					c2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TWMJulDV8d3IZkElUxuAAA
 | 
				
			||||||
 | 
					AIggISIjICEiIwAAAAtzc2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TW
 | 
				
			||||||
 | 
					MJulDV8d3IZkElUxuAAAAEAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0e
 | 
				
			||||||
 | 
					HwOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4AAAAAAECAwQF
 | 
				
			||||||
 | 
					-----END OPENSSH PRIVATE KEY-----` + "\n"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestGeneratingEd25519Keypair(t *testing.T) {
 | 
				
			||||||
 | 
						defer test.MockProtect(&rand.Reader)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Only 32 bytes needs to be provided to generate a ed25519 keypair.
 | 
				
			||||||
 | 
						// And another 32 bytes are required, which is included as random value
 | 
				
			||||||
 | 
						// in the OpenSSH format.
 | 
				
			||||||
 | 
						b := make([]byte, 64)
 | 
				
			||||||
 | 
						for i := 0; i < 64; i++ {
 | 
				
			||||||
 | 
							b[i] = byte(i)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						rand.Reader = bytes.NewReader(b)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						publicKey, privateKey, err := util.GenerateSSHKeypair()
 | 
				
			||||||
 | 
						require.NoError(t, err)
 | 
				
			||||||
 | 
						assert.EqualValues(t, testPublicKey, string(publicKey))
 | 
				
			||||||
 | 
						assert.EqualValues(t, testPrivateKey, string(privateKey))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1102,6 +1102,10 @@ mirror_prune = Prune
 | 
				
			||||||
mirror_prune_desc = Remove obsolete remote-tracking references
 | 
					mirror_prune_desc = Remove obsolete remote-tracking references
 | 
				
			||||||
mirror_interval = Mirror interval (valid time units are "h", "m", "s"). 0 to disable periodic sync. (Minimum interval: %s)
 | 
					mirror_interval = Mirror interval (valid time units are "h", "m", "s"). 0 to disable periodic sync. (Minimum interval: %s)
 | 
				
			||||||
mirror_interval_invalid = The mirror interval is not valid.
 | 
					mirror_interval_invalid = The mirror interval is not valid.
 | 
				
			||||||
 | 
					mirror_public_key = Public SSH key
 | 
				
			||||||
 | 
					mirror_use_ssh.text = Use SSH authentication
 | 
				
			||||||
 | 
					mirror_use_ssh.helper = Forgejo will mirror the repository via Git over SSH and create a keypair for you when you select this option. You must ensure that the generated public key is authorized to push to the destination repository. You cannot use password-based authorization when selecting this.
 | 
				
			||||||
 | 
					mirror_denied_combination = Cannot use public key and password based authentication in combination.
 | 
				
			||||||
mirror_sync = synced
 | 
					mirror_sync = synced
 | 
				
			||||||
mirror_sync_on_commit = Sync when commits are pushed
 | 
					mirror_sync_on_commit = Sync when commits are pushed
 | 
				
			||||||
mirror_address = Clone from URL
 | 
					mirror_address = Clone from URL
 | 
				
			||||||
| 
						 | 
					@ -2177,12 +2181,14 @@ settings.mirror_settings.push_mirror.none = No push mirrors configured
 | 
				
			||||||
settings.mirror_settings.push_mirror.remote_url = Git remote repository URL
 | 
					settings.mirror_settings.push_mirror.remote_url = Git remote repository URL
 | 
				
			||||||
settings.mirror_settings.push_mirror.add = Add push mirror
 | 
					settings.mirror_settings.push_mirror.add = Add push mirror
 | 
				
			||||||
settings.mirror_settings.push_mirror.edit_sync_time = Edit mirror sync interval
 | 
					settings.mirror_settings.push_mirror.edit_sync_time = Edit mirror sync interval
 | 
				
			||||||
 | 
					settings.mirror_settings.push_mirror.none = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
settings.units.units = Repository units
 | 
					settings.units.units = Repository units
 | 
				
			||||||
settings.units.overview = Overview
 | 
					settings.units.overview = Overview
 | 
				
			||||||
settings.units.add_more = Add more...
 | 
					settings.units.add_more = Add more...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
settings.sync_mirror = Synchronize now
 | 
					settings.sync_mirror = Synchronize now
 | 
				
			||||||
 | 
					settings.mirror_settings.push_mirror.copy_public_key = Copy public key
 | 
				
			||||||
settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment.
 | 
					settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment.
 | 
				
			||||||
settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes.
 | 
					settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes.
 | 
				
			||||||
settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment.
 | 
					settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										1
									
								
								release-notes/4819.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								release-notes/4819.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					Allow push mirrors to use a SSH key as the authentication method for the mirroring action instead of using user:password authentication. The SSH keypair is created by Forgejo and the destination repository must be configured with the public key to allow for push over SSH.
 | 
				
			||||||
| 
						 | 
					@ -350,6 +350,11 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") {
 | 
				
			||||||
 | 
							ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
 | 
						address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
 | 
				
			||||||
	if err == nil {
 | 
						if err == nil {
 | 
				
			||||||
		err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
 | 
							err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
 | 
				
			||||||
| 
						 | 
					@ -365,7 +370,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress)
 | 
						remoteAddress, err := util.SanitizeURL(address)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		ctx.ServerError("SanitizeURL", err)
 | 
							ctx.ServerError("SanitizeURL", err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
| 
						 | 
					@ -380,11 +385,29 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
 | 
				
			||||||
		RemoteAddress: remoteAddress,
 | 
							RemoteAddress: remoteAddress,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var plainPrivateKey []byte
 | 
				
			||||||
 | 
						if mirrorOption.UseSSH {
 | 
				
			||||||
 | 
							publicKey, privateKey, err := util.GenerateSSHKeypair()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								ctx.ServerError("GenerateSSHKeypair", err)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							plainPrivateKey = privateKey
 | 
				
			||||||
 | 
							pushMirror.PublicKey = string(publicKey)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err = db.Insert(ctx, pushMirror); err != nil {
 | 
						if err = db.Insert(ctx, pushMirror); err != nil {
 | 
				
			||||||
		ctx.ServerError("InsertPushMirror", err)
 | 
							ctx.ServerError("InsertPushMirror", err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if mirrorOption.UseSSH {
 | 
				
			||||||
 | 
							if err = pushMirror.SetPrivatekey(ctx, plainPrivateKey); err != nil {
 | 
				
			||||||
 | 
								ctx.ServerError("SetPrivatekey", err)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// if the registration of the push mirrorOption fails remove it from the database
 | 
						// if the registration of the push mirrorOption fails remove it from the database
 | 
				
			||||||
	if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil {
 | 
						if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil {
 | 
				
			||||||
		if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil {
 | 
							if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -478,8 +478,7 @@ func SettingsPost(ctx *context.Context) {
 | 
				
			||||||
			ctx.ServerError("UpdateAddress", err)
 | 
								ctx.ServerError("UpdateAddress", err)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							remoteAddress, err := util.SanitizeURL(address)
 | 
				
			||||||
		remoteAddress, err := util.SanitizeURL(form.MirrorAddress)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			ctx.ServerError("SanitizeURL", err)
 | 
								ctx.ServerError("SanitizeURL", err)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
| 
						 | 
					@ -638,6 +637,12 @@ func SettingsPost(ctx *context.Context) {
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") {
 | 
				
			||||||
 | 
								ctx.Data["Err_PushMirrorUseSSH"] = true
 | 
				
			||||||
 | 
								ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword)
 | 
							address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword)
 | 
				
			||||||
		if err == nil {
 | 
							if err == nil {
 | 
				
			||||||
			err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
 | 
								err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
 | 
				
			||||||
| 
						 | 
					@ -654,7 +659,7 @@ func SettingsPost(ctx *context.Context) {
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress)
 | 
							remoteAddress, err := util.SanitizeURL(address)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			ctx.ServerError("SanitizeURL", err)
 | 
								ctx.ServerError("SanitizeURL", err)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
| 
						 | 
					@ -668,11 +673,30 @@ func SettingsPost(ctx *context.Context) {
 | 
				
			||||||
			Interval:      interval,
 | 
								Interval:      interval,
 | 
				
			||||||
			RemoteAddress: remoteAddress,
 | 
								RemoteAddress: remoteAddress,
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							var plainPrivateKey []byte
 | 
				
			||||||
 | 
							if form.PushMirrorUseSSH {
 | 
				
			||||||
 | 
								publicKey, privateKey, err := util.GenerateSSHKeypair()
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									ctx.ServerError("GenerateSSHKeypair", err)
 | 
				
			||||||
 | 
									return
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								plainPrivateKey = privateKey
 | 
				
			||||||
 | 
								m.PublicKey = string(publicKey)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if err := db.Insert(ctx, m); err != nil {
 | 
							if err := db.Insert(ctx, m); err != nil {
 | 
				
			||||||
			ctx.ServerError("InsertPushMirror", err)
 | 
								ctx.ServerError("InsertPushMirror", err)
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if form.PushMirrorUseSSH {
 | 
				
			||||||
 | 
								if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil {
 | 
				
			||||||
 | 
									ctx.ServerError("SetPrivatekey", err)
 | 
				
			||||||
 | 
									return
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil {
 | 
							if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil {
 | 
				
			||||||
			if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
 | 
								if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
 | 
				
			||||||
				log.Error("DeletePushMirrors %v", err)
 | 
									log.Error("DeletePushMirrors %v", err)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -22,5 +22,6 @@ func ToPushMirror(ctx context.Context, pm *repo_model.PushMirror) (*api.PushMirr
 | 
				
			||||||
		LastError:      pm.LastError,
 | 
							LastError:      pm.LastError,
 | 
				
			||||||
		Interval:       pm.Interval.String(),
 | 
							Interval:       pm.Interval.String(),
 | 
				
			||||||
		SyncOnCommit:   pm.SyncOnCommit,
 | 
							SyncOnCommit:   pm.SyncOnCommit,
 | 
				
			||||||
 | 
							PublicKey:      pm.GetPublicKey(),
 | 
				
			||||||
	}, nil
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,8 +6,10 @@
 | 
				
			||||||
package forms
 | 
					package forms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"net/url"
 | 
						"net/url"
 | 
				
			||||||
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/models"
 | 
						"code.gitea.io/gitea/models"
 | 
				
			||||||
| 
						 | 
					@ -88,6 +90,9 @@ func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) bindi
 | 
				
			||||||
	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
						return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// scpRegex matches the SCP-like addresses used by Git to access repositories over SSH.
 | 
				
			||||||
 | 
					var scpRegex = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// ParseRemoteAddr checks if given remote address is valid,
 | 
					// ParseRemoteAddr checks if given remote address is valid,
 | 
				
			||||||
// and returns composed URL with needed username and password.
 | 
					// and returns composed URL with needed username and password.
 | 
				
			||||||
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
 | 
					func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
 | 
				
			||||||
| 
						 | 
					@ -103,7 +108,15 @@ func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, err
 | 
				
			||||||
		if len(authUsername)+len(authPassword) > 0 {
 | 
							if len(authUsername)+len(authPassword) > 0 {
 | 
				
			||||||
			u.User = url.UserPassword(authUsername, authPassword)
 | 
								u.User = url.UserPassword(authUsername, authPassword)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		remoteAddr = u.String()
 | 
							return u.String(), nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Detect SCP-like remote addresses and return host.
 | 
				
			||||||
 | 
						if m := scpRegex.FindStringSubmatch(remoteAddr); m != nil {
 | 
				
			||||||
 | 
							// Match SCP-like syntax and convert it to a URL.
 | 
				
			||||||
 | 
							// Eg, "git@forgejo.org:user/repo" becomes
 | 
				
			||||||
 | 
							// "ssh://git@forgejo.org/user/repo".
 | 
				
			||||||
 | 
							return fmt.Sprintf("ssh://%s@%s/%s", url.User(m[1]), m[2], m[3]), nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return remoteAddr, nil
 | 
						return remoteAddr, nil
 | 
				
			||||||
| 
						 | 
					@ -127,6 +140,7 @@ type RepoSettingForm struct {
 | 
				
			||||||
	PushMirrorPassword     string
 | 
						PushMirrorPassword     string
 | 
				
			||||||
	PushMirrorSyncOnCommit bool
 | 
						PushMirrorSyncOnCommit bool
 | 
				
			||||||
	PushMirrorInterval     string
 | 
						PushMirrorInterval     string
 | 
				
			||||||
 | 
						PushMirrorUseSSH       bool
 | 
				
			||||||
	Private                bool
 | 
						Private                bool
 | 
				
			||||||
	Template               bool
 | 
						Template               bool
 | 
				
			||||||
	EnablePrune            bool
 | 
						EnablePrune            bool
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -71,7 +71,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error {
 | 
				
			||||||
		return &models.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true}
 | 
							return &models.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 &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true}
 | 
							return &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import (
 | 
				
			||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"io"
 | 
						"io"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
	"regexp"
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
| 
						 | 
					@ -169,11 +170,43 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName)
 | 
							log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// OpenSSH isn't very intuitive when you want to specify a specific keypair.
 | 
				
			||||||
 | 
							// Therefore, we need to create a temporary file that stores the private key, so that OpenSSH can use it.
 | 
				
			||||||
 | 
							// We delete the the temporary file afterwards.
 | 
				
			||||||
 | 
							privateKeyPath := ""
 | 
				
			||||||
 | 
							if m.PublicKey != "" {
 | 
				
			||||||
 | 
								f, err := os.CreateTemp(os.TempDir(), m.RemoteName)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									log.Error("os.CreateTemp: %v", err)
 | 
				
			||||||
 | 
									return errors.New("unexpected error")
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								defer func() {
 | 
				
			||||||
 | 
									f.Close()
 | 
				
			||||||
 | 
									if err := os.Remove(f.Name()); err != nil {
 | 
				
			||||||
 | 
										log.Error("os.Remove: %v", err)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								privateKey, err := m.Privatekey()
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									log.Error("Privatekey: %v", err)
 | 
				
			||||||
 | 
									return errors.New("unexpected error")
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if _, err := f.Write(privateKey); err != nil {
 | 
				
			||||||
 | 
									log.Error("f.Write: %v", err)
 | 
				
			||||||
 | 
									return errors.New("unexpected error")
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								privateKeyPath = f.Name()
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		if err := git.Push(ctx, path, git.PushOptions{
 | 
							if err := git.Push(ctx, path, git.PushOptions{
 | 
				
			||||||
			Remote:  m.RemoteName,
 | 
								Remote:         m.RemoteName,
 | 
				
			||||||
			Force:   true,
 | 
								Force:          true,
 | 
				
			||||||
			Mirror:  true,
 | 
								Mirror:         true,
 | 
				
			||||||
			Timeout: timeout,
 | 
								Timeout:        timeout,
 | 
				
			||||||
 | 
								PrivateKeyPath: privateKeyPath,
 | 
				
			||||||
		}); err != nil {
 | 
							}); err != nil {
 | 
				
			||||||
			log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err)
 | 
								log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -136,6 +136,7 @@
 | 
				
			||||||
								<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.mirrored_repository"}}</th>
 | 
													<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.mirrored_repository"}}</th>
 | 
				
			||||||
								<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
 | 
													<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
 | 
				
			||||||
								<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
 | 
													<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
 | 
				
			||||||
 | 
													<th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th>
 | 
				
			||||||
								<th></th>
 | 
													<th></th>
 | 
				
			||||||
							</tr>
 | 
												</tr>
 | 
				
			||||||
						</thead>
 | 
											</thead>
 | 
				
			||||||
| 
						 | 
					@ -233,6 +234,7 @@
 | 
				
			||||||
								<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.pushed_repository"}}</th>
 | 
													<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.pushed_repository"}}</th>
 | 
				
			||||||
								<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
 | 
													<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
 | 
				
			||||||
								<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
 | 
													<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
 | 
				
			||||||
 | 
													<th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th>
 | 
				
			||||||
								<th></th>
 | 
													<th></th>
 | 
				
			||||||
							</tr>
 | 
												</tr>
 | 
				
			||||||
						</thead>
 | 
											</thead>
 | 
				
			||||||
| 
						 | 
					@ -242,7 +244,8 @@
 | 
				
			||||||
								<td class="tw-break-anywhere">{{.RemoteAddress}}</td>
 | 
													<td class="tw-break-anywhere">{{.RemoteAddress}}</td>
 | 
				
			||||||
								<td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td>
 | 
													<td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td>
 | 
				
			||||||
								<td>{{if .LastUpdateUnix}}{{DateTime "full" .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td>
 | 
													<td>{{if .LastUpdateUnix}}{{DateTime "full" .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td>
 | 
				
			||||||
								<td class="right aligned">
 | 
													<td>{{if not (eq (len .GetPublicKey) 0)}}<a data-clipboard-text="{{.GetPublicKey}}">{{ctx.Locale.Tr "repo.settings.mirror_settings.push_mirror.copy_public_key"}}</a>{{else}}{{ctx.Locale.Tr "repo.settings.mirror_settings.push_mirror.none"}}{{end}}</td>
 | 
				
			||||||
 | 
													<td class="right aligned df">
 | 
				
			||||||
									<button
 | 
														<button
 | 
				
			||||||
										class="ui tiny button show-modal"
 | 
															class="ui tiny button show-modal"
 | 
				
			||||||
										data-modal="#push-mirror-edit-modal"
 | 
															data-modal="#push-mirror-edit-modal"
 | 
				
			||||||
| 
						 | 
					@ -274,7 +277,7 @@
 | 
				
			||||||
							{{end}}
 | 
												{{end}}
 | 
				
			||||||
							{{if (not .DisableNewPushMirrors)}}
 | 
												{{if (not .DisableNewPushMirrors)}}
 | 
				
			||||||
								<tr>
 | 
													<tr>
 | 
				
			||||||
									<td colspan="4">
 | 
														<td colspan="5">
 | 
				
			||||||
										<form class="ui form" method="post">
 | 
															<form class="ui form" method="post">
 | 
				
			||||||
											{{template "base/disable_form_autofill"}}
 | 
																{{template "base/disable_form_autofill"}}
 | 
				
			||||||
											{{.CsrfTokenHtml}}
 | 
																{{.CsrfTokenHtml}}
 | 
				
			||||||
| 
						 | 
					@ -297,6 +300,13 @@
 | 
				
			||||||
														<label for="push_mirror_password">{{ctx.Locale.Tr "password"}}</label>
 | 
																			<label for="push_mirror_password">{{ctx.Locale.Tr "password"}}</label>
 | 
				
			||||||
														<input id="push_mirror_password" name="push_mirror_password" type="password" value="{{.push_mirror_password}}" autocomplete="off">
 | 
																			<input id="push_mirror_password" name="push_mirror_password" type="password" value="{{.push_mirror_password}}" autocomplete="off">
 | 
				
			||||||
													</div>
 | 
																		</div>
 | 
				
			||||||
 | 
																		<div class="inline field {{if .Err_PushMirrorUseSSH}}error{{end}}">
 | 
				
			||||||
 | 
																			<div class="ui checkbox df ac">
 | 
				
			||||||
 | 
																				<input id="push_mirror_use_ssh" name="push_mirror_use_ssh" type="checkbox" {{if .push_mirror_use_ssh}}checked{{end}}>
 | 
				
			||||||
 | 
																				<label for="push_mirror_use_ssh" class="inline">{{ctx.Locale.Tr "repo.mirror_use_ssh.text"}}</label>
 | 
				
			||||||
 | 
																				<span class="help tw-block">{{ctx.Locale.Tr "repo.mirror_use_ssh.helper"}}
 | 
				
			||||||
 | 
																			</div>
 | 
				
			||||||
 | 
																		</div>
 | 
				
			||||||
												</div>
 | 
																	</div>
 | 
				
			||||||
											</details>
 | 
																</details>
 | 
				
			||||||
											<div class="field">
 | 
																<div class="field">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										8
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							| 
						 | 
					@ -21529,6 +21529,10 @@
 | 
				
			||||||
        "sync_on_commit": {
 | 
					        "sync_on_commit": {
 | 
				
			||||||
          "type": "boolean",
 | 
					          "type": "boolean",
 | 
				
			||||||
          "x-go-name": "SyncOnCommit"
 | 
					          "x-go-name": "SyncOnCommit"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "use_ssh": {
 | 
				
			||||||
 | 
					          "type": "boolean",
 | 
				
			||||||
 | 
					          "x-go-name": "UseSSH"
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "x-go-package": "code.gitea.io/gitea/modules/structs"
 | 
					      "x-go-package": "code.gitea.io/gitea/modules/structs"
 | 
				
			||||||
| 
						 | 
					@ -25325,6 +25329,10 @@
 | 
				
			||||||
          "format": "date-time",
 | 
					          "format": "date-time",
 | 
				
			||||||
          "x-go-name": "LastUpdateUnix"
 | 
					          "x-go-name": "LastUpdateUnix"
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        "public_key": {
 | 
				
			||||||
 | 
					          "type": "string",
 | 
				
			||||||
 | 
					          "x-go-name": "PublicKey"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
        "remote_address": {
 | 
					        "remote_address": {
 | 
				
			||||||
          "type": "string",
 | 
					          "type": "string",
 | 
				
			||||||
          "x-go-name": "RemoteAddress"
 | 
					          "x-go-name": "RemoteAddress"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,21 +7,30 @@ import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
						"net"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"net/url"
 | 
						"net/url"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"path/filepath"
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
	"testing"
 | 
						"testing"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						asymkey_model "code.gitea.io/gitea/models/asymkey"
 | 
				
			||||||
	auth_model "code.gitea.io/gitea/models/auth"
 | 
						auth_model "code.gitea.io/gitea/models/auth"
 | 
				
			||||||
	"code.gitea.io/gitea/models/db"
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
						repo_model "code.gitea.io/gitea/models/repo"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/unit"
 | 
				
			||||||
	"code.gitea.io/gitea/models/unittest"
 | 
						"code.gitea.io/gitea/models/unittest"
 | 
				
			||||||
	user_model "code.gitea.io/gitea/models/user"
 | 
						user_model "code.gitea.io/gitea/models/user"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/optional"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
	api "code.gitea.io/gitea/modules/structs"
 | 
						api "code.gitea.io/gitea/modules/structs"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/test"
 | 
						"code.gitea.io/gitea/modules/test"
 | 
				
			||||||
	"code.gitea.io/gitea/services/migrations"
 | 
						"code.gitea.io/gitea/services/migrations"
 | 
				
			||||||
	mirror_service "code.gitea.io/gitea/services/mirror"
 | 
						mirror_service "code.gitea.io/gitea/services/mirror"
 | 
				
			||||||
	repo_service "code.gitea.io/gitea/services/repository"
 | 
						repo_service "code.gitea.io/gitea/services/repository"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/tests"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/stretchr/testify/assert"
 | 
						"github.com/stretchr/testify/assert"
 | 
				
			||||||
	"github.com/stretchr/testify/require"
 | 
						"github.com/stretchr/testify/require"
 | 
				
			||||||
| 
						 | 
					@ -130,3 +139,130 @@ func testAPIPushMirror(t *testing.T, u *url.URL) {
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestAPIPushMirrorSSH(t *testing.T) {
 | 
				
			||||||
 | 
						onGiteaRun(t, func(t *testing.T, _ *url.URL) {
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
 | 
				
			||||||
 | 
							require.NoError(t, migrations.Init())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
				
			||||||
 | 
							srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
 | 
				
			||||||
 | 
							assert.False(t, srcRepo.HasWiki())
 | 
				
			||||||
 | 
							session := loginUser(t, user.Name)
 | 
				
			||||||
 | 
							token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
 | 
				
			||||||
 | 
							pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{
 | 
				
			||||||
 | 
								Name:         optional.Some("push-mirror-test"),
 | 
				
			||||||
 | 
								AutoInit:     optional.Some(false),
 | 
				
			||||||
 | 
								EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							defer f()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Mutual exclusive", func(t *testing.T) {
 | 
				
			||||||
 | 
								defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
 | 
				
			||||||
 | 
									RemoteAddress:  sshURL,
 | 
				
			||||||
 | 
									Interval:       "8h",
 | 
				
			||||||
 | 
									UseSSH:         true,
 | 
				
			||||||
 | 
									RemoteUsername: "user",
 | 
				
			||||||
 | 
									RemotePassword: "password",
 | 
				
			||||||
 | 
								}).AddTokenAuth(token)
 | 
				
			||||||
 | 
								resp := MakeRequest(t, req, http.StatusBadRequest)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								var apiError api.APIError
 | 
				
			||||||
 | 
								DecodeJSON(t, resp, &apiError)
 | 
				
			||||||
 | 
								assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message)
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Normal", func(t *testing.T) {
 | 
				
			||||||
 | 
								var pushMirror *repo_model.PushMirror
 | 
				
			||||||
 | 
								t.Run("Adding", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
 | 
				
			||||||
 | 
										RemoteAddress: sshURL,
 | 
				
			||||||
 | 
										Interval:      "8h",
 | 
				
			||||||
 | 
										UseSSH:        true,
 | 
				
			||||||
 | 
									}).AddTokenAuth(token)
 | 
				
			||||||
 | 
									MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
 | 
				
			||||||
 | 
									assert.NotEmpty(t, pushMirror.PrivateKey)
 | 
				
			||||||
 | 
									assert.NotEmpty(t, pushMirror.PublicKey)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								publickey := pushMirror.GetPublicKey()
 | 
				
			||||||
 | 
								t.Run("Publickey", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName())).AddTokenAuth(token)
 | 
				
			||||||
 | 
									resp := MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									var pushMirrors []*api.PushMirror
 | 
				
			||||||
 | 
									DecodeJSON(t, resp, &pushMirrors)
 | 
				
			||||||
 | 
									assert.Len(t, pushMirrors, 1)
 | 
				
			||||||
 | 
									assert.EqualValues(t, publickey, pushMirrors[0].PublicKey)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Add deploy key", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/keys", pushToRepo.FullName()), &api.CreateKeyOption{
 | 
				
			||||||
 | 
										Title:    "push mirror key",
 | 
				
			||||||
 | 
										Key:      publickey,
 | 
				
			||||||
 | 
										ReadOnly: false,
 | 
				
			||||||
 | 
									}).AddTokenAuth(token)
 | 
				
			||||||
 | 
									MakeRequest(t, req, http.StatusCreated)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Synchronize", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors-sync", srcRepo.FullName())).AddTokenAuth(token)
 | 
				
			||||||
 | 
									MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Check mirrored content", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
									sha := "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
 | 
				
			||||||
 | 
									resp := MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									var commitList []*api.Commit
 | 
				
			||||||
 | 
									DecodeJSON(t, resp, &commitList)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Len(t, commitList, 1)
 | 
				
			||||||
 | 
									assert.EqualValues(t, sha, commitList[0].SHA)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Eventually(t, func() bool {
 | 
				
			||||||
 | 
										req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
 | 
				
			||||||
 | 
										resp := MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										var commitList []*api.Commit
 | 
				
			||||||
 | 
										DecodeJSON(t, resp, &commitList)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										return len(commitList) != 0 && commitList[0].SHA == sha
 | 
				
			||||||
 | 
									}, time.Second*30, time.Second)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Check known host keys", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
 | 
				
			||||||
 | 
									require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
 | 
				
			||||||
 | 
									require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Contains(t, string(knownHosts), string(publicKey))
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,5 @@
 | 
				
			||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
 | 
					// Copyright 2021 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
				
			||||||
// SPDX-License-Identifier: MIT
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
package integration
 | 
					package integration
 | 
				
			||||||
| 
						 | 
					@ -6,18 +7,26 @@ package integration
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
						"net"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"net/url"
 | 
						"net/url"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"path/filepath"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"testing"
 | 
						"testing"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						asymkey_model "code.gitea.io/gitea/models/asymkey"
 | 
				
			||||||
	"code.gitea.io/gitea/models/db"
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
						repo_model "code.gitea.io/gitea/models/repo"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/unit"
 | 
				
			||||||
	"code.gitea.io/gitea/models/unittest"
 | 
						"code.gitea.io/gitea/models/unittest"
 | 
				
			||||||
	user_model "code.gitea.io/gitea/models/user"
 | 
						user_model "code.gitea.io/gitea/models/user"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/git"
 | 
						"code.gitea.io/gitea/modules/git"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/gitrepo"
 | 
						"code.gitea.io/gitea/modules/gitrepo"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/optional"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/test"
 | 
				
			||||||
	gitea_context "code.gitea.io/gitea/services/context"
 | 
						gitea_context "code.gitea.io/gitea/services/context"
 | 
				
			||||||
	doctor "code.gitea.io/gitea/services/doctor"
 | 
						doctor "code.gitea.io/gitea/services/doctor"
 | 
				
			||||||
	"code.gitea.io/gitea/services/migrations"
 | 
						"code.gitea.io/gitea/services/migrations"
 | 
				
			||||||
| 
						 | 
					@ -35,8 +44,8 @@ func TestMirrorPush(t *testing.T) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func testMirrorPush(t *testing.T, u *url.URL) {
 | 
					func testMirrorPush(t *testing.T, u *url.URL) {
 | 
				
			||||||
	defer tests.PrepareTestEnv(t)()
 | 
						defer tests.PrepareTestEnv(t)()
 | 
				
			||||||
 | 
						defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	setting.Migrations.AllowLocalNetworks = true
 | 
					 | 
				
			||||||
	require.NoError(t, migrations.Init())
 | 
						require.NoError(t, migrations.Init())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
						user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
				
			||||||
| 
						 | 
					@ -146,3 +155,135 @@ func doRemovePushMirror(ctx APITestContext, address, username, password string,
 | 
				
			||||||
		assert.Contains(t, flashCookie.Value, "success")
 | 
							assert.Contains(t, flashCookie.Value, "success")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestSSHPushMirror(t *testing.T) {
 | 
				
			||||||
 | 
						onGiteaRun(t, func(t *testing.T, _ *url.URL) {
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
 | 
				
			||||||
 | 
							defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
 | 
				
			||||||
 | 
							require.NoError(t, migrations.Init())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
				
			||||||
 | 
							srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
 | 
				
			||||||
 | 
							assert.False(t, srcRepo.HasWiki())
 | 
				
			||||||
 | 
							sess := loginUser(t, user.Name)
 | 
				
			||||||
 | 
							pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{
 | 
				
			||||||
 | 
								Name:         optional.Some("push-mirror-test"),
 | 
				
			||||||
 | 
								AutoInit:     optional.Some(false),
 | 
				
			||||||
 | 
								EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							defer f()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
 | 
				
			||||||
 | 
							t.Run("Mutual exclusive", func(t *testing.T) {
 | 
				
			||||||
 | 
								defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
 | 
				
			||||||
 | 
									"_csrf":                GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
 | 
				
			||||||
 | 
									"action":               "push-mirror-add",
 | 
				
			||||||
 | 
									"push_mirror_address":  sshURL,
 | 
				
			||||||
 | 
									"push_mirror_username": "username",
 | 
				
			||||||
 | 
									"push_mirror_password": "password",
 | 
				
			||||||
 | 
									"push_mirror_use_ssh":  "true",
 | 
				
			||||||
 | 
									"push_mirror_interval": "0",
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								resp := sess.MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
								htmlDoc := NewHTMLParser(t, resp.Body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								errMsg := htmlDoc.Find(".ui.negative.message").Text()
 | 
				
			||||||
 | 
								assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.")
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Normal", func(t *testing.T) {
 | 
				
			||||||
 | 
								var pushMirror *repo_model.PushMirror
 | 
				
			||||||
 | 
								t.Run("Adding", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
 | 
				
			||||||
 | 
										"_csrf":                GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
 | 
				
			||||||
 | 
										"action":               "push-mirror-add",
 | 
				
			||||||
 | 
										"push_mirror_address":  sshURL,
 | 
				
			||||||
 | 
										"push_mirror_use_ssh":  "true",
 | 
				
			||||||
 | 
										"push_mirror_interval": "0",
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
									sess.MakeRequest(t, req, http.StatusSeeOther)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									flashCookie := sess.GetCookie(gitea_context.CookieNameFlash)
 | 
				
			||||||
 | 
									assert.NotNil(t, flashCookie)
 | 
				
			||||||
 | 
									assert.Contains(t, flashCookie.Value, "success")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
 | 
				
			||||||
 | 
									assert.NotEmpty(t, pushMirror.PrivateKey)
 | 
				
			||||||
 | 
									assert.NotEmpty(t, pushMirror.PublicKey)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								publickey := ""
 | 
				
			||||||
 | 
								t.Run("Publickey", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName()))
 | 
				
			||||||
 | 
									resp := sess.MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
									htmlDoc := NewHTMLParser(t, resp.Body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									publickey = htmlDoc.Find(".ui.table td a[data-clipboard-text]").AttrOr("data-clipboard-text", "")
 | 
				
			||||||
 | 
									assert.EqualValues(t, publickey, pushMirror.GetPublicKey())
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Add deploy key", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName()), map[string]string{
 | 
				
			||||||
 | 
										"_csrf":       GetCSRF(t, sess, fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName())),
 | 
				
			||||||
 | 
										"title":       "push mirror key",
 | 
				
			||||||
 | 
										"content":     publickey,
 | 
				
			||||||
 | 
										"is_writable": "true",
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
									sess.MakeRequest(t, req, http.StatusSeeOther)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Synchronize", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
 | 
				
			||||||
 | 
										"_csrf":          GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
 | 
				
			||||||
 | 
										"action":         "push-mirror-sync",
 | 
				
			||||||
 | 
										"push_mirror_id": strconv.FormatInt(pushMirror.ID, 10),
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
									sess.MakeRequest(t, req, http.StatusSeeOther)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Check mirrored content", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
									shortSHA := "1032bbf17f"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									req := NewRequest(t, "GET", fmt.Sprintf("/%s", srcRepo.FullName()))
 | 
				
			||||||
 | 
									resp := sess.MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
									htmlDoc := NewHTMLParser(t, resp.Body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Contains(t, htmlDoc.Find(".shortsha").Text(), shortSHA)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Eventually(t, func() bool {
 | 
				
			||||||
 | 
										req = NewRequest(t, "GET", fmt.Sprintf("/%s", pushToRepo.FullName()))
 | 
				
			||||||
 | 
										resp = sess.MakeRequest(t, req, http.StatusOK)
 | 
				
			||||||
 | 
										htmlDoc = NewHTMLParser(t, resp.Body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										return htmlDoc.Find(".shortsha").Text() == shortSHA
 | 
				
			||||||
 | 
									}, time.Second*30, time.Second)
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								t.Run("Check known host keys", func(t *testing.T) {
 | 
				
			||||||
 | 
									defer tests.PrintCurrentTest(t)()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
 | 
				
			||||||
 | 
									require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
 | 
				
			||||||
 | 
									require.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									assert.Contains(t, string(knownHosts), string(publicKey))
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue