loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[FEAT] Allow pushmirror to use publickey authentication

- Continuation of https://github.com/go-gitea/gitea/pull/18835 (by
@Gusted, so it's fine to change copyright holder to Forgejo).
- Add the option to use SSH for push mirrors, this would allow for the
deploy keys feature to be used and not require tokens to be used which
cannot be limited to a specific repository. The private key is stored
encrypted (via the `keying` module) on the database and NEVER given to
the user, to avoid accidental exposure and misuse.
- CAVEAT: This does require the `ssh` binary to be present, which may
not be available in containerized environments, this could be solved by
adding a SSH client into forgejo itself and use the forgejo binary as
SSH command, but should be done in another PR.
- CAVEAT: Mirroring of LFS content is not supported, this would require
the previous stated problem to be solved due to LFS authentication (an
attempt was made at forgejo/forgejo#2544).
- Integration test added.
- Resolves #4416

authored by

Philip Peterson and committed by
Gusted
03508b33 61e018f8

+648 -66
-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 + }