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}