Skip to content
quarry Artifact [1079f4585d]
Login

Artifact [1079f4585d]

Artifact 1079f4585d6feaea67ec4f6f8f3d07a721128d4eb208f6d5f4eb001118f749d2:


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{}, fmt.Errorf("%w: a private repository cannot be mirrored", ErrMirrorRefused)
	}
	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 fmt.Errorf("%w: %s", ErrMirrorRefused, 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 fmt.Errorf("%w: %q is not a Git branch name", ErrMirrorRefused, branch)
	}
	return nil
}