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