internal/hookd/hookd.go
487 lines · 16295 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"
19 "path/filepath"
20 "strings"
21 "time"
22
23 "gitbay.org/gitbay/internal/ci"
24 "gitbay.org/gitbay/internal/config"
25 "gitbay.org/gitbay/internal/control"
26 "gitbay.org/gitbay/internal/gitutil"
27 "gitbay.org/gitbay/internal/policy"
28 "gitbay.org/gitbay/internal/sig"
29 "gitbay.org/gitbay/internal/store"
30)
31
32// Env variable names passed to git transport subprocesses and inherited by
33// hooks.
34const (
35 EnvSocket = "GITBAY_HOOK_SOCKET"
36 EnvRepoID = "GITBAY_REPO_ID"
37 EnvUserID = "GITBAY_USER_ID"
38 EnvScope = "GITBAY_KEY_SCOPE"
39 // EnvToken names the receive-pack this hook runs under. sshd mints
40 // it per push; hookd answers only a request carrying a live one
41 // whose repository, account and scope match the request's.
42 EnvToken = "GITBAY_PUSH_TOKEN"
43)
44
45type Request struct {
46 Hook string `json:"hook"` // pre-receive | post-receive
47 RepoID int64 `json:"repo_id"`
48 UserID int64 `json:"user_id"`
49 // Scope is the pushing key's scope. The user id alone is the account
50 // the key belongs to, and a deploy key grants nothing outside its
51 // binding, so anything acting on another repository needs this too.
52 Scope string `json:"scope"`
53 Token string `json:"token"`
54 Updates []policy.RefUpdate `json:"updates"`
55}
56
57// RawCommit is one incoming commit object. When NeedCommits is set the
58// hook streams these one per JSON value and ends with a zero one, rather
59// than sending a single message holding every commit in the push: an
60// initial push of a large history is tens of thousands of them (#100).
61type RawCommit struct {
62 SHA string `json:"sha"`
63 Raw []byte `json:"raw"`
64}
65
66// Done marks the end of the commit stream.
67func (c RawCommit) Done() bool { return c.SHA == "" }
68
69type Response struct {
70 Allow bool `json:"allow"`
71 Message string `json:"message,omitempty"`
72 NeedCommits bool `json:"need_commits,omitempty"`
73}
74
75// SocketPath returns the hook socket location. It prefers the server root,
76// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
77// deep roots fall back to a hashed name under the system temp directory.
78// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
79// agree.
80func SocketPath(root string) string {
81 p := filepath.Join(root, "hook.sock")
82 if len(p) <= 100 {
83 return p
84 }
85 sum := sha256.Sum256([]byte(root))
86 return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
87}
88
89type Server struct {
90 cfg config.Config
91 st *store.Store
92}
93
94// Serve listens on the unix socket until the listener is closed.
95func Serve(cfg config.Config, st *store.Store) (func() error, error) {
96 path := SocketPath(cfg.Server.Root)
97 os.Remove(path)
98 ln, err := net.Listen("unix", path)
99 if err != nil {
100 return nil, err
101 }
102 // Listen creates the socket under the process umask. Hooks run as
103 // the daemon's own user; nobody else has a reason to connect.
104 if err := os.Chmod(path, 0o600); err != nil {
105 ln.Close()
106 return nil, err
107 }
108 s := &Server{cfg: cfg, st: st}
109 go func() {
110 for {
111 conn, err := ln.Accept()
112 if err != nil {
113 return
114 }
115 go s.handle(conn)
116 }
117 }()
118 return ln.Close, nil
119}
120
121func (s *Server) handle(conn net.Conn) {
122 defer conn.Close()
123 dec := json.NewDecoder(conn)
124 enc := json.NewEncoder(conn)
125 if err := checkPeer(conn); err != nil {
126 slog.Warn("hook socket: refused connection", "err", err)
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 msg := s.authorize(req); msg != "" {
136 enc.Encode(Response{Allow: false, Message: msg})
137 return
138 }
139 switch req.Hook {
140 case "pre-receive":
141 s.preReceive(req, dec, enc)
142 case "post-receive":
143 s.postReceive(req)
144 enc.Encode(Response{Allow: true})
145 default:
146 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
147 }
148}
149
150// authorize ties a request to a receive-pack sshd started: its token
151// must be live and name the same repository, account and key scope.
152func (s *Server) authorize(req Request) string {
153 if req.Token == "" {
154 return "push not started by this server"
155 }
156 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
157 if err != nil {
158 return "push not started by this server"
159 }
160 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
161 return "push token does not match this request"
162 }
163 return ""
164}
165
166func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
167 repo, err := s.st.RepoByID(req.RepoID)
168 if err != nil {
169 enc.Encode(Response{Allow: false, Message: "unknown repository"})
170 return
171 }
172 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
173 enc.Encode(Response{Allow: false, Message: msg})
174 return
175 }
176 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
177 enc.Encode(Response{Allow: false, Message: msg})
178 return
179 }
180 if !repo.Settings.RequireSignedCommits {
181 enc.Encode(Response{Allow: true})
182 return
183 }
184
185 // Phase two: ask the hook for the incoming commit objects.
186 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
187 return
188 }
189 // Each commit is verified as it arrives, so nothing holds the push in
190 // memory. The first refusal decides the answer, but the stream is
191 // still drained to its end before replying: the hook is writing, and
192 // answering early would leave it writing into a socket nobody reads.
193 // Draining costs a decode per commit and no verification.
194 db := store.SigDB{Store: s.st}
195 refusal := ""
196 for {
197 var rc RawCommit
198 if err := dec.Decode(&rc); err != nil {
199 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
200 return
201 }
202 if rc.Done() {
203 break
204 }
205 if refusal != "" {
206 continue
207 }
208 parsed, err := sig.ParseCommit(rc.Raw)
209 if err != nil {
210 refusal = fmt.Sprintf("unparseable commit %s", rc.SHA)
211 continue
212 }
213 res, err := sig.VerifyCommit(db, parsed)
214 if err != nil || res.State != sig.Verified {
215 state := "error"
216 if err == nil {
217 state = string(res.State)
218 }
219 refusal = fmt.Sprintf("this repository requires signed commits: %.10s is %s", rc.SHA, state)
220 }
221 }
222 if refusal != "" {
223 enc.Encode(Response{Allow: false, Message: refusal})
224 return
225 }
226 enc.Encode(Response{Allow: true})
227}
228
229// releaseAnchors refuses deleting or moving a tag that a release is
230// anchored to. A release outliving its tag served assets for a commit
231// nobody could reach (#201); the release goes first, then the tag.
232func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
233 for _, u := range updates {
234 tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
235 if !ok || gitutil.ZeroSHA(u.Old) {
236 continue
237 }
238 if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
239 continue
240 }
241 verb := "moved"
242 if u.IsDelete {
243 verb = "deleted"
244 }
245 return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
246 }
247 return ""
248}
249
250// postReceive applies the cross-repo MR effect: a push to a source branch
251// refreshes refs/merge-requests/N/head in every target repo, by fetching —
252// the target owns the objects, so the MR outlives the fork. This is the only
253// place a hook writes outside its own repository.
254func (s *Server) postReceive(req Request) {
255 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
256 if pushedRepoErr == nil {
257 s.adoptDefaultBranch(&pushedRepo, req.Updates)
258 }
259 for _, u := range req.Updates {
260 // Every ref update is an event webhooks can subscribe to.
261 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
262 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
263 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
264
265 // Any ref update — branch or tag — schedules the push mirrors.
266 s.st.MarkMirrorsDirty(req.RepoID, "push")
267
268 // Tag pushes run the tag-triggered CI jobs.
269 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
270 s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
271 }
272
273 branch, ok := cutHeads(u.Ref)
274 if !ok {
275 continue
276 }
277 // Commits landing on the default branch act on issue references
278 // in their messages (closes #N, plain #N).
279 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
280 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
281 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
282 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
283 }
284 // A branch push with a .gitbay/ci.yml queues one build per job.
285 if pushedRepoErr == nil && !u.IsDelete {
286 s.queueBuilds(pushedRepo, req.UserID, branch, u.Old, u.New)
287 }
288 if u.IsForce {
289 s.st.Audit(req.UserID, "push.forced", map[string]any{
290 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
291 }
292 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
293 if err != nil {
294 slog.Error("post-receive: listing MRs", "err", err)
295 continue
296 }
297 srcRepo, err := s.st.RepoByID(req.RepoID)
298 if err != nil {
299 continue
300 }
301 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
302 for _, mr := range mrs {
303 target, err := s.st.RepoByID(mr.RepoID)
304 if err != nil {
305 continue
306 }
307 if u.IsDelete {
308 if mr.State == "open" {
309 s.st.SetMRState(mr.ID, "source_gone")
310 }
311 continue // head ref retained: the diff stays viewable
312 }
313 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
314 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
315 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
316 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
317 continue
318 }
319 // The merge base as it stands now, so a later range-diff
320 // compares each revision against the target it was written
321 // on rather than against today's. Best-effort: a base that
322 // cannot be worked out costs precision, not the record.
323 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
324 if err != nil {
325 base = ""
326 }
327 if err := s.st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
328 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
329 }
330 if srcRepo.ID != target.ID {
331 control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
332 target, req.UserID, mr.Number, u.New)
333 }
334 if mr.State == "source_gone" {
335 s.st.SetMRState(mr.ID, "open") // branch came back
336 }
337 }
338 }
339}
340
341// adoptDefaultBranch moves an unborn HEAD to the first branch a push
342// creates. A repository is initialised with HEAD at the stored default,
343// and a first push of master or trunk left HEAD naming a branch that did
344// not exist: clones checked out nothing and every surface asked git for
345// a branch that was not there (#189). A push that includes the default
346// branch itself needs nothing.
347func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
348 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
349 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
350 return
351 }
352 for _, u := range updates {
353 branch, ok := cutHeads(u.Ref)
354 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
355 continue
356 }
357 if err := gitutil.SetHead(dir, branch); err != nil {
358 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
359 return
360 }
361 if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
362 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
363 return
364 }
365 repo.DefaultBranch = branch
366 return
367 }
368}
369
370// sameChange reports whether the new head proposes the diff the old one
371// did: the patch-id of each revision against its own merge base. A
372// rebase onto a moved target changes every sha and nothing about the
373// change, and the reviews of it should not go stale for that (#198).
374// Any doubt answers false, which is the old behaviour.
375func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
376 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
377 return false
378 }
379 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
380 if err != nil {
381 return false
382 }
383 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
384 if err != nil || oldID == "" {
385 return false
386 }
387 newID, err := gitutil.PatchID(dir, newBase, newHead)
388 return err == nil && newID == oldID
389}
390
391// queueBuilds queues the push jobs for a branch update. The work is
392// shared with the merge path, which moves a ref without reaching a hook.
393func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
394 control.QueueBranchBuilds(
395 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
396 repo, userID, branch, old, sha, time.Now())
397}
398
399// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
400// The build records the tag as its ref and the peeled commit as its sha,
401// so statuses land on the commit, not an annotated tag object.
402func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
403 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
404 sha, err := gitutil.PeelToCommit(dir, pushed)
405 if err != nil {
406 return
407 }
408 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
409 if err != nil {
410 return
411 }
412 jobs, err := ci.Parse(raw)
413 if err != nil {
414 return // the branch push already reported ci/config
415 }
416 for _, j := range jobs {
417 if j.Tags == "" {
418 continue
419 }
420 if ok, _ := path.Match(j.Tags, tag); !ok {
421 continue
422 }
423 steps, _ := json.Marshal(j.Steps)
424 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
425 if err != nil {
426 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
427 continue
428 }
429 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
430 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
431 }
432}
433
434func cutHeads(ref string) (string, bool) {
435 const p = "refs/heads/"
436 if len(ref) > len(p) && ref[:len(p)] == p {
437 return ref[len(p):], true
438 }
439 return "", false
440}
441
442// Ask sends one request from the hook process to the daemon. stream is
443// called if the daemon asks for the incoming commit objects; it hands each
444// commit to the callback, which writes it on the wire.
445func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
446 conn, err := net.Dial("unix", socketPath)
447 if err != nil {
448 return Response{}, err
449 }
450 defer conn.Close()
451 enc := json.NewEncoder(conn)
452 dec := json.NewDecoder(conn)
453 if err := enc.Encode(req); err != nil {
454 return Response{}, err
455 }
456 var resp Response
457 if err := dec.Decode(&resp); err != nil {
458 return Response{}, err
459 }
460 if !resp.NeedCommits {
461 return resp, nil
462 }
463 if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
464 return Response{}, err
465 }
466 if err := enc.Encode(RawCommit{}); err != nil { // end of stream
467 return Response{}, err
468 }
469 err = dec.Decode(&resp)
470 return resp, err
471}
472
473// WriteHookScripts (re)generates the shared hooks directory. Called at
474// daemon startup so a moved binary self-heals; every repo points here via
475// core.hooksPath.
476func WriteHookScripts(hooksDir, gitbaydPath string) error {
477 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
478 return err
479 }
480 for _, hook := range []string{"pre-receive", "post-receive"} {
481 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
482 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
483 return err
484 }
485 }
486 return nil
487}