[FEAT] Allow pushmirror to use publickey authentication

- Continuation of https://github.com/go-gitea/gitea/pull/18835 (by
@Gusted, so it's fine to change copyright holder to Forgejo).
- Add the option to use SSH for push mirrors, this would allow for the
deploy keys feature to be used and not require tokens to be used which
cannot be limited to a specific repository. The private key is stored
encrypted (via the `keying` module) on the database and NEVER given to
the user, to avoid accidental exposure and misuse.
- CAVEAT: This does require the `ssh` binary to be present, which may
not be available in containerized environments, this could be solved by
adding a SSH client into forgejo itself and use the forgejo binary as
SSH command, but should be done in another PR.
- CAVEAT: Mirroring of LFS content is not supported, this would require
the previous stated problem to be solved due to LFS authentication (an
attempt was made at forgejo/forgejo#2544).
- Integration test added.
- Resolves #4416
This commit is contained in:
Philip Peterson 2024-08-04 14:46:05 -04:00 committed by Gusted
parent 61e018f8b4
commit 03508b33a8
No known key found for this signature in database
GPG key ID: FD821B732837125F
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"
) )
@ -190,17 +192,39 @@ func CloneWithArgs(ctx context.Context, args TrustedCmdArgs, from, to string, op
// PushOptions options when push to remote // PushOptions options when push to remote
type PushOptions struct { type PushOptions struct {
Remote string Remote string
Branch string Branch string
Force bool Force bool
Mirror bool Mirror bool
Env []string Env []string
Timeout time.Duration Timeout time.Duration
PrivateKeyPath string
} }
// Push pushs local commits to given remote branch. // Push pushs local commits to given remote branch.
func Push(ctx context.Context, repoPath string, opts PushOptions) error { func Push(ctx context.Context, repoPath string, opts PushOptions) error {
cmd := NewCommand(ctx, "push") cmd := NewCommand(ctx, "push")
if opts.PrivateKeyPath != "" {
// Preserve the behavior that existing environments are used if no
// environments are passed.
if len(opts.Env) == 0 {
opts.Env = os.Environ()
}
// Use environment because it takes precedence over using -c core.sshcommand
// and it's possible that a system might have an existing GIT_SSH_COMMAND
// environment set.
opts.Env = append(opts.Env, "GIT_SSH_COMMAND=ssh"+
fmt.Sprintf(` -i %s`, opts.PrivateKeyPath)+
" -o IdentitiesOnly=yes"+
// This will store new SSH host keys and verify connections to existing
// host keys, but it doesn't allow replacement of existing host keys. This
// means TOFU is used for Git over SSH pushes.
" -o StrictHostKeyChecking=accept-new"+
" -o UserKnownHostsFile="+filepath.Join(setting.SSH.RootPath, "known_hosts"))
}
if opts.Force { if opts.Force {
cmd.AddArguments("-f") cmd.AddArguments("-f")
} }

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))
})
})
})
}