package oltp
import (
"crypto/ed25519"
"crypto/rand"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"golang.org/x/crypto/ssh"
)
// A repository's mirror to a Git host (RFC 0022): where trunk is pushed,
// and the deploy key the push signs with.
//
// The row exists only while the mirror is on. Turning it off deletes
// the row, and the key with it, so a key the admin has stopped trusting
// cannot linger to be reused; turning it on again makes a new one.
const gitMirrorSchema = `
CREATE TABLE IF NOT EXISTS git_mirror (
repo TEXT PRIMARY KEY,
remote TEXT NOT NULL, -- an SSH address, never with a password
branch TEXT NOT NULL, -- the remote branch trunk is pushed to
key_sealed TEXT NOT NULL, -- the OpenSSH private key, sealed (secret.go)
public_key TEXT NOT NULL, -- its authorized_keys line, shown to add as a deploy key
pushed TEXT NOT NULL DEFAULT '', -- the last check-in the host accepted
pushed_at TEXT NOT NULL DEFAULT '',
err TEXT NOT NULL DEFAULT '', -- the last failure, in words
err_at TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL,
created_by TEXT NOT NULL
);`
// gitMirrorMigrations: fails counts the failed pushes since the last
// success, which sets how long the next retry waits.
var gitMirrorMigrations = []string{
`ALTER TABLE git_mirror ADD COLUMN fails INTEGER NOT NULL DEFAULT 0`,
}
// ErrMirrorRefused is a mirror setting the forge will not keep: an
// address it cannot push to safely, a bad branch name, or a repository
// that may not be mirrored. The message says which.
var ErrMirrorRefused = errors.New("refused")
// GitMirror is the setting as a page or an op shows it. The private key
// is not on it: nothing that renders a mirror has a reason to hold it.
type GitMirror struct {
Repo string
Remote string
Branch string
PublicKey string
Pushed string
PushedAt string
Err string
ErrAt string
Created string
CreatedBy string
// Fails is how many pushes have failed since the last success.
Fails int
}
// GitMirrorOf reads a repository's mirror; false when it has none.
func (d *DB) GitMirrorOf(repo string) (GitMirror, bool, error) {
m := GitMirror{Repo: repo}
err := d.db.QueryRow(`SELECT remote, branch, public_key, pushed, pushed_at, err, err_at, created, created_by, fails
FROM git_mirror WHERE repo = ?`, repo).
Scan(&m.Remote, &m.Branch, &m.PublicKey, &m.Pushed, &m.PushedAt, &m.Err, &m.ErrAt, &m.Created, &m.CreatedBy, &m.Fails)
if errors.Is(err, sql.ErrNoRows) {
return GitMirror{}, false, nil
}
if err != nil {
return GitMirror{}, false, err
}
return m, true, nil
}
// SetGitMirror turns a repository's mirror on, or changes where an
// existing one pushes, and records each field that changed in the
// settings log, in one transaction.
//
// The first enable makes the key. A later call that only changes the
// remote or the branch keeps it, because the admin has already added it
// to the host; the push status is cleared, because it described the old
// destination.
//
// Refused for a private repository (RFC 0022 Decision 3) and while a
// takedown is in force: a mirror would publish elsewhere exactly what
// the forge has stopped serving. Both are read inside the transaction,
// so a repository made private a moment earlier cannot slip through.
func (d *DB) SetGitMirror(repo, remote, branch, by, via string) (GitMirror, error) {
remote = strings.TrimSpace(remote)
branch = strings.TrimSpace(branch)
if branch == "" {
branch = "main"
}
if err := CheckGitRemote(remote); err != nil {
return GitMirror{}, err
}
if err := checkGitBranch(branch); err != nil {
return GitMirror{}, err
}
tx, err := d.w.Begin()
if err != nil {
return GitMirror{}, err
}
defer tx.Rollback()
var visibility string
switch err := tx.QueryRow(`SELECT visibility FROM repo WHERE name = ?`, repo).Scan(&visibility); {
case errors.Is(err, sql.ErrNoRows):
return GitMirror{}, fmt.Errorf("no repository named %q", repo)
case err != nil:
return GitMirror{}, err
}
if visibility == Private {
return GitMirror{}, refuseMirror("", "a private repository cannot be mirrored")
}
if err := refuseHeld(tx, repo); err != nil {
return GitMirror{}, err
}
stamp := now()
var oldRemote, oldBranch string
err = tx.QueryRow(`SELECT remote, branch FROM git_mirror WHERE repo = ?`, repo).Scan(&oldRemote, &oldBranch)
fresh := errors.Is(err, sql.ErrNoRows)
if err != nil && !fresh {
return GitMirror{}, err
}
var changes []SettingsChange
if fresh {
private, public, err := newDeployKey(repo)
if err != nil {
return GitMirror{}, err
}
sealed, err := d.seal.seal(private)
if err != nil {
return GitMirror{}, err
}
if _, err := tx.Exec(`INSERT INTO git_mirror (repo, remote, branch, key_sealed, public_key, created, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`, repo, remote, branch, sealed, public, stamp, by); err != nil {
return GitMirror{}, err
}
changes = append(changes, SettingsChange{Field: "git-mirror", Was: "off", Now: "on"})
} else if remote != oldRemote || branch != oldBranch {
if _, err := tx.Exec(`UPDATE git_mirror SET remote = ?, branch = ?, pushed = '', pushed_at = '', err = '', err_at = '', fails = 0
WHERE repo = ?`, remote, branch, repo); err != nil {
return GitMirror{}, err
}
}
if remote != oldRemote {
changes = append(changes, SettingsChange{Field: "git-mirror-remote", Was: oldRemote, Now: remote})
}
if branch != oldBranch {
changes = append(changes, SettingsChange{Field: "git-mirror-branch", Was: oldBranch, Now: branch})
}
if err := logSettings(tx, repo, stamp, by, via, changes); err != nil {
return GitMirror{}, err
}
if err := tx.Commit(); err != nil {
return GitMirror{}, err
}
m, _, err := d.GitMirrorOf(repo)
return m, err
}
// DisableGitMirror turns a repository's mirror off and discards its key.
// Never refused: stopping a push is always safe. False when there was
// no mirror to stop, which records nothing.
func (d *DB) DisableGitMirror(repo, by, via string) (bool, error) {
tx, err := d.w.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
res, err := tx.Exec(`DELETE FROM git_mirror WHERE repo = ?`, repo)
if err != nil {
return false, err
}
if n, _ := res.RowsAffected(); n == 0 {
return false, nil
}
if err := logSettings(tx, repo, now(), by, via,
[]SettingsChange{{Field: "git-mirror", Was: "on", Now: "off"}}); err != nil {
return false, err
}
return true, tx.Commit()
}
// GitMirrorRepos lists the repositories that have a mirror on.
func (d *DB) GitMirrorRepos() ([]string, error) {
rows, err := d.db.Query(`SELECT repo FROM git_mirror ORDER BY repo`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// GitMirrorKey opens a mirror's private key, for the length of one push.
// The only reader of the sealed column: nothing that renders a mirror
// calls it.
func (d *DB) GitMirrorKey(repo string) (string, error) {
var sealed string
if err := d.db.QueryRow(`SELECT key_sealed FROM git_mirror WHERE repo = ?`, repo).Scan(&sealed); err != nil {
return "", err
}
return d.seal.open(sealed)
}
// RecordGitMirrorPush and RecordGitMirrorFailure write a push's outcome,
// but only while the mirror is still the one the push went out for:
// the same key (a mirror turned off and on again has a new one), remote
// and branch. An admin who changed any of them mid-push must not see the
// old push's result reported as the new mirror's.
func (d *DB) RecordGitMirrorPush(repo, publicKey, remote, branch, pushed string) error {
_, err := d.w.Exec(`UPDATE git_mirror SET pushed = ?, pushed_at = ?, err = '', err_at = '', fails = 0
WHERE repo = ? AND public_key = ? AND remote = ? AND branch = ?`, pushed, now(), repo, publicKey, remote, branch)
return err
}
func (d *DB) RecordGitMirrorFailure(repo, publicKey, remote, branch, why string) error {
_, err := d.w.Exec(`UPDATE git_mirror SET err = ?, err_at = ?, fails = fails + 1
WHERE repo = ? AND public_key = ? AND remote = ? AND branch = ?`, clipValue(why), now(), repo, publicKey, remote, branch)
return err
}
// logSettings writes changes to the settings log inside the caller's
// transaction, so the write and its record cannot come apart.
func logSettings(tx *sql.Tx, repo, stamp, by, via string, changes []SettingsChange) error {
for _, c := range changes {
if _, err := tx.Exec(`INSERT INTO repo_settings_log (repo, at, actor, via, field, was, "now")
VALUES (?, ?, ?, ?, ?, ?, ?)`, repo, stamp, by, via, c.Field, clipValue(c.Was), clipValue(c.Now)); err != nil {
return err
}
}
return nil
}
// newDeployKey makes an ed25519 pair: the private half as an OpenSSH
// key file, the public half as the line a Git host's deploy-key form
// takes, commented with the repository so the host's list says whose
// it is.
func newDeployKey(repo string) (private, public string, err error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
block, err := ssh.MarshalPrivateKey(priv, "quarry "+repo)
if err != nil {
return "", "", err
}
sp, err := ssh.NewPublicKey(pub)
if err != nil {
return "", "", err
}
line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " quarry " + repo
return string(pem.EncodeToMemory(block)), line, nil
}
var (
gitUser = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
gitHost = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`)
gitPath = regexp.MustCompile(`^[A-Za-z0-9._~/-]+$`)
gitBranch = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*)*$`)
)
// CheckGitRemote accepts an SSH address a push can use with the deploy
// key: `user@host:path` or `ssh://[user@]host[:port]/path`.
//
// Strict on purpose. The address becomes an argument to git and ssh, so
// a user, host or path that starts with a dash would be read as an
// option;
// the character sets here admit what Git hosts actually hand out and
// nothing that could. An HTTPS address is refused because it would need
// a credential this design does not keep, and any password because zit
// refuses one in an autopush address.
func CheckGitRemote(remote string) error {
refuse := func(why string) error { return refuseMirror("remote", why) }
if remote == "" {
return refuse("give the remote's SSH address, such as [email protected]:owner/repo.git")
}
if len(remote) > 300 {
return refuse("the remote address is at most 300 characters")
}
if strings.ContainsAny(remote, "%?# \t\r\n") {
// Refused rather than decoded: what is checked has to be the
// exact string git is handed.
return refuse("the remote address has characters a Git host does not use")
}
var user, host, port, path string
switch {
case strings.HasPrefix(remote, "ssh://"):
u, err := url.Parse(remote)
if err != nil || u.Opaque != "" || u.RawQuery != "" || u.Fragment != "" {
return refuse("the remote address is not a URL")
}
if _, has := u.User.Password(); has {
return refuse("the remote address carries a password; the deploy key is the credential")
}
user, host, port, path = u.User.Username(), u.Hostname(), u.Port(), strings.TrimPrefix(u.Path, "/")
case strings.Contains(remote, "://"):
return refuse("only an SSH address can push with a deploy key; use the form git@host:owner/repo.git")
default:
at, colon := strings.IndexByte(remote, '@'), strings.IndexByte(remote, ':')
if at <= 0 || colon < at {
return refuse("an SSH address names a user and a host, such as [email protected]:owner/repo.git")
}
user, host, path = remote[:at], remote[at+1:colon], remote[colon+1:]
}
if user != "" && !gitUser.MatchString(user) {
return refuse("the remote's user name has characters an SSH login cannot")
}
if !gitHost.MatchString(host) {
return refuse("the remote's host is not a host name")
}
// A Git host has a public name. An address or a one-word name would
// let anyone holding a on a repository aim the forge's ssh at its own
// network, or at an alias in the forge account's ssh config.
if net.ParseIP(host) != nil || !strings.Contains(strings.Trim(host, "."), ".") {
return refuse("give the Git host by its full name, such as github.com")
}
if port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return refuse("the remote's port is not a port")
}
}
if path == "" || !gitPath.MatchString(path) || strings.HasPrefix(path, "-") ||
strings.Contains(path, "..") {
return refuse("the remote's repository path has characters a Git host does not use")
}
return nil
}
// checkGitBranch accepts a plain Git branch name: no leading dash or
// dot, no `..`, no `.lock` ending, nothing Git would refuse or read as
// an option.
func checkGitBranch(branch string) error {
if len(branch) > 100 || !gitBranch.MatchString(branch) || strings.Contains(branch, "..") ||
strings.HasSuffix(branch, ".lock") {
return refuseMirror("branch", fmt.Sprintf("%q is not a Git branch name", branch))
}
return nil
}