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:
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",