-5
.deadcode-out
-5
.deadcode-out
+2
models/forgejo_migrations/migrate.go
+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
+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
+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
+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
+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
+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
+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
+4
modules/lfs/endpoint.go
+2
modules/structs/mirror.go
+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
+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
+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
+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
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
+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
+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
+1
services/convert/mirror.go
+15
-1
services/forms/repo_form.go
+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
+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
+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
+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
+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
+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
+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
+
}