Commit ca9fd6021b
Verified · cmc
Layout: unified · split
cmd/forged/main.go +18 −14
| @@ -53,6 +53,8 @@ func main() { | ||
| 53 | 53 | migrateCmd(), |
| 54 | 54 | adminCmd(), |
| 55 | 55 | hookCmd(), |
| 56 | authorizedKeysCmd(), | |
| 57 | shellCmd(), | |
| 56 | 58 | ) |
| 57 | 59 | |
| 58 | 60 | if err := root.Execute(); err != nil { |
| @@ -99,10 +101,6 @@ func serveCmd() *cobra.Command { | ||
| 99 | 101 | } |
| 100 | 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 | 104 | // Regenerate hook scripts so a moved binary self-heals, then |
| 107 | 105 | // start the hook policy socket. |
| 108 | 106 | self, err := os.Executable() |
| @@ -118,18 +116,24 @@ func serveCmd() *cobra.Command { | ||
| 118 | 116 | } |
| 119 | 117 | defer stopHookd() |
| 120 | 118 | |
| 121 | srv, err := sshd.New(cfg, st) | |
| 122 | if err != nil { | |
| 123 | return err | |
| 124 | } | |
| 125 | ln, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(cfg.SSH.Port))) | |
| 126 | if err != nil { | |
| 127 | return err | |
| 119 | errCh := make(chan error, 3) | |
| 120 | if cfg.SSH.Mode == "embedded" { | |
| 121 | srv, err := sshd.New(cfg, st) | |
| 122 | if err != nil { | |
| 123 | return err | |
| 124 | } | |
| 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 | 138 | web := httpd.New(cfg, st) |
| 135 | 139 | hs := &http.Server{Addr: cfg.HTTP.Addr, Handler: web.Handler()} |
cmd/forged/system.go added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 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". | |
| 24 | func 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. | |
| 62 | func 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 17 | func 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] | |
| 33 | root = %q | |
| 34 | site_url = "https://forge.test" | |
| 35 | [ssh] | |
| 36 | mode = "system" | |
| 37 | [http] | |
| 38 | addr = "127.0.0.1:%d" | |
| 39 | tls = "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 | |
| 76 | ListenAddress 127.0.0.1 | |
| 77 | HostKey %s | |
| 78 | PasswordAuthentication no | |
| 79 | KbdInteractiveAuthentication no | |
| 80 | PubkeyAuthentication yes | |
| 81 | AuthorizedKeysFile none | |
| 82 | AuthorizedKeysCommand /bin/sh %s %%t %%k | |
| 83 | AuthorizedKeysCommandUser %s | |
| 84 | StrictModes no | |
| 85 | UsePAM no | |
| 86 | PidFile %s | |
| 87 | LogLevel 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 | 31 | // Transport streams one git transport service (upload-pack, receive-pack, |
| 32 | // upload-archive) over rw. extraEnv entries are appended to the daemon's | |
| 33 | // environment; hooks read the FORGE_* variables from it. | |
| 34 | func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error { | |
| 32 | // upload-archive). extraEnv entries are appended to the process environment; | |
| 33 | // hooks read the FORGE_* variables from it. | |
| 34 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string) error { | |
| 35 | 35 | var args []string |
| 36 | 36 | switch service { |
| 37 | 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 | 42 | cmd := exec.Command("git", args...) |
| 43 | 43 | cmd.Env = append(os.Environ(), extraEnv...) |
| 44 | cmd.Stdin = rw | |
| 45 | cmd.Stdout = rw | |
| 44 | cmd.Stdin = stdin | |
| 45 | cmd.Stdout = stdout | |
| 46 | 46 | cmd.Stderr = errW |
| 47 | 47 | return cmd.Run() |
| 48 | 48 | } |
internal/sshd/sshd.go +29 −20
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "encoding/pem" |
| 9 | 9 | "errors" |
| 10 | 10 | "fmt" |
| 11 | "io" | |
| 11 | 12 | "log/slog" |
| 12 | 13 | "net" |
| 13 | 14 | "os" |
| @@ -184,70 +185,78 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) | ||
| 184 | 185 | return protocol.ExitDenied |
| 185 | 186 | } |
| 186 | 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). | |
| 194 | func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string, | |
| 195 | stdin io.Reader, stdout, stderr io.Writer) int { | |
| 188 | 196 | argv, err := protocol.Tokenize(cmdline) |
| 189 | 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 | 199 | return protocol.ExitUsage |
| 192 | 200 | } |
| 193 | 201 | if len(argv) > 0 { |
| 194 | 202 | switch argv[0] { |
| 195 | 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 | 207 | ctx := &control.Ctx{ |
| 200 | 208 | User: user, |
| 201 | Scope: ext["scope"], | |
| 202 | Store: s.st, | |
| 203 | Cfg: s.cfg, | |
| 204 | Stdin: ch, | |
| 205 | Stdout: ch, | |
| 206 | Stderr: ch.Stderr(), | |
| 209 | Scope: scope, | |
| 210 | Store: st, | |
| 211 | Cfg: cfg, | |
| 212 | Stdin: stdin, | |
| 213 | Stdout: stdout, | |
| 214 | Stderr: stderr, | |
| 207 | 215 | } |
| 208 | 216 | return control.Dispatch(ctx, argv) |
| 209 | 217 | } |
| 210 | 218 | |
| 211 | 219 | // runGit streams a git transport service after access checks. |
| 212 | func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int { | |
| 220 | func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string, | |
| 221 | stdin io.Reader, stdout, stderr io.Writer) int { | |
| 213 | 222 | service := argv[0] |
| 214 | 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 | 225 | return protocol.ExitUsage |
| 217 | 226 | } |
| 218 | 227 | write := service == "git-receive-pack" |
| 219 | 228 | |
| 220 | repo, err := s.st.RepoByPath(argv[1]) | |
| 229 | repo, err := st.RepoByPath(argv[1]) | |
| 221 | 230 | if err != nil { |
| 222 | fmt.Fprintln(ch.Stderr(), "repository not found") | |
| 231 | fmt.Fprintln(stderr, "repository not found") | |
| 223 | 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 | 235 | if err != nil { |
| 227 | fmt.Fprintln(ch.Stderr(), "internal error") | |
| 236 | fmt.Fprintln(stderr, "internal error") | |
| 228 | 237 | return protocol.ExitFailure |
| 229 | 238 | } |
| 230 | 239 | if !policy.CanRead(user, repo, grant) { |
| 231 | 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 | 242 | return protocol.ExitNotFound |
| 234 | 243 | } |
| 235 | 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 | 246 | return protocol.ExitDenied |
| 238 | 247 | } |
| 239 | 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 | 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 | 254 | env := []string{ |
| 246 | hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root), | |
| 255 | hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root), | |
| 247 | 256 | hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), |
| 248 | 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 | 260 | return protocol.ExitFailure |
| 252 | 261 | } |
| 253 | 262 | return protocol.ExitOK |
internal/store/users.go +11
| @@ -198,3 +198,14 @@ func boolInt(b bool) int { | ||
| 198 | 198 | func isUniqueErr(err error) bool { |
| 199 | 199 | return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") |
| 200 | 200 | } |
| 201 | ||
| 202 | func (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 | } | |