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