diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 28ffc998860bf..274238780860a 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -413,6 +413,8 @@ var migrations = []Migration{ NewMigration("Add badges to users", createUserBadgesTable), // v225 -> v226 NewMigration("Alter gpg_key/public_key content TEXT fields to MEDIUMTEXT", alterPublicGPGKeyContentFieldsToMediumText), + // v226 -> v227 + NewMigration("Add keypair fields to PushMirror struct", addKeypairToPushMirror), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v226.go b/models/migrations/v226.go new file mode 100644 index 0000000000000..65221a9e379aa --- /dev/null +++ b/models/migrations/v226.go @@ -0,0 +1,34 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "time" + + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/xorm" +) + +func addKeypairToPushMirror(x *xorm.Engine) error { + type PushMirror struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + Repo *repo.Repository `xorm:"-"` + RemoteName string + + // A keypair formatted in OpenSSH format. + PublicKey string + PrivateKey string `xorm:"VARCHAR(400)"` + + SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"` + Interval time.Duration + CreatedUnix timeutil.TimeStamp `xorm:"created"` + LastUpdateUnix timeutil.TimeStamp `xorm:"INDEX last_update"` + LastError string `xorm:"text"` + } + + return x.Sync2(new(PushMirror)) +} diff --git a/models/repo/pushmirror.go b/models/repo/pushmirror.go index 38d6e72019700..e6271f08ca8e6 100644 --- a/models/repo/pushmirror.go +++ b/models/repo/pushmirror.go @@ -7,6 +7,7 @@ package repo import ( "context" "errors" + "strings" "time" "code.gitea.io/gitea/models/db" @@ -26,6 +27,10 @@ type PushMirror struct { Repo *Repository `xorm:"-"` RemoteName string + // A keypair formatted in OpenSSH format. + PublicKey string + PrivateKey string `xorm:"VARCHAR(400)"` + SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"` Interval time.Duration CreatedUnix timeutil.TimeStamp `xorm:"created"` @@ -74,6 +79,12 @@ func (m *PushMirror) GetRemoteName() string { 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") +} + // InsertPushMirror inserts a push-mirror to database func InsertPushMirror(ctx context.Context, m *PushMirror) error { _, err := db.GetEngine(ctx).Insert(m) diff --git a/modules/crypto/ed25519.go b/modules/crypto/ed25519.go new file mode 100644 index 0000000000000..ae77bd4cda4aa --- /dev/null +++ b/modules/crypto/ed25519.go @@ -0,0 +1,136 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package crypto + +import ( + "crypto/rand" + "encoding/binary" + "encoding/pem" + "fmt" + + "golang.org/x/crypto/ed25519" + "golang.org/x/crypto/ssh" +) + +// GenerateEd25519Keypair generates a new public and private key from the 25519 curve. +func GenerateEd25519Keypair() (publicKey, privateKey []byte, err error) { + // Generate the private key from ed25519. + public, private, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, nil, fmt.Errorf("ed25519.GenerateKey: %v", err) + } + + // Marshal the privateKey into the OpenSSH format. + privPEM, err := marshalPrivateKey(private) + if err != nil { + return nil, nil, fmt.Errorf("not able to marshal private key into OpenSSH format: %v", err) + } + + sshPublicKey, err := ssh.NewPublicKey(public) + if err != nil { + return nil, nil, fmt.Errorf("not able to create new SSH public key: %v", err) + } + + return ssh.MarshalAuthorizedKey(sshPublicKey), pem.EncodeToMemory(privPEM), nil +} + +// openSSHMagic contains the magic bytes, which is used to indicate it's a v1 +// OpenSSH key format. "openssh-key-v1\x00" in bytes. +const openSSHMagic = "openssh-key-v1\x00" + +// MarshalPrivateKey returns a PEM block with the private key serialized in the +// OpenSSH format. +// Adopted from: https://go-review.googlesource.com/c/crypto/+/218620/ +func marshalPrivateKey(key ed25519.PrivateKey) (*pem.Block, error) { + // The ed25519.PrivateKey is a []byte (Seed, Public) + + // Split the provided key in to a public key and private key bytes. + publicKeyBytes := make([]byte, ed25519.PublicKeySize) + privateKeyBytes := make([]byte, ed25519.PrivateKeySize) + copy(publicKeyBytes, key[ed25519.SeedSize:]) + copy(privateKeyBytes, key) + + // Now we want to eventually marshal the sshPrivateKeyStruct below but ssh.Marshal doesn't allow submarshalling + // So we need to create a number of structs in order to marshal them and build the struct we need. + // + // 1. Create a struct that holds the public key for this private key + pubKeyStruct := struct { + KeyType string + Pub []byte + }{ + KeyType: ssh.KeyAlgoED25519, + Pub: publicKeyBytes, + } + + // 2. Create a struct to contain the privateKeyBlock + // 2a. Marshal keypair as the rest struct + restStruct := struct { + Pub []byte + Priv []byte + Comment string + }{ + publicKeyBytes, privateKeyBytes, "", + } + // 2b. Generate a random uint32 number. + // These can be random bytes or anything else, as long it's the same. + // See: https://github.com/openssh/openssh-portable/blob/f7fc6a43f1173e8b2c38770bf6cee485a562d03b/sshkey.c#L4228-L4235 + var check uint32 + if err := binary.Read(rand.Reader, binary.BigEndian, &check); err != nil { + return nil, err + } + + // 2c. Create the privateKeyBlock struct + privateKeyBlockStruct := struct { + Check1 uint32 + Check2 uint32 + Keytype string + Rest []byte `ssh:"rest"` + }{ + Check1: check, + Check2: check, + Keytype: ssh.KeyAlgoED25519, + Rest: ssh.Marshal(restStruct), + } + + // 3. Now we're finally ready to create the OpenSSH sshPrivateKey + // Head struct of the OpenSSH format. + sshPrivateKeyStruct := struct { + CipherName string + KdfName string + KdfOpts string + NumKeys uint32 + PubKey []byte // See pubKey + PrivKeyBlock []byte // See KeyPair + }{ + CipherName: "none", // This is not a password protected key + KdfName: "none", // so these fields are left as none and empty + KdfOpts: "", // + NumKeys: 1, + PubKey: ssh.Marshal(pubKeyStruct), + PrivKeyBlock: generateOpenSSHPadding(ssh.Marshal(privateKeyBlockStruct)), + } + + // 4. Finally marshal the sshPrivateKeyStruct struct. + bs := ssh.Marshal(sshPrivateKeyStruct) + block := &pem.Block{ + Type: "OPENSSH PRIVATE KEY", + Bytes: append([]byte(openSSHMagic), bs...), + } + + return block, nil +} + +// generateOpenSSHPaddins converts the block to +// accomplish a block size of 8 bytes. +func generateOpenSSHPadding(block []byte) []byte { + padding := []byte{1, 2, 3, 4, 5, 6, 7} + + mod8 := len(block) % 8 + if mod8 > 0 { + block = append(block, padding[:8-mod8]...) + } + + return block +} diff --git a/modules/crypto/ed25519_test.go b/modules/crypto/ed25519_test.go new file mode 100644 index 0000000000000..a153a3dca93a9 --- /dev/null +++ b/modules/crypto/ed25519_test.go @@ -0,0 +1,47 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package crypto + +import ( + "bytes" + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" +) + +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----- +` +) + +func TestGeneratingEd25519Keypair(t *testing.T) { + // Temp override the rand.Reader for deterministic testing. + oldReader := rand.Reader + defer func() { + rand.Reader = oldReader + }() + + // 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 := GenerateEd25519Keypair() + assert.NoError(t, err) + assert.EqualValues(t, testPublicKey, string(publicKey)) + assert.EqualValues(t, testPrivateKey, string(privateKey)) +} diff --git a/modules/git/repo.go b/modules/git/repo.go index 3176e276959a0..34615f9f8dab7 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -186,17 +186,21 @@ func CloneWithArgs(ctx context.Context, from, to string, args []string, opts Clo // PushOptions options when push to remote type PushOptions struct { - Remote string - Branch string - Force bool - Mirror bool - Env []string - Timeout time.Duration + Remote string + Branch string + Force bool + Mirror bool + Env []string + InitArgs []string + Timeout time.Duration } // Push pushs local commits to given remote branch. func Push(ctx context.Context, repoPath string, opts PushOptions) error { - cmd := NewCommand(ctx, "push") + initArgs := opts.InitArgs + initArgs = append(initArgs, "push") + + cmd := NewCommand(ctx, initArgs...) if opts.Force { cmd.AddArguments("-f") } @@ -207,11 +211,13 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { if len(opts.Branch) > 0 { cmd.AddArguments(opts.Branch) } + if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") { cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, util.SanitizeCredentialURLs(opts.Remote), opts.Force, opts.Mirror)) } else { cmd.SetDescription(fmt.Sprintf("push branch %s to %s (force: %t, mirror: %t)", opts.Branch, opts.Remote, opts.Force, opts.Mirror)) } + var outbuf, errbuf strings.Builder if opts.Timeout == 0 { diff --git a/modules/lfs/endpoint.go b/modules/lfs/endpoint.go index 943966ed15592..4b60ac60b62d8 100644 --- a/modules/lfs/endpoint.go +++ b/modules/lfs/endpoint.go @@ -61,6 +61,10 @@ func endpointFromURL(rawurl string) *url.URL { case "git": u.Scheme = "https" return u + case "ssh": + u.Scheme = "https" + u.User = nil + return u case "file": return u default: diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1dba1d71d8ffe..656082593d48a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -867,6 +867,10 @@ mirror_prune = Prune 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_invalid = The mirror interval is not valid. +mirror_public_key = Public SSH Key +mirror_use_ssh = Use SSH authentication +mirror_use_ssh_tooltip = Gitea will mirror the repository via Git over SSH and create a keypair for you when you select this option. You must check afterwards that the generated public key is authorized to push to the 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_on_commit = Sync when commits are pushed mirror_address = Clone From URL mirror_address_desc = Put any required credentials in the Authorization section. @@ -1791,6 +1795,7 @@ settings.mirror_settings.last_update = Last update 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.add = Add Push Mirror +settings.mirror_settings.push_mirror.copy_public_key = Copy Public Key settings.sync_mirror = Synchronize Now settings.mirror_sync_in_progress = Mirror synchronization is in progress. Check back in a minute. settings.site = Website diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go index e7abec0d3e895..d857ed5d56159 100644 --- a/routers/web/repo/setting.go +++ b/routers/web/repo/setting.go @@ -24,6 +24,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/crypto" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/indexer/stats" @@ -345,6 +346,12 @@ func SettingsPost(ctx *context.Context) { 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) if err == nil { err = migrations.IsMigrateURLAllowed(address, ctx.Doer) @@ -368,6 +375,17 @@ func SettingsPost(ctx *context.Context) { SyncOnCommit: form.PushMirrorSyncOnCommit, Interval: interval, } + + if form.PushMirrorUseSSH { + publicKey, privateKey, err := crypto.GenerateEd25519Keypair() + if err != nil { + ctx.ServerError("GenerateEd25519Keypair", err) + return + } + m.PrivateKey = string(privateKey) + m.PublicKey = string(publicKey) + } + if err := repo_model.InsertPushMirror(ctx, m); err != nil { ctx.ServerError("InsertPushMirror", err) return diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index c1e9cb3197c0b..9f8aa37c8d195 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -6,8 +6,10 @@ package forms import ( + "fmt" "net/http" "net/url" + "regexp" "strings" "code.gitea.io/gitea/models" @@ -92,6 +94,9 @@ func (f *MigrateRepoForm) Validate(req *http.Request, errs binding.Errors) bindi 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, // and returns composed URL with needed username and password. func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, error) { @@ -107,7 +112,15 @@ func ParseRemoteAddr(remoteAddr, authUsername, authPassword string) (string, err if len(authUsername)+len(authPassword) > 0 { 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@gitea.com:user/repo" becomes + // "ssh://git@gitea.com/user/repo". + return fmt.Sprintf("ssh://%s@%s/%s", url.User(m[1]), m[2], m[3]), nil } return remoteAddr, nil @@ -128,8 +141,9 @@ type RepoSettingForm struct { PushMirrorAddress string PushMirrorUsername string PushMirrorPassword string - PushMirrorSyncOnCommit bool PushMirrorInterval string + PushMirrorUseSSH bool + PushMirrorSyncOnCommit bool Private bool Template bool EnablePrune bool diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go index 040f0aebb192d..3edf8d826a464 100644 --- a/services/migrations/migrate.go +++ b/services/migrations/migrate.go @@ -71,7 +71,7 @@ func IsMigrateURLAllowed(remoteURL string, doer *user_model.User) error { 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} } diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index 0c8960d78bf20..942d51141b605 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "os" "regexp" "strings" "time" @@ -156,11 +157,37 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { log.Trace("Pushing %s mirror[%d] remote %s", path, m.ID, m.RemoteName) + var initArgs []string + + // 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. + if m.PublicKey != "" { + f, err := os.CreateTemp(os.TempDir(), m.RemoteName) + if err != nil { + log.Error("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) + } + }() + + if _, err := f.Write([]byte(m.PrivateKey)); err != nil { + log.Error("f.Write: %v", err) + return errors.New("unexpected error") + } + + initArgs = append(initArgs, "-c", fmt.Sprintf("core.sshcommand=ssh -i %q -o IdentitiesOnly=yes -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no", f.Name())) + } if err := git.Push(ctx, path, git.PushOptions{ - Remote: m.RemoteName, - Force: true, - Mirror: true, - Timeout: timeout, + Remote: m.RemoteName, + Force: true, + Mirror: true, + Timeout: timeout, + InitArgs: initArgs, }); err != nil { log.Error("Error pushing %s mirror[%d] remote %s: %v", path, m.ID, m.RemoteName, err) diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 9030235e7c120..2c2c4444a16ec 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -84,6 +84,7 @@ {{$.locale.Tr "repo.settings.mirror_settings.mirrored_repository"}} {{$.locale.Tr "repo.settings.mirror_settings.direction"}} {{$.locale.Tr "repo.settings.mirror_settings.last_update"}} + {{$.locale.Tr "repo.mirror_public_key"}} @@ -172,7 +173,8 @@ {{$address.Address}} {{$.locale.Tr "repo.settings.mirror_settings.direction.push"}} {{if .LastUpdateUnix}}{{.LastUpdateUnix.AsTime}}{{else}}{{$.locale.Tr "never"}}{{end}} {{if .LastError}}
{{$.locale.Tr "error"}}
{{end}} - + {{$.locale.Tr "repo.settings.mirror_settings.push_mirror.copy_public_key"}} +
{{$.CsrfTokenHtml}} @@ -211,11 +213,18 @@
- +
- + +
+
+
+ + + {{svg "octicon-question" 16 "ml-3"}} +
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index 2c93ca03424b0..b4866fe49cd26 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -27,7 +27,7 @@ import attachTribute from './tribute.js'; import createDropzone from './dropzone.js'; import {initCommentContent, initMarkupContent} from '../markup/content.js'; import {initCompReactionSelector} from './comp/ReactionSelector.js'; -import {initRepoSettingBranches} from './repo-settings.js'; +import {initRepoSettingBranches, initRepoSettingMirror} from './repo-settings.js'; import initRepoPullRequestMergeForm from './repo-issue-pr-form.js'; const {csrfToken} = window.config; @@ -507,6 +507,7 @@ export function initRepository() { initRepoCloneLink(); initRepoCommonLanguageStats(); initRepoSettingBranches(); + initRepoSettingMirror(); // Issues if ($('.repository.view.issue').length > 0) { diff --git a/web_src/js/features/repo-settings.js b/web_src/js/features/repo-settings.js index 3d02a82bb6554..53c2d2a8740cb 100644 --- a/web_src/js/features/repo-settings.js +++ b/web_src/js/features/repo-settings.js @@ -90,3 +90,18 @@ export function initRepoSettingBranches() { }); } } + +function togglePushMirrorFields(disable) { + const els = document.querySelectorAll('#push_mirror_password, #push_mirror_username'); + for (const el of els || []) { + el.classList.toggle('disabled', disable); + } +} +export function initRepoSettingMirror() { + const checkbox = document.getElementById('push_mirror_use_ssh'); + if (!checkbox) return; + togglePushMirrorFields(checkbox.checked); + checkbox.addEventListener('change', () => { + togglePushMirrorFields(checkbox.checked); + }); +}