Commit ca9fd6021b

ca9fd6021b3910ce1ae78fb6f433760e9b7daf74

parent: a0dd5878fb

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 23:23 UTC

M9: ssh.mode = system (host sshd)

- forged authorized-keys: AuthorizedKeysCommand backend; prints a
  restrict,command="forged shell --key-id N" line for registered keys
  and nothing for unknown ones, so strangers fail auth inside sshd
- forged shell: forced command; reads SSH_ORIGINAL_COMMAND and runs the
  same dispatch path as the embedded listener (sshd.Exec, extracted);
  opens the store directly (WAL) while hooks still reach the daemon
  over the unix socket
- serve skips the SSH listener in system mode; http/git-daemon/hookd
  unchanged
- gitutil.Transport takes separate stdin/stdout for process stdio
- e2e against a real unprivileged /usr/sbin/sshd: whoami --json,
  unknown-key auth failure, git-scope control denial, private repo
  create/push, bob not-found denial, protected-branch force-push
  refused through the full forced-command -> hook -> daemon path;
  AuthorizedKeysCommand routed via root-owned /bin/sh wrapper (sshd
  requires the command binary be root-owned)

Layout: unified · split

cmd/forged/main.go +18 −14
@@ -53,6 +53,8 @@ func main() {
53 migrateCmd(), 53 migrateCmd(),
54 adminCmd(), 54 adminCmd(),
55 hookCmd(), 55 hookCmd(),
56 authorizedKeysCmd(),
57 shellCmd(),
56 ) 58 )
57 59
58 if err := root.Execute(); err != nil { 60 if err := root.Execute(); err != nil {
@@ -99,10 +101,6 @@ func serveCmd() *cobra.Command {
99 } 101 }
100 defer st.Close() 102 defer st.Close()
101 103
102 if cfg.SSH.Mode != "embedded" {
103 return fmt.Errorf("ssh.mode = %q not implemented (M9)", cfg.SSH.Mode)
104 }
105
106 // Regenerate hook scripts so a moved binary self-heals, then 104 // Regenerate hook scripts so a moved binary self-heals, then
107 // start the hook policy socket. 105 // start the hook policy socket.
108 self, err := os.Executable() 106 self, err := os.Executable()
@@ -118,18 +116,24 @@ func serveCmd() *cobra.Command {
118 } 116 }
119 defer stopHookd() 117 defer stopHookd()
120 118
121 srv, err := sshd.New(cfg, st) 119 errCh := make(chan error, 3)
122 if err != nil { 120 if cfg.SSH.Mode == "embedded" {
123 return err 121 srv, err := sshd.New(cfg, st)
124 } 122 if err != nil {
125 ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) 123 return err
126 if err != nil { 124 }
127 return err 125 ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port)))
126 if err != nil {
127 return err
128 }
129 slog.Info("ssh listening", "addr", ln.Addr())
130 go func() { errCh <- srv.Serve(ln) }()
131 } else {
132 // system mode: the host sshd owns the SSH port and invokes
133 // this binary via AuthorizedKeysCommand + forced command.
134 slog.Info("ssh handled by host sshd (ssh.mode = system)")
128 } 135 }
129 slog.Info("ssh listening", "addr", ln.Addr())
130 136
131 errCh := make(chan error, 3)
132 go func() { errCh <- srv.Serve(ln) }()
133 137
134 web := httpd.New(cfg, st) 138 web := httpd.New(cfg, st)
135 hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()} 139 hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()}
cmd/forged/system.go added +105
@@ -0,0 +1,105 @@
1package main
2
3import (
4 "fmt"
5 "os"
6
7 "github.com/spf13/cobra"
8 "golang.org/x/crypto/ssh"
9
10 "github.com/krazywarez/forge/internal/config"
11 "github.com/krazywarez/forge/internal/protocol"
12 "github.com/krazywarez/forge/internal/sshd"
13)
14
15// authorizedKeysCmd backs sshd's AuthorizedKeysCommand in system mode:
16//
17// AuthorizedKeysCommand /usr/bin/forged --config /etc/forge/config.toml authorized-keys %t %k
18// AuthorizedKeysCommandUser git
19//
20// It prints a forced-command authorized_keys line for registered keys and
21// nothing for unknown ones — so unknown keys fail authentication inside
22// sshd, before any forge code runs. That is why system mode requires
23// registration = "closed".
24func authorizedKeysCmd() *cobra.Command {
25 return &cobra.Command{
26 Use: "authorized-keys <key-type> <key-base64>",
27 Hidden: true,
28 Args: cobra.ExactArgs(2),
29 RunE: func(cmd *cobra.Command, args []string) error {
30 cfg, err := config.Load(configPath)
31 if err != nil {
32 return err
33 }
34 st, err := openStore(cfg)
35 if err != nil {
36 return err
37 }
38 defer st.Close()
39
40 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(args[0] + " " + args[1]))
41 if err != nil {
42 return nil // unparseable key: no output, auth fails
43 }
44 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
45 if err != nil {
46 return nil // unknown key: no output, auth fails
47 }
48 self, err := os.Executable()
49 if err != nil {
50 return err
51 }
52 fmt.Printf("restrict,command=\"%s --config %s shell --key-id %d\" %s %s\n",
53 self, configPath, key.ID, args[0], args[1])
54 return nil
55 },
56 }
57}
58
59// shellCmd is the forced command sshd runs for an authenticated key. The
60// original client command arrives in SSH_ORIGINAL_COMMAND; dispatch is the
61// same code path as the embedded listener.
62func shellCmd() *cobra.Command {
63 var keyID int64
64 cmd := &cobra.Command{
65 Use: "shell",
66 Hidden: true,
67 Args: cobra.NoArgs,
68 RunE: func(cmd *cobra.Command, args []string) error {
69 cfg, err := config.Load(configPath)
70 if err != nil {
71 return err
72 }
73 st, err := openStore(cfg)
74 if err != nil {
75 return err
76 }
77 defer st.Close()
78
79 key, err := st.SSHKeyByID(keyID)
80 if err != nil {
81 fmt.Fprintln(os.Stderr, "key no longer registered")
82 os.Exit(protocol.ExitDenied)
83 }
84 user, err := st.UserByID(key.UserID)
85 if err != nil {
86 fmt.Fprintln(os.Stderr, "account no longer exists")
87 os.Exit(protocol.ExitDenied)
88 }
89 _ = st.TouchSSHKey(key.ID)
90
91 cmdline := os.Getenv("SSH_ORIGINAL_COMMAND")
92 if cmdline == "" {
93 fmt.Fprintf(os.Stderr, "forge control plane: interactive shells are not available.\nTry: ssh <host> help\n")
94 os.Exit(protocol.ExitUsage)
95 }
96 code := sshd.Exec(cfg, st, user, key.Scope, cmdline, os.Stdin, os.Stdout, os.Stderr)
97 st.Close()
98 os.Exit(code)
99 return nil
100 },
101 }
102 cmd.Flags().Int64Var(&keyID, "key-id", 0, "registered key id (set by authorized-keys)")
103 cmd.MarkFlagRequired("key-id")
104 return cmd
105}
e2e/system_test.go added +211
@@ -0,0 +1,211 @@
1package e2e
2
3import (
4 "fmt"
5 "net"
6 "os"
7 "os/exec"
8 "os/user"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13)
14
15// TestSystemSSHMode runs the M1/M2 scenarios against a real host sshd using
16// AuthorizedKeysCommand + forced command instead of the embedded listener.
17func TestSystemSSHMode(t *testing.T) {
18 sshdBin := "/usr/sbin/sshd"
19 if _, err := os.Stat(sshdBin); err != nil {
20 t.Skipf("no host sshd at %s", sshdBin)
21 }
22 me, err := user.Current()
23 if err != nil {
24 t.Fatal(err)
25 }
26
27 // forged in system mode: no embedded SSH listener; hookd + http still run.
28 inst := startInstanceWith(t, "") // placeholder to reuse helpers; killed below
29 inst.proc.Process.Kill()
30 inst.proc.Wait()
31 cfg := fmt.Sprintf(`
32[server]
33root = %q
34site_url = "https://forge.test"
35[ssh]
36mode = "system"
37[http]
38addr = "127.0.0.1:%d"
39tls = "off"
40`, inst.root, inst.httpPort)
41 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
42 t.Fatal(err)
43 }
44 inst.proc = exec.Command(inst.forged, "--config", inst.config, "serve")
45 inst.proc.Stderr = os.Stderr
46 if err := inst.proc.Start(); err != nil {
47 t.Fatal(err)
48 }
49 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
50
51 aliceKey := inst.newKey(t, "alice")
52 bobKey := inst.newKey(t, "bob")
53 strangerKey := inst.newKey(t, "stranger")
54 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
55 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
56
57 // Host sshd on a high port as the current user.
58 sshdDir := t.TempDir()
59 hostKey := filepath.Join(sshdDir, "host_ed25519")
60 if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", hostKey).CombinedOutput(); err != nil {
61 t.Fatalf("host keygen: %v\n%s", err, out)
62 }
63 // sshd requires the AuthorizedKeysCommand program itself to be owned by
64 // root; a test-built binary is not. Use root-owned /bin/sh with a
65 // wrapper script argument — only the command path is ownership-checked.
66 wrapper := filepath.Join(sshdDir, "akc.sh")
67 script := fmt.Sprintf("#!/bin/sh\nexec %q --config %q authorized-keys \"$1\" \"$2\"\n",
68 inst.forged, inst.config)
69 if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil {
70 t.Fatal(err)
71 }
72
73 sshdPort := freePort(t)
74 sshdConf := filepath.Join(sshdDir, "sshd_config")
75 conf := fmt.Sprintf(`Port %d
76ListenAddress 127.0.0.1
77HostKey %s
78PasswordAuthentication no
79KbdInteractiveAuthentication no
80PubkeyAuthentication yes
81AuthorizedKeysFile none
82AuthorizedKeysCommand /bin/sh %s %%t %%k
83AuthorizedKeysCommandUser %s
84StrictModes no
85UsePAM no
86PidFile %s
87LogLevel ERROR
88`, sshdPort, hostKey, wrapper, me.Username, filepath.Join(sshdDir, "sshd.pid"))
89 if err := os.WriteFile(sshdConf, []byte(conf), 0o600); err != nil {
90 t.Fatal(err)
91 }
92 sshd := exec.Command(sshdBin, "-D", "-e", "-f", sshdConf)
93 sshd.Stderr = os.Stderr
94 if err := sshd.Start(); err != nil {
95 t.Fatal(err)
96 }
97 t.Cleanup(func() { sshd.Process.Kill(); sshd.Wait() })
98
99 deadline := time.Now().Add(10 * time.Second)
100 for {
101 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", sshdPort), 200*time.Millisecond)
102 if err == nil {
103 conn.Close()
104 break
105 }
106 if time.Now().After(deadline) {
107 t.Fatal("sshd did not start")
108 }
109 time.Sleep(100 * time.Millisecond)
110 }
111
112 // ssh helper against the host sshd (login user = current user; identity
113 // still comes from the key).
114 sysSSH := func(key, stdin string, args ...string) (string, string, int) {
115 base := []string{
116 "-p", fmt.Sprint(sshdPort),
117 "-i", key,
118 "-o", "IdentitiesOnly=yes",
119 "-o", "StrictHostKeyChecking=no",
120 "-o", "UserKnownHostsFile=" + filepath.Join(sshdDir, "kh"),
121 "-o", "BatchMode=yes",
122 me.Username + "@127.0.0.1",
123 }
124 cmd := exec.Command("ssh", append(base, args...)...)
125 if stdin != "" {
126 cmd.Stdin = strings.NewReader(stdin)
127 }
128 var out, errOut strings.Builder
129 cmd.Stdout = &out
130 cmd.Stderr = &errOut
131 err := cmd.Run()
132 code := 0
133 if ee, ok := err.(*exec.ExitError); ok {
134 code = ee.ExitCode()
135 } else if err != nil {
136 t.Fatalf("ssh: %v", err)
137 }
138 return out.String(), errOut.String(), code
139 }
140
141 // M1: whoami over the host sshd.
142 out, errOut, code := sysSSH(aliceKey, "", "whoami", "--json")
143 if code != 0 {
144 t.Fatalf("whoami via sshd: exit %d\nstdout: %s\nstderr: %s", code, out, errOut)
145 }
146 if !strings.Contains(out, `"username":"alice"`) || !strings.Contains(out, `"protocol_version":1`) {
147 t.Fatalf("whoami output: %s", out)
148 }
149
150 // Unknown key: authentication fails inside sshd (authorized-keys emits
151 // nothing), before any forge code runs.
152 _, _, code = sysSSH(strangerKey, "", "whoami")
153 if code == 0 {
154 t.Fatal("stranger authenticated via host sshd")
155 }
156
157 // Scoped key: registered with git-only scope, denied control commands.
158 scopedKey := inst.newKey(t, "scoped")
159 pub, _ := os.ReadFile(scopedKey + ".pub")
160 if _, errOut, code := sysSSH(aliceKey, string(pub), "keys", "add", "--scope", "git"); code != 0 {
161 t.Fatalf("keys add: %s", errOut)
162 }
163 _, errOut, code = sysSSH(scopedKey, "", "whoami")
164 if code != 4 || !strings.Contains(errOut, "does not allow control commands") {
165 t.Fatalf("scoped denial via sshd: exit %d, %s", code, errOut)
166 }
167
168 // M2: private repo, push, denial, protected branch — through host sshd.
169 if _, errOut, code = sysSSH(aliceKey, "", "repo", "create", "alice/proj", "--private"); code != 0 {
170 t.Fatalf("repo create: %s", errOut)
171 }
172 sysGitEnv := func(key string) []string {
173 return append(os.Environ(),
174 fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
175 key, filepath.Join(sshdDir, "kh")),
176 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
177 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
178 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
179 )
180 }
181 urlFor := func(repo string) string {
182 return fmt.Sprintf("ssh://%s@127.0.0.1:%d/%s.git", me.Username, sshdPort, repo)
183 }
184
185 work := t.TempDir()
186 aliceEnv := sysGitEnv(aliceKey)
187 mustGit(t, work, aliceEnv, "clone", urlFor("alice/proj"), "w")
188 dir := filepath.Join(work, "w")
189 os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644)
190 mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
191 mustGit(t, dir, aliceEnv, "add", ".")
192 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
193 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
194
195 // Bob: authenticated but no access — not-found, not permission-denied.
196 cloneOut, cloneCode := gitRun(t, t.TempDir(), sysGitEnv(bobKey), "clone", urlFor("alice/proj"))
197 if cloneCode == 0 || !strings.Contains(cloneOut, "repository not found") {
198 t.Fatalf("bob clone via sshd: %d\n%s", cloneCode, cloneOut)
199 }
200
201 // Protected branch: the hook path (forced command -> git -> pre-receive
202 // -> daemon unix socket) refuses the force-push.
203 if _, errOut, code = sysSSH(aliceKey, "", "repo", "settings", "protect", "alice/proj", "main"); code != 0 {
204 t.Fatalf("protect: %s", errOut)
205 }
206 mustGit(t, dir, aliceEnv, "commit", "-q", "--amend", "-m", "rewritten")
207 pushOut, pushCode := gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
208 if pushCode == 0 || !strings.Contains(pushOut, "force-push refused") {
209 t.Fatalf("force-push via sshd: %d\n%s", pushCode, pushOut)
210 }
211}
internal/gitutil/gitutil.go +5 −5
@@ -29,9 +29,9 @@ func InitBare(path, defaultBranch, hooksPath string) error {
29} 29}
30 30
31// Transport streams one git transport service (upload-pack, receive-pack, 31// Transport streams one git transport service (upload-pack, receive-pack,
32// upload-archive) over rw. extraEnv entries are appended to the daemon's 32// upload-archive). extraEnv entries are appended to the process environment;
33// environment; hooks read the FORGE_* variables from it. 33// hooks read the FORGE_* variables from it.
34func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error { 34func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error {
35 var args []string 35 var args []string
36 switch service { 36 switch service {
37 case "git-upload-pack", "git-receive-pack", "git-upload-archive": 37 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
41 } 41 }
42 cmd := exec.Command("git", args...) 42 cmd := exec.Command("git", args...)
43 cmd.Env = append(os.Environ(), extraEnv...) 43 cmd.Env = append(os.Environ(), extraEnv...)
44 cmd.Stdin = rw 44 cmd.Stdin = stdin
45 cmd.Stdout = rw 45 cmd.Stdout = stdout
46 cmd.Stderr = errW 46 cmd.Stderr = errW
47 return cmd.Run() 47 return cmd.Run()
48} 48}
internal/sshd/sshd.go +29 −20
@@ -8,6 +8,7 @@ import (
8 "encoding/pem" 8 "encoding/pem"
9 "errors" 9 "errors"
10 "fmt" 10 "fmt"
11 "io"
11 "log/slog" 12 "log/slog"
12 "net" 13 "net"
13 "os" 14 "os"
@@ -184,70 +185,78 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
184 return protocol.ExitDenied 185 return protocol.ExitDenied
185 } 186 }
186 _ = s.st.TouchSSHKey(keyID) 187 _ = s.st.TouchSSHKey(keyID)
188 return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
189}
187 190
191// Exec runs one SSH exec command line for an authenticated key. It is the
192// single dispatch path shared by the embedded listener and the system-sshd
193// forced command (forged shell).
194func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string,
195 stdin io.Reader, stdout, stderr io.Writer) int {
188 argv, err := protocol.Tokenize(cmdline) 196 argv, err := protocol.Tokenize(cmdline)
189 if err != nil { 197 if err != nil {
190 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) 198 fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
191 return protocol.ExitUsage 199 return protocol.ExitUsage
192 } 200 }
193 if len(argv) > 0 { 201 if len(argv) > 0 {
194 switch argv[0] { 202 switch argv[0] {
195 case "git-upload-pack", "git-receive-pack", "git-upload-archive": 203 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
196 return s.runGit(ch, user, ext["scope"], argv) 204 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
197 } 205 }
198 } 206 }
199 ctx := &control.Ctx{ 207 ctx := &control.Ctx{
200 User: user, 208 User: user,
201 Scope: ext["scope"], 209 Scope: scope,
202 Store: s.st, 210 Store: st,
203 Cfg: s.cfg, 211 Cfg: cfg,
204 Stdin: ch, 212 Stdin: stdin,
205 Stdout: ch, 213 Stdout: stdout,
206 Stderr: ch.Stderr(), 214 Stderr: stderr,
207 } 215 }
208 return control.Dispatch(ctx, argv) 216 return control.Dispatch(ctx, argv)
209} 217}
210 218
211// runGit streams a git transport service after access checks. 219// runGit streams a git transport service after access checks.
212func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int { 220func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
221 stdin io.Reader, stdout, stderr io.Writer) int {
213 service := argv[0] 222 service := argv[0]
214 if len(argv) != 2 { 223 if len(argv) != 2 {
215 fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service) 224 fmt.Fprintf(stderr, "usage: %s <path>\n", service)
216 return protocol.ExitUsage 225 return protocol.ExitUsage
217 } 226 }
218 write := service == "git-receive-pack" 227 write := service == "git-receive-pack"
219 228
220 repo, err := s.st.RepoByPath(argv[1]) 229 repo, err := st.RepoByPath(argv[1])
221 if err != nil { 230 if err != nil {
222 fmt.Fprintln(ch.Stderr(), "repository not found") 231 fmt.Fprintln(stderr, "repository not found")
223 return protocol.ExitNotFound 232 return protocol.ExitNotFound
224 } 233 }
225 grant, err := s.st.AccessRole(repo.ID, user.ID) 234 grant, err := st.AccessRole(repo.ID, user.ID)
226 if err != nil { 235 if err != nil {
227 fmt.Fprintln(ch.Stderr(), "internal error") 236 fmt.Fprintln(stderr, "internal error")
228 return protocol.ExitFailure 237 return protocol.ExitFailure
229 } 238 }
230 if !policy.CanRead(user, repo, grant) { 239 if !policy.CanRead(user, repo, grant) {
231 // Same answer as nonexistence: private repos must not be enumerable. 240 // Same answer as nonexistence: private repos must not be enumerable.
232 fmt.Fprintln(ch.Stderr(), "repository not found") 241 fmt.Fprintln(stderr, "repository not found")
233 return protocol.ExitNotFound 242 return protocol.ExitNotFound
234 } 243 }
235 if !policy.ScopeAllowsGit(scope, repo.Path(), write) { 244 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
236 fmt.Fprintf(ch.Stderr(), "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path()) 245 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
237 return protocol.ExitDenied 246 return protocol.ExitDenied
238 } 247 }
239 if write && !policy.CanWrite(user, repo, grant) { 248 if write && !policy.CanWrite(user, repo, grant) {
240 fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path()) 249 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
241 return protocol.ExitDenied 250 return protocol.ExitDenied
242 } 251 }
243 252
244 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) 253 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
245 env := []string{ 254 env := []string{
246 hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root), 255 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
247 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), 256 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
248 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10), 257 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
249 } 258 }
250 if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil { 259 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil {
251 return protocol.ExitFailure 260 return protocol.ExitFailure
252 } 261 }
253 return protocol.ExitOK 262 return protocol.ExitOK
internal/store/users.go +11
@@ -198,3 +198,14 @@ func boolInt(b bool) int {
198func isUniqueErr(err error) bool { 198func isUniqueErr(err error) bool {
199 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") 199 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
200} 200}
201
202func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
203 var k SSHKey
204 err := s.DB.QueryRow(
205 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE id = ?",
206 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
207 if errors.Is(err, sql.ErrNoRows) {
208 return k, ErrNotFound
209 }
210 return k, err
211}