internal/hookd/hookd.go

617b05d09be747ad310179ce5432dec98ebe2c17
gitbay/internal/hookd/hookd.go history · blame · raw

314 lines · 9840 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
 20	"gitbay.org/gitbay/internal/ci"
 21	"gitbay.org/gitbay/internal/config"
 22	"gitbay.org/gitbay/internal/control"
 23	"gitbay.org/gitbay/internal/gitutil"
 24	"gitbay.org/gitbay/internal/policy"
 25	"gitbay.org/gitbay/internal/sig"
 26	"gitbay.org/gitbay/internal/store"
 27)
 28
 29// Env variable names passed to git transport subprocesses and inherited by
 30// hooks.
 31const (
 32	EnvSocket = "GITBAY_HOOK_SOCKET"
 33	EnvRepoID = "GITBAY_REPO_ID"
 34	EnvUserID = "GITBAY_USER_ID"
 35)
 36
 37type Request struct {
 38	Hook    string             `json:"hook"` // pre-receive | post-receive
 39	RepoID  int64              `json:"repo_id"`
 40	UserID  int64              `json:"user_id"`
 41	Updates []policy.RefUpdate `json:"updates"`
 42}
 43
 44type RawCommit struct {
 45	SHA string `json:"sha"`
 46	Raw []byte `json:"raw"`
 47}
 48
 49// CommitsPayload is the hook's second message when NeedCommits was set.
 50type CommitsPayload struct {
 51	Commits []RawCommit `json:"commits"`
 52}
 53
 54type Response struct {
 55	Allow       bool   `json:"allow"`
 56	Message     string `json:"message,omitempty"`
 57	NeedCommits bool   `json:"need_commits,omitempty"`
 58}
 59
 60// SocketPath returns the hook socket location. It prefers the server root,
 61// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
 62// deep roots fall back to a hashed name under the system temp directory.
 63// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
 64// agree.
 65func SocketPath(root string) string {
 66	p := filepath.Join(root, "hook.sock")
 67	if len(p) <= 100 {
 68		return p
 69	}
 70	sum := sha256.Sum256([]byte(root))
 71	return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
 72}
 73
 74type Server struct {
 75	cfg config.Config
 76	st  *store.Store
 77}
 78
 79// Serve listens on the unix socket until the listener is closed.
 80func Serve(cfg config.Config, st *store.Store) (func() error, error) {
 81	path := SocketPath(cfg.Server.Root)
 82	os.Remove(path)
 83	ln, err := net.Listen("unix", path)
 84	if err != nil {
 85		return nil, err
 86	}
 87	s := &Server{cfg: cfg, st: st}
 88	go func() {
 89		for {
 90			conn, err := ln.Accept()
 91			if err != nil {
 92				return
 93			}
 94			go s.handle(conn)
 95		}
 96	}()
 97	return ln.Close, nil
 98}
 99
100func (s *Server) handle(conn net.Conn) {
101	defer conn.Close()
102	dec := json.NewDecoder(conn)
103	enc := json.NewEncoder(conn)
104	var req Request
105	if err := dec.Decode(&req); err != nil {
106		enc.Encode(Response{Allow: false, Message: "bad hook request"})
107		return
108	}
109	switch req.Hook {
110	case "pre-receive":
111		s.preReceive(req, dec, enc)
112	case "post-receive":
113		s.postReceive(req)
114		enc.Encode(Response{Allow: true})
115	default:
116		enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
117	}
118}
119
120func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
121	repo, err := s.st.RepoByID(req.RepoID)
122	if err != nil {
123		enc.Encode(Response{Allow: false, Message: "unknown repository"})
124		return
125	}
126	if msg := policy.CheckPush(repo, req.Updates); msg != "" {
127		enc.Encode(Response{Allow: false, Message: msg})
128		return
129	}
130	if !repo.Settings.RequireSignedCommits {
131		enc.Encode(Response{Allow: true})
132		return
133	}
134
135	// Phase two: ask the hook for the incoming commit objects.
136	if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
137		return
138	}
139	var payload CommitsPayload
140	if err := dec.Decode(&payload); err != nil {
141		enc.Encode(Response{Allow: false, Message: "bad commits payload"})
142		return
143	}
144	db := store.SigDB{Store: s.st}
145	for _, rc := range payload.Commits {
146		parsed, err := sig.ParseCommit(rc.Raw)
147		if err != nil {
148			enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unparseable commit %s", rc.SHA)})
149			return
150		}
151		res, err := sig.VerifyCommit(db, parsed)
152		if err != nil || res.State != sig.Verified {
153			state := "error"
154			if err == nil {
155				state = string(res.State)
156			}
157			enc.Encode(Response{Allow: false, Message: fmt.Sprintf(
158				"this repository requires signed commits: %.10s is %s", rc.SHA, state)})
159			return
160		}
161	}
162	enc.Encode(Response{Allow: true})
163}
164
165// postReceive applies the cross-repo MR effect: a push to a source branch
166// refreshes refs/merge-requests/N/head in every target repo, by fetching —
167// the target owns the objects, so the MR outlives the fork. This is the only
168// place a hook writes outside its own repository.
169func (s *Server) postReceive(req Request) {
170	pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
171	for _, u := range req.Updates {
172		// Every ref update is an event webhooks can subscribe to.
173		s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
174			`{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
175			u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
176
177		branch, ok := cutHeads(u.Ref)
178		if !ok {
179			continue
180		}
181		// Commits landing on the default branch act on issue references
182		// in their messages (closes #N, plain #N).
183		if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
184			dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
185			control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
186			control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
187		}
188		// A branch push with a .gitbay/ci.yml queues one build per job.
189		if pushedRepoErr == nil && !u.IsDelete {
190			s.queueBuilds(pushedRepo, req.UserID, branch, u.New)
191		}
192		// Any branch/tag update schedules the push mirrors.
193		s.st.MarkMirrorsDirty(req.RepoID, "push")
194		if u.IsForce {
195			s.st.Audit(req.UserID, "push.forced", map[string]any{
196				"repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
197		}
198		mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
199		if err != nil {
200			slog.Error("post-receive: listing MRs", "err", err)
201			continue
202		}
203		srcRepo, err := s.st.RepoByID(req.RepoID)
204		if err != nil {
205			continue
206		}
207		srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
208		for _, mr := range mrs {
209			target, err := s.st.RepoByID(mr.RepoID)
210			if err != nil {
211				continue
212			}
213			if u.IsDelete {
214				if mr.State == "open" {
215					s.st.SetMRState(mr.ID, "source_gone")
216				}
217				continue // head ref retained: the diff stays viewable
218			}
219			dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
220			headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
221			if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
222				slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
223				continue
224			}
225			if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
226				slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
227			}
228			if mr.State == "source_gone" {
229				s.st.SetMRState(mr.ID, "open") // branch came back
230			}
231		}
232	}
233}
234
235// queueBuilds reads .gitbay/ci.yml at the pushed commit and creates one
236// pending build per job, with a pending commit status the runner resolves.
237// A broken config surfaces as a failed "ci/config" status, not silence.
238func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, sha string) {
239	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
240	raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
241	if err != nil {
242		return // no CI config at this commit
243	}
244	jobs, err := ci.Parse(raw)
245	if err != nil {
246		s.st.SetCommitStatus(repo.ID, sha, "ci/config", "failure", err.Error(), "", userID)
247		return
248	}
249	for _, j := range jobs {
250		steps, _ := json.Marshal(j.Steps)
251		n, err := s.st.CreateBuild(repo.ID, j.Name, sha, branch, string(steps))
252		if err != nil {
253			slog.Error("queueing build", "repo", repo.Path(), "job", j.Name, "err", err)
254			continue
255		}
256		url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
257		s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "queued", url, userID)
258	}
259}
260
261func cutHeads(ref string) (string, bool) {
262	const p = "refs/heads/"
263	if len(ref) > len(p) && ref[:len(p)] == p {
264		return ref[len(p):], true
265	}
266	return "", false
267}
268
269// Ask sends one request from the hook process to the daemon. commits is
270// called if the daemon asks for the incoming commit objects.
271func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) {
272	conn, err := net.Dial("unix", socketPath)
273	if err != nil {
274		return Response{}, err
275	}
276	defer conn.Close()
277	enc := json.NewEncoder(conn)
278	dec := json.NewDecoder(conn)
279	if err := enc.Encode(req); err != nil {
280		return Response{}, err
281	}
282	var resp Response
283	if err := dec.Decode(&resp); err != nil {
284		return Response{}, err
285	}
286	if !resp.NeedCommits {
287		return resp, nil
288	}
289	payload, err := commits()
290	if err != nil {
291		return Response{}, err
292	}
293	if err := enc.Encode(payload); err != nil {
294		return Response{}, err
295	}
296	err = dec.Decode(&resp)
297	return resp, err
298}
299
300// WriteHookScripts (re)generates the shared hooks directory. Called at
301// daemon startup so a moved binary self-heals; every repo points here via
302// core.hooksPath.
303func WriteHookScripts(hooksDir, gitbaydPath string) error {
304	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
305		return err
306	}
307	for _, hook := range []string{"pre-receive", "post-receive"} {
308		script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
309		if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
310			return err
311		}
312	}
313	return nil
314}