krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
ca9fd6021b3910ce1ae78fb6f433760e9b7daf74
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:23:44Z
cmd/forged/main.go | 32 ++++--- cmd/forged/system.go | 105 ++++++++++++++++++++++ e2e/system_test.go | 211 ++++++++++++++++++++++++++++++++++++++++++++ internal/gitutil/gitutil.go | 10 +-- internal/sshd/sshd.go | 49 +++++----- internal/store/users.go | 11 +++ 6 files changed, 379 insertions(+), 39 deletions(-) @@ -53,6 +53,8 @@ func main() { migrateCmd(), adminCmd(), hookCmd(), + authorizedKeysCmd(), + shellCmd(), ) if err := root.Execute(); err != nil { @@ -99,10 +101,6 @@ func serveCmd() *cobra.Command { } defer st.Close() - if cfg.SSH.Mode != "embedded" { - return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode) - } - // Regenerate hook scripts so a moved binary self-heals, then // start the hook policy socket. self, err := os.Executable() @@ -118,18 +116,24 @@ func serveCmd() *cobra.Command { } defer stopHookd() - srv, err := sshd.New(cfg, st) - if err != nil { - return err - } - ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) - if err != nil { - return err + errCh := make(chan error, 3) + if cfg.SSH.Mode == "embedded" { + srv, err := sshd.New(cfg, st) + if err != nil { + return err + } + ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) + if err != nil { + return err + } + slog.Info("ssh listening", "addr", ln.Addr()) + go func() { errCh <- srv.Serve(ln) }() + } else { + // system mode: the host sshd owns the SSH port and invokes + // this binary via AuthorizedKeysCommand + forced command. + slog.Info("ssh handled by host sshd (ssh.mode = system)") } - slog.Info("ssh listening", "addr", ln.Addr()) - errCh := make(chan error, 3) - go func() { errCh <- srv.Serve(ln) }() web := httpd.New(cfg, st) hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()} new file mode 100644 @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + + "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/sshd" +) + +// authorizedKeysCmd backs sshd's AuthorizedKeysCommand in system mode: +// +// AuthorizedKeysCommand /usr/bin/forged --config /etc/forge/config.toml authorized-keys %t %k +// AuthorizedKeysCommandUser git +// +// It prints a forced-command authorized_keys line for registered keys and +// nothing for unknown ones — so unknown keys fail authentication inside +// sshd, before any forge code runs. That is why system mode requires +// registration = "closed". +func authorizedKeysCmd() *cobra.Command { + return &cobra.Command{ + Use: "authorized-keys <key-type> <key-base64>", + Hidden: true, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + st, err := openStore(cfg) + if err != nil { + return err + } + defer st.Close() + + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(args[0] + " " + args[1])) + if err != nil { + return nil // unparseable key: no output, auth fails + } + key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub)) + if err != nil { + return nil // unknown key: no output, auth fails + } + self, err := os.Executable() + if err != nil { + return err + } + fmt.Printf("restrict,command=\"%s --config %s shell --key-id %d\" %s %s\n", + self, configPath, key.ID, args[0], args[1]) + return nil + }, + } +} + +// shellCmd is the forced command sshd runs for an authenticated key. The +// original client command arrives in SSH_ORIGINAL_COMMAND; dispatch is the +// same code path as the embedded listener. +func shellCmd() *cobra.Command { + var keyID int64 + cmd := &cobra.Command{ + Use: "shell", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + st, err := openStore(cfg) + if err != nil { + return err + } + defer st.Close() + + key, err := st.SSHKeyByID(keyID) + if err != nil { + fmt.Fprintln(os.Stderr, "key no longer registered") + os.Exit(protocol.ExitDenied) + } + user, err := st.UserByID(key.UserID) + if err != nil { + fmt.Fprintln(os.Stderr, "account no longer exists") + os.Exit(protocol.ExitDenied) + } + _ = st.TouchSSHKey(key.ID) + + cmdline := os.Getenv("SSH_ORIGINAL_COMMAND") + if cmdline == "" { + fmt.Fprintf(os.Stderr, "forge control plane: interactive shells are not available.\nTry: ssh <host> help\n") + os.Exit(protocol.ExitUsage) + } + code := sshd.Exec(cfg, st, user, key.Scope, cmdline, os.Stdin, os.Stdout, os.Stderr) + st.Close() + os.Exit(code) + return nil + }, + } + cmd.Flags().Int64Var(&keyID, "key-id", 0, "registered key id (set by authorized-keys)") + cmd.MarkFlagRequired("key-id") + return cmd +} new file mode 100644 @@ -0,0 +1,211 @@ +package e2e + +import ( + "fmt" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestSystemSSHMode runs the M1/M2 scenarios against a real host sshd using +// AuthorizedKeysCommand + forced command instead of the embedded listener. +func TestSystemSSHMode(t *testing.T) { + sshdBin := "/usr/sbin/sshd" + if _, err := os.Stat(sshdBin); err != nil { + t.Skipf("no host sshd at %s", sshdBin) + } + me, err := user.Current() + if err != nil { + t.Fatal(err) + } + + // forged in system mode: no embedded SSH listener; hookd + http still run. + inst := startInstanceWith(t, "") // placeholder to reuse helpers; killed below + inst.proc.Process.Kill() + inst.proc.Wait() + cfg := fmt.Sprintf(` +[server] +root = %q +site_url = "https://forge.test" +[ssh] +mode = "system" +[http] +addr = "127.0.0.1:%d" +tls = "off" +`, inst.root, inst.httpPort) + if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + inst.proc = exec.Command(inst.forged, "--config", inst.config, "serve") + inst.proc.Stderr = os.Stderr + if err := inst.proc.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) + + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + strangerKey := inst.newKey(t, "stranger") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + + // Host sshd on a high port as the current user. + sshdDir := t.TempDir() + hostKey := filepath.Join(sshdDir, "host_ed25519") + if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", hostKey).CombinedOutput(); err != nil { + t.Fatalf("host keygen: %v\n%s", err, out) + } + // sshd requires the AuthorizedKeysCommand program itself to be owned by + // root; a test-built binary is not. Use root-owned /bin/sh with a + // wrapper script argument — only the command path is ownership-checked. + wrapper := filepath.Join(sshdDir, "akc.sh") + script := fmt.Sprintf("#!/bin/sh\nexec %q --config %q authorized-keys \"$1\" \"$2\"\n", + inst.forged, inst.config) + if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + sshdPort := freePort(t) + sshdConf := filepath.Join(sshdDir, "sshd_config") + conf := fmt.Sprintf(`Port %d +ListenAddress 127.0.0.1 +HostKey %s +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +AuthorizedKeysFile none +AuthorizedKeysCommand /bin/sh %s %%t %%k +AuthorizedKeysCommandUser %s +StrictModes no +UsePAM no +PidFile %s +LogLevel ERROR +`, sshdPort, hostKey, wrapper, me.Username, filepath.Join(sshdDir, "sshd.pid")) + if err := os.WriteFile(sshdConf, []byte(conf), 0o600); err != nil { + t.Fatal(err) + } + sshd := exec.Command(sshdBin, "-D", "-e", "-f", sshdConf) + sshd.Stderr = os.Stderr + if err := sshd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sshd.Process.Kill(); sshd.Wait() }) + + deadline := time.Now().Add(10 * time.Second) + for { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", sshdPort), 200*time.Millisecond) + if err == nil { + conn.Close() + break + } + if time.Now().After(deadline) { + t.Fatal("sshd did not start") + } + time.Sleep(100 * time.Millisecond) + } + + // ssh helper against the host sshd (login user = current user; identity + // still comes from the key). + sysSSH := func(key, stdin string, args ...string) (string, string, int) { + base := []string{ + "-p", fmt.Sprint(sshdPort), + "-i", key, + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=" + filepath.Join(sshdDir, "kh"), + "-o", "BatchMode=yes", + me.Username + "@127.0.0.1", + } + cmd := exec.Command("ssh", append(base, args...)...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errOut strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errOut + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("ssh: %v", err) + } + return out.String(), errOut.String(), code + } + + // M1: whoami over the host sshd. + out, errOut, code := sysSSH(aliceKey, "", "whoami", "--json") + if code != 0 { + t.Fatalf("whoami via sshd: exit %d\nstdout: %s\nstderr: %s", code, out, errOut) + } + if !strings.Contains(out, `"username":"alice"`) || !strings.Contains(out, `"protocol_version":1`) { + t.Fatalf("whoami output: %s", out) + } + + // Unknown key: authentication fails inside sshd (authorized-keys emits + // nothing), before any forge code runs. + _, _, code = sysSSH(strangerKey, "", "whoami") + if code == 0 { + t.Fatal("stranger authenticated via host sshd") + } + + // Scoped key: registered with git-only scope, denied control commands. + scopedKey := inst.newKey(t, "scoped") + pub, _ := os.ReadFile(scopedKey + ".pub") + if _, errOut, code := sysSSH(aliceKey, string(pub), "keys", "add", "--scope", "git"); code != 0 { + t.Fatalf("keys add: %s", errOut) + } + _, errOut, code = sysSSH(scopedKey, "", "whoami") + if code != 4 || !strings.Contains(errOut, "does not allow control commands") { + t.Fatalf("scoped denial via sshd: exit %d, %s", code, errOut) + } + + // M2: private repo, push, denial, protected branch — through host sshd. + if _, errOut, code = sysSSH(aliceKey, "", "repo", "create", "alice/proj", "--private"); code != 0 { + t.Fatalf("repo create: %s", errOut) + } + sysGitEnv := func(key string) []string { + return append(os.Environ(), + fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes", + key, filepath.Join(sshdDir, "kh")), + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test", + ) + } + urlFor := func(repo string) string { + return fmt.Sprintf("ssh://%s@127.0.0.1:%d/%s.git", me.Username, sshdPort, repo) + } + + work := t.TempDir() + aliceEnv := sysGitEnv(aliceKey) + mustGit(t, work, aliceEnv, "clone", urlFor("alice/proj"), "w") + dir := filepath.Join(work, "w") + os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644) + mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main") + mustGit(t, dir, aliceEnv, "add", ".") + mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init") + mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main") + + // Bob: authenticated but no access — not-found, not permission-denied. + cloneOut, cloneCode := gitRun(t, t.TempDir(), sysGitEnv(bobKey), "clone", urlFor("alice/proj")) + if cloneCode == 0 || !strings.Contains(cloneOut, "repository not found") { + t.Fatalf("bob clone via sshd: %d\n%s", cloneCode, cloneOut) + } + + // Protected branch: the hook path (forced command -> git -> pre-receive + // -> daemon unix socket) refuses the force-push. + if _, errOut, code = sysSSH(aliceKey, "", "repo", "settings", "protect", "alice/proj", "main"); code != 0 { + t.Fatalf("protect: %s", errOut) + } + mustGit(t, dir, aliceEnv, "commit", "-q", "--amend", "-m", "rewritten") + pushOut, pushCode := gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main") + if pushCode == 0 || !strings.Contains(pushOut, "force-push refused") { + t.Fatalf("force-push via sshd: %d\n%s", pushCode, pushOut) + } +} @@ -29,9 +29,9 @@ func InitBare(path, defaultBranch, hooksPath string) error { } // Transport streams one git transport service (upload-pack, receive-pack, -// upload-archive) over rw. extraEnv entries are appended to the daemon's -// environment; hooks read the FORGE_* variables from it. -func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error { +// upload-archive). extraEnv entries are appended to the process environment; +// hooks read the FORGE_* variables from it. +func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error { var args []string switch service { case "git-upload-pack", "git-receive-pack", "git-upload-archive": @@ -41,8 +41,8 @@ func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extra } cmd := exec.Command("git", args...) cmd.Env = append(os.Environ(), extraEnv...) - cmd.Stdin = rw - cmd.Stdout = rw + cmd.Stdin = stdin + cmd.Stdout = stdout cmd.Stderr = errW return cmd.Run() } @@ -8,6 +8,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "log/slog" "net" "os" @@ -184,70 +185,78 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) return protocol.ExitDenied } _ = s.st.TouchSSHKey(keyID) + return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) +} +// Exec runs one SSH exec command line for an authenticated key. It is the +// single dispatch path shared by the embedded listener and the system-sshd +// forced command (forged shell). +func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string, + stdin io.Reader, stdout, stderr io.Writer) int { argv, err := protocol.Tokenize(cmdline) if err != nil { - fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) + fmt.Fprintf(stderr, "cannot parse command: %v\n", err) return protocol.ExitUsage } if len(argv) > 0 { switch argv[0] { case "git-upload-pack", "git-receive-pack", "git-upload-archive": - return s.runGit(ch, user, ext["scope"], argv) + return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) } } ctx := &control.Ctx{ User: user, - Scope: ext["scope"], - Store: s.st, - Cfg: s.cfg, - Stdin: ch, - Stdout: ch, - Stderr: ch.Stderr(), + Scope: scope, + Store: st, + Cfg: cfg, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, } return control.Dispatch(ctx, argv) } // runGit streams a git transport service after access checks. -func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int { +func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string, + stdin io.Reader, stdout, stderr io.Writer) int { service := argv[0] if len(argv) != 2 { - fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service) + fmt.Fprintf(stderr, "usage: %s <path>\n", service) return protocol.ExitUsage } write := service == "git-receive-pack" - repo, err := s.st.RepoByPath(argv[1]) + repo, err := st.RepoByPath(argv[1]) if err != nil { - fmt.Fprintln(ch.Stderr(), "repository not found") + fmt.Fprintln(stderr, "repository not found") return protocol.ExitNotFound } - grant, err := s.st.AccessRole(repo.ID, user.ID) + grant, err := st.AccessRole(repo.ID, user.ID) if err != nil { - fmt.Fprintln(ch.Stderr(), "internal error") + fmt.Fprintln(stderr, "internal error") return protocol.ExitFailure } if !policy.CanRead(user, repo, grant) { // Same answer as nonexistence: private repos must not be enumerable. - fmt.Fprintln(ch.Stderr(), "repository not found") + fmt.Fprintln(stderr, "repository not found") return protocol.ExitNotFound } if !policy.ScopeAllowsGit(scope, repo.Path(), write) { - fmt.Fprintf(ch.Stderr(), "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) + fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) return protocol.ExitDenied } if write && !policy.CanWrite(user, repo, grant) { - fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path()) + fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path()) return protocol.ExitDenied } - dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) + dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) env := []string{ - hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root), + hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root), hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), } - if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil { + if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil { return protocol.ExitFailure } return protocol.ExitOK @@ -198,3 +198,14 @@ func boolInt(b bool) int { func isUniqueErr(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } + +func (s *Store) SSHKeyByID(id int64) (SSHKey, error) { + var k SSHKey + err := s.DB.QueryRow( + "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE id = ?", + id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope) + if errors.Is(err, sql.ErrNoRows) { + return k, ErrNotFound + } + return k, err +}