internal/hookd/hookd.go
326 lines · 10476 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 "strings"
20
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 EnvScope = "GITBAY_KEY_SCOPE"
36 // EnvToken names the receive-pack this hook runs under. sshd mints
37 // it per push; hookd answers only a request carrying a live one
38 // whose repository, account and scope match the request's.
39 EnvToken = "GITBAY_PUSH_TOKEN"
40)
41
42type Request struct {
43 Hook string `json:"hook"` // pre-receive | post-receive
44 RepoID int64 `json:"repo_id"`
45 UserID int64 `json:"user_id"`
46 // Scope is the pushing key's scope. The user id alone is the account
47 // the key belongs to, and a deploy key grants nothing outside its
48 // binding, so anything acting on another repository needs this too.
49 Scope string `json:"scope"`
50 Token string `json:"token"`
51 Updates []policy.RefUpdate `json:"updates"`
52}
53
54// RawCommit is one incoming commit object. When NeedCommits is set the
55// hook streams these one per JSON value and ends with a zero one, rather
56// than sending a single message holding every commit in the push: an
57// initial push of a large history is tens of thousands of them (#100).
58type RawCommit struct {
59 SHA string `json:"sha"`
60 Raw []byte `json:"raw"`
61}
62
63// Done marks the end of the commit stream.
64func (c RawCommit) Done() bool { return c.SHA == "" }
65
66type Response struct {
67 Allow bool `json:"allow"`
68 Message string `json:"message,omitempty"`
69 NeedCommits bool `json:"need_commits,omitempty"`
70}
71
72// SocketPath returns the hook socket location. It prefers the server root,
73// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
74// deep roots fall back to a hashed name under the system temp directory.
75// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
76// agree.
77func SocketPath(root string) string {
78 p := filepath.Join(root, "hook.sock")
79 if len(p) <= 100 {
80 return p
81 }
82 sum := sha256.Sum256([]byte(root))
83 return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
84}
85
86type Server struct {
87 cfg config.Config
88 st *store.Store
89}
90
91// Serve listens on the unix socket until the listener is closed.
92func Serve(cfg config.Config, st *store.Store) (func() error, error) {
93 path := SocketPath(cfg.Server.Root)
94 os.Remove(path)
95 ln, err := net.Listen("unix", path)
96 if err != nil {
97 return nil, err
98 }
99 // Listen creates the socket under the process umask. Hooks run as
100 // the daemon's own user; nobody else has a reason to connect.
101 if err := os.Chmod(path, 0o600); err != nil {
102 ln.Close()
103 return nil, err
104 }
105 s := &Server{cfg: cfg, st: st}
106 go func() {
107 for {
108 conn, err := ln.Accept()
109 if err != nil {
110 return
111 }
112 go s.handle(conn)
113 }
114 }()
115 return ln.Close, nil
116}
117
118func (s *Server) handle(conn net.Conn) {
119 defer conn.Close()
120 dec := json.NewDecoder(conn)
121 enc := json.NewEncoder(conn)
122 if err := peerCheck(conn); err != nil {
123 slog.Warn("hook socket: refused connection", "err", err)
124 // Nothing about the request is known yet, and no account.
125 control.AuditRefused(s.st, 0, "refused hook", map[string]any{"reason": err.Error()})
126 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
127 return
128 }
129 var req Request
130 if err := dec.Decode(&req); err != nil {
131 enc.Encode(Response{Allow: false, Message: "bad hook request"})
132 return
133 }
134 if actor, msg := s.authorize(req); msg != "" {
135 control.AuditRefused(s.st, actor, "refused hook",
136 map[string]any{"repo_id": req.RepoID, "hook": req.Hook, "reason": msg})
137 enc.Encode(Response{Allow: false, Message: msg})
138 return
139 }
140 switch req.Hook {
141 case "pre-receive":
142 s.preReceive(req, dec, enc)
143 case "post-receive":
144 s.postReceive(req)
145 enc.Encode(Response{Allow: true})
146 default:
147 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
148 }
149}
150
151// authorize ties a request to a receive-pack sshd started: its token
152// must be live and name the same repository, account and key scope.
153// On a refusal actor is the token's account when the token is live,
154// and 0 otherwise: the request's own user id is only a claim.
155func (s *Server) authorize(req Request) (actor int64, msg string) {
156 if req.Token == "" {
157 return 0, "push not started by this server"
158 }
159 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
160 if err != nil {
161 return 0, "push not started by this server"
162 }
163 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
164 return tok.UserID, "push token does not match this request"
165 }
166 return 0, ""
167}
168
169// peerCheck is checkPeer; tests replace it.
170var peerCheck = checkPeer
171
172// auditedRefs is how many ref names a refused-push row keeps; the rest
173// are counted, so one push of many refs cannot write an unbounded row.
174const auditedRefs = 20
175
176// refusePush answers a pre-receive refusal and audits it.
177func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
178 n := min(len(req.Updates), auditedRefs)
179 refs := make([]string, n)
180 for i, u := range req.Updates[:n] {
181 refs[i] = u.Ref
182 }
183 data := map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg}
184 if more := len(req.Updates) - n; more > 0 {
185 data["more_refs"] = more
186 }
187 control.AuditRefused(s.st, req.UserID, "refused push", data)
188 enc.Encode(Response{Allow: false, Message: msg})
189}
190
191func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
192 repo, err := s.st.RepoByID(req.RepoID)
193 if err != nil {
194 enc.Encode(Response{Allow: false, Message: "unknown repository"})
195 return
196 }
197 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
198 s.refusePush(enc, req, repo, msg)
199 return
200 }
201 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
202 s.refusePush(enc, req, repo, msg)
203 return
204 }
205 if !repo.Settings.RequireSignedCommits {
206 enc.Encode(Response{Allow: true})
207 return
208 }
209
210 // Phase two: ask the hook for the incoming commit objects.
211 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
212 return
213 }
214 // Each commit is verified as it arrives, so nothing holds the push in
215 // memory. The first refusal decides the answer, but the stream is
216 // still drained to its end before replying: the hook is writing, and
217 // answering early would leave it writing into a socket nobody reads.
218 // Draining costs a decode per commit and no verification.
219 db := store.SigDB{Store: s.st}
220 refusal := ""
221 for {
222 var rc RawCommit
223 if err := dec.Decode(&rc); err != nil {
224 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
225 return
226 }
227 if rc.Done() {
228 break
229 }
230 if refusal != "" {
231 continue
232 }
233 parsed, err := sig.ParseCommit(rc.Raw)
234 if err != nil {
235 refusal = fmt.Sprintf("unparseable commit %s", rc.SHA)
236 continue
237 }
238 res, err := sig.VerifyCommit(db, parsed)
239 if err != nil || res.State != sig.Verified {
240 state := "error"
241 if err == nil {
242 state = string(res.State)
243 }
244 refusal = fmt.Sprintf("this repository requires signed commits: %.10s is %s", rc.SHA, state)
245 }
246 }
247 if refusal != "" {
248 s.refusePush(enc, req, repo, refusal)
249 return
250 }
251 enc.Encode(Response{Allow: true})
252}
253
254// releaseAnchors refuses deleting or moving a tag that a release is
255// anchored to. A release outliving its tag served assets for a commit
256// nobody could reach (#201); the release goes first, then the tag.
257func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
258 for _, u := range updates {
259 tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
260 if !ok || gitutil.ZeroSHA(u.Old) {
261 continue
262 }
263 if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
264 continue
265 }
266 verb := "moved"
267 if u.IsDelete {
268 verb = "deleted"
269 }
270 return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
271 }
272 return ""
273}
274
275// postReceive runs the ref-update work for a push; see
276// control.RefsUpdated, which server-side writes to a branch share.
277func (s *Server) postReceive(req Request) {
278 control.RefsUpdated(s.st, s.cfg, req.RepoID, req.UserID, req.Scope, req.Updates)
279}
280
281// Ask sends one request from the hook process to the daemon. stream is
282// called if the daemon asks for the incoming commit objects; it hands each
283// commit to the callback, which writes it on the wire.
284func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
285 conn, err := net.Dial("unix", socketPath)
286 if err != nil {
287 return Response{}, err
288 }
289 defer conn.Close()
290 enc := json.NewEncoder(conn)
291 dec := json.NewDecoder(conn)
292 if err := enc.Encode(req); err != nil {
293 return Response{}, err
294 }
295 var resp Response
296 if err := dec.Decode(&resp); err != nil {
297 return Response{}, err
298 }
299 if !resp.NeedCommits {
300 return resp, nil
301 }
302 if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
303 return Response{}, err
304 }
305 if err := enc.Encode(RawCommit{}); err != nil { // end of stream
306 return Response{}, err
307 }
308 err = dec.Decode(&resp)
309 return resp, err
310}
311
312// WriteHookScripts (re)generates the shared hooks directory. Called at
313// daemon startup so a moved binary self-heals; every repo points here via
314// core.hooksPath.
315func WriteHookScripts(hooksDir, gitbaydPath string) error {
316 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
317 return err
318 }
319 for _, hook := range []string{"pre-receive", "post-receive"} {
320 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
321 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
322 return err
323 }
324 }
325 return nil
326}