Merge pull request 'Allow pushmirror to use publickey authentication' (#4819) from ironmagma/forgejo:publickey-auth-push-mirror into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4819
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Gusted 2024-08-24 16:53:56 +00:00
commit 5dbacb70f4
24 changed files with 648 additions and 66 deletions

View file

@ -170,11 +170,6 @@ code.gitea.io/gitea/modules/json
StdJSON.NewDecoder StdJSON.NewDecoder
StdJSON.Indent StdJSON.Indent
code.gitea.io/gitea/modules/keying
DeriveKey
Key.Encrypt
Key.Decrypt
code.gitea.io/gitea/modules/markup code.gitea.io/gitea/modules/markup
GetRendererByType GetRendererByType
RenderString RenderString

View file

@ -78,6 +78,8 @@ var migrations = []*Migration{
NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable), NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
// v20 -> v21 // v20 -> v21
NewMigration("Creating Quota-related tables", CreateQuotaTables), NewMigration("Creating Quota-related tables", CreateQuotaTables),
// v21 -> v22
NewMigration("Add SSH keypair to `pull_mirror` table", AddSSHKeypairToPushMirror),
} }
// GetCurrentDBVersion returns the current Forgejo database version. // GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,16 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import "xorm.io/xorm"
func AddSSHKeypairToPushMirror(x *xorm.Engine) error {
type PushMirror struct {
ID int64 `xorm:"pk autoincr"`
PublicKey string `xorm:"VARCHAR(100)"`
PrivateKey []byte `xorm:"BLOB"`
}
return x.Sync(&PushMirror{})
}

View file

@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
giturl "code.gitea.io/gitea/modules/git/url" giturl "code.gitea.io/gitea/modules/git/url"
"code.gitea.io/gitea/modules/keying"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
@ -32,6 +33,10 @@ type PushMirror struct {
RemoteName string RemoteName string
RemoteAddress string `xorm:"VARCHAR(2048)"` RemoteAddress string `xorm:"VARCHAR(2048)"`
// A keypair formatted in OpenSSH format.
PublicKey string `xorm:"VARCHAR(100)"`
PrivateKey []byte `xorm:"BLOB"`
SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"` SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"`
Interval time.Duration Interval time.Duration
CreatedUnix timeutil.TimeStamp `xorm:"created"` CreatedUnix timeutil.TimeStamp `xorm:"created"`
@ -82,6 +87,29 @@ func (m *PushMirror) GetRemoteName() string {
return m.RemoteName return m.RemoteName
} }
// GetPublicKey returns a sanitized version of the public key.
// This should only be used when displaying the public key to the user, not for actual code.
func (m *PushMirror) GetPublicKey() string {
return strings.TrimSuffix(m.PublicKey, "\n")
}
// SetPrivatekey encrypts the given private key and store it in the database.
// The ID of the push mirror must be known, so this should be done after the
// push mirror is inserted.
func (m *PushMirror) SetPrivatekey(ctx context.Context, privateKey []byte) error {
key := keying.DeriveKey(keying.ContextPushMirror)
m.PrivateKey = key.Encrypt(privateKey, keying.ColumnAndID("private_key", m.ID))
_, err := db.GetEngine(ctx).ID(m.ID).Cols("private_key").Update(m)
return err
}
// Privatekey retrieves the encrypted private key and decrypts it.
func (m *PushMirror) Privatekey() ([]byte, error) {
key := keying.DeriveKey(keying.ContextPushMirror)
return key.Decrypt(m.PrivateKey, keying.ColumnAndID("private_key", m.ID))
}
// UpdatePushMirror updates the push-mirror // UpdatePushMirror updates the push-mirror
func UpdatePushMirror(ctx context.Context, m *PushMirror) error { func UpdatePushMirror(ctx context.Context, m *PushMirror) error {
_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m) _, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)

View file

@ -50,3 +50,30 @@ func TestPushMirrorsIterate(t *testing.T) {
return nil return nil
}) })
} }
func TestPushMirrorPrivatekey(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
m := &repo_model.PushMirror{
RemoteName: "test-privatekey",
}
require.NoError(t, db.Insert(db.DefaultContext, m))
privateKey := []byte{0x00, 0x01, 0x02, 0x04, 0x08, 0x10}
t.Run("Set privatekey", func(t *testing.T) {
require.NoError(t, m.SetPrivatekey(db.DefaultContext, privateKey))
})
t.Run("Normal retrieval", func(t *testing.T) {
actualPrivateKey, err := m.Privatekey()
require.NoError(t, err)
assert.EqualValues(t, privateKey, actualPrivateKey)
})
t.Run("Incorrect retrieval", func(t *testing.T) {
m.ID++
actualPrivateKey, err := m.Privatekey()
require.Error(t, err)
assert.Empty(t, actualPrivateKey)
})
}

View file

@ -1,5 +1,6 @@
// Copyright 2015 The Gogs Authors. All rights reserved. // Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2017 The Gitea Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package git package git
@ -18,6 +19,7 @@ import (
"time" "time"
"code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/proxy"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
@ -196,11 +198,33 @@ type PushOptions struct {
Mirror bool Mirror bool
Env []string Env []string
Timeout time.Duration Timeout time.Duration
PrivateKeyPath string
} }
// Push pushs local commits to given remote branch. // Push pushs local commits to given remote branch.
func Push(ctx context.Context, repoPath string, opts PushOptions) error { func Push(ctx context.Context, repoPath string, opts PushOptions) error {
cmd := NewCommand(ctx, "push") cmd := NewCommand(ctx, "push")
if opts.PrivateKeyPath != "" {
// Preserve the behavior that existing environments are used if no
// environments are passed.
if len(opts.Env) == 0 {
opts.Env = os.Environ()
}
// Use environment because it takes precedence over using -c core.sshcommand
// and it's possible that a system might have an existing GIT_SSH_COMMAND
// environment set.
opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
" -o IdentitiesOnly=yes"+
// This will store new SSH host keys and verify connections to existing
// host keys, but it doesn't allow replacement of existing host keys. This
// means TOFU is used for Git over SSH pushes.
" -o StrictHostKeyChecking=accept-new"+
" -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
}
if opts.Force { if opts.Force {
cmd.AddArguments("-f") cmd.AddArguments("-f")
} }

View file

@ -18,6 +18,7 @@ package keying
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf" "golang.org/x/crypto/hkdf"
@ -44,6 +45,9 @@ func Init(ikm []byte) {
// This must be a hardcoded string and must not be arbitrarily constructed. // This must be a hardcoded string and must not be arbitrarily constructed.
type Context string type Context string
// Used for the `push_mirror` table.
var ContextPushMirror Context = "pushmirror"
// Derive *the* key for a given context, this is a determistic function. The // Derive *the* key for a given context, this is a determistic function. The
// same key will be provided for the same context. // same key will be provided for the same context.
func DeriveKey(context Context) *Key { func DeriveKey(context Context) *Key {
@ -109,3 +113,13 @@ func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
return e.Open(nil, nonce, ciphertext, additionalData) return e.Open(nil, nonce, ciphertext, additionalData)
} }
// ColumnAndID generates a context that can be used as additional context for
// encrypting and decrypting data. It requires the column name and the row ID
// (this requires to be known beforehand). Be careful when using this, as the
// table name isn't part of this context. This means it's not bound to a
// particular table. The table should be part of the context that the key was
// derived for, in which case it binds through that.
func ColumnAndID(column string, id int64) []byte {
return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
}

View file

@ -4,6 +4,7 @@
package keying_test package keying_test
import ( import (
"math"
"testing" "testing"
"code.gitea.io/gitea/modules/keying" "code.gitea.io/gitea/modules/keying"
@ -94,3 +95,17 @@ func TestKeying(t *testing.T) {
}) })
}) })
} }
func TestKeyingColumnAndID(t *testing.T) {
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", math.MinInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", -1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table", 0))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table", 1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table", math.MaxInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", math.MinInt64))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", -1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, keying.ColumnAndID("table2", 0))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
assert.EqualValues(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
}

View file

@ -60,6 +60,10 @@ func endpointFromURL(rawurl string) *url.URL {
case "git": case "git":
u.Scheme = "https" u.Scheme = "https"
return u return u
case "ssh":
u.Scheme = "https"
u.User = nil
return u
case "file": case "file":
return u return u
default: default:

View file

@ -12,6 +12,7 @@ type CreatePushMirrorOption struct {
RemotePassword string `json:"remote_password"` RemotePassword string `json:"remote_password"`
Interval string `json:"interval"` Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"` SyncOnCommit bool `json:"sync_on_commit"`
UseSSH bool `json:"use_ssh"`
} }
// PushMirror represents information of a push mirror // PushMirror represents information of a push mirror
@ -27,4 +28,5 @@ type PushMirror struct {
LastError string `json:"last_error"` LastError string `json:"last_error"`
Interval string `json:"interval"` Interval string `json:"interval"`
SyncOnCommit bool `json:"sync_on_commit"` SyncOnCommit bool `json:"sync_on_commit"`
PublicKey string `json:"public_key"`
} }

View file

@ -1,11 +1,14 @@
// Copyright 2017 The Gitea Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package util package util
import ( import (
"bytes" "bytes"
"crypto/ed25519"
"crypto/rand" "crypto/rand"
"encoding/pem"
"fmt" "fmt"
"math/big" "math/big"
"strconv" "strconv"
@ -13,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"golang.org/x/crypto/ssh"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@ -229,3 +233,23 @@ func ReserveLineBreakForTextarea(input string) string {
// Other than this, we should respect the original content, even leading or trailing spaces. // Other than this, we should respect the original content, even leading or trailing spaces.
return strings.ReplaceAll(input, "\r\n", "\n") return strings.ReplaceAll(input, "\r\n", "\n")
} }
// GenerateSSHKeypair generates a ed25519 SSH-compatible keypair.
func GenerateSSHKeypair() (publicKey, privateKey []byte, err error) {
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, nil, fmt.Errorf("ed25519.GenerateKey: %w", err)
}
privPEM, err := ssh.MarshalPrivateKey(private, "")
if err != nil {
return nil, nil, fmt.Errorf("ssh.MarshalPrivateKey: %w", err)
}
sshPublicKey, err := ssh.NewPublicKey(public)
if err != nil {
return nil, nil, fmt.Errorf("ssh.NewPublicKey: %w", err)
}
return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil
}

View file

@ -1,14 +1,19 @@
// Copyright 2018 The Gitea Authors. All rights reserved. // Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package util package util_test
import ( import (
"bytes"
"crypto/rand"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -43,7 +48,7 @@ func TestURLJoin(t *testing.T) {
newTest("/a/b/c#hash", newTest("/a/b/c#hash",
"/a", "b/c#hash"), "/a", "b/c#hash"),
} { } {
assert.Equal(t, test.Expected, URLJoin(test.Base, test.Elements...)) assert.Equal(t, test.Expected, util.URLJoin(test.Base, test.Elements...))
} }
} }
@ -59,7 +64,7 @@ func TestIsEmptyString(t *testing.T) {
} }
for _, v := range cases { for _, v := range cases {
assert.Equal(t, v.expected, IsEmptyString(v.s)) assert.Equal(t, v.expected, util.IsEmptyString(v.s))
} }
} }
@ -100,42 +105,42 @@ func Test_NormalizeEOL(t *testing.T) {
unix := buildEOLData(data1, "\n") unix := buildEOLData(data1, "\n")
mac := buildEOLData(data1, "\r") mac := buildEOLData(data1, "\r")
assert.Equal(t, unix, NormalizeEOL(dos)) assert.Equal(t, unix, util.NormalizeEOL(dos))
assert.Equal(t, unix, NormalizeEOL(mac)) assert.Equal(t, unix, util.NormalizeEOL(mac))
assert.Equal(t, unix, NormalizeEOL(unix)) assert.Equal(t, unix, util.NormalizeEOL(unix))
dos = buildEOLData(data2, "\r\n") dos = buildEOLData(data2, "\r\n")
unix = buildEOLData(data2, "\n") unix = buildEOLData(data2, "\n")
mac = buildEOLData(data2, "\r") mac = buildEOLData(data2, "\r")
assert.Equal(t, unix, NormalizeEOL(dos)) assert.Equal(t, unix, util.NormalizeEOL(dos))
assert.Equal(t, unix, NormalizeEOL(mac)) assert.Equal(t, unix, util.NormalizeEOL(mac))
assert.Equal(t, unix, NormalizeEOL(unix)) assert.Equal(t, unix, util.NormalizeEOL(unix))
assert.Equal(t, []byte("one liner"), NormalizeEOL([]byte("one liner"))) assert.Equal(t, []byte("one liner"), util.NormalizeEOL([]byte("one liner")))
assert.Equal(t, []byte("\n"), NormalizeEOL([]byte("\n"))) assert.Equal(t, []byte("\n"), util.NormalizeEOL([]byte("\n")))
assert.Equal(t, []byte("\ntwo liner"), NormalizeEOL([]byte("\ntwo liner"))) assert.Equal(t, []byte("\ntwo liner"), util.NormalizeEOL([]byte("\ntwo liner")))
assert.Equal(t, []byte("two liner\n"), NormalizeEOL([]byte("two liner\n"))) assert.Equal(t, []byte("two liner\n"), util.NormalizeEOL([]byte("two liner\n")))
assert.Equal(t, []byte{}, NormalizeEOL([]byte{})) assert.Equal(t, []byte{}, util.NormalizeEOL([]byte{}))
assert.Equal(t, []byte("mix\nand\nmatch\n."), NormalizeEOL([]byte("mix\r\nand\rmatch\n."))) assert.Equal(t, []byte("mix\nand\nmatch\n."), util.NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
} }
func Test_RandomInt(t *testing.T) { func Test_RandomInt(t *testing.T) {
randInt, err := CryptoRandomInt(255) randInt, err := util.CryptoRandomInt(255)
assert.GreaterOrEqual(t, randInt, int64(0)) assert.GreaterOrEqual(t, randInt, int64(0))
assert.LessOrEqual(t, randInt, int64(255)) assert.LessOrEqual(t, randInt, int64(255))
require.NoError(t, err) require.NoError(t, err)
} }
func Test_RandomString(t *testing.T) { func Test_RandomString(t *testing.T) {
str1, err := CryptoRandomString(32) str1, err := util.CryptoRandomString(32)
require.NoError(t, err) require.NoError(t, err)
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1) matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, matches) assert.True(t, matches)
str2, err := CryptoRandomString(32) str2, err := util.CryptoRandomString(32)
require.NoError(t, err) require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1) matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
require.NoError(t, err) require.NoError(t, err)
@ -143,13 +148,13 @@ func Test_RandomString(t *testing.T) {
assert.NotEqual(t, str1, str2) assert.NotEqual(t, str1, str2)
str3, err := CryptoRandomString(256) str3, err := util.CryptoRandomString(256)
require.NoError(t, err) require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3) matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, matches) assert.True(t, matches)
str4, err := CryptoRandomString(256) str4, err := util.CryptoRandomString(256)
require.NoError(t, err) require.NoError(t, err)
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4) matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
require.NoError(t, err) require.NoError(t, err)
@ -159,34 +164,34 @@ func Test_RandomString(t *testing.T) {
} }
func Test_RandomBytes(t *testing.T) { func Test_RandomBytes(t *testing.T) {
bytes1, err := CryptoRandomBytes(32) bytes1, err := util.CryptoRandomBytes(32)
require.NoError(t, err) require.NoError(t, err)
bytes2, err := CryptoRandomBytes(32) bytes2, err := util.CryptoRandomBytes(32)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, bytes1, bytes2) assert.NotEqual(t, bytes1, bytes2)
bytes3, err := CryptoRandomBytes(256) bytes3, err := util.CryptoRandomBytes(256)
require.NoError(t, err) require.NoError(t, err)
bytes4, err := CryptoRandomBytes(256) bytes4, err := util.CryptoRandomBytes(256)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, bytes3, bytes4) assert.NotEqual(t, bytes3, bytes4)
} }
func TestOptionalBoolParse(t *testing.T) { func TestOptionalBoolParse(t *testing.T) {
assert.Equal(t, optional.None[bool](), OptionalBoolParse("")) assert.Equal(t, optional.None[bool](), util.OptionalBoolParse(""))
assert.Equal(t, optional.None[bool](), OptionalBoolParse("x")) assert.Equal(t, optional.None[bool](), util.OptionalBoolParse("x"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("0")) assert.Equal(t, optional.Some(false), util.OptionalBoolParse("0"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("f")) assert.Equal(t, optional.Some(false), util.OptionalBoolParse("f"))
assert.Equal(t, optional.Some(false), OptionalBoolParse("False")) assert.Equal(t, optional.Some(false), util.OptionalBoolParse("False"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("1")) assert.Equal(t, optional.Some(true), util.OptionalBoolParse("1"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("t")) assert.Equal(t, optional.Some(true), util.OptionalBoolParse("t"))
assert.Equal(t, optional.Some(true), OptionalBoolParse("True")) assert.Equal(t, optional.Some(true), util.OptionalBoolParse("True"))
} }
// Test case for any function which accepts and returns a single string. // Test case for any function which accepts and returns a single string.
@ -209,7 +214,7 @@ var upperTests = []StringTest{
func TestToUpperASCII(t *testing.T) { func TestToUpperASCII(t *testing.T) {
for _, tc := range upperTests { for _, tc := range upperTests {
assert.Equal(t, ToUpperASCII(tc.in), tc.out) assert.Equal(t, util.ToUpperASCII(tc.in), tc.out)
} }
} }
@ -217,27 +222,56 @@ func BenchmarkToUpper(b *testing.B) {
for _, tc := range upperTests { for _, tc := range upperTests {
b.Run(tc.in, func(b *testing.B) { b.Run(tc.in, func(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
ToUpperASCII(tc.in) util.ToUpperASCII(tc.in)
} }
}) })
} }
} }
func TestToTitleCase(t *testing.T) { func TestToTitleCase(t *testing.T) {
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`foo bar baz`)) assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`foo bar baz`))
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`)) assert.Equal(t, `Foo Bar Baz`, util.ToTitleCase(`FOO BAR BAZ`))
} }
func TestToPointer(t *testing.T) { func TestToPointer(t *testing.T) {
assert.Equal(t, "abc", *ToPointer("abc")) assert.Equal(t, "abc", *util.ToPointer("abc"))
assert.Equal(t, 123, *ToPointer(123)) assert.Equal(t, 123, *util.ToPointer(123))
abc := "abc" abc := "abc"
assert.NotSame(t, &abc, ToPointer(abc)) assert.NotSame(t, &abc, util.ToPointer(abc))
val123 := 123 val123 := 123
assert.NotSame(t, &val123, ToPointer(val123)) assert.NotSame(t, &val123, util.ToPointer(val123))
} }
func TestReserveLineBreakForTextarea(t *testing.T) { func TestReserveLineBreakForTextarea(t *testing.T) {
assert.Equal(t, "test\ndata", ReserveLineBreakForTextarea("test\r\ndata")) assert.Equal(t, "test\ndata", util.ReserveLineBreakForTextarea("test\r\ndata"))
assert.Equal(t, "test\ndata\n", ReserveLineBreakForTextarea("test\r\ndata\r\n")) assert.Equal(t, "test\ndata\n", util.ReserveLineBreakForTextarea("test\r\ndata\r\n"))
}
const (
testPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4\n"
testPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TWMJulDV8d3IZkElUxuAAA
AIggISIjICEiIwAAAAtzc2gtZWQyNTUxOQAAACADoQe/884Qvh1w3RjnS8CZZ+TW
MJulDV8d3IZkElUxuAAAAEAAAQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0e
HwOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4AAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----` + "\n"
)
func TestGeneratingEd25519Keypair(t *testing.T) {
defer test.MockProtect(&rand.Reader)()
// Only 32 bytes needs to be provided to generate a ed25519 keypair.
// And another 32 bytes are required, which is included as random value
// in the OpenSSH format.
b := make([]byte, 64)
for i := 0; i < 64; i++ {
b[i] = byte(i)
}
rand.Reader = bytes.NewReader(b)
publicKey, privateKey, err := util.GenerateSSHKeypair()
require.NoError(t, err)
assert.EqualValues(t, testPublicKey, string(publicKey))
assert.EqualValues(t, testPrivateKey, string(privateKey))
} }

View file

@ -1102,6 +1102,10 @@ mirror_prune = Prune
mirror_prune_desc = Remove obsolete remote-tracking references mirror_prune_desc = Remove obsolete remote-tracking references
mirror_interval = Mirror interval (valid time units are "h", "m", "s"). 0 to disable periodic sync. (Minimum interval: %s) mirror_interval = Mirror interval (valid time units are "h", "m", "s"). 0 to disable periodic sync. (Minimum interval: %s)
mirror_interval_invalid = The mirror interval is not valid. mirror_interval_invalid = The mirror interval is not valid.
mirror_public_key = Public SSH key
mirror_use_ssh.text = Use SSH authentication
mirror_use_ssh.helper = Forgejo will mirror the repository via Git over SSH and create a keypair for you when you select this option. You must ensure that the generated public key is authorized to push to the destination repository. You cannot use password-based authorization when selecting this.
mirror_denied_combination = Cannot use public key and password based authentication in combination.
mirror_sync = synced mirror_sync = synced
mirror_sync_on_commit = Sync when commits are pushed mirror_sync_on_commit = Sync when commits are pushed
mirror_address = Clone from URL mirror_address = Clone from URL
@ -2177,12 +2181,14 @@ settings.mirror_settings.push_mirror.none = No push mirrors configured
settings.mirror_settings.push_mirror.remote_url = Git remote repository URL settings.mirror_settings.push_mirror.remote_url = Git remote repository URL
settings.mirror_settings.push_mirror.add = Add push mirror settings.mirror_settings.push_mirror.add = Add push mirror
settings.mirror_settings.push_mirror.edit_sync_time = Edit mirror sync interval settings.mirror_settings.push_mirror.edit_sync_time = Edit mirror sync interval
settings.mirror_settings.push_mirror.none = None
settings.units.units = Repository units settings.units.units = Repository units
settings.units.overview = Overview settings.units.overview = Overview
settings.units.add_more = Add more... settings.units.add_more = Add more...
settings.sync_mirror = Synchronize now settings.sync_mirror = Synchronize now
settings.mirror_settings.push_mirror.copy_public_key = Copy public key
settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment. settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment.
settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes. settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes.
settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment. settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment.

1
release-notes/4819.md Normal file
View file

@ -0,0 +1 @@
Allow push mirrors to use a SSH key as the authentication method for the mirroring action instead of using user:password authentication. The SSH keypair is created by Forgejo and the destination repository must be configured with the public key to allow for push over SSH.

View file

@ -350,6 +350,11 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
return return
} }
if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") {
ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'")
return
}
address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
if err == nil { if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser)
@ -365,7 +370,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
return return
} }
remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress) remoteAddress, err := util.SanitizeURL(address)
if err != nil { if err != nil {
ctx.ServerError("SanitizeURL", err) ctx.ServerError("SanitizeURL", err)
return return
@ -380,11 +385,29 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
RemoteAddress: remoteAddress, RemoteAddress: remoteAddress,
} }
var plainPrivateKey []byte
if mirrorOption.UseSSH {
publicKey, privateKey, err := util.GenerateSSHKeypair()
if err != nil {
ctx.ServerError("GenerateSSHKeypair", err)
return
}
plainPrivateKey = privateKey
pushMirror.PublicKey = string(publicKey)
}
if err = db.Insert(ctx, pushMirror); err != nil { if err = db.Insert(ctx, pushMirror); err != nil {
ctx.ServerError("InsertPushMirror", err) ctx.ServerError("InsertPushMirror", err)
return return
} }
if mirrorOption.UseSSH {
if err = pushMirror.SetPrivatekey(ctx, plainPrivateKey); err != nil {
ctx.ServerError("SetPrivatekey", err)
return
}
}
// if the registration of the push mirrorOption fails remove it from the database // if the registration of the push mirrorOption fails remove it from the database
if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil { if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil {
if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil { if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil {

View file

@ -478,8 +478,7 @@ func SettingsPost(ctx *context.Context) {
ctx.ServerError("UpdateAddress", err) ctx.ServerError("UpdateAddress", err)
return return
} }
remoteAddress, err := util.SanitizeURL(address)
remoteAddress, err := util.SanitizeURL(form.MirrorAddress)
if err != nil { if err != nil {
ctx.ServerError("SanitizeURL", err) ctx.ServerError("SanitizeURL", err)
return return
@ -638,6 +637,12 @@ func SettingsPost(ctx *context.Context) {
return return
} }
if form.PushMirrorUseSSH && (form.PushMirrorUsername != "" || form.PushMirrorPassword != "") {
ctx.Data["Err_PushMirrorUseSSH"] = true
ctx.RenderWithErr(ctx.Tr("repo.mirror_denied_combination"), tplSettingsOptions, &form)
return
}
address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) address, err := forms.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword)
if err == nil { if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.Doer) err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
@ -654,7 +659,7 @@ func SettingsPost(ctx *context.Context) {
return return
} }
remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) remoteAddress, err := util.SanitizeURL(address)
if err != nil { if err != nil {
ctx.ServerError("SanitizeURL", err) ctx.ServerError("SanitizeURL", err)
return return
@ -668,11 +673,30 @@ func SettingsPost(ctx *context.Context) {
Interval: interval, Interval: interval,
RemoteAddress: remoteAddress, RemoteAddress: remoteAddress,
} }
var plainPrivateKey []byte
if form.PushMirrorUseSSH {
publicKey, privateKey, err := util.GenerateSSHKeypair()
if err != nil {
ctx.ServerError("GenerateSSHKeypair", err)
return
}
plainPrivateKey = privateKey
m.PublicKey = string(publicKey)
}
if err := db.Insert(ctx, m); err != nil { if err := db.Insert(ctx, m); err != nil {
ctx.ServerError("InsertPushMirror", err) ctx.ServerError("InsertPushMirror", err)
return return
} }
if form.PushMirrorUseSSH {
if err := m.SetPrivatekey(ctx, plainPrivateKey); err != nil {
ctx.ServerError("SetPrivatekey", err)
return
}
}
if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil { if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil {
if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil {
log.Error("DeletePushMirrors %v", err) log.Error("DeletePushMirrors %v", err)

View file

@ -22,5 +22,6 @@ func ToPushMirror(ctx context.Context, pm *repo_model.PushMirror) (*api.PushMirr
LastError: pm.LastError, LastError: pm.LastError,
Interval: pm.Interval.String(), Interval: pm.Interval.String(),
SyncOnCommit: pm.SyncOnCommit, SyncOnCommit: pm.SyncOnCommit,
PublicKey: pm.GetPublicKey(),
}, nil }, nil
} }

View file

@ -6,8 +6,10 @@
package forms package forms
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
@ -88,6 +90,9 @@ func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) bindi
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
} }
// scpRegex matches the SCP-like addresses used by Git to access repositories over SSH.
var scpRegex = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`)
// ParseRemoteAddr checks if given remote address is valid, // ParseRemoteAddr checks if given remote address is valid,
// and returns composed URL with needed username and password. // and returns composed URL with needed username and password.
func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) { func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) {
@ -103,7 +108,15 @@ func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, err
if len(authUsername)+len(authPassword) > 0 { if len(authUsername)+len(authPassword) > 0 {
u.User = url.UserPassword(authUsername, authPassword) u.User = url.UserPassword(authUsername, authPassword)
} }
remoteAddr = u.String() return u.String(), nil
}
// Detect SCP-like remote addresses and return host.
if m := scpRegex.FindStringSubmatch(remoteAddr); m != nil {
// Match SCP-like syntax and convert it to a URL.
// Eg, "git@forgejo.org:user/repo" becomes
// "ssh://git@forgejo.org/user/repo".
return fmt.Sprintf("ssh://%s@%s/%s", url.User(m[1]), m[2], m[3]), nil
} }
return remoteAddr, nil return remoteAddr, nil
@ -127,6 +140,7 @@ type RepoSettingForm struct {
PushMirrorPassword string PushMirrorPassword string
PushMirrorSyncOnCommit bool PushMirrorSyncOnCommit bool
PushMirrorInterval string PushMirrorInterval string
PushMirrorUseSSH bool
Private bool Private bool
Template bool Template bool
EnablePrune bool EnablePrune bool

View file

@ -71,7 +71,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error {
return &models.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true} return &models.ErrInvalidCloneAddr{Host: u.Host, IsURLError: true}
} }
if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" { if u.Opaque != "" || u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "git" && u.Scheme != "ssh" {
return &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true} return &models.ErrInvalidCloneAddr{Host: u.Host, IsProtocolInvalid: true, IsPermissionDenied: true, IsURLError: true}
} }

View file

@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -169,11 +170,43 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName) log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName)
// OpenSSH isn't very intuitive when you want to specify a specific keypair.
// Therefore, we need to create a temporary file that stores the private key, so that OpenSSH can use it.
// We delete the the temporary file afterwards.
privateKeyPath := ""
if m.PublicKey != "" {
f, err := os.CreateTemp(os.TempDir(), m.RemoteName)
if err != nil {
log.Error("os.CreateTemp: %v", err)
return errors.New("unexpected error")
}
defer func() {
f.Close()
if err := os.Remove(f.Name()); err != nil {
log.Error("os.Remove: %v", err)
}
}()
privateKey, err := m.Privatekey()
if err != nil {
log.Error("Privatekey: %v", err)
return errors.New("unexpected error")
}
if _, err := f.Write(privateKey); err != nil {
log.Error("f.Write: %v", err)
return errors.New("unexpected error")
}
privateKeyPath = f.Name()
}
if err := git.Push(ctx, path, git.PushOptions{ if err := git.Push(ctx, path, git.PushOptions{
Remote: m.RemoteName, Remote: m.RemoteName,
Force: true, Force: true,
Mirror: true, Mirror: true,
Timeout: timeout, Timeout: timeout,
PrivateKeyPath: privateKeyPath,
}); err != nil { }); err != nil {
log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err) log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err)

View file

@ -136,6 +136,7 @@
<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.mirrored_repository"}}</th> <th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.mirrored_repository"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th> <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th> <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
<th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -233,6 +234,7 @@
<th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.pushed_repository"}}</th> <th class="tw-w-2/5">{{ctx.Locale.Tr "repo.settings.mirror_settings.pushed_repository"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th> <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th> <th>{{ctx.Locale.Tr "repo.settings.mirror_settings.last_update"}}</th>
<th>{{ctx.Locale.Tr "repo.mirror_public_key"}}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -242,7 +244,8 @@
<td class="tw-break-anywhere">{{.RemoteAddress}}</td> <td class="tw-break-anywhere">{{.RemoteAddress}}</td>
<td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td> <td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td>
<td>{{if .LastUpdateUnix}}{{DateTime "full" .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td> <td>{{if .LastUpdateUnix}}{{DateTime "full" .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td>
<td class="right aligned"> <td>{{if not (eq (len .GetPublicKey) 0)}}<a data-clipboard-text="{{.GetPublicKey}}">{{ctx.Locale.Tr "repo.settings.mirror_settings.push_mirror.copy_public_key"}}</a>{{else}}{{ctx.Locale.Tr "repo.settings.mirror_settings.push_mirror.none"}}{{end}}</td>
<td class="right aligned df">
<button <button
class="ui tiny button show-modal" class="ui tiny button show-modal"
data-modal="#push-mirror-edit-modal" data-modal="#push-mirror-edit-modal"
@ -274,7 +277,7 @@
{{end}} {{end}}
{{if (not .DisableNewPushMirrors)}} {{if (not .DisableNewPushMirrors)}}
<tr> <tr>
<td colspan="4"> <td colspan="5">
<form class="ui form" method="post"> <form class="ui form" method="post">
{{template "base/disable_form_autofill"}} {{template "base/disable_form_autofill"}}
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
@ -297,6 +300,13 @@
<label for="push_mirror_password">{{ctx.Locale.Tr "password"}}</label> <label for="push_mirror_password">{{ctx.Locale.Tr "password"}}</label>
<input id="push_mirror_password" name="push_mirror_password" type="password" value="{{.push_mirror_password}}" autocomplete="off"> <input id="push_mirror_password" name="push_mirror_password" type="password" value="{{.push_mirror_password}}" autocomplete="off">
</div> </div>
<div class="inline field {{if .Err_PushMirrorUseSSH}}error{{end}}">
<div class="ui checkbox df ac">
<input id="push_mirror_use_ssh" name="push_mirror_use_ssh" type="checkbox" {{if .push_mirror_use_ssh}}checked{{end}}>
<label for="push_mirror_use_ssh" class="inline">{{ctx.Locale.Tr "repo.mirror_use_ssh.text"}}</label>
<span class="help tw-block">{{ctx.Locale.Tr "repo.mirror_use_ssh.helper"}}
</div>
</div>
</div> </div>
</details> </details>
<div class="field"> <div class="field">

View file

@ -21529,6 +21529,10 @@
"sync_on_commit": { "sync_on_commit": {
"type": "boolean", "type": "boolean",
"x-go-name": "SyncOnCommit" "x-go-name": "SyncOnCommit"
},
"use_ssh": {
"type": "boolean",
"x-go-name": "UseSSH"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
@ -25325,6 +25329,10 @@
"format": "date-time", "format": "date-time",
"x-go-name": "LastUpdateUnix" "x-go-name": "LastUpdateUnix"
}, },
"public_key": {
"type": "string",
"x-go-name": "PublicKey"
},
"remote_address": { "remote_address": {
"type": "string", "type": "string",
"x-go-name": "RemoteAddress" "x-go-name": "RemoteAddress"

View file

@ -7,21 +7,30 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv"
"testing" "testing"
"time"
asymkey_model "code.gitea.io/gitea/models/asymkey"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/migrations" "code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror" mirror_service "code.gitea.io/gitea/services/mirror"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -130,3 +139,130 @@ func testAPIPushMirror(t *testing.T, u *url.URL) {
}) })
} }
} }
func TestAPIPushMirrorSSH(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
require.NoError(t, migrations.Init())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.False(t, srcRepo.HasWiki())
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{
Name: optional.Some("push-mirror-test"),
AutoInit: optional.Some(false),
EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
})
defer f()
sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
t.Run("Mutual exclusive", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
RemoteAddress: sshURL,
Interval: "8h",
UseSSH: true,
RemoteUsername: "user",
RemotePassword: "password",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
var apiError api.APIError
DecodeJSON(t, resp, &apiError)
assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message)
})
t.Run("Normal", func(t *testing.T) {
var pushMirror *repo_model.PushMirror
t.Run("Adding", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
RemoteAddress: sshURL,
Interval: "8h",
UseSSH: true,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
assert.NotEmpty(t, pushMirror.PrivateKey)
assert.NotEmpty(t, pushMirror.PublicKey)
})
publickey := pushMirror.GetPublicKey()
t.Run("Publickey", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var pushMirrors []*api.PushMirror
DecodeJSON(t, resp, &pushMirrors)
assert.Len(t, pushMirrors, 1)
assert.EqualValues(t, publickey, pushMirrors[0].PublicKey)
})
t.Run("Add deploy key", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/keys", pushToRepo.FullName()), &api.CreateKeyOption{
Title: "push mirror key",
Key: publickey,
ReadOnly: false,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
})
t.Run("Synchronize", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors-sync", srcRepo.FullName())).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
t.Run("Check mirrored content", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
sha := "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var commitList []*api.Commit
DecodeJSON(t, resp, &commitList)
assert.Len(t, commitList, 1)
assert.EqualValues(t, sha, commitList[0].SHA)
assert.Eventually(t, func() bool {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var commitList []*api.Commit
DecodeJSON(t, resp, &commitList)
return len(commitList) != 0 && commitList[0].SHA == sha
}, time.Second*30, time.Second)
})
t.Run("Check known host keys", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
require.NoError(t, err)
publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
require.NoError(t, err)
assert.Contains(t, string(knownHosts), string(publicKey))
})
})
})
}

View file

@ -1,4 +1,5 @@
// Copyright 2021 The Gitea Authors. All rights reserved. // Copyright 2021 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package integration package integration
@ -6,18 +7,26 @@ package integration
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"testing" "testing"
"time"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
gitea_context "code.gitea.io/gitea/services/context" gitea_context "code.gitea.io/gitea/services/context"
doctor "code.gitea.io/gitea/services/doctor" doctor "code.gitea.io/gitea/services/doctor"
"code.gitea.io/gitea/services/migrations" "code.gitea.io/gitea/services/migrations"
@ -35,8 +44,8 @@ func TestMirrorPush(t *testing.T) {
func testMirrorPush(t *testing.T, u *url.URL) { func testMirrorPush(t *testing.T, u *url.URL) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
setting.Migrations.AllowLocalNetworks = true
require.NoError(t, migrations.Init()) require.NoError(t, migrations.Init())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@ -146,3 +155,135 @@ func doRemovePushMirror(ctx APITestContext, address, username, password string,
assert.Contains(t, flashCookie.Value, "success") assert.Contains(t, flashCookie.Value, "success")
} }
} }
func TestSSHPushMirror(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
require.NoError(t, migrations.Init())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.False(t, srcRepo.HasWiki())
sess := loginUser(t, user.Name)
pushToRepo, _, f := CreateDeclarativeRepoWithOptions(t, user, DeclarativeRepoOptions{
Name: optional.Some("push-mirror-test"),
AutoInit: optional.Some(false),
EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
})
defer f()
sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
t.Run("Mutual exclusive", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-add",
"push_mirror_address": sshURL,
"push_mirror_username": "username",
"push_mirror_password": "password",
"push_mirror_use_ssh": "true",
"push_mirror_interval": "0",
})
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
errMsg := htmlDoc.Find(".ui.negative.message").Text()
assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.")
})
t.Run("Normal", func(t *testing.T) {
var pushMirror *repo_model.PushMirror
t.Run("Adding", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-add",
"push_mirror_address": sshURL,
"push_mirror_use_ssh": "true",
"push_mirror_interval": "0",
})
sess.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := sess.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success")
pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
assert.NotEmpty(t, pushMirror.PrivateKey)
assert.NotEmpty(t, pushMirror.PublicKey)
})
publickey := ""
t.Run("Publickey", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName()))
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
publickey = htmlDoc.Find(".ui.table td a[data-clipboard-text]").AttrOr("data-clipboard-text", "")
assert.EqualValues(t, publickey, pushMirror.GetPublicKey())
})
t.Run("Add deploy key", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName())),
"title": "push mirror key",
"content": publickey,
"is_writable": "true",
})
sess.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
})
t.Run("Synchronize", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-sync",
"push_mirror_id": strconv.FormatInt(pushMirror.ID, 10),
})
sess.MakeRequest(t, req, http.StatusSeeOther)
})
t.Run("Check mirrored content", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
shortSHA := "1032bbf17f"
req := NewRequest(t, "GET", fmt.Sprintf("/%s", srcRepo.FullName()))
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t, htmlDoc.Find(".shortsha").Text(), shortSHA)
assert.Eventually(t, func() bool {
req = NewRequest(t, "GET", fmt.Sprintf("/%s", pushToRepo.FullName()))
resp = sess.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
return htmlDoc.Find(".shortsha").Text() == shortSHA
}, time.Second*30, time.Second)
})
t.Run("Check known host keys", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
require.NoError(t, err)
publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
require.NoError(t, err)
assert.Contains(t, string(knownHosts), string(publicKey))
})
})
})
}