internal/hookd/hookd.go
361 lines · 11425 bytes
1// Package hookd is the unix-socket bridge between git hooks and the daemon.
2// The hook process (gitbayd in hook mode) computes git facts — it inherits
3// git's quarantine environment, which the daemon does not see — and sends
4// them here; the daemon answers with a policy decision.
5//
6// pre-receive is two-phase when the repo requires signed commits: the first
7// response sets NeedCommits, and the hook answers with the raw commit
8// objects (only the hook can read them out of quarantine) for verification.
9package hookd
10
11import (
12 "crypto/sha256"
13 "encoding/json"
14 "fmt"
15 "log/slog"
16 "net"
17 "os"
18 "path/filepath"
19 "strings"
20 "sync"
21
22 "gitbay.org/gitbay/internal/config"
23 "gitbay.org/gitbay/internal/control"
24 "gitbay.org/gitbay/internal/gitutil"
25 "gitbay.org/gitbay/internal/policy"
26 "gitbay.org/gitbay/internal/sig"
27 "gitbay.org/gitbay/internal/store"
28)
29
30// Env variable names passed to git transport subprocesses and inherited by
31// hooks.
32const (
33 EnvSocket = "GITBAY_HOOK_SOCKET"
34 EnvRepoID = "GITBAY_REPO_ID"
35 EnvUserID = "GITBAY_USER_ID"
36 EnvScope = "GITBAY_KEY_SCOPE"
37 // EnvToken names the receive-pack this hook runs under. sshd mints
38 // it per push; hookd answers only a request carrying a live one
39 // whose repository, account and scope match the request's.
40 EnvToken = "GITBAY_PUSH_TOKEN"
41)
42
43type Request struct {
44 Hook string `json:"hook"` // pre-receive | post-receive
45 RepoID int64 `json:"repo_id"`
46 UserID int64 `json:"user_id"`
47 // Scope is the pushing key's scope. The user id alone is the account
48 // the key belongs to, and a deploy key grants nothing outside its
49 // binding, so anything acting on another repository needs this too.
50 Scope string `json:"scope"`
51 Token string `json:"token"`
52 Updates []policy.RefUpdate `json:"updates"`
53}
54
55// RawCommit is one incoming commit object. When NeedCommits is set the
56// hook streams these one per JSON value and ends with a zero one, rather
57// than sending a single message holding every commit in the push: an
58// initial push of a large history is tens of thousands of them (#100).
59type RawCommit struct {
60 SHA string `json:"sha"`
61 Raw []byte `json:"raw"`
62}
63
64// Done marks the end of the commit stream.
65func (c RawCommit) Done() bool { return c.SHA == "" }
66
67type Response struct {
68 Allow bool `json:"allow"`
69 Message string `json:"message,omitempty"`
70 NeedCommits bool `json:"need_commits,omitempty"`
71}
72
73// SocketPath returns the hook socket location. It prefers the server root,
74// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
75// deep roots fall back to a hashed name under the system temp directory.
76// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
77// agree.
78func SocketPath(root string) string {
79 p := filepath.Join(root, "hook.sock")
80 if len(p) <= 100 {
81 return p
82 }
83 sum := sha256.Sum256([]byte(root))
84 return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
85}
86
87type Server struct {
88 cfg config.Config
89 st *store.Store
90}
91
92// Serve listens on the unix socket until the listener is closed.
93func Serve(cfg config.Config, st *store.Store) (func() error, error) {
94 path := SocketPath(cfg.Server.Root)
95 os.Remove(path)
96 ln, err := net.Listen("unix", path)
97 if err != nil {
98 return nil, err
99 }
100 // Listen creates the socket under the process umask. Hooks run as
101 // the daemon's own user; nobody else has a reason to connect.
102 if err := os.Chmod(path, 0o600); err != nil {
103 ln.Close()
104 return nil, err
105 }
106 s := &Server{cfg: cfg, st: st}
107 go func() {
108 for {
109 conn, err := ln.Accept()
110 if err != nil {
111 return
112 }
113 go s.handle(conn)
114 }
115 }()
116 return ln.Close, nil
117}
118
119func (s *Server) handle(conn net.Conn) {
120 defer conn.Close()
121 dec := json.NewDecoder(conn)
122 enc := json.NewEncoder(conn)
123 if err := peerCheck(conn); err != nil {
124 slog.Warn("hook socket: refused connection", "err", err)
125 // Nothing about the request is known yet, and no account.
126 control.AuditRefused(s.st, 0, "refused hook", map[string]any{"reason": err.Error()})
127 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
128 return
129 }
130 var req Request
131 if err := dec.Decode(&req); err != nil {
132 enc.Encode(Response{Allow: false, Message: "bad hook request"})
133 return
134 }
135 if actor, msg := s.authorize(req); msg != "" {
136 control.AuditRefused(s.st, actor, "refused hook",
137 map[string]any{"repo_id": req.RepoID, "hook": req.Hook, "reason": msg})
138 enc.Encode(Response{Allow: false, Message: msg})
139 return
140 }
141 switch req.Hook {
142 case "pre-receive":
143 preReceiveStarted(req.Token)
144 s.preReceive(req, dec, enc)
145 case "post-receive":
146 s.postReceive(req)
147 enc.Encode(Response{Allow: true})
148 default:
149 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
150 }
151}
152
153// authorize ties a request to a receive-pack sshd started: its token
154// must be live and name the same repository, account and key scope.
155// On a refusal actor is the token's account when the token is live,
156// and 0 otherwise: the request's own user id is only a claim.
157func (s *Server) authorize(req Request) (actor int64, msg string) {
158 if req.Token == "" {
159 return 0, "push not started by this server"
160 }
161 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
162 if err != nil {
163 return 0, "push not started by this server"
164 }
165 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
166 return tok.UserID, "push token does not match this request"
167 }
168 return 0, ""
169}
170
171// peerCheck is checkPeer; tests replace it.
172var peerCheck = checkPeer
173
174// auditedRefs is how many ref names a refused-push row keeps; the rest
175// are counted, so one push of many refs cannot write an unbounded row.
176const auditedRefs = 20
177
178// refusePush answers a pre-receive refusal and audits it.
179func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
180 n := min(len(req.Updates), auditedRefs)
181 refs := make([]string, n)
182 for i, u := range req.Updates[:n] {
183 refs[i] = u.Ref
184 }
185 data := map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg}
186 if more := len(req.Updates) - n; more > 0 {
187 data["more_refs"] = more
188 }
189 control.AuditRefused(s.st, req.UserID, "refused push", data)
190 enc.Encode(Response{Allow: false, Message: msg})
191}
192
193func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
194 repo, err := s.st.RepoByID(req.RepoID)
195 if err != nil {
196 enc.Encode(Response{Allow: false, Message: "unknown repository"})
197 return
198 }
199 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
200 s.refusePush(enc, req, repo, msg)
201 return
202 }
203 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
204 s.refusePush(enc, req, repo, msg)
205 return
206 }
207 if !repo.Settings.RequireSignedCommits {
208 enc.Encode(Response{Allow: true})
209 return
210 }
211
212 // Phase two: ask the hook for the incoming commit objects.
213 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
214 return
215 }
216 // Each commit is verified as it arrives, so nothing holds the push in
217 // memory. The first refusal decides the answer, but the stream is
218 // still drained to its end before replying: the hook is writing, and
219 // answering early would leave it writing into a socket nobody reads.
220 // Draining costs a decode per commit and no verification.
221 db := store.SigDB{Store: s.st}
222 refusal := ""
223 for {
224 var rc RawCommit
225 if err := dec.Decode(&rc); err != nil {
226 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
227 return
228 }
229 if rc.Done() {
230 break
231 }
232 if refusal != "" {
233 continue
234 }
235 parsed, err := sig.ParseCommit(rc.Raw)
236 if err != nil {
237 refusal = fmt.Sprintf("unparseable commit %s", rc.SHA)
238 continue
239 }
240 res, err := sig.VerifyCommit(db, parsed)
241 if err != nil || res.State != sig.Verified {
242 state := "error"
243 if err == nil {
244 state = string(res.State)
245 }
246 refusal = fmt.Sprintf("this repository requires signed commits: %.10s is %s", rc.SHA, state)
247 }
248 }
249 if refusal != "" {
250 s.refusePush(enc, req, repo, refusal)
251 return
252 }
253 enc.Encode(Response{Allow: true})
254}
255
256// releaseAnchors refuses deleting or moving a tag that a release is
257// anchored to. A release outliving its tag served assets for a commit
258// nobody could reach (#201); the release goes first, then the tag.
259func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
260 for _, u := range updates {
261 tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
262 if !ok || gitutil.ZeroSHA(u.Old) {
263 continue
264 }
265 if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
266 continue
267 }
268 verb := "moved"
269 if u.IsDelete {
270 verb = "deleted"
271 }
272 return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
273 }
274 return ""
275}
276
277// postReceive runs the ref-update work for a push; see
278// control.RefsUpdated, which server-side writes to a branch share.
279func (s *Server) postReceive(req Request) {
280 control.RefsUpdated(s.st, s.cfg, req.RepoID, req.UserID, req.Scope, req.Updates)
281}
282
283// Ask sends one request from the hook process to the daemon. stream is
284// called if the daemon asks for the incoming commit objects; it hands each
285// commit to the callback, which writes it on the wire.
286func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
287 conn, err := net.Dial("unix", socketPath)
288 if err != nil {
289 return Response{}, err
290 }
291 defer conn.Close()
292 enc := json.NewEncoder(conn)
293 dec := json.NewDecoder(conn)
294 if err := enc.Encode(req); err != nil {
295 return Response{}, err
296 }
297 var resp Response
298 if err := dec.Decode(&resp); err != nil {
299 return Response{}, err
300 }
301 if !resp.NeedCommits {
302 return resp, nil
303 }
304 if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
305 return Response{}, err
306 }
307 if err := enc.Encode(RawCommit{}); err != nil { // end of stream
308 return Response{}, err
309 }
310 err = dec.Decode(&resp)
311 return resp, err
312}
313
314// WriteHookScripts (re)generates the shared hooks directory. Called at
315// daemon startup so a moved binary self-heals; every repo points here via
316// core.hooksPath.
317func WriteHookScripts(hooksDir, gitbaydPath string) error {
318 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
319 return err
320 }
321 for _, hook := range []string{"pre-receive", "post-receive"} {
322 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
323 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
324 return err
325 }
326 }
327 return nil
328}
329
330// awaiting holds, per push token, a channel closed when that push's
331// pre-receive reaches hookd. sshd uses it to tell a pack still arriving
332// from one being checked.
333var (
334 awaitMu sync.Mutex
335 awaiting = map[string]chan struct{}{}
336)
337
338// AwaitPreReceive returns a channel that closes when pre-receive for
339// the push holding token reaches this process's hookd, and forget,
340// which drops the registration. Under ssh.mode = "system" the push runs
341// in another process and the channel never closes.
342func AwaitPreReceive(token string) (started <-chan struct{}, forget func()) {
343 ch := make(chan struct{})
344 awaitMu.Lock()
345 awaiting[token] = ch
346 awaitMu.Unlock()
347 return ch, func() {
348 awaitMu.Lock()
349 delete(awaiting, token)
350 awaitMu.Unlock()
351 }
352}
353
354func preReceiveStarted(token string) {
355 awaitMu.Lock()
356 defer awaitMu.Unlock()
357 if ch, ok := awaiting[token]; ok {
358 close(ch)
359 delete(awaiting, token)
360 }
361}