krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/hookd/hookd.go · raw
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 for _, u := range req.Updates {
170 // Every ref update is an event webhooks can subscribe to.
171 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
172 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
173 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
174
175 branch, ok := cutHeads(u.Ref)
176 if !ok {
177 continue
178 }
179 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
180 if err != nil {
181 slog.Error("post-receive: listing MRs", "err", err)
182 continue
183 }
184 srcRepo, err := s.st.RepoByID(req.RepoID)
185 if err != nil {
186 continue
187 }
188 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
189 for _, mr := range mrs {
190 target, err := s.st.RepoByID(mr.RepoID)
191 if err != nil {
192 continue
193 }
194 if u.IsDelete {
195 if mr.State == "open" {
196 s.st.SetMRState(mr.ID, "source_gone")
197 }
198 continue // head ref retained: the diff stays viewable
199 }
200 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
201 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
202 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
203 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
204 continue
205 }
206 if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
207 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
208 }
209 if mr.State == "source_gone" {
210 s.st.SetMRState(mr.ID, "open") // branch came back
211 }
212 }
213 }
214}
215
216func cutHeads(ref string) (string, bool) {
217 const p = "refs/heads/"
218 if len(ref) > len(p) && ref[:len(p)] == p {
219 return ref[len(p):], true
220 }
221 return "", false
222}
223
224// Ask sends one request from the hook process to the daemon. commits is
225// called if the daemon asks for the incoming commit objects.
226func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) {
227 conn, err := net.Dial("unix", socketPath)
228 if err != nil {
229 return Response{}, err
230 }
231 defer conn.Close()
232 enc := json.NewEncoder(conn)
233 dec := json.NewDecoder(conn)
234 if err := enc.Encode(req); err != nil {
235 return Response{}, err
236 }
237 var resp Response
238 if err := dec.Decode(&resp); err != nil {
239 return Response{}, err
240 }
241 if !resp.NeedCommits {
242 return resp, nil
243 }
244 payload, err := commits()
245 if err != nil {
246 return Response{}, err
247 }
248 if err := enc.Encode(payload); err != nil {
249 return Response{}, err
250 }
251 err = dec.Decode(&resp)
252 return resp, err
253}
254
255// WriteHookScripts (re)generates the shared hooks directory. Called at
256// daemon startup so a moved binary self-heals; every repo points here via
257// core.hooksPath.
258func WriteHookScripts(hooksDir, gitbaydPath string) error {
259 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
260 return err
261 }
262 for _, hook := range []string{"pre-receive", "post-receive"} {
263 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
264 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
265 return err
266 }
267 }
268 return nil
269}