internal/hookd/hookd.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/hookd/hookd.go history · blame · raw

515 lines · 17484 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 := peerCheck(conn); err != nil {
126		slog.Warn("hook socket: refused connection", "err", err)
127		// Nothing about the request is known yet, and no account.
128		control.AuditRefused(s.st, 0, "refused hook", map[string]any{"reason": err.Error()})
129		enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
130		return
131	}
132	var req Request
133	if err := dec.Decode(&req); err != nil {
134		enc.Encode(Response{Allow: false, Message: "bad hook request"})
135		return
136	}
137	if actor, msg := s.authorize(req); msg != "" {
138		control.AuditRefused(s.st, actor, "refused hook",
139			map[string]any{"repo_id": req.RepoID, "hook": req.Hook, "reason": msg})
140		enc.Encode(Response{Allow: false, Message: msg})
141		return
142	}
143	switch req.Hook {
144	case "pre-receive":
145		s.preReceive(req, dec, enc)
146	case "post-receive":
147		s.postReceive(req)
148		enc.Encode(Response{Allow: true})
149	default:
150		enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
151	}
152}
153
154// authorize ties a request to a receive-pack sshd started: its token
155// must be live and name the same repository, account and key scope.
156// On a refusal actor is the token's account when the token is live,
157// and 0 otherwise: the request's own user id is only a claim.
158func (s *Server) authorize(req Request) (actor int64, msg string) {
159	if req.Token == "" {
160		return 0, "push not started by this server"
161	}
162	tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
163	if err != nil {
164		return 0, "push not started by this server"
165	}
166	if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
167		return tok.UserID, "push token does not match this request"
168	}
169	return 0, ""
170}
171
172// peerCheck is checkPeer; tests replace it.
173var peerCheck = checkPeer
174
175// auditedRefs is how many ref names a refused-push row keeps; the rest
176// are counted, so one push of many refs cannot write an unbounded row.
177const auditedRefs = 20
178
179// refusePush answers a pre-receive refusal and audits it.
180func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
181	n := min(len(req.Updates), auditedRefs)
182	refs := make([]string, n)
183	for i, u := range req.Updates[:n] {
184		refs[i] = u.Ref
185	}
186	data := map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg}
187	if more := len(req.Updates) - n; more > 0 {
188		data["more_refs"] = more
189	}
190	control.AuditRefused(s.st, req.UserID, "refused push", data)
191	enc.Encode(Response{Allow: false, Message: msg})
192}
193
194func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
195	repo, err := s.st.RepoByID(req.RepoID)
196	if err != nil {
197		enc.Encode(Response{Allow: false, Message: "unknown repository"})
198		return
199	}
200	if msg := policy.CheckPush(repo, req.Updates); msg != "" {
201		s.refusePush(enc, req, repo, msg)
202		return
203	}
204	if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
205		s.refusePush(enc, req, repo, msg)
206		return
207	}
208	if !repo.Settings.RequireSignedCommits {
209		enc.Encode(Response{Allow: true})
210		return
211	}
212
213	// Phase two: ask the hook for the incoming commit objects.
214	if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
215		return
216	}
217	// Each commit is verified as it arrives, so nothing holds the push in
218	// memory. The first refusal decides the answer, but the stream is
219	// still drained to its end before replying: the hook is writing, and
220	// answering early would leave it writing into a socket nobody reads.
221	// Draining costs a decode per commit and no verification.
222	db := store.SigDB{Store: s.st}
223	refusal := ""
224	for {
225		var rc RawCommit
226		if err := dec.Decode(&rc); err != nil {
227			enc.Encode(Response{Allow: false, Message: "bad commits payload"})
228			return
229		}
230		if rc.Done() {
231			break
232		}
233		if refusal != "" {
234			continue
235		}
236		parsed, err := sig.ParseCommit(rc.Raw)
237		if err != nil {
238			refusal = fmt.Sprintf("unparseable commit %s", rc.SHA)
239			continue
240		}
241		res, err := sig.VerifyCommit(db, parsed)
242		if err != nil || res.State != sig.Verified {
243			state := "error"
244			if err == nil {
245				state = string(res.State)
246			}
247			refusal = fmt.Sprintf("this repository requires signed commits: %.10s is %s", rc.SHA, state)
248		}
249	}
250	if refusal != "" {
251		s.refusePush(enc, req, repo, refusal)
252		return
253	}
254	enc.Encode(Response{Allow: true})
255}
256
257// releaseAnchors refuses deleting or moving a tag that a release is
258// anchored to. A release outliving its tag served assets for a commit
259// nobody could reach (#201); the release goes first, then the tag.
260func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
261	for _, u := range updates {
262		tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
263		if !ok || gitutil.ZeroSHA(u.Old) {
264			continue
265		}
266		if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
267			continue
268		}
269		verb := "moved"
270		if u.IsDelete {
271			verb = "deleted"
272		}
273		return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
274	}
275	return ""
276}
277
278// postReceive applies the cross-repo MR effect: a push to a source branch
279// refreshes refs/merge-requests/N/head in every target repo, by fetching —
280// the target owns the objects, so the MR outlives the fork. This is the only
281// place a hook writes outside its own repository.
282func (s *Server) postReceive(req Request) {
283	pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
284	if pushedRepoErr == nil {
285		s.adoptDefaultBranch(&pushedRepo, req.Updates)
286	}
287	for _, u := range req.Updates {
288		// Every ref update is an event webhooks can subscribe to.
289		s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
290			`{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
291			u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
292
293		// Any ref update — branch or tag — schedules the push mirrors.
294		s.st.MarkMirrorsDirty(req.RepoID, "push")
295
296		// Tag pushes run the tag-triggered CI jobs.
297		if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
298			s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
299		}
300
301		branch, ok := cutHeads(u.Ref)
302		if !ok {
303			continue
304		}
305		// Commits landing on the default branch act on issue references
306		// in their messages (closes #N, plain #N).
307		if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
308			dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
309			control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
310			control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
311		}
312		// A branch push with a .gitbay/ci.yml queues one build per job.
313		if pushedRepoErr == nil && !u.IsDelete {
314			s.queueBuilds(pushedRepo, req.UserID, branch, u.Old, u.New)
315		}
316		if u.IsForce {
317			s.st.Audit(req.UserID, "push.forced", map[string]any{
318				"repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
319		}
320		mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
321		if err != nil {
322			slog.Error("post-receive: listing MRs", "err", err)
323			continue
324		}
325		srcRepo, err := s.st.RepoByID(req.RepoID)
326		if err != nil {
327			continue
328		}
329		srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
330		for _, mr := range mrs {
331			target, err := s.st.RepoByID(mr.RepoID)
332			if err != nil {
333				continue
334			}
335			if u.IsDelete {
336				if mr.State == "open" {
337					s.st.SetMRState(mr.ID, "source_gone")
338				}
339				continue // head ref retained: the diff stays viewable
340			}
341			dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
342			headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
343			if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
344				slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
345				continue
346			}
347			// The merge base as it stands now, so a later range-diff
348			// compares each revision against the target it was written
349			// on rather than against today's. Best-effort: a base that
350			// cannot be worked out costs precision, not the record.
351			base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
352			if err != nil {
353				base = ""
354			}
355			if err := s.st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
356				slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
357			}
358			if srcRepo.ID != target.ID {
359				control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
360					target, req.UserID, mr.Number, u.New)
361			}
362			if mr.State == "source_gone" {
363				s.st.SetMRState(mr.ID, "open") // branch came back
364			}
365		}
366	}
367}
368
369// adoptDefaultBranch moves an unborn HEAD to the first branch a push
370// creates. A repository is initialised with HEAD at the stored default,
371// and a first push of master or trunk left HEAD naming a branch that did
372// not exist: clones checked out nothing and every surface asked git for
373// a branch that was not there (#189). A push that includes the default
374// branch itself needs nothing.
375func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
376	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
377	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
378		return
379	}
380	for _, u := range updates {
381		branch, ok := cutHeads(u.Ref)
382		if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
383			continue
384		}
385		if err := gitutil.SetHead(dir, branch); err != nil {
386			slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
387			return
388		}
389		if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
390			slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
391			return
392		}
393		repo.DefaultBranch = branch
394		return
395	}
396}
397
398// sameChange reports whether the new head proposes the diff the old one
399// did: the patch-id of each revision against its own merge base. A
400// rebase onto a moved target changes every sha and nothing about the
401// change, and the reviews of it should not go stale for that (#198).
402// Any doubt answers false, which is the old behaviour.
403func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
404	if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
405		return false
406	}
407	oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
408	if err != nil {
409		return false
410	}
411	oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
412	if err != nil || oldID == "" {
413		return false
414	}
415	newID, err := gitutil.PatchID(dir, newBase, newHead)
416	return err == nil && newID == oldID
417}
418
419// queueBuilds queues the push jobs for a branch update. The work is
420// shared with the merge path, which moves a ref without reaching a hook.
421func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
422	control.QueueBranchBuilds(
423		s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
424		repo, userID, branch, old, sha, time.Now())
425}
426
427// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
428// The build records the tag as its ref and the peeled commit as its sha,
429// so statuses land on the commit, not an annotated tag object.
430func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
431	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
432	sha, err := gitutil.PeelToCommit(dir, pushed)
433	if err != nil {
434		return
435	}
436	raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
437	if err != nil {
438		return
439	}
440	jobs, err := ci.Parse(raw)
441	if err != nil {
442		return // the branch push already reported ci/config
443	}
444	for _, j := range jobs {
445		if j.Tags == "" {
446			continue
447		}
448		if ok, _ := path.Match(j.Tags, tag); !ok {
449			continue
450		}
451		steps, _ := json.Marshal(j.Steps)
452		n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
453		if err != nil {
454			slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
455			continue
456		}
457		url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
458		s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
459	}
460}
461
462func cutHeads(ref string) (string, bool) {
463	const p = "refs/heads/"
464	if len(ref) > len(p) && ref[:len(p)] == p {
465		return ref[len(p):], true
466	}
467	return "", false
468}
469
470// Ask sends one request from the hook process to the daemon. stream is
471// called if the daemon asks for the incoming commit objects; it hands each
472// commit to the callback, which writes it on the wire.
473func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
474	conn, err := net.Dial("unix", socketPath)
475	if err != nil {
476		return Response{}, err
477	}
478	defer conn.Close()
479	enc := json.NewEncoder(conn)
480	dec := json.NewDecoder(conn)
481	if err := enc.Encode(req); err != nil {
482		return Response{}, err
483	}
484	var resp Response
485	if err := dec.Decode(&resp); err != nil {
486		return Response{}, err
487	}
488	if !resp.NeedCommits {
489		return resp, nil
490	}
491	if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
492		return Response{}, err
493	}
494	if err := enc.Encode(RawCommit{}); err != nil { // end of stream
495		return Response{}, err
496	}
497	err = dec.Decode(&resp)
498	return resp, err
499}
500
501// WriteHookScripts (re)generates the shared hooks directory. Called at
502// daemon startup so a moved binary self-heals; every repo points here via
503// core.hooksPath.
504func WriteHookScripts(hooksDir, gitbaydPath string) error {
505	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
506		return err
507	}
508	for _, hook := range []string{"pre-receive", "post-receive"} {
509		script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
510		if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
511			return err
512		}
513	}
514	return nil
515}