internal/hookd/hookd.go
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}