Commit ca9fd6021b
Verified · cmc
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 @@ | |||
| 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 | // 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. |
| 34 | func Transport(service, repoPath string, rw io.ReadWriter, errW io.Writer, extraEnv []string) error { | 34 | func 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). | ||
| 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 | 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. |
| 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 | 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 { | |||
| 198 | func isUniqueErr(err error) bool { | 198 | func 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 | |||
| 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 | } | ||