prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 402b653d8b17a6bf1c83cf0986cfef21925fb9b4
parent 944707323a61d63de41a7dc57fb1df09ff30fd8d
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Oct 2026 18:00:52 +0000

ssh: owner key can push, read private repos, create repos

Keys in GIT_OWNER_KEYS (authorized_keys format, re-read on change) get
git-receive-pack and any repo; guests fall through to keyboard-interactive
and stay clone-only on public repos. A push to a new name inits a private
bare repo. Pushes run git-hooks/post-receive, which touches
/srv/git/deploy/<repo> for the host's publish/deploy path unit.

Diffstat:
MDockerfile | 4++++
Agit-hooks/post-receive | 6++++++
Minternal/ssh/git.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Minternal/ssh/git_test.go | 22++++++++++++++++++++++
Ainternal/ssh/owner.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/ssh/owner_test.go | 44++++++++++++++++++++++++++++++++++++++++++++
Minternal/ssh/route.go | 8+++++---
Minternal/ssh/server.go | 11+++++++----
8 files changed, 274 insertions(+), 19 deletions(-)

diff --git a/Dockerfile b/Dockerfile @@ -20,9 +20,13 @@ RUN apk add --no-cache git \ ENV GIT_REPOS_DIR=/srv/git/repos ENV GIT_CLONE_BASE=https://prime8.dev +ENV GIT_HOOKS_DIR=/app/git-hooks +ENV GIT_DEPLOY_DIR=/srv/git/deploy +ENV GIT_OWNER_KEYS=/app/.ssh/owner_keys COPY --from=build /server ./server COPY --from=build /app/static ./static +COPY --from=build /app/git-hooks ./git-hooks ENV SSH_PORT=22 ENV TERM=xterm-256color diff --git a/git-hooks/post-receive b/git-hooks/post-receive @@ -0,0 +1,6 @@ +#!/bin/sh +# Runs in the site container after every push over prime8.dev:22. Touching +# /srv/git/deploy/<repo> wakes the host's git-publish path unit, which +# regenerates the repo's pages and deploys the site when it was prime8dev. +name=$(basename "$(pwd)" .git) +touch "${GIT_DEPLOY_DIR:-/srv/git/deploy}/$name" 2>/dev/null || true diff --git a/internal/ssh/git.go b/internal/ssh/git.go @@ -23,17 +23,25 @@ func gitReposDir() string { return defaultGitReposDir } -// publicRepoDir maps the path a git client asks for ("janitor.git", -// "/janitor", "~/janitor.git") to a public bare repo directory. Only repos -// marked with git-daemon-export-ok are served. -func publicRepoDir(requested string) (string, bool) { +// repoName maps the path a git client asks for ("janitor.git", "/janitor", +// "~/janitor.git") to a bare repo name. +func repoName(requested string) (string, bool) { name := strings.TrimPrefix(strings.TrimSpace(requested), "~") name = strings.Trim(name, "/") name = strings.TrimSuffix(name, ".git") if !repoNameRegex.MatchString(name) || strings.Contains(name, "..") { return "", false } + return name, true +} +// publicRepoDir maps a requested path to a public bare repo directory. Only +// repos marked with git-daemon-export-ok are served to guests. +func publicRepoDir(requested string) (string, bool) { + name, ok := repoName(requested) + if !ok { + return "", false + } dir := filepath.Join(gitReposDir(), name+".git") if _, err := os.Stat(filepath.Join(dir, "git-daemon-export-ok")); err != nil { return "", false @@ -41,22 +49,67 @@ func publicRepoDir(requested string) (string, bool) { return dir, true } -// isGitUploadPack reports whether the session is a git fetch or clone. -func isGitUploadPack(sess charmssh.Session) bool { +// ownerRepoDir maps a requested path to any existing bare repo, private +// ones included. +func ownerRepoDir(requested string) (string, bool) { + name, ok := repoName(requested) + if !ok { + return "", false + } + dir := filepath.Join(gitReposDir(), name+".git") + if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil { + return "", false + } + return dir, true +} + +// gitService returns the git service a session asks for, or "". +func gitService(sess charmssh.Session) string { cmd := sess.Command() - return len(cmd) == 2 && cmd[0] == "git-upload-pack" + if len(cmd) != 2 { + return "" + } + switch cmd[0] { + case "git-upload-pack", "git-receive-pack": + return cmd[0] + } + return "" } -// serveGitUploadPack runs git-upload-pack for a read-only clone or fetch and -// returns the exit code for the session. -func serveGitUploadPack(sess charmssh.Session) int { - dir, ok := publicRepoDir(sess.Command()[1]) +// serveGit runs a git service for the session and returns its exit code. +// Guests may only fetch public repos. The owner may fetch any repo and push, +// and a push to a new name creates it (private until marked export-ok). +func serveGit(sess charmssh.Session, service string, owner bool) int { + requested := sess.Command()[1] + var ( + dir string + ok bool + ) + switch { + case service == "git-upload-pack" && owner: + dir, ok = ownerRepoDir(requested) + case service == "git-upload-pack": + dir, ok = publicRepoDir(requested) + case owner: + dir, ok = ownerRepoDir(requested) + if !ok { + dir, ok = createRepo(sess, requested) + } + default: + _, _ = sess.Stderr().Write([]byte("fatal: push needs the owner's key\n")) + return 128 + } if !ok { _, _ = sess.Stderr().Write([]byte("fatal: repository not found\n")) return 128 } - cmd := exec.CommandContext(sess.Context(), "git-upload-pack", "--strict", "--timeout=60", dir) + var cmd *exec.Cmd + if service == "git-upload-pack" { + cmd = exec.CommandContext(sess.Context(), "git-upload-pack", "--strict", "--timeout=60", dir) + } else { + cmd = exec.CommandContext(sess.Context(), "git", "-c", "core.hooksPath="+gitHooksDir(), "receive-pack", dir) + } cmd.Env = append(os.Environ(), "GIT_PROTOCOL="+sessionEnv(sess, "GIT_PROTOCOL")) cmd.Stdin = sess cmd.Stdout = sess @@ -72,6 +125,30 @@ func serveGitUploadPack(sess charmssh.Session) int { return 0 } +// gitHooksDir holds the hooks every push runs (env GIT_HOOKS_DIR). The repos' +// own hooks are host scripts that don't exist in the container. +func gitHooksDir() string { + if dir := strings.TrimSpace(os.Getenv("GIT_HOOKS_DIR")); dir != "" { + return dir + } + return "/app/git-hooks" +} + +func createRepo(sess charmssh.Session, requested string) (string, bool) { + name, ok := repoName(requested) + if !ok { + return "", false + } + dir := filepath.Join(gitReposDir(), name+".git") + cmd := exec.CommandContext(sess.Context(), "git", "init", "-q", "--bare", "-b", "master", dir) + cmd.Stderr = sess.Stderr() + if err := cmd.Run(); err != nil { + return "", false + } + _, _ = sess.Stderr().Write([]byte("created private repo " + name + ".git\n")) + return dir, true +} + func sessionEnv(sess charmssh.Session, key string) string { for _, kv := range sess.Environ() { if k, v, ok := strings.Cut(kv, "="); ok && k == key { diff --git a/internal/ssh/git_test.go b/internal/ssh/git_test.go @@ -30,3 +30,25 @@ func TestPublicRepoDir(t *testing.T) { } } } + +func TestOwnerRepoDirIncludesPrivate(t *testing.T) { + dir := t.TempDir() + t.Setenv("GIT_REPOS_DIR", dir) + if err := os.MkdirAll(filepath.Join(dir, "mos.git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mos.git", "HEAD"), []byte("ref: refs/heads/master\n"), 0o644); err != nil { + t.Fatal(err) + } + if got, ok := ownerRepoDir("mos.git"); !ok || got != filepath.Join(dir, "mos.git") { + t.Errorf("ownerRepoDir(mos.git) = %q, %v", got, ok) + } + if _, ok := publicRepoDir("mos.git"); ok { + t.Error("private repo served to guests") + } + for _, in := range []string{"missing.git", "../mos.git", "a/mos.git"} { + if _, ok := ownerRepoDir(in); ok { + t.Errorf("ownerRepoDir(%q) accepted", in) + } + } +} diff --git a/internal/ssh/owner.go b/internal/ssh/owner.go @@ -0,0 +1,97 @@ +package ssh + +import ( + "bufio" + "bytes" + "os" + "strings" + "sync" + "time" + + charmssh "github.com/charmbracelet/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// The owner's keys (env GIT_OWNER_KEYS, an authorized_keys file) may push, +// create repos, and read private ones. Every other key, and keyboard- +// interactive, stays a read-only guest. + +const defaultOwnerKeysPath = ".ssh/owner_keys" + +type ownerCtxKey struct{} + +var ( + ownerMu sync.Mutex + ownerKeys [][]byte + ownerModTime time.Time +) + +func ownerKeysPath() string { + if p := strings.TrimSpace(os.Getenv("GIT_OWNER_KEYS")); p != "" { + return p + } + return defaultOwnerKeysPath +} + +// loadOwnerKeys re-reads the keys file when it changes, so adding a key +// needs no restart. +func loadOwnerKeys() [][]byte { + ownerMu.Lock() + defer ownerMu.Unlock() + info, err := os.Stat(ownerKeysPath()) + if err != nil { + ownerKeys, ownerModTime = nil, time.Time{} + return nil + } + if info.ModTime().Equal(ownerModTime) { + return ownerKeys + } + data, err := os.ReadFile(ownerKeysPath()) + if err != nil { + return ownerKeys + } + var keys [][]byte + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + pk, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line)) + if err != nil { + continue + } + keys = append(keys, pk.Marshal()) + } + ownerKeys, ownerModTime = keys, info.ModTime() + return keys +} + +func isOwnerKey(key charmssh.PublicKey) bool { + if key == nil { + return false + } + raw := key.Marshal() + for _, k := range loadOwnerKeys() { + if bytes.Equal(k, raw) { + return true + } + } + return false +} + +// publicKeyAuth accepts only the owner's keys, so a client offering several +// keys reaches the owner's one; guests fall through to keyboard-interactive, +// which asks nothing. +func publicKeyAuth(ctx charmssh.Context, key charmssh.PublicKey) bool { + if isOwnerKey(key) { + ctx.SetValue(ownerCtxKey{}, true) + return true + } + return false +} + +func isOwner(sess charmssh.Session) bool { + v, _ := sess.Context().Value(ownerCtxKey{}).(bool) + return v && isOwnerKey(sess.PublicKey()) +} diff --git a/internal/ssh/owner_test.go b/internal/ssh/owner_test.go @@ -0,0 +1,44 @@ +package ssh + +import ( + "crypto/ed25519" + "crypto/rand" + "os" + "path/filepath" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +func newKey(t *testing.T) gossh.PublicKey { + t.Helper() + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + pk, err := gossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + return pk +} + +func TestOwnerKeys(t *testing.T) { + owner, guest := newKey(t), newKey(t) + path := filepath.Join(t.TempDir(), "owner_keys") + t.Setenv("GIT_OWNER_KEYS", path) + + if isOwnerKey(owner) { + t.Fatal("owner accepted with no keys file") + } + line := "# me\n" + string(gossh.MarshalAuthorizedKey(owner)) + if err := os.WriteFile(path, []byte(line), 0o600); err != nil { + t.Fatal(err) + } + if !isOwnerKey(owner) { + t.Error("owner key not recognised") + } + if isOwnerKey(guest) || isOwnerKey(nil) { + t.Error("guest key treated as owner") + } +} diff --git a/internal/ssh/route.go b/internal/ssh/route.go @@ -14,12 +14,14 @@ func routeMiddleware(logger *slog.Logger) wish.Middleware { ip := remoteIP(sess.RemoteAddr()) user := sess.User() - if isGitUploadPack(sess) { - code := serveGitUploadPack(sess) + if service := gitService(sess); service != "" { + owner := isOwner(sess) + code := serveGit(sess, service, owner) logger.Info("route decision", "user", user, "ip", ip, - "result", "git-upload-pack", + "result", service, + "owner", owner, "command", sess.RawCommand(), "exit", code, ) diff --git a/internal/ssh/server.go b/internal/ssh/server.go @@ -37,9 +37,7 @@ func Run(ctx context.Context, logger *slog.Logger) error { wish.WithHostKeyPath(hostKeyPath), wish.WithIdleTimeout(idleTimeout), wish.WithMaxTimeout(maxTimeout), - wish.WithPublicKeyAuth(func(_ charmssh.Context, _ charmssh.PublicKey) bool { - return true - }), + wish.WithPublicKeyAuth(publicKeyAuth), wish.WithKeyboardInteractiveAuth(func(_ charmssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { return true }), @@ -57,7 +55,12 @@ func Run(ctx context.Context, logger *slog.Logger) error { }), func(s *charmssh.Server) error { s.ServerConfigCallback = func(_ charmssh.Context) *gossh.ServerConfig { - return &gossh.ServerConfig{Config: gossh.Config{KeyExchanges: keyExchanges}} + return &gossh.ServerConfig{ + Config: gossh.Config{KeyExchanges: keyExchanges}, + // Guests' keys are all refused before keyboard-interactive + // lets them in, so don't drop people with many keys. + MaxAuthTries: 32, + } } s.ConnectionFailedCallback = func(conn net.Conn, err error) { logger.Warn("connection failed",