krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
c0b0f1fca2fb9ed924070f80118015b0cdee2915
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:23:36Z
cmd/forged/main.go | 148 ++++++++++++++++++++++++- e2e/ssh_test.go | 218 +++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 4 + internal/control/control.go | 140 ++++++++++++++++++++++++ internal/control/control_test.go | 56 ++++++++++ internal/control/identity.go | 133 ++++++++++++++++++++++ internal/protocol/tokenize.go | 89 +++++++++++++++ internal/protocol/tokenize_test.go | 94 ++++++++++++++++ internal/sshd/sshd.go | 209 +++++++++++++++++++++++++++++++++++ internal/store/users.go | 185 +++++++++++++++++++++++++++++++ 11 files changed, 1273 insertions(+), 4 deletions(-) @@ -4,14 +4,33 @@ package main import ( "fmt" + "log/slog" + "net" "os" + "path/filepath" + "strconv" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/sshd" "github.com/krazywarez/forge/internal/store" ) +func openStore(cfg config.Config) (*store.Store, error) { + s, err := store.Open(filepath.Join(cfg.Server.Root, "forge.db")) + if err != nil { + return nil, err + } + if err := s.MigrateUp(); err != nil { + s.Close() + return nil, err + } + return s, nil +} + var configPath string func main() { @@ -64,7 +83,29 @@ func serveCmd() *cobra.Command { Use: "serve", Short: "run the ssh, http, and git listeners", RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not implemented (M1)") + cfg, err := config.Load(configPath) + if err != nil { + return err + } + st, err := openStore(cfg) + if err != nil { + return err + } + defer st.Close() + + if cfg.SSH.Mode != "embedded" { + return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode) + } + 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()) + return srv.Serve(ln) }, } } @@ -109,17 +150,116 @@ func adminCmd() *cobra.Command { Use: use, Short: short, RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not implemented (M1)") + return fmt.Errorf("not implemented") }, } } + userCmd := &cobra.Command{Use: "user", Short: "manage users"} + userCmd.AddCommand(adminUserCreateCmd()) + emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} + emailCmd.AddCommand(adminEmailVerifyCmd()) admin.AddCommand( - notImplemented("user", "create and manage users"), + userCmd, + emailCmd, notImplemented("invite", "issue registration invites"), - notImplemented("email", "verify user emails"), notImplemented("backup", "consistent backup: repos first, then database"), notImplemented("gc", "run git gc across repositories"), notImplemented("stats", "instance statistics"), ) return admin } + +func adminUserCreateCmd() *cobra.Command { + var keyPath, email string + var verified, isAdmin bool + cmd := &cobra.Command{ + Use: "create <username>", + Short: "create a user (host-local bootstrap; the only path in closed mode)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + if err := policy.ValidateOwnerName(username); err != nil { + return err + } + cfg, err := config.Load(configPath) + if err != nil { + return err + } + st, err := openStore(cfg) + if err != nil { + return err + } + defer st.Close() + + uid, err := st.CreateUser(username, isAdmin) + if err != nil { + return err + } + if email != "" { + verifiedBy := "" + if verified { + verifiedBy = "admin" + } + if err := st.AddEmail(uid, email, verifiedBy, true); err != nil { + return err + } + } + if keyPath != "" { + raw, err := os.ReadFile(keyPath) + if err != nil { + return err + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) + if err != nil { + return fmt.Errorf("%s: not a public key in authorized_keys format: %w", keyPath, err) + } + fp := ssh.FingerprintSHA256(pub) + if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full"); err != nil { + return err + } + fmt.Println("key", fp) + } + fmt.Println("created user", username) + return nil + }, + } + cmd.Flags().StringVar(&keyPath, "key", "", "path to an SSH public key to register") + cmd.Flags().StringVar(&email, "email", "", "primary email address") + cmd.Flags().BoolVar(&verified, "verified", false, "mark the email verified (admin assertion)") + cmd.Flags().BoolVar(&isAdmin, "admin", false, "grant instance admin") + return cmd +} + +func adminEmailVerifyCmd() *cobra.Command { + return &cobra.Command{ + Use: "verify <username> <address>", + Short: "mark an email verified by admin assertion", + 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() + u, err := st.UserByUsername(args[0]) + if err != nil { + return fmt.Errorf("user %s: %w", args[0], err) + } + res, err := st.DB.Exec( + `UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = 'admin' + WHERE user_id = ? AND address = ?`, u.ID, args[1]) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("no address %s on user %s", args[1], args[0]) + } + fmt.Println("verified", args[1]) + return nil + }, + } +} new file mode 100644 @@ -0,0 +1,218 @@ +// Package e2e drives a real forged with the real ssh and git clients. +package e2e + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +type instance struct { + forged string // path to built binary + root string + config string + port int + proc *exec.Cmd + sshDir string // per-user client keys live here +} + +func buildForged(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "forged") + cmd := exec.Command("go", "build", "-o", bin, "github.com/krazywarez/forge/cmd/forged") + cmd.Dir = ".." + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build forged: %v\n%s", err, out) + } + return bin +} + +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func startInstance(t *testing.T) *instance { + t.Helper() + inst := &instance{ + forged: buildForged(t), + root: t.TempDir(), + port: freePort(t), + sshDir: t.TempDir(), + } + inst.config = filepath.Join(inst.root, "config.toml") + cfg := fmt.Sprintf(` +[server] +root = %q +site_url = "https://forge.test" +[ssh] +port = %d +`, inst.root, inst.port) + 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() + }) + + // Wait for the listener. + deadline := time.Now().Add(10 * time.Second) + for { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond) + if err == nil { + conn.Close() + return inst + } + if time.Now().After(deadline) { + t.Fatal("forged did not start listening") + } + time.Sleep(50 * time.Millisecond) + } +} + +// admin runs a forged admin command against the instance's database. +func (i *instance) admin(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command(i.forged, append([]string{"--config", i.config}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("forged %v: %v\n%s", args, err, out) + } + return string(out) +} + +// newKey generates a client keypair and returns the private key path. +func (i *instance) newKey(t *testing.T, name string) string { + t.Helper() + priv := filepath.Join(i.sshDir, name) + cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("ssh-keygen: %v\n%s", err, out) + } + return priv +} + +// ssh runs the real OpenSSH client against the instance with the given key. +func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) { + t.Helper() + base := []string{ + "-p", fmt.Sprint(i.port), + "-i", key, + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"), + "-o", "BatchMode=yes", + "git@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 +} + +func TestControlPlaneOverBareSSH(t *testing.T) { + inst := startInstance(t) + + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + + // whoami --json from bare OpenSSH. + out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json") + if code != 0 { + t.Fatalf("whoami exit %d, stderr: %s", code, errOut) + } + var env struct { + ProtocolVersion int `json:"protocol_version"` + Data struct { + Username string `json:"username"` + KeyScope string `json:"key_scope"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("whoami output not JSON: %v\n%s", err, out) + } + if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" { + t.Fatalf("whoami = %+v", env) + } + + // Unknown key is refused at auth. + strangerKey := inst.newKey(t, "stranger") + _, _, code = inst.ssh(t, strangerKey, "", "whoami") + if code == 0 { + t.Fatal("unknown key was authenticated") + } + + // keys add over stdin, then list shows both. + secondKey := inst.newKey(t, "alice2") + pub, _ := os.ReadFile(secondKey + ".pub") + out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git") + if code != 0 { + t.Fatalf("keys add exit %d, stderr: %s", code, errOut) + } + out, _, code = inst.ssh(t, aliceKey, "", "keys", "list") + if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 { + t.Fatalf("keys list exit %d:\n%s", code, out) + } + + // The git-scoped key authenticates but is denied control commands. + out, errOut, code = inst.ssh(t, secondKey, "", "whoami") + if code != 4 { + t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut) + } + if !strings.Contains(errOut, "does not allow control commands") { + t.Fatalf("scope denial message missing: %q", errOut) + } + + // Duplicate key registration: bob cannot claim alice's key, and the + // message is the exact spec text, naming no account. + bobKey := inst.newKey(t, "bob") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + alicePub, _ := os.ReadFile(aliceKey + ".pub") + _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add") + if code != 2 { + t.Fatalf("duplicate key add: exit %d, want 2", code) + } + want := "that key is already registered to another account; remove it there first or use a different key" + if !strings.Contains(errOut, want) { + t.Fatalf("duplicate key message = %q, want %q", errOut, want) + } + if strings.Contains(errOut, "alice") { + t.Fatalf("duplicate key message leaks account name: %q", errOut) + } + + // Arguments with spaces survive the tokenizer round trip. + _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'") + if code != 3 { + t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut) + } +} @@ -5,6 +5,7 @@ go 1.27.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.57.0 ) @@ -23,12 +23,16 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= new file mode 100644 @@ -0,0 +1,140 @@ +// Package control implements the forge control commands executed over SSH. +// Every command here is reachable from bare OpenSSH: argv in, JSON or plain +// text on stdout, diagnostics on stderr, exit code out. +package control + +import ( + "encoding/json" + "fmt" + "io" + "slices" + + "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +type Ctx struct { + User store.User + Scope string // scope of the key that authenticated this session + Store *store.Store + Cfg config.Config + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + JSON bool +} + +type Command struct { + Path []string // e.g. ["keys", "add"] + Summary string + ReadsStdin bool + Run func(c *Ctx, args []string) int +} + +var registry []Command + +func register(cmd Command) { registry = append(registry, cmd) } + +// Commands returns the registry, for the bare-ssh reachability test. +func Commands() []Command { return registry } + +// Lookup resolves argv to a command by longest path match, returning the +// command and the remaining arguments. +func Lookup(argv []string) (Command, []string, bool) { + best := -1 + var found Command + for _, cmd := range registry { + if len(cmd.Path) <= len(argv) && slices.Equal(cmd.Path, argv[:len(cmd.Path)]) && len(cmd.Path) > best { + best = len(cmd.Path) + found = cmd + } + } + if best < 0 { + return Command{}, nil, false + } + return found, argv[best:], true +} + +// Dispatch runs argv for an authenticated session. The dispatcher — not the +// handlers — enforces key scope: control commands require a full-scope key. +func Dispatch(c *Ctx, argv []string) int { + if len(argv) == 0 { + return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") + } + cmd, rest, ok := Lookup(argv) + if !ok { + return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) + } + if c.Scope != "full" { + return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) + } + // Strip the global --json flag wherever it appears. + args := rest[:0:0] + for _, a := range rest { + if a == "--json" { + c.JSON = true + continue + } + args = append(args, a) + } + if !cmd.ReadsStdin { + c.Stdin = emptyReader{} + } + return cmd.Run(c, args) +} + +type emptyReader struct{} + +func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } + +// emit writes data as the command result: a JSON envelope under --json, +// otherwise via the plain formatter. +func (c *Ctx) emit(data any, plain func(w io.Writer)) int { + if c.JSON { + enc := json.NewEncoder(c.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Data: data}); err != nil { + return protocol.ExitFailure + } + return protocol.ExitOK + } + plain(c.Stdout) + return protocol.ExitOK +} + +func (c *Ctx) fail(code int, format string, args ...any) int { + msg := fmt.Sprintf(format, args...) + if c.JSON { + enc := json.NewEncoder(c.Stdout) + enc.SetEscapeHTML(false) + enc.Encode(protocol.Envelope{ProtocolVersion: protocol.Version, Error: msg}) + } else { + fmt.Fprintln(c.Stderr, msg) + } + return code +} + +func init() { + register(Command{ + Path: []string{"help"}, + Summary: "list available commands", + Run: func(c *Ctx, args []string) int { + for _, cmd := range registry { + fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary) + } + return protocol.ExitOK + }, + }) +} + +func joinPath(p []string) string { + out := "" + for i, s := range p { + if i > 0 { + out += " " + } + out += s + } + return out +} new file mode 100644 @@ -0,0 +1,56 @@ +package control + +import ( + "strings" + "testing" + + "github.com/krazywarez/forge/internal/protocol" +) + +// TestEveryCommandReachableFromBareSSH asserts that each registered command's +// path, rendered exactly as a user would type it after `ssh <host>`, resolves +// back to that command through the tokenizer and Lookup. This is the guard +// that keeps the forge CLI optional. +func TestEveryCommandReachableFromBareSSH(t *testing.T) { + cmds := Commands() + if len(cmds) == 0 { + t.Fatal("no commands registered") + } + for _, cmd := range cmds { + line := strings.Join(cmd.Path, " ") + argv, err := protocol.Tokenize(line) + if err != nil { + t.Errorf("command %q not tokenizable: %v", line, err) + continue + } + got, rest, ok := Lookup(argv) + if !ok { + t.Errorf("command %q not found by Lookup", line) + continue + } + if strings.Join(got.Path, " ") != line || len(rest) != 0 { + t.Errorf("Lookup(%q) resolved to %q with rest %v", line, strings.Join(got.Path, " "), rest) + } + if cmd.Run == nil { + t.Errorf("command %q has no Run", line) + } + if cmd.Summary == "" { + t.Errorf("command %q has no summary", line) + } + } +} + +func TestLookupLongestMatch(t *testing.T) { + // "keys list" must not resolve to a hypothetical shorter prefix and + // unknown commands must not match. + if _, _, ok := Lookup([]string{"keys"}); ok { + t.Error("bare \"keys\" resolved; group prefixes must not be runnable") + } + if _, _, ok := Lookup([]string{"nope"}); ok { + t.Error("unknown command resolved") + } + cmd, rest, ok := Lookup([]string{"keys", "list", "--json"}) + if !ok || strings.Join(cmd.Path, " ") != "keys list" || len(rest) != 1 { + t.Errorf("Lookup keys list --json = %v %v %v", cmd.Path, rest, ok) + } +} new file mode 100644 @@ -0,0 +1,133 @@ +package control + +import ( + "errors" + "fmt" + "io" + + "golang.org/x/crypto/ssh" + + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +func init() { + register(Command{ + Path: []string{"whoami"}, + Summary: "show the authenticated account", + Run: runWhoami, + }) + register(Command{ + Path: []string{"keys", "list"}, + Summary: "list registered SSH keys", + Run: runKeysList, + }) + register(Command{ + Path: []string{"keys", "add"}, + Summary: "register an SSH public key (authorized_keys format on stdin) [--scope full|git]", + ReadsStdin: true, + Run: runKeysAdd, + }) + register(Command{ + Path: []string{"keys", "remove"}, + Summary: "remove an SSH key by fingerprint", + Run: runKeysRemove, + }) +} + +func runWhoami(c *Ctx, args []string) int { + if len(args) != 0 { + return c.fail(protocol.ExitUsage, "usage: whoami [--json]") + } + type out struct { + Username string `json:"username"` + Admin bool `json:"admin"` + KeyScope string `json:"key_scope"` + } + d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope} + return c.emit(d, func(w io.Writer) { + fmt.Fprintln(w, d.Username) + }) +} + +func runKeysList(c *Ctx, args []string) int { + if len(args) != 0 { + return c.fail(protocol.ExitUsage, "usage: keys list [--json]") + } + keys, err := c.Store.ListSSHKeys(c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "listing keys: %v", err) + } + type out struct { + Fingerprint string `json:"fingerprint"` + Algo string `json:"algo"` + Scope string `json:"scope"` + } + var ds []out + for _, k := range keys { + ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope}) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope) + } + }) +} + +func runKeysAdd(c *Ctx, args []string) int { + scope := "full" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--scope": + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--scope requires a value") + } + scope = args[i+1] + i++ + default: + return c.fail(protocol.ExitUsage, "usage: keys add [--scope full|git] < key.pub") + } + } + if scope != "full" && scope != "git" { + // deploy:* scopes are granted via repo settings, not self-service. + return c.fail(protocol.ExitUsage, "scope must be full or git") + } + raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) + if err != nil { + return c.fail(protocol.ExitFailure, "reading key: %v", err) + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) + if err != nil { + return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) + } + fp := ssh.FingerprintSHA256(pub) + if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope); err != nil { + if errors.Is(err, store.ErrDuplicateKey) { + return c.fail(protocol.ExitUsage, "%v", err) + } + return c.fail(protocol.ExitFailure, "adding key: %v", err) + } + type out struct { + Fingerprint string `json:"fingerprint"` + Scope string `json:"scope"` + } + d := out{fp, scope} + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope) + }) +} + +func runKeysRemove(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: keys remove <fingerprint>") + } + if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0]) + } + return c.fail(protocol.ExitFailure, "removing key: %v", err) + } + return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) { + fmt.Fprintf(w, "removed %s\n", args[0]) + }) +} new file mode 100644 @@ -0,0 +1,89 @@ +package protocol + +import ( + "errors" + "fmt" + "strings" +) + +// Tokenize splits an SSH exec command string into argv using POSIX +// shell-word rules: whitespace separates words; single quotes preserve +// everything literally; double quotes preserve everything except backslash +// before \, ", or $; a bare backslash escapes the next character. There is +// no expansion of any kind — no globbing, variables, or substitution. The +// client's shell has already applied one layer of quoting before the string +// reaches us. +func Tokenize(s string) ([]string, error) { + var argv []string + var cur strings.Builder + inWord := false + + i := 0 + for i < len(s) { + c := s[i] + switch { + case c == ' ' || c == '\t' || c == '\n': + if inWord { + argv = append(argv, cur.String()) + cur.Reset() + inWord = false + } + i++ + case c == '\'': + inWord = true + end := strings.IndexByte(s[i+1:], '\'') + if end < 0 { + return nil, errors.New("unterminated single quote") + } + cur.WriteString(s[i+1 : i+1+end]) + i += end + 2 + case c == '"': + inWord = true + i++ + closed := false + for i < len(s) { + c = s[i] + if c == '"' { + closed = true + i++ + break + } + if c == '\\' && i+1 < len(s) { + switch s[i+1] { + case '\\', '"', '$', '`': + cur.WriteByte(s[i+1]) + i += 2 + continue + } + } + cur.WriteByte(c) + i++ + } + if !closed { + return nil, errors.New("unterminated double quote") + } + case c == '\\': + if i+1 >= len(s) { + return nil, errors.New("trailing backslash") + } + inWord = true + cur.WriteByte(s[i+1]) + i += 2 + case c == '$' || c == '`' || c == ';' || c == '&' || c == '|' || + c == '<' || c == '>' || c == '(' || c == ')' || c == '*' || + c == '?' || c == '[' || c == '#' || c == '~': + // Unquoted shell metacharacters are rejected outright rather + // than passed through: there is no shell here, and silently + // treating them as literals would mask client quoting bugs. + return nil, fmt.Errorf("unquoted shell metacharacter %q", c) + default: + inWord = true + cur.WriteByte(c) + i++ + } + } + if inWord { + argv = append(argv, cur.String()) + } + return argv, nil +} new file mode 100644 @@ -0,0 +1,94 @@ +package protocol + +import ( + "reflect" + "strings" + "testing" +) + +func TestTokenize(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {`whoami --json`, []string{"whoami", "--json"}}, + {`repo create krz/newthing --private`, []string{"repo", "create", "krz/newthing", "--private"}}, + {`git-upload-pack '/krz/hutch.git'`, []string{"git-upload-pack", "/krz/hutch.git"}}, + {`issue create --title 'a b c'`, []string{"issue", "create", "--title", "a b c"}}, + {`issue create --title "a \"b\" c"`, []string{"issue", "create", "--title", `a "b" c`}}, + {`a\ b`, []string{"a b"}}, + {`'it''s'`, []string{"its"}}, + {`"don't"`, []string{"don't"}}, + {" spaced \t out ", []string{"spaced", "out"}}, + {`""`, []string{""}}, + {``, nil}, + {`--message "line1\nliteral"`, []string{"--message", `line1\nliteral`}}, + } + for _, tc := range cases { + got, err := Tokenize(tc.in) + if err != nil { + t.Errorf("Tokenize(%q) error: %v", tc.in, err) + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Tokenize(%q) = %#v, want %#v", tc.in, got, tc.want) + } + } +} + +func TestTokenizeRejects(t *testing.T) { + bad := []string{ + `echo $(rm -rf /)`, + "`id`", + `a; b`, + `a | b`, + `a > f`, + `a & b`, + `'unterminated`, + `"unterminated`, + `trailing\`, + `glob *`, + `~root`, + } + for _, in := range bad { + if got, err := Tokenize(in); err == nil { + t.Errorf("Tokenize(%q) = %#v, want error", in, got) + } + } +} + +// shellQuote quotes one word the way a POSIX client shell would. +func shellQuote(w string) string { + return "'" + strings.ReplaceAll(w, "'", `'\''`) + "'" +} + +// FuzzTokenizeRoundTrip checks that any argv, single-quoted as a client +// shell would emit it, tokenizes back to the identical argv. +func FuzzTokenizeRoundTrip(f *testing.F) { + f.Add("whoami", "--json", "") + f.Add("issue create", "--title", "a 'quoted' \"title\" with $pecial\\chars") + f.Add("répo", "\t", "\n\n") + f.Fuzz(func(t *testing.T, a, b, c string) { + want := []string{a, b, c} + quoted := make([]string, len(want)) + for i, w := range want { + quoted[i] = shellQuote(w) + } + got, err := Tokenize(strings.Join(quoted, " ")) + if err != nil { + t.Fatalf("Tokenize error on %q: %v", strings.Join(quoted, " "), err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip: got %#v, want %#v", got, want) + } + }) +} + +// FuzzTokenizeNoPanic feeds arbitrary bytes; Tokenize must return, never panic. +func FuzzTokenizeNoPanic(f *testing.F) { + f.Add(`repo create 'x`) + f.Add(`\\\'\"`) + f.Fuzz(func(t *testing.T, s string) { + _, _ = Tokenize(s) + }) +} new file mode 100644 @@ -0,0 +1,209 @@ +// Package sshd implements the embedded SSH listener: public-key auth against +// registered keys, then dispatch to git transport or control commands. +package sshd + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/crypto/ssh" + + "github.com/krazywarez/forge/internal/config" + "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +type Server struct { + cfg config.Config + st *store.Store + sshCfg *ssh.ServerConfig +} + +func New(cfg config.Config, st *store.Store) (*Server, error) { + s := &Server{cfg: cfg, st: st} + + sc := &ssh.ServerConfig{ + PublicKeyCallback: s.authenticate, + ServerVersion: "SSH-2.0-forged", + } + signers, err := loadHostKeys(cfg) + if err != nil { + return nil, err + } + for _, sg := range signers { + sc.AddHostKey(sg) + } + s.sshCfg = sc + return s, nil +} + +// loadHostKeys loads the configured host keys, or generates an ed25519 key +// under server.root/ssh/ when none are configured. +func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) { + paths := cfg.SSH.HostKeys + if len(paths) == 0 { + p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519") + if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { + if err := generateHostKey(p); err != nil { + return nil, fmt.Errorf("generating host key: %w", err) + } + slog.Info("generated ssh host key", "path", p) + } + paths = []string{p} + } + var signers []ssh.Signer + for _, p := range paths { + raw, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("host key %s: %w", p, err) + } + sg, err := ssh.ParsePrivateKey(raw) + if err != nil { + return nil, fmt.Errorf("host key %s: %w", p, err) + } + signers = append(signers, sg) + } + return signers, nil +} + +func generateHostKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + block, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return err + } + return os.WriteFile(path, pem.EncodeToMemory(block), 0o600) +} + +// authenticate resolves the presented key to a registered account. The SSH +// username is ignored; identity comes from the key alone. +func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { + fp := ssh.FingerprintSHA256(pub) + key, err := s.st.SSHKeyByFingerprint(fp) + if err != nil { + return nil, fmt.Errorf("unknown key %s", fp) + } + return &ssh.Permissions{Extensions: map[string]string{ + "user-id": strconv.FormatInt(key.UserID, 10), + "key-id": strconv.FormatInt(key.ID, 10), + "scope": key.Scope, + }}, nil +} + +// Serve accepts connections on ln until it is closed. +func (s *Server) Serve(ln net.Listener) error { + for { + conn, err := ln.Accept() + if err != nil { + return err + } + go s.handleConn(conn) + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg) + if err != nil { + return + } + defer sconn.Close() + go ssh.DiscardRequests(reqs) + + for newCh := range chans { + if newCh.ChannelType() != "session" { + newCh.Reject(ssh.UnknownChannelType, "only session channels are supported") + continue + } + ch, chReqs, err := newCh.Accept() + if err != nil { + continue + } + go s.handleSession(sconn, ch, chReqs) + } +} + +func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { + defer ch.Close() + for req := range reqs { + switch req.Type { + case "exec": + var payload struct{ Command string } + if err := ssh.Unmarshal(req.Payload, &payload); err != nil { + req.Reply(false, nil) + continue + } + req.Reply(true, nil) + code := s.runExec(sconn, ch, payload.Command) + sendExit(ch, code) + return + case "shell": + req.Reply(true, nil) + fmt.Fprintf(ch, "forge control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL) + sendExit(ch, protocol.ExitUsage) + return + case "pty-req", "env": + // Harmless; accept and ignore. + req.Reply(true, nil) + default: + req.Reply(false, nil) + } + } +} + +func sendExit(ch ssh.Channel, code int) { + var msg = struct{ Status uint32 }{uint32(code)} + ch.SendRequest("exit-status", false, ssh.Marshal(&msg)) +} + +func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { + ext := sconn.Permissions.Extensions + userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) + keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) + user, err := s.st.UserByID(userID) + if err != nil { + fmt.Fprintln(ch.Stderr(), "account no longer exists") + return protocol.ExitDenied + } + _ = s.st.TouchSSHKey(keyID) + + if name, _, ok := strings.Cut(cmdline, " "); ok || name != "" { + switch name { + case "git-upload-pack", "git-receive-pack", "git-upload-archive": + fmt.Fprintln(ch.Stderr(), "git transport not implemented (M2)") + return protocol.ExitFailure + } + } + + argv, err := protocol.Tokenize(cmdline) + if err != nil { + fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) + return protocol.ExitUsage + } + ctx := &control.Ctx{ + User: user, + Scope: ext["scope"], + Store: s.st, + Cfg: s.cfg, + Stdin: ch, + Stdout: ch, + Stderr: ch.Stderr(), + } + return control.Dispatch(ctx, argv) +} new file mode 100644 @@ -0,0 +1,185 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +type User struct { + ID int64 + Username string + IsAdmin bool +} + +type SSHKey struct { + ID int64 + UserID int64 + Fingerprint string + Algo string + Blob []byte + Scope string +} + +// ErrDuplicateKey carries the exact user-facing message from the spec. It +// deliberately does not name the owning account (enumeration oracle). +var ErrDuplicateKey = errors.New("that key is already registered to another account; remove it there first or use a different key") + +var ErrNotFound = errors.New("not found") + +func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { + res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) + if err != nil { + if isUniqueErr(err) { + return 0, fmt.Errorf("username %q is taken", username) + } + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) UserByUsername(name string) (User, error) { + var u User + var admin int + err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). + Scan(&u.ID, &u.Username, &admin) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.IsAdmin = admin != 0 + return u, err +} + +func (s *Store) UserByID(id int64) (User, error) { + var u User + var admin int + err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). + Scan(&u.ID, &u.Username, &admin) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.IsAdmin = admin != 0 + return u, err +} + +// AddSSHKey registers a key and bumps the key epoch in one transaction. +func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec( + "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)", + userID, fingerprint, algo, blob, scope); err != nil { + if isUniqueErr(err) { + return ErrDuplicateKey + } + return err + } + if err := bumpKeyEpoch(tx); err != nil { + return err + } + return tx.Commit() +} + +// RemoveSSHKey removes a key owned by userID and bumps the key epoch. +func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + if err := bumpKeyEpoch(tx); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { + var k SSHKey + err := s.DB.QueryRow( + "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?", + fingerprint).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 +} + +func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { + rows, err := s.DB.Query( + "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE user_id = ? ORDER BY id", + userID) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []SSHKey + for rows.Next() { + var k SSHKey + if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} + +// TouchSSHKey records key use; best-effort, callers ignore the error. +func (s *Store) TouchSSHKey(id int64) error { + _, err := s.DB.Exec( + "UPDATE ssh_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id) + return err +} + +// AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin". +func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error { + var vAt, vBy any + if verifiedBy != "" { + vAt = "now" + vBy = verifiedBy + } + _, err := s.DB.Exec( + `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) + VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`, + userID, address, vAt, vBy, boolInt(primary)) + if isUniqueErr(err) { + return fmt.Errorf("address %q is already in use", address) + } + return err +} + +func (s *Store) KeyEpoch() (int64, error) { + var v int64 + err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&v) + return v, err +} + +type execer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func bumpKeyEpoch(tx execer) error { + _, err := tx.Exec("UPDATE settings SET value = value + 1 WHERE key = 'key_epoch'") + return err +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func isUniqueErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +}