internal/hookd/hookd.go

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

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