internal/hookd/hookd.go

8246c58f81115a972853af8d0ed7cbaa35bd1339
gitbay/internal/hookd/hookd.go history · blame · raw

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