internal/sshd/sshd.go
254 lines · 6975 bytes
1// Package sshd implements the embedded SSH listener: public-key auth against
2// registered keys, then dispatch to git transport or control commands.
3package sshd
4
5import (
6 "crypto/ed25519"
7 "crypto/rand"
8 "encoding/pem"
9 "errors"
10 "fmt"
11 "log/slog"
12 "net"
13 "os"
14 "path/filepath"
15 "strconv"
16
17 "golang.org/x/crypto/ssh"
18
19 "github.com/krazywarez/forge/internal/config"
20 "github.com/krazywarez/forge/internal/control"
21 "github.com/krazywarez/forge/internal/gitutil"
22 "github.com/krazywarez/forge/internal/hookd"
23 "github.com/krazywarez/forge/internal/policy"
24 "github.com/krazywarez/forge/internal/protocol"
25 "github.com/krazywarez/forge/internal/store"
26)
27
28type Server struct {
29 cfg config.Config
30 st *store.Store
31 sshCfg *ssh.ServerConfig
32}
33
34func New(cfg config.Config, st *store.Store) (*Server, error) {
35 s := &Server{cfg: cfg, st: st}
36
37 sc := &ssh.ServerConfig{
38 PublicKeyCallback: s.authenticate,
39 ServerVersion: "SSH-2.0-forged",
40 }
41 signers, err := loadHostKeys(cfg)
42 if err != nil {
43 return nil, err
44 }
45 for _, sg := range signers {
46 sc.AddHostKey(sg)
47 }
48 s.sshCfg = sc
49 return s, nil
50}
51
52// loadHostKeys loads the configured host keys, or generates an ed25519 key
53// under server.root/ssh/ when none are configured.
54func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
55 paths := cfg.SSH.HostKeys
56 if len(paths) == 0 {
57 p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
58 if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
59 if err := generateHostKey(p); err != nil {
60 return nil, fmt.Errorf("generating host key: %w", err)
61 }
62 slog.Info("generated ssh host key", "path", p)
63 }
64 paths = []string{p}
65 }
66 var signers []ssh.Signer
67 for _, p := range paths {
68 raw, err := os.ReadFile(p)
69 if err != nil {
70 return nil, fmt.Errorf("host key %s: %w", p, err)
71 }
72 sg, err := ssh.ParsePrivateKey(raw)
73 if err != nil {
74 return nil, fmt.Errorf("host key %s: %w", p, err)
75 }
76 signers = append(signers, sg)
77 }
78 return signers, nil
79}
80
81func generateHostKey(path string) error {
82 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
83 return err
84 }
85 _, priv, err := ed25519.GenerateKey(rand.Reader)
86 if err != nil {
87 return err
88 }
89 block, err := ssh.MarshalPrivateKey(priv, "")
90 if err != nil {
91 return err
92 }
93 return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
94}
95
96// authenticate resolves the presented key to a registered account. The SSH
97// username is ignored; identity comes from the key alone.
98func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
99 fp := ssh.FingerprintSHA256(pub)
100 key, err := s.st.SSHKeyByFingerprint(fp)
101 if err != nil {
102 return nil, fmt.Errorf("unknown key %s", fp)
103 }
104 return &ssh.Permissions{Extensions: map[string]string{
105 "user-id": strconv.FormatInt(key.UserID, 10),
106 "key-id": strconv.FormatInt(key.ID, 10),
107 "scope": key.Scope,
108 }}, nil
109}
110
111// Serve accepts connections on ln until it is closed.
112func (s *Server) Serve(ln net.Listener) error {
113 for {
114 conn, err := ln.Accept()
115 if err != nil {
116 return err
117 }
118 go s.handleConn(conn)
119 }
120}
121
122func (s *Server) handleConn(conn net.Conn) {
123 defer conn.Close()
124 sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
125 if err != nil {
126 return
127 }
128 defer sconn.Close()
129 go ssh.DiscardRequests(reqs)
130
131 for newCh := range chans {
132 if newCh.ChannelType() != "session" {
133 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
134 continue
135 }
136 ch, chReqs, err := newCh.Accept()
137 if err != nil {
138 continue
139 }
140 go s.handleSession(sconn, ch, chReqs)
141 }
142}
143
144func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
145 defer ch.Close()
146 for req := range reqs {
147 switch req.Type {
148 case "exec":
149 var payload struct{ Command string }
150 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
151 req.Reply(false, nil)
152 continue
153 }
154 req.Reply(true, nil)
155 code := s.runExec(sconn, ch, payload.Command)
156 sendExit(ch, code)
157 return
158 case "shell":
159 req.Reply(true, nil)
160 fmt.Fprintf(ch, "forge control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
161 sendExit(ch, protocol.ExitUsage)
162 return
163 case "pty-req", "env":
164 // Harmless; accept and ignore.
165 req.Reply(true, nil)
166 default:
167 req.Reply(false, nil)
168 }
169 }
170}
171
172func sendExit(ch ssh.Channel, code int) {
173 var msg = struct{ Status uint32 }{uint32(code)}
174 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
175}
176
177func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
178 ext := sconn.Permissions.Extensions
179 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
180 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
181 user, err := s.st.UserByID(userID)
182 if err != nil {
183 fmt.Fprintln(ch.Stderr(), "account no longer exists")
184 return protocol.ExitDenied
185 }
186 _ = s.st.TouchSSHKey(keyID)
187
188 argv, err := protocol.Tokenize(cmdline)
189 if err != nil {
190 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
191 return protocol.ExitUsage
192 }
193 if len(argv) > 0 {
194 switch argv[0] {
195 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
196 return s.runGit(ch, user, ext["scope"], argv)
197 }
198 }
199 ctx := &control.Ctx{
200 User: user,
201 Scope: ext["scope"],
202 Store: s.st,
203 Cfg: s.cfg,
204 Stdin: ch,
205 Stdout: ch,
206 Stderr: ch.Stderr(),
207 }
208 return control.Dispatch(ctx, argv)
209}
210
211// runGit streams a git transport service after access checks.
212func (s *Server) runGit(ch ssh.Channel, user store.User, scope string, argv []string) int {
213 service := argv[0]
214 if len(argv) != 2 {
215 fmt.Fprintf(ch.Stderr(), "usage: %s <path>\n", service)
216 return protocol.ExitUsage
217 }
218 write := service == "git-receive-pack"
219
220 repo, err := s.st.RepoByPath(argv[1])
221 if err != nil {
222 fmt.Fprintln(ch.Stderr(), "repository not found")
223 return protocol.ExitNotFound
224 }
225 grant, err := s.st.AccessRole(repo.ID, user.ID)
226 if err != nil {
227 fmt.Fprintln(ch.Stderr(), "internal error")
228 return protocol.ExitFailure
229 }
230 if !policy.CanRead(user, repo, grant) {
231 // Same answer as nonexistence: private repos must not be enumerable.
232 fmt.Fprintln(ch.Stderr(), "repository not found")
233 return protocol.ExitNotFound
234 }
235 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())
237 return protocol.ExitDenied
238 }
239 if write && !policy.CanWrite(user, repo, grant) {
240 fmt.Fprintf(ch.Stderr(), "write access to %s denied\n", repo.Path())
241 return protocol.ExitDenied
242 }
243
244 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
245 env := []string{
246 hookd.EnvSocket + "=" + hookd.SocketPath(s.cfg.Server.Root),
247 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
248 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
249 }
250 if err := gitutil.Transport(service, dir, ch, ch.Stderr(), env); err != nil {
251 return protocol.ExitFailure
252 }
253 return protocol.ExitOK
254}