loading up the forgejo repo on tangled to test page performance

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>

Gusted 5dbacb70 eb25bc9e

Changed files
+648 -66
models
modules
options
release-notes
routers
api
v1
repo
web
repo
setting
services
convert
forms
migrations
mirror
templates
repo
settings
swagger
tests
-5
.deadcode-out
··· 170 170 StdJSON.NewDecoder 171 171 StdJSON.Indent 172 172 173 - code.gitea.io/gitea/modules/keying 174 - DeriveKey 175 - Key.Encrypt 176 - Key.Decrypt 177 - 178 173 code.gitea.io/gitea/modules/markup 179 174 GetRendererByType 180 175 RenderString
+2
models/forgejo_migrations/migrate.go
··· 78 78 NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable), 79 79 // v20 -> v21 80 80 NewMigration("Creating Quota-related tables", CreateQuotaTables), 81 + // v21 -> v22 82 + NewMigration("Add SSH keypair to `pull_mirror` table", AddSSHKeypairToPushMirror), 81 83 } 82 84 83 85 // GetCurrentDBVersion returns the current Forgejo database version.
+16
models/forgejo_migrations/v21.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import "xorm.io/xorm" 7 + 8 + func AddSSHKeypairToPushMirror(x *xorm.Engine) error { 9 + type PushMirror struct { 10 + ID int64 `xorm:"pk autoincr"` 11 + PublicKey string `xorm:"VARCHAR(100)"` 12 + PrivateKey []byte `xorm:"BLOB"` 13 + } 14 + 15 + return x.Sync(&PushMirror{}) 16 + }
+28
models/repo/pushmirror.go
··· 13 13 "code.gitea.io/gitea/models/db" 14 14 "code.gitea.io/gitea/modules/git" 15 15 giturl "code.gitea.io/gitea/modules/git/url" 16 + "code.gitea.io/gitea/modules/keying" 16 17 "code.gitea.io/gitea/modules/log" 17 18 "code.gitea.io/gitea/modules/setting" 18 19 "code.gitea.io/gitea/modules/timeutil" ··· 31 32 Repo *Repository `xorm:"-"` 32 33 RemoteName string 33 34 RemoteAddress string `xorm:"VARCHAR(2048)"` 35 + 36 + // A keypair formatted in OpenSSH format. 37 + PublicKey string `xorm:"VARCHAR(100)"` 38 + PrivateKey []byte `xorm:"BLOB"` 34 39 35 40 SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"` 36 41 Interval time.Duration ··· 80 85 // GetRemoteName returns the name of the remote. 81 86 func (m *PushMirror) GetRemoteName() string { 82 87 return m.RemoteName 88 + } 89 + 90 + // GetPublicKey returns a sanitized version of the public key. 91 + // This should only be used when displaying the public key to the user, not for actual code. 92 + func (m *PushMirror) GetPublicKey() string { 93 + return strings.TrimSuffix(m.PublicKey, "\n") 94 + } 95 + 96 + // SetPrivatekey encrypts the given private key and store it in the database. 97 + // The ID of the push mirror must be known, so this should be done after the 98 + // push mirror is inserted. 99 + func (m *PushMirror) SetPrivatekey(ctx context.Context, privateKey []byte) error { 100 + key := keying.DeriveKey(keying.ContextPushMirror) 101 + m.PrivateKey = key.Encrypt(privateKey, keying.ColumnAndID("private_key", m.ID)) 102 + 103 + _, err := db.GetEngine(ctx).ID(m.ID).Cols("private_key").Update(m) 104 + return err 105 + } 106 + 107 + // Privatekey retrieves the encrypted private key and decrypts it. 108 + func (m *PushMirror) Privatekey() ([]byte, error) { 109 + key := keying.DeriveKey(keying.ContextPushMirror) 110 + return key.Decrypt(m.PrivateKey, keying.ColumnAndID("private_key", m.ID)) 83 111 } 84 112 85 113 // UpdatePushMirror updates the push-mirror
+27
models/repo/pushmirror_test.go
··· 50 50 return nil 51 51 }) 52 52 } 53 + 54 + func TestPushMirrorPrivatekey(t *testing.T) { 55 + require.NoError(t, unittest.PrepareTestDatabase()) 56 + 57 + m := &repo_model.PushMirror{ 58 + RemoteName: "test-privatekey", 59 + } 60 + require.NoError(t, db.Insert(db.DefaultContext, m)) 61 + 62 + privateKey := []byte{0x00, 0x01, 0x02, 0x04, 0x08, 0x10} 63 + t.Run("Set privatekey", func(t *testing.T) { 64 + require.NoError(t, m.SetPrivatekey(db.DefaultContext, privateKey)) 65 + }) 66 + 67 + t.Run("Normal retrieval", func(t *testing.T) { 68 + actualPrivateKey, err := m.Privatekey() 69 + require.NoError(t, err) 70 + assert.EqualValues(t, privateKey, actualPrivateKey) 71 + }) 72 + 73 + t.Run("Incorrect retrieval", func(t *testing.T) { 74 + m.ID++ 75 + actualPrivateKey, err := m.Privatekey() 76 + require.Error(t, err) 77 + assert.Empty(t, actualPrivateKey) 78 + }) 79 + }
+30 -6
modules/git/repo.go
··· 1 1 // Copyright 2015 The Gogs Authors. All rights reserved. 2 2 // Copyright 2017 The Gitea Authors. All rights reserved. 3 + // Copyright 2024 The Forgejo Authors. All rights reserved. 3 4 // SPDX-License-Identifier: MIT 4 5 5 6 package git ··· 18 19 "time" 19 20 20 21 "code.gitea.io/gitea/modules/proxy" 22 + "code.gitea.io/gitea/modules/setting" 21 23 "code.gitea.io/gitea/modules/util" 22 24 ) 23 25 ··· 190 192 191 193 // PushOptions options when push to remote 192 194 type PushOptions struct { 193 - Remote string 194 - Branch string 195 - Force bool 196 - Mirror bool 197 - Env []string 198 - Timeout time.Duration 195 + Remote string 196 + Branch string 197 + Force bool 198 + Mirror bool 199 + Env []string 200 + Timeout time.Duration 201 + PrivateKeyPath string 199 202 } 200 203 201 204 // Push pushs local commits to given remote branch. 202 205 func Push(ctx context.Context, repoPath string, opts PushOptions) error { 203 206 cmd := NewCommand(ctx, "push") 207 + 208 + if opts.PrivateKeyPath != "" { 209 + // Preserve the behavior that existing environments are used if no 210 + // environments are passed. 211 + if len(opts.Env) == 0 { 212 + opts.Env = os.Environ() 213 + } 214 + 215 + // Use environment because it takes precedence over using -c core.sshcommand 216 + // and it's possible that a system might have an existing GIT_SSH_COMMAND 217 + // environment set. 218 + opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+ 219 + fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+ 220 + " -o IdentitiesOnly=yes"+ 221 + // This will store new SSH host keys and verify connections to existing 222 + // host keys, but it doesn't allow replacement of existing host keys. This 223 + // means TOFU is used for Git over SSH pushes. 224 + " -o StrictHostKeyChecking=accept-new"+ 225 + " -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts")) 226 + } 227 + 204 228 if opts.Force { 205 229 cmd.AddArguments("-f") 206 230 }
+14
modules/keying/keying.go
··· 18 18 import ( 19 19 "crypto/rand" 20 20 "crypto/sha256" 21 + "encoding/binary" 21 22 22 23 "golang.org/x/crypto/chacha20poly1305" 23 24 "golang.org/x/crypto/hkdf" ··· 43 44 // Specifies the context for which a subkey should be derived for. 44 45 // This must be a hardcoded string and must not be arbitrarily constructed. 45 46 type Context string 47 + 48 + // Used for the `push_mirror` table. 49 + var ContextPushMirror Context = "pushmirror" 46 50 47 51 // Derive *the* key for a given context, this is a determistic function. The 48 52 // same key will be provided for the same context. ··· 109 113 110 114 return e.Open(nil, nonce, ciphertext, additionalData) 111 115 } 116 + 117 + // ColumnAndID generates a context that can be used as additional context for 118 + // encrypting and decrypting data. It requires the column name and the row ID 119 + // (this requires to be known beforehand). Be careful when using this, as the 120 + // table name isn't part of this context. This means it's not bound to a 121 + // particular table. The table should be part of the context that the key was 122 + // derived for, in which case it binds through that. 123 + func ColumnAndID(column string, id int64) []byte { 124 + return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id)) 125 + }
+15
modules/keying/keying_test.go
··· 4 4 package keying_test 5 5 6 6 import ( 7 + "math" 7 8 "testing" 8 9 9 10 "code.gitea.io/gitea/modules/keying" ··· 94 95 }) 95 96 }) 96 97 } 98 + 99 + func TestKeyingColumnAndID(t *testing.T) { 100 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", math.MinInt64)) 101 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", -1)) 102 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", 0)) 103 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table", 1)) 104 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", math.MaxInt64)) 105 + 106 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", math.MinInt64)) 107 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", -1)) 108 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", 0)) 109 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1)) 110 + assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64)) 111 + }
+4
modules/lfs/endpoint.go
··· 60 60 case "git": 61 61 u.Scheme = "https" 62 62 return u 63 + case "ssh": 64 + u.Scheme = "https" 65 + u.User = nil 66 + return u 63 67 case "file": 64 68 return u 65 69 default:
+2
modules/structs/mirror.go
··· 12 12 RemotePassword string `json:"remote_password"` 13 13 Interval string `json:"interval"` 14 14 SyncOnCommit bool `json:"sync_on_commit"` 15 + UseSSH bool `json:"use_ssh"` 15 16 } 16 17 17 18 // PushMirror represents information of a push mirror ··· 27 28 LastError string `json:"last_error"` 28 29 Interval string `json:"interval"` 29 30 SyncOnCommit bool `json:"sync_on_commit"` 31 + PublicKey string `json:"public_key"` 30 32 }
+24
modules/util/util.go
··· 1 1 // Copyright 2017 The Gitea Authors. All rights reserved. 2 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 4 5 package util 5 6 6 7 import ( 7 8 "bytes" 9 + "crypto/ed25519" 8 10 "crypto/rand" 11 + "encoding/pem" 9 12 "fmt" 10 13 "math/big" 11 14 "strconv" ··· 13 16 14 17 "code.gitea.io/gitea/modules/optional" 15 18 19 + "golang.org/x/crypto/ssh" 16 20 "golang.org/x/text/cases" 17 21 "golang.org/x/text/language" 18 22 ) ··· 229 233 // Other than this, we should respect the original content, even leading or trailing spaces. 230 234 return strings.ReplaceAll(input, "\r\n", "\n") 231 235 } 236 + 237 + // GenerateSSHKeypair generates a ed25519 SSH-compatible keypair. 238 + func GenerateSSHKeypair() (publicKey, privateKey []byte, err error) { 239 + public, private, err := ed25519.GenerateKey(nil) 240 + if err != nil { 241 + return nil, nil, fmt.Errorf("ed25519.GenerateKey: %w", err) 242 + } 243 + 244 + privPEM, err := ssh.MarshalPrivateKey(private, "") 245 + if err != nil { 246 + return nil, nil, fmt.Errorf("ssh.MarshalPrivateKey: %w", err) 247 + } 248 + 249 + sshPublicKey, err := ssh.NewPublicKey(public) 250 + if err != nil { 251 + return nil, nil, fmt.Errorf("ssh.NewPublicKey: %w", err) 252 + } 253 + 254 + return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil 255 + }
+76 -42
modules/util/util_test.go
··· 1 1 // Copyright 2018 The Gitea Authors. All rights reserved. 2 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 4 - package util 5 + package util_test 5 6 6 7 import ( 8 + "bytes" 9 + "crypto/rand" 7 10 "regexp" 8 11 "strings" 9 12 "testing" 10 13 11 14 "code.gitea.io/gitea/modules/optional" 15 + "code.gitea.io/gitea/modules/test" 16 + "code.gitea.io/gitea/modules/util" 12 17 13 18 "github.com/stretchr/testify/assert" 14 19 "github.com/stretchr/testify/require" ··· 43 48 newTest("/a/b/c#hash", 44 49 "/a", "b/c#hash"), 45 50 } { 46 - assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...)) 51 + assert.Equal(t, test.Expected, util.URLJoin(test.Base, test.Elements...)) 47 52 } 48 53 } 49 54 ··· 59 64 } 60 65 61 66 for _, v := range cases { 62 - assert.Equal(t, v.expected, IsEmptyString(v.s)) 67 + assert.Equal(t, v.expected, util.IsEmptyString(v.s)) 63 68 } 64 69 } 65 70 ··· 100 105 unix := buildEOLData(data1, "\n") 101 106 mac := buildEOLData(data1, "\r") 102 107 103 - assert.Equal(t, unix, NormalizeEOL(dos)) 104 - assert.Equal(t, unix, NormalizeEOL(mac)) 105 - assert.Equal(t, unix, NormalizeEOL(unix)) 108 + assert.Equal(t, unix, util.NormalizeEOL(dos)) 109 + assert.Equal(t, unix, util.NormalizeEOL(mac)) 110 + assert.Equal(t, unix, util.NormalizeEOL(unix)) 106 111 107 112 dos = buildEOLData(data2, "\r\n") 108 113 unix = buildEOLData(data2, "\n") 109 114 mac = buildEOLData(data2, "\r") 110 115 111 - assert.Equal(t, unix, NormalizeEOL(dos)) 112 - assert.Equal(t, unix, NormalizeEOL(mac)) 113 - assert.Equal(t, unix, NormalizeEOL(unix)) 116 + assert.Equal(t, unix, util.NormalizeEOL(dos)) 117 + assert.Equal(t, unix, util.NormalizeEOL(mac)) 118 + assert.Equal(t, unix, util.NormalizeEOL(unix)) 114 119 115 - assert.Equal(t, []byte("one liner"), NormalizeEOL([]byte("one liner"))) 116 - assert.Equal(t, []byte("\n"), NormalizeEOL([]byte("\n"))) 117 - assert.Equal(t, []byte("\ntwo liner"), NormalizeEOL([]byte("\ntwo liner"))) 118 - assert.Equal(t, []byte("two liner\n"), NormalizeEOL([]byte("two liner\n"))) 119 - assert.Equal(t, []byte{}, NormalizeEOL([]byte{})) 120 + assert.Equal(t, []byte("one liner"), util.NormalizeEOL([]byte("one liner"))) 121 + assert.Equal(t, []byte("\n"), util.NormalizeEOL([]byte("\n"))) 122 + assert.Equal(t, []byte("\ntwo liner"), util.NormalizeEOL([]byte("\ntwo liner"))) 123 + assert.Equal(t, []byte("two liner\n"), util.NormalizeEOL([]byte("two liner\n"))) 124 + assert.Equal(t, []byte{}, util.NormalizeEOL([]byte{})) 120 125 121 - assert.Equal(t, []byte("mix\nand\nmatch\n."), NormalizeEOL([]byte("mix\r\nand\rmatch\n."))) 126 + assert.Equal(t, []byte("mix\nand\nmatch\n."), util.NormalizeEOL([]byte("mix\r\nand\rmatch\n."))) 122 127 } 123 128 124 129 func Test_RandomInt(t *testing.T) { 125 - randInt, err := CryptoRandomInt(255) 130 + randInt, err := util.CryptoRandomInt(255) 126 131 assert.GreaterOrEqual(t, randInt, int64(0)) 127 132 assert.LessOrEqual(t, randInt, int64(255)) 128 133 require.NoError(t, err) 129 134 } 130 135 131 136 func Test_RandomString(t *testing.T) { 132 - str1, err := CryptoRandomString(32) 137 + str1, err := util.CryptoRandomString(32) 133 138 require.NoError(t, err) 134 139 matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1) 135 140 require.NoError(t, err) 136 141 assert.True(t, matches) 137 142 138 - str2, err := CryptoRandomString(32) 143 + str2, err := util.CryptoRandomString(32) 139 144 require.NoError(t, err) 140 145 matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1) 141 146 require.NoError(t, err) ··· 143 148 144 149 assert.NotEqual(t, str1, str2) 145 150 146 - str3, err := CryptoRandomString(256) 151 + str3, err := util.CryptoRandomString(256) 147 152 require.NoError(t, err) 148 153 matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3) 149 154 require.NoError(t, err) 150 155 assert.True(t, matches) 151 156 152 - str4, err := CryptoRandomString(256) 157 + str4, err := util.CryptoRandomString(256) 153 158 require.NoError(t, err) 154 159 matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4) 155 160 require.NoError(t, err) ··· 159 164 } 160 165 161 166 func Test_RandomBytes(t *testing.T) { 162 - bytes1, err := CryptoRandomBytes(32) 167 + bytes1, err := util.CryptoRandomBytes(32) 163 168 require.NoError(t, err) 164 169 165 - bytes2, err := CryptoRandomBytes(32) 170 + bytes2, err := util.CryptoRandomBytes(32) 166 171 require.NoError(t, err) 167 172 168 173 assert.NotEqual(t, bytes1, bytes2) 169 174 170 - bytes3, err := CryptoRandomBytes(256) 175 + bytes3, err := util.CryptoRandomBytes(256) 171 176 require.NoError(t, err) 172 177 173 - bytes4, err := CryptoRandomBytes(256) 178 + bytes4, err := util.CryptoRandomBytes(256) 174 179 require.NoError(t, err) 175 180 176 181 assert.NotEqual(t, bytes3, bytes4) 177 182 } 178 183 179 184 func TestOptionalBoolParse(t *testing.T) { 180 - assert.Equal(t, optional.None[bool](), OptionalBoolParse("")) 181 - assert.Equal(t, optional.None[bool](), OptionalBoolParse("x")) 185 + assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("")) 186 + assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("x")) 182 187 183 - assert.Equal(t, optional.Some(false), OptionalBoolParse("0")) 184 - assert.Equal(t, optional.Some(false), OptionalBoolParse("f")) 185 - assert.Equal(t, optional.Some(false), OptionalBoolParse("False")) 188 + assert.Equal(t, optional.Some(false), util.OptionalBoolParse("0")) 189 + assert.Equal(t, optional.Some(false), util.OptionalBoolParse("f")) 190 + assert.Equal(t, optional.Some(false), util.OptionalBoolParse("False")) 186 191 187 - assert.Equal(t, optional.Some(true), OptionalBoolParse("1")) 188 - assert.Equal(t, optional.Some(true), OptionalBoolParse("t")) 189 - assert.Equal(t, optional.Some(true), OptionalBoolParse("True")) 192 + assert.Equal(t, optional.Some(true), util.OptionalBoolParse("1")) 193 + assert.Equal(t, optional.Some(true), util.OptionalBoolParse("t")) 194 + assert.Equal(t, optional.Some(true), util.OptionalBoolParse("True")) 190 195 } 191 196 192 197 // Test case for any function which accepts and returns a single string. ··· 209 214 210 215 func TestToUpperASCII(t *testing.T) { 211 216 for _, tc := range upperTests { 212 - assert.Equal(t, ToUpperASCII(tc.in), tc.out) 217 + assert.Equal(t, util.ToUpperASCII(tc.in), tc.out) 213 218 } 214 219 } 215 220 ··· 217 222 for _, tc := range upperTests { 218 223 b.Run(tc.in, func(b *testing.B) { 219 224 for i := 0; i < b.N; i++ { 220 - ToUpperASCII(tc.in) 225 + util.ToUpperASCII(tc.in) 221 226 } 222 227 }) 223 228 } 224 229 } 225 230 226 231 func TestToTitleCase(t *testing.T) { 227 - assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`foo bar baz`)) 228 - assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`)) 232 + assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`foo bar baz`)) 233 + assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`FOO BAR BAZ`)) 229 234 } 230 235 231 236 func TestToPointer(t *testing.T) { 232 - assert.Equal(t, "abc", *ToPointer("abc")) 233 - assert.Equal(t, 123, *ToPointer(123)) 237 + assert.Equal(t, "abc", *util.ToPointer("abc")) 238 + assert.Equal(t, 123, *util.ToPointer(123)) 234 239 abc := "abc" 235 - assert.NotSame(t, &abc, ToPointer(abc)) 240 + assert.NotSame(t, &abc, util.ToPointer(abc)) 236 241 val123 := 123 237 - assert.NotSame(t, &val123, ToPointer(val123)) 242 + assert.NotSame(t, &val123, util.ToPointer(val123)) 238 243 } 239 244 240 245 func TestReserveLineBreakForTextarea(t *testing.T) { 241 - assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata")) 242 - assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n")) 246 + assert.Equal(t, "test\ndata", util.ReserveLineBreakForTextarea("test\r\ndata")) 247 + assert.Equal(t, "test\ndata\n", util.ReserveLineBreakForTextarea("test\r\ndata\r\n")) 248 + } 249 + 250 + const ( 251 + testPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4\n" 252 + testPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- 253 + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz 254 + c2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TWMJulDV8d3IZkElUxuAAA 255 + AIggISIjICEiIwAAAAtzc2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TW 256 + MJulDV8d3IZkElUxuAAAAEAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0e 257 + HwOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4AAAAAAECAwQF 258 + -----END OPENSSH PRIVATE KEY-----` + "\n" 259 + ) 260 + 261 + func TestGeneratingEd25519Keypair(t *testing.T) { 262 + defer test.MockProtect(&rand.Reader)() 263 + 264 + // Only 32 bytes needs to be provided to generate a ed25519 keypair. 265 + // And another 32 bytes are required, which is included as random value 266 + // in the OpenSSH format. 267 + b := make([]byte, 64) 268 + for i := 0; i < 64; i++ { 269 + b[i] = byte(i) 270 + } 271 + rand.Reader = bytes.NewReader(b) 272 + 273 + publicKey, privateKey, err := util.GenerateSSHKeypair() 274 + require.NoError(t, err) 275 + assert.EqualValues(t, testPublicKey, string(publicKey)) 276 + assert.EqualValues(t, testPrivateKey, string(privateKey)) 243 277 }
+6
options/locale/locale_en-US.ini
··· 1102 1102 mirror_prune_desc = Remove obsolete remote-tracking references 1103 1103 mirror_interval = Mirror interval (valid time units are "h", "m", "s"). 0 to disable periodic sync. (Minimum interval: %s) 1104 1104 mirror_interval_invalid = The mirror interval is not valid. 1105 + mirror_public_key = Public SSH key 1106 + mirror_use_ssh.text = Use SSH authentication 1107 + 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. 1108 + mirror_denied_combination = Cannot use public key and password based authentication in combination. 1105 1109 mirror_sync = synced 1106 1110 mirror_sync_on_commit = Sync when commits are pushed 1107 1111 mirror_address = Clone from URL ··· 2177 2181 settings.mirror_settings.push_mirror.remote_url = Git remote repository URL 2178 2182 settings.mirror_settings.push_mirror.add = Add push mirror 2179 2183 settings.mirror_settings.push_mirror.edit_sync_time = Edit mirror sync interval 2184 + settings.mirror_settings.push_mirror.none = None 2180 2185 2181 2186 settings.units.units = Repository units 2182 2187 settings.units.overview = Overview 2183 2188 settings.units.add_more = Add more... 2184 2189 2185 2190 settings.sync_mirror = Synchronize now 2191 + settings.mirror_settings.push_mirror.copy_public_key = Copy public key 2186 2192 settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment. 2187 2193 settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes. 2188 2194 settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment.
+1
release-notes/4819.md
··· 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.
+24 -1
routers/api/v1/repo/mirror.go
··· 350 350 return 351 351 } 352 352 353 + if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") { 354 + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'") 355 + return 356 + } 357 + 353 358 address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) 354 359 if err == nil { 355 360 err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) ··· 365 370 return 366 371 } 367 372 368 - remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress) 373 + remoteAddress, err := util.SanitizeURL(address) 369 374 if err != nil { 370 375 ctx.ServerError("SanitizeURL", err) 371 376 return ··· 380 385 RemoteAddress: remoteAddress, 381 386 } 382 387 388 + var plainPrivateKey []byte 389 + if mirrorOption.UseSSH { 390 + publicKey, privateKey, err := util.GenerateSSHKeypair() 391 + if err != nil { 392 + ctx.ServerError("GenerateSSHKeypair", err) 393 + return 394 + } 395 + plainPrivateKey = privateKey 396 + pushMirror.PublicKey = string(publicKey) 397 + } 398 + 383 399 if err = db.Insert(ctx, pushMirror); err != nil { 384 400 ctx.ServerError("InsertPushMirror", err) 385 401 return 402 + } 403 + 404 + if mirrorOption.UseSSH { 405 + if err = pushMirror.SetPrivatekey(ctx, plainPrivateKey); err != nil { 406 + ctx.ServerError("SetPrivatekey", err) 407 + return 408 + } 386 409 } 387 410 388 411 // if the registration of the push mirrorOption fails remove it from the database
+27 -3
routers/web/repo/setting/setting.go
··· 478 478 ctx.ServerError("UpdateAddress", err) 479 479 return 480 480 } 481 - 482 - remoteAddress, err := util.SanitizeURL(form.MirrorAddress) 481 + remoteAddress, err := util.SanitizeURL(address) 483 482 if err != nil { 484 483 ctx.ServerError("SanitizeURL", err) 485 484 return ··· 638 637 return 639 638 } 640 639 640 + if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") { 641 + ctx.Data["Err_PushMirrorUseSSH"] = true 642 + ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form) 643 + return 644 + } 645 + 641 646 address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) 642 647 if err == nil { 643 648 err = migrations.IsMigrateURLAllowed(address, ctx.Doer) ··· 654 659 return 655 660 } 656 661 657 - remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) 662 + remoteAddress, err := util.SanitizeURL(address) 658 663 if err != nil { 659 664 ctx.ServerError("SanitizeURL", err) 660 665 return ··· 668 673 Interval: interval, 669 674 RemoteAddress: remoteAddress, 670 675 } 676 + 677 + var plainPrivateKey []byte 678 + if form.PushMirrorUseSSH { 679 + publicKey, privateKey, err := util.GenerateSSHKeypair() 680 + if err != nil { 681 + ctx.ServerError("GenerateSSHKeypair", err) 682 + return 683 + } 684 + plainPrivateKey = privateKey 685 + m.PublicKey = string(publicKey) 686 + } 687 + 671 688 if err := db.Insert(ctx, m); err != nil { 672 689 ctx.ServerError("InsertPushMirror", err) 673 690 return 691 + } 692 + 693 + if form.PushMirrorUseSSH { 694 + if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil { 695 + ctx.ServerError("SetPrivatekey", err) 696 + return 697 + } 674 698 } 675 699 676 700 if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil {
+1
services/convert/mirror.go
··· 22 22 LastError: pm.LastError, 23 23 Interval: pm.Interval.String(), 24 24 SyncOnCommit: pm.SyncOnCommit, 25 + PublicKey: pm.GetPublicKey(), 25 26 }, nil 26 27 }
+15 -1
services/forms/repo_form.go
··· 6 6 package forms 7 7 8 8 import ( 9 + "fmt" 9 10 "net/http" 10 11 "net/url" 12 + "regexp" 11 13 "strings" 12 14 13 15 "code.gitea.io/gitea/models" ··· 88 90 return middleware.Validate(errs, ctx.Data, f, ctx.Locale) 89 91 } 90 92 93 + // scpRegex matches the SCP-like addresses used by Git to access repositories over SSH. 94 + var scpRegex = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) 95 + 91 96 // ParseRemoteAddr checks if given remote address is valid, 92 97 // and returns composed URL with needed username and password. 93 98 func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) { ··· 103 108 if len(authUsername)+len(authPassword) > 0 { 104 109 u.User = url.UserPassword(authUsername, authPassword) 105 110 } 106 - remoteAddr = u.String() 111 + return u.String(), nil 112 + } 113 + 114 + // Detect SCP-like remote addresses and return host. 115 + if m := scpRegex.FindStringSubmatch(remoteAddr); m != nil { 116 + // Match SCP-like syntax and convert it to a URL. 117 + // Eg, "git@forgejo.org:user/repo" becomes 118 + // "ssh://git@forgejo.org/user/repo". 119 + return fmt.Sprintf("ssh://%s@%s/%s", url.User(m[1]), m[2], m[3]), nil 107 120 } 108 121 109 122 return remoteAddr, nil ··· 127 140 PushMirrorPassword string 128 141 PushMirrorSyncOnCommit bool 129 142 PushMirrorInterval string 143 + PushMirrorUseSSH bool 130 144 Private bool 131 145 Template bool 132 146 EnablePrune bool
+1 -1
services/migrations/migrate.go
··· 71 71 return &models.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true} 72 72 } 73 73 74 - if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" { 74 + if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" && u.Scheme != "ssh" { 75 75 return &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true} 76 76 } 77 77
+37 -4
services/mirror/mirror_push.go
··· 8 8 "errors" 9 9 "fmt" 10 10 "io" 11 + "os" 11 12 "regexp" 12 13 "strings" 13 14 "time" ··· 169 170 170 171 log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName) 171 172 173 + // OpenSSH isn't very intuitive when you want to specify a specific keypair. 174 + // Therefore, we need to create a temporary file that stores the private key, so that OpenSSH can use it. 175 + // We delete the the temporary file afterwards. 176 + privateKeyPath := "" 177 + if m.PublicKey != "" { 178 + f, err := os.CreateTemp(os.TempDir(), m.RemoteName) 179 + if err != nil { 180 + log.Error("os.CreateTemp: %v", err) 181 + return errors.New("unexpected error") 182 + } 183 + 184 + defer func() { 185 + f.Close() 186 + if err := os.Remove(f.Name()); err != nil { 187 + log.Error("os.Remove: %v", err) 188 + } 189 + }() 190 + 191 + privateKey, err := m.Privatekey() 192 + if err != nil { 193 + log.Error("Privatekey: %v", err) 194 + return errors.New("unexpected error") 195 + } 196 + 197 + if _, err := f.Write(privateKey); err != nil { 198 + log.Error("f.Write: %v", err) 199 + return errors.New("unexpected error") 200 + } 201 + 202 + privateKeyPath = f.Name() 203 + } 172 204 if err := git.Push(ctx, path, git.PushOptions{ 173 - Remote: m.RemoteName, 174 - Force: true, 175 - Mirror: true, 176 - Timeout: timeout, 205 + Remote: m.RemoteName, 206 + Force: true, 207 + Mirror: true, 208 + Timeout: timeout, 209 + PrivateKeyPath: privateKeyPath, 177 210 }); err != nil { 178 211 log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err) 179 212
+12 -2
templates/repo/settings/options.tmpl
··· 136 136 <th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.mirrored_repository"}}</th> 137 137 <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th> 138 138 <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th> 139 + <th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th> 139 140 <th></th> 140 141 </tr> 141 142 </thead> ··· 233 234 <th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.pushed_repository"}}</th> 234 235 <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th> 235 236 <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th> 237 + <th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th> 236 238 <th></th> 237 239 </tr> 238 240 </thead> ··· 242 244 <td class="tw-break-anywhere">{{.RemoteAddress}}</td> 243 245 <td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td> 244 246 <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> 245 - <td class="right aligned"> 247 + <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> 248 + <td class="right aligned df"> 246 249 <button 247 250 class="ui tiny button show-modal" 248 251 data-modal="#push-mirror-edit-modal" ··· 274 277 {{end}} 275 278 {{if (not .DisableNewPushMirrors)}} 276 279 <tr> 277 - <td colspan="4"> 280 + <td colspan="5"> 278 281 <form class="ui form" method="post"> 279 282 {{template "base/disable_form_autofill"}} 280 283 {{.CsrfTokenHtml}} ··· 296 299 <div class="inline field {{if .Err_PushMirrorAuth}}error{{end}}"> 297 300 <label for="push_mirror_password">{{ctx.Locale.Tr "password"}}</label> 298 301 <input id="push_mirror_password" name="push_mirror_password" type="password" value="{{.push_mirror_password}}" autocomplete="off"> 302 + </div> 303 + <div class="inline field {{if .Err_PushMirrorUseSSH}}error{{end}}"> 304 + <div class="ui checkbox df ac"> 305 + <input id="push_mirror_use_ssh" name="push_mirror_use_ssh" type="checkbox" {{if .push_mirror_use_ssh}}checked{{end}}> 306 + <label for="push_mirror_use_ssh" class="inline">{{ctx.Locale.Tr "repo.mirror_use_ssh.text"}}</label> 307 + <span class="help tw-block">{{ctx.Locale.Tr "repo.mirror_use_ssh.helper"}} 308 + </div> 299 309 </div> 300 310 </div> 301 311 </details>
+8
templates/swagger/v1_json.tmpl
··· 21529 21529 "sync_on_commit": { 21530 21530 "type": "boolean", 21531 21531 "x-go-name": "SyncOnCommit" 21532 + }, 21533 + "use_ssh": { 21534 + "type": "boolean", 21535 + "x-go-name": "UseSSH" 21532 21536 } 21533 21537 }, 21534 21538 "x-go-package": "code.gitea.io/gitea/modules/structs" ··· 25324 25328 "type": "string", 25325 25329 "format": "date-time", 25326 25330 "x-go-name": "LastUpdateUnix" 25331 + }, 25332 + "public_key": { 25333 + "type": "string", 25334 + "x-go-name": "PublicKey" 25327 25335 }, 25328 25336 "remote_address": { 25329 25337 "type": "string",
+136
tests/integration/api_push_mirror_test.go
··· 7 7 "context" 8 8 "errors" 9 9 "fmt" 10 + "net" 10 11 "net/http" 11 12 "net/url" 13 + "os" 14 + "path/filepath" 15 + "strconv" 12 16 "testing" 17 + "time" 13 18 19 + asymkey_model "code.gitea.io/gitea/models/asymkey" 14 20 auth_model "code.gitea.io/gitea/models/auth" 15 21 "code.gitea.io/gitea/models/db" 16 22 repo_model "code.gitea.io/gitea/models/repo" 23 + "code.gitea.io/gitea/models/unit" 17 24 "code.gitea.io/gitea/models/unittest" 18 25 user_model "code.gitea.io/gitea/models/user" 26 + "code.gitea.io/gitea/modules/optional" 19 27 "code.gitea.io/gitea/modules/setting" 20 28 api "code.gitea.io/gitea/modules/structs" 21 29 "code.gitea.io/gitea/modules/test" 22 30 "code.gitea.io/gitea/services/migrations" 23 31 mirror_service "code.gitea.io/gitea/services/mirror" 24 32 repo_service "code.gitea.io/gitea/services/repository" 33 + "code.gitea.io/gitea/tests" 25 34 26 35 "github.com/stretchr/testify/assert" 27 36 "github.com/stretchr/testify/require" ··· 130 139 }) 131 140 } 132 141 } 142 + 143 + func TestAPIPushMirrorSSH(t *testing.T) { 144 + onGiteaRun(t, func(t *testing.T, _ *url.URL) { 145 + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() 146 + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() 147 + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() 148 + require.NoError(t, migrations.Init()) 149 + 150 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 151 + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) 152 + assert.False(t, srcRepo.HasWiki()) 153 + session := loginUser(t, user.Name) 154 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 155 + pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{ 156 + Name: optional.Some("push-mirror-test"), 157 + AutoInit: optional.Some(false), 158 + EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}), 159 + }) 160 + defer f() 161 + 162 + sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName()) 163 + 164 + t.Run("Mutual exclusive", func(t *testing.T) { 165 + defer tests.PrintCurrentTest(t)() 166 + 167 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ 168 + RemoteAddress: sshURL, 169 + Interval: "8h", 170 + UseSSH: true, 171 + RemoteUsername: "user", 172 + RemotePassword: "password", 173 + }).AddTokenAuth(token) 174 + resp := MakeRequest(t, req, http.StatusBadRequest) 175 + 176 + var apiError api.APIError 177 + DecodeJSON(t, resp, &apiError) 178 + assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message) 179 + }) 180 + 181 + t.Run("Normal", func(t *testing.T) { 182 + var pushMirror *repo_model.PushMirror 183 + t.Run("Adding", func(t *testing.T) { 184 + defer tests.PrintCurrentTest(t)() 185 + 186 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ 187 + RemoteAddress: sshURL, 188 + Interval: "8h", 189 + UseSSH: true, 190 + }).AddTokenAuth(token) 191 + MakeRequest(t, req, http.StatusOK) 192 + 193 + pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID}) 194 + assert.NotEmpty(t, pushMirror.PrivateKey) 195 + assert.NotEmpty(t, pushMirror.PublicKey) 196 + }) 197 + 198 + publickey := pushMirror.GetPublicKey() 199 + t.Run("Publickey", func(t *testing.T) { 200 + defer tests.PrintCurrentTest(t)() 201 + 202 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName())).AddTokenAuth(token) 203 + resp := MakeRequest(t, req, http.StatusOK) 204 + 205 + var pushMirrors []*api.PushMirror 206 + DecodeJSON(t, resp, &pushMirrors) 207 + assert.Len(t, pushMirrors, 1) 208 + assert.EqualValues(t, publickey, pushMirrors[0].PublicKey) 209 + }) 210 + 211 + t.Run("Add deploy key", func(t *testing.T) { 212 + defer tests.PrintCurrentTest(t)() 213 + 214 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/keys", pushToRepo.FullName()), &api.CreateKeyOption{ 215 + Title: "push mirror key", 216 + Key: publickey, 217 + ReadOnly: false, 218 + }).AddTokenAuth(token) 219 + MakeRequest(t, req, http.StatusCreated) 220 + 221 + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID}) 222 + }) 223 + 224 + t.Run("Synchronize", func(t *testing.T) { 225 + defer tests.PrintCurrentTest(t)() 226 + 227 + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors-sync", srcRepo.FullName())).AddTokenAuth(token) 228 + MakeRequest(t, req, http.StatusOK) 229 + }) 230 + 231 + t.Run("Check mirrored content", func(t *testing.T) { 232 + defer tests.PrintCurrentTest(t)() 233 + sha := "1032bbf17fbc0d9c95bb5418dabe8f8c99278700" 234 + 235 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token) 236 + resp := MakeRequest(t, req, http.StatusOK) 237 + 238 + var commitList []*api.Commit 239 + DecodeJSON(t, resp, &commitList) 240 + 241 + assert.Len(t, commitList, 1) 242 + assert.EqualValues(t, sha, commitList[0].SHA) 243 + 244 + assert.Eventually(t, func() bool { 245 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token) 246 + resp := MakeRequest(t, req, http.StatusOK) 247 + 248 + var commitList []*api.Commit 249 + DecodeJSON(t, resp, &commitList) 250 + 251 + return len(commitList) != 0 && commitList[0].SHA == sha 252 + }, time.Second*30, time.Second) 253 + }) 254 + 255 + t.Run("Check known host keys", func(t *testing.T) { 256 + defer tests.PrintCurrentTest(t)() 257 + 258 + knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts")) 259 + require.NoError(t, err) 260 + 261 + publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub") 262 + require.NoError(t, err) 263 + 264 + assert.Contains(t, string(knownHosts), string(publicKey)) 265 + }) 266 + }) 267 + }) 268 + }
+142 -1
tests/integration/mirror_push_test.go
··· 1 1 // Copyright 2021 The Gitea Authors. All rights reserved. 2 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 4 5 package integration ··· 6 7 import ( 7 8 "context" 8 9 "fmt" 10 + "net" 9 11 "net/http" 10 12 "net/url" 13 + "os" 14 + "path/filepath" 11 15 "strconv" 12 16 "testing" 17 + "time" 13 18 19 + asymkey_model "code.gitea.io/gitea/models/asymkey" 14 20 "code.gitea.io/gitea/models/db" 15 21 repo_model "code.gitea.io/gitea/models/repo" 22 + "code.gitea.io/gitea/models/unit" 16 23 "code.gitea.io/gitea/models/unittest" 17 24 user_model "code.gitea.io/gitea/models/user" 18 25 "code.gitea.io/gitea/modules/git" 19 26 "code.gitea.io/gitea/modules/gitrepo" 27 + "code.gitea.io/gitea/modules/optional" 20 28 "code.gitea.io/gitea/modules/setting" 29 + "code.gitea.io/gitea/modules/test" 21 30 gitea_context "code.gitea.io/gitea/services/context" 22 31 doctor "code.gitea.io/gitea/services/doctor" 23 32 "code.gitea.io/gitea/services/migrations" ··· 35 44 36 45 func testMirrorPush(t *testing.T, u *url.URL) { 37 46 defer tests.PrepareTestEnv(t)() 47 + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() 38 48 39 - setting.Migrations.AllowLocalNetworks = true 40 49 require.NoError(t, migrations.Init()) 41 50 42 51 user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) ··· 146 155 assert.Contains(t, flashCookie.Value, "success") 147 156 } 148 157 } 158 + 159 + func TestSSHPushMirror(t *testing.T) { 160 + onGiteaRun(t, func(t *testing.T, _ *url.URL) { 161 + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() 162 + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() 163 + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() 164 + require.NoError(t, migrations.Init()) 165 + 166 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 167 + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) 168 + assert.False(t, srcRepo.HasWiki()) 169 + sess := loginUser(t, user.Name) 170 + pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{ 171 + Name: optional.Some("push-mirror-test"), 172 + AutoInit: optional.Some(false), 173 + EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}), 174 + }) 175 + defer f() 176 + 177 + sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName()) 178 + t.Run("Mutual exclusive", func(t *testing.T) { 179 + defer tests.PrintCurrentTest(t)() 180 + 181 + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ 182 + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), 183 + "action": "push-mirror-add", 184 + "push_mirror_address": sshURL, 185 + "push_mirror_username": "username", 186 + "push_mirror_password": "password", 187 + "push_mirror_use_ssh": "true", 188 + "push_mirror_interval": "0", 189 + }) 190 + resp := sess.MakeRequest(t, req, http.StatusOK) 191 + htmlDoc := NewHTMLParser(t, resp.Body) 192 + 193 + errMsg := htmlDoc.Find(".ui.negative.message").Text() 194 + assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.") 195 + }) 196 + 197 + t.Run("Normal", func(t *testing.T) { 198 + var pushMirror *repo_model.PushMirror 199 + t.Run("Adding", func(t *testing.T) { 200 + defer tests.PrintCurrentTest(t)() 201 + 202 + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ 203 + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), 204 + "action": "push-mirror-add", 205 + "push_mirror_address": sshURL, 206 + "push_mirror_use_ssh": "true", 207 + "push_mirror_interval": "0", 208 + }) 209 + sess.MakeRequest(t, req, http.StatusSeeOther) 210 + 211 + flashCookie := sess.GetCookie(gitea_context.CookieNameFlash) 212 + assert.NotNil(t, flashCookie) 213 + assert.Contains(t, flashCookie.Value, "success") 214 + 215 + pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID}) 216 + assert.NotEmpty(t, pushMirror.PrivateKey) 217 + assert.NotEmpty(t, pushMirror.PublicKey) 218 + }) 219 + 220 + publickey := "" 221 + t.Run("Publickey", func(t *testing.T) { 222 + defer tests.PrintCurrentTest(t)() 223 + 224 + req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName())) 225 + resp := sess.MakeRequest(t, req, http.StatusOK) 226 + htmlDoc := NewHTMLParser(t, resp.Body) 227 + 228 + publickey = htmlDoc.Find(".ui.table td a[data-clipboard-text]").AttrOr("data-clipboard-text", "") 229 + assert.EqualValues(t, publickey, pushMirror.GetPublicKey()) 230 + }) 231 + 232 + t.Run("Add deploy key", func(t *testing.T) { 233 + defer tests.PrintCurrentTest(t)() 234 + 235 + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName()), map[string]string{ 236 + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName())), 237 + "title": "push mirror key", 238 + "content": publickey, 239 + "is_writable": "true", 240 + }) 241 + sess.MakeRequest(t, req, http.StatusSeeOther) 242 + 243 + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID}) 244 + }) 245 + 246 + t.Run("Synchronize", func(t *testing.T) { 247 + defer tests.PrintCurrentTest(t)() 248 + 249 + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ 250 + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), 251 + "action": "push-mirror-sync", 252 + "push_mirror_id": strconv.FormatInt(pushMirror.ID, 10), 253 + }) 254 + sess.MakeRequest(t, req, http.StatusSeeOther) 255 + }) 256 + 257 + t.Run("Check mirrored content", func(t *testing.T) { 258 + defer tests.PrintCurrentTest(t)() 259 + shortSHA := "1032bbf17f" 260 + 261 + req := NewRequest(t, "GET", fmt.Sprintf("/%s", srcRepo.FullName())) 262 + resp := sess.MakeRequest(t, req, http.StatusOK) 263 + htmlDoc := NewHTMLParser(t, resp.Body) 264 + 265 + assert.Contains(t, htmlDoc.Find(".shortsha").Text(), shortSHA) 266 + 267 + assert.Eventually(t, func() bool { 268 + req = NewRequest(t, "GET", fmt.Sprintf("/%s", pushToRepo.FullName())) 269 + resp = sess.MakeRequest(t, req, http.StatusOK) 270 + htmlDoc = NewHTMLParser(t, resp.Body) 271 + 272 + return htmlDoc.Find(".shortsha").Text() == shortSHA 273 + }, time.Second*30, time.Second) 274 + }) 275 + 276 + t.Run("Check known host keys", func(t *testing.T) { 277 + defer tests.PrintCurrentTest(t)() 278 + 279 + knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts")) 280 + require.NoError(t, err) 281 + 282 + publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub") 283 + require.NoError(t, err) 284 + 285 + assert.Contains(t, string(knownHosts), string(publicKey)) 286 + }) 287 + }) 288 + }) 289 + }