Download
466 lines 14 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
// Package admin adds repositories to a server: the work behind
// repo-render add, which git-selfhost runs as root on the server, and
// repo-render info, which tells it what is there already.
package admin

import (
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"syscall"

	"code.bruce-hill.com/repo-renderer/internal/config"
	"code.bruce-hill.com/repo-renderer/internal/gitx"
	"code.bruce-hill.com/repo-renderer/internal/mirror"
)

// KeyDir is where the repositories' mirror keys are, by default: keys/
// beside the config.
func KeyDir(cfg *config.Config) string { return filepath.Join(filepath.Dir(cfg.Path), "keys") }

// SSHCommand is a repository's core.sshCommand, for pushing to its
// mirrors with its key: it never prompts, gives up on a connection that
// hangs, and only talks to hosts it knows.
func SSHCommand(key string) string {
	return "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i " + key
}

// Info writes what there is of repo on the server, a line each, as
// "what value", for git-selfhost to read:
//
//	git_dir DIR          where the repositories are
//	path PATH            what follows git@HOST: to push to it: NAME.git if
//	                     git_dir is the git user's home, else the full path
//	section NAME TITLE   each section, in order
//	exists yes|no        whether the repository is there; if it is:
//	current NAME         the section it's in, if any
//	tag TAG              each of its tags
//	head BRANCH          its default branch
//	ref SHA REF          each branch and tag
//	description TEXT
//	mirror URL           each mirror
//	key PATH             its mirror key, and, if that's there:
//	pubkey KEY
func Info(w io.Writer, cfg *config.Config, repo, keys string) error {
	if !config.ValidRepoName(repo) {
		return fmt.Errorf("invalid repository name %q", repo)
	}
	dir := filepath.Join(cfg.GitDir, repo+".git")
	line := func(k, v string) {
		// One line each, whatever is in a title or a description.
		fmt.Fprintf(w, "%s %s\n", k, strings.Join(strings.Fields(v), " "))
	}
	line("git_dir", cfg.GitDir)
	path := dir
	if o, err := ownerOf(cfg.GitDir); err == nil && filepath.Clean(o.home) == filepath.Clean(cfg.GitDir) {
		path = repo + ".git"
	}
	line("path", path)
	for _, s := range cfg.Sections {
		line("section", s.Name+" "+s.Title)
	}
	r := gitx.Repo{Dir: dir}
	if _, err := os.Stat(dir); err != nil {
		line("exists", "no")
	} else {
		if !r.Exists() {
			return fmt.Errorf("%s is there, but isn't a git repository", dir)
		}
		line("exists", "yes")
		section, tags, err := r.Placement()
		if err != nil {
			return err
		}
		if cfg.SectionIndex(section) >= 0 {
			line("current", section)
		}
		for _, t := range tags {
			line("tag", t)
		}
		head, err := r.HeadBranch()
		if err != nil {
			return fmt.Errorf("%s: %w", dir, err)
		}
		line("head", head)
		out, err := r.Run("for-each-ref", "--format=%(objectname) %(refname)", "refs/heads/", "refs/tags/")
		if err != nil {
			return err
		}
		for l := range strings.Lines(string(out)) {
			line("ref", l)
		}
		line("description", r.Description())
		ms, err := r.Mirrors()
		if err != nil {
			return err
		}
		for _, m := range ms {
			line("mirror", m)
		}
	}
	key := filepath.Join(keys, repo)
	line("key", key)
	if pub, err := os.ReadFile(key + ".pub"); err == nil {
		line("pubkey", string(pub))
	}
	return nil
}

// Options are what to make of a repository.
type Options struct {
	Repo   string
	Branch string // its default branch
	// Section is the name of the section it's in, "" for none; nil leaves
	// it as it is.
	Section *string
	// Tags are its tags; nil leaves them as they are.
	Tags        []string
	Description string   // "" leaves it as it is
	Mirrors     []string // added to the ones it has
	Keys        string   // the directory of mirror keys
	KnownHosts  string   // where to add a mirror host's host key
	NewRepo     string   // the new-repo program
	Renderer    string   // the repo-render program (default: this one)
	DryRun      bool     // say what it would do, and do nothing
}

// Add makes repo on the server, or brings it up to date: it creates or
// rewires the repository with new-repo, as the user that owns git_dir,
// sets its section and tags in its git config, gives it a mirror key if
// it has mirrors, and makes sure the server knows each mirror host's host
// key. Run again after a failure, it picks up where it was. It says what
// it does on w.
func Add(w io.Writer, cfg *config.Config, o Options) error {
	if !config.ValidRepoName(o.Repo) {
		return fmt.Errorf("invalid repository name %q", o.Repo)
	}
	if !config.ValidBranchName(o.Branch) {
		return fmt.Errorf("%q is not a usable branch name (letters, digits, . _ - and / between components)", o.Branch)
	}
	for _, m := range o.Mirrors {
		if err := checkMirror(m); err != nil {
			return err
		}
	}
	if o.Section != nil && *o.Section != "" && cfg.SectionIndex(*o.Section) < 0 {
		var names []string
		for _, s := range cfg.Sections {
			names = append(names, s.Name)
		}
		return fmt.Errorf("there's no section named %q (the config has %s)", *o.Section, strings.Join(names, ", "))
	}
	for i, t := range o.Tags {
		if !config.ValidTag(t) {
			return fmt.Errorf("%q is not a usable tag (not empty, no control characters or surrounding spaces)", t)
		}
		if slices.Contains(o.Tags[:i], t) {
			return fmt.Errorf("tag %q given twice", t)
		}
	}
	dir := filepath.Join(cfg.GitDir, o.Repo+".git")
	key := filepath.Join(o.Keys, o.Repo)
	owner, err := ownerOf(cfg.GitDir)
	if err != nil {
		return err
	}

	r := gitx.Repo{Dir: dir}
	_, err = os.Stat(dir)
	exists := err == nil
	empty := true
	section, tags := "", []string(nil)
	if exists {
		if !r.Exists() {
			return fmt.Errorf("%s is there, but isn't a git repository", dir)
		}
		out, err := r.Run("for-each-ref", "--count=1", "refs/heads/")
		if err != nil {
			return err
		}
		empty = len(out) == 0
		head, err := r.HeadBranch()
		if err != nil {
			return fmt.Errorf("%s: %w", dir, err)
		}
		if !empty && head != o.Branch {
			return fmt.Errorf("%s's default branch is %s, not %s", dir, head, o.Branch)
		}
		if section, tags, err = r.Placement(); err != nil {
			return err
		}
	}

	var steps []step
	add := func(what string, do func() error) { steps = append(steps, step{what, do}) }

	var args []string
	what := "rewire " + dir
	if !exists {
		args = append(args, "-b", o.Branch)
		what = "create " + dir + ", with " + o.Branch + " as its default branch"
	}
	if o.Description != "" {
		args = append(args, "-d", o.Description)
		what += ", described as " + strconv.Quote(o.Description)
	}
	for _, m := range o.Mirrors {
		args = append(args, "-m", m)
	}
	if len(o.Mirrors) > 0 {
		what += ", mirrored to " + strings.Join(o.Mirrors, " ")
	}
	add(what, func() error {
		prog, err := newRepoProgram(o.NewRepo)
		if err != nil {
			return err
		}
		return owner.run(w, prog, append(args, dir)...)
	})
	if exists && empty {
		add("make "+o.Branch+" its default branch", func() error {
			return owner.run(w, gitx.Program, "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+o.Branch)
		})
	}

	// Its place on the site, in its own git config
	placed := false
	if o.Section != nil && *o.Section != section {
		placed = true
		if *o.Section == "" {
			add("put it in no section", func() error {
				return owner.gitConfig(w, dir, "--unset-all", "repo-render.section")
			})
		} else {
			add("put it in section "+*o.Section, func() error {
				return owner.gitConfig(w, dir, "repo-render.section", *o.Section)
			})
		}
	}
	if o.Tags != nil && !slices.Equal(o.Tags, tags) {
		placed = true
		what := "give it no tags"
		if len(o.Tags) > 0 {
			what = "give it the tags " + strings.Join(o.Tags, ", ")
		}
		add(what, func() error {
			if err := owner.gitConfig(w, dir, "--unset-all", "repo-render.tag"); err != nil {
				return err
			}
			for _, t := range o.Tags {
				if err := owner.gitConfig(w, dir, "--add", "repo-render.tag", t); err != nil {
					return err
				}
			}
			return nil
		})
	}

	if len(o.Mirrors) > 0 {
		// A new repository gets a new key, so one left by an old
		// repository of the name isn't used for this one.
		_, err := os.Stat(key)
		if !exists || err != nil {
			add("make its mirror key, "+key, func() error {
				if err := os.MkdirAll(o.Keys, 0o755); err != nil {
					return err
				}
				for _, p := range []string{key, key + ".pub"} {
					if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
						return err
					}
				}
				cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "repo-render "+o.Repo+"@"+cfg.Host, "-f", key)
				cmd.Stdout, cmd.Stderr = w, w
				if err := cmd.Run(); err != nil {
					return fmt.Errorf("ssh-keygen: %w", err)
				}
				return os.Chown(key, owner.uid, owner.gid)
			})
		}
		add("push to the mirrors with it", func() error {
			return owner.run(w, gitx.Program, "-C", dir, "config", "core.sshCommand", SSHCommand(key))
		})
		for _, m := range o.Mirrors {
			host, port := mirror.Host(m)
			if host == "" || knowsHost(o.KnownHosts, host, port) {
				continue
			}
			add("trust "+host+"'s host key, from ssh-keyscan, in "+o.KnownHosts, func() error {
				return addHostKey(o.KnownHosts, host, port)
			})
		}
	}

	// A push renders a new repository; one with commits already is
	// rendered now, so the site shows what changed without one. A render
	// that fails is the next push's to redo.
	if exists && !empty && (placed || (o.Description != "" && o.Description != r.Description())) {
		add("render it, and the index", func() error {
			prog := o.Renderer
			if prog == "" {
				if prog, err = os.Executable(); err != nil {
					return err
				}
			}
			if err := owner.run(w, prog, "--config", cfg.Path, "render", o.Repo); err != nil {
				fmt.Fprintf(w, "warning: rendering %s failed (%v): the next push renders it\n", o.Repo, err)
			}
			return nil
		})
	}

	for _, s := range steps {
		if o.DryRun {
			fmt.Fprintln(w, "would "+s.what)
			continue
		}
		fmt.Fprintln(w, s.what)
		if err := s.do(); err != nil {
			return err
		}
	}
	return nil
}

type step struct {
	what string
	do   func() error
}

// checkMirror refuses a mirror URL that git would take for an option, or
// that isn't one line, or that deploy keys can't push to (not over ssh).
func checkMirror(m string) error {
	if m == "" || strings.HasPrefix(m, "-") || strings.ContainsFunc(m, func(r rune) bool { return r <= ' ' || r == 0x7f }) {
		return fmt.Errorf("mirror %q is not a usable git URL", m)
	}
	if u, err := url.Parse(m); err == nil && strings.Contains(m, "://") && !slices.Contains([]string{"ssh", "git+ssh", "ssh+git", "file"}, u.Scheme) {
		return fmt.Errorf("mirror %s isn't an ssh URL, which a deploy key needs: use one like git@HOST:PATH.git", m)
	}
	return nil
}

func knowsHost(file, host, port string) bool {
	out, err := exec.Command("ssh-keygen", "-F", mirror.Entry(host, port), "-f", file).Output()
	return err == nil && len(out) > 0
}

func addHostKey(file, host, port string) error {
	args := []string{"-T", "10"}
	if port != "" {
		args = append(args, "-p", port)
	}
	out, err := exec.Command("ssh-keyscan", append(args, host)...).Output()
	if err != nil || len(out) == 0 {
		return fmt.Errorf("ssh-keyscan %s found no host key: the hook can't push there until %s has it", host, file)
	}
	f, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
	if err != nil {
		return err
	}
	if _, err := f.Write(out); err != nil {
		_ = f.Close()
		return err
	}
	return f.Close()
}

// newRepoProgram finds new-repo: as given, beside this program, or on
// PATH.
func newRepoProgram(p string) (string, error) {
	if p != "" {
		return p, nil
	}
	if self, err := os.Executable(); err == nil {
		beside := filepath.Join(filepath.Dir(self), "repo-render-new-repo")
		if _, err := os.Stat(beside); err == nil {
			return beside, nil
		}
	}
	return exec.LookPath("repo-render-new-repo")
}

// owner is the user that owns the repositories, whom git runs as.
type owner struct {
	uid, gid int
	home     string
}

func ownerOf(dir string) (owner, error) {
	st, err := os.Stat(dir)
	if err != nil {
		return owner{}, err
	}
	s, ok := st.Sys().(*syscall.Stat_t)
	if !ok {
		return owner{}, fmt.Errorf("%s: can't tell who owns it", dir)
	}
	o := owner{uid: int(s.Uid), gid: int(s.Gid)}
	if u, err := user.LookupId(strconv.Itoa(o.uid)); err == nil {
		o.home = u.HomeDir
	}
	return o, nil
}

// Mirror pushes repo to its mirrors now, as the post-receive hook does
// after a push, and fails if any mirror does; with force, over commits
// made on the mirrors.
func Mirror(w io.Writer, cfg *config.Config, repo string, force bool) error {
	if !config.ValidRepoName(repo) {
		return fmt.Errorf("invalid repository name %q", repo)
	}
	dir := filepath.Join(cfg.GitDir, repo+".git")
	if _, err := os.Stat(dir); err != nil {
		return err
	}
	owner, err := ownerOf(cfg.GitDir)
	if err != nil {
		return err
	}
	args := []string{"mirror"}
	if force {
		args = append(args, "--force")
	}
	if err := owner.runIn(w, dir, filepath.Join(dir, "hooks", "post-receive"), args...); err != nil {
		return errors.New("a mirror push failed")
	}
	return nil
}

// gitConfig runs git config in the repository dir as the owner. Unsetting
// what isn't set is no error.
func (o owner) gitConfig(w io.Writer, dir string, args ...string) error {
	err := o.run(w, gitx.Program, append([]string{"-C", dir, "config"}, args...)...)
	var exit *exec.ExitError
	if args[0] == "--unset-all" && errors.As(err, &exit) && exit.ExitCode() == 5 {
		return nil
	}
	return err
}

// run runs a program as the owner, with its output on w.
func (o owner) run(w io.Writer, prog string, args ...string) error {
	return o.runIn(w, "", prog, args...)
}

// runIn runs a program as the owner in dir.
func (o owner) runIn(w io.Writer, dir, prog string, args ...string) error {
	cmd := exec.Command(prog, args...)
	cmd.Dir = dir
	cmd.Stdout, cmd.Stderr = w, w
	if os.Getuid() != o.uid {
		cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(o.uid), Gid: uint32(o.gid)}}
		cmd.Env = append(os.Environ(), "HOME="+o.home)
	}
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("%s: %w", filepath.Base(prog), err)
	}
	return nil
}