internal/hookd/hookd.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/hookd/hookd.go history · blame · raw

361 lines · 11425 bytes

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