v0.6.1 3d41bc5

control.go RawHistoryFolder

1// Package control implements the private recording control socket.
2//
3// A recording process calls Listen to own $XDG_RUNTIME_DIR/nefercap/record.sock
4// (mode 0600, inside a 0700 directory owned by the caller). Another process
5// asks it to stop or reports its state with Stop and Query. The wire protocol
6// is two request lines, "stop\n" and "status\n", answered with "ok\n" and
7// "recording\n". No path, pid or file name is ever sent.
8//
9// Ownership is enforced by an flock(2) on a private lock file held for the
10// server's lifetime, so a leftover socket is removed only by the process that
11// holds the lock and clients never unlink anything. Peers are checked with
12// SO_PEERCRED and must run as the same user; this is not general Unix
13// authentication, and processes of the same user are trusted.
14package control
15
16import (
17	"bytes"
18	"context"
19	"errors"
20	"fmt"
21	"io"
22	"net"
23	"os"
24	"path/filepath"
25	"strconv"
26	"strings"
27	"sync"
28	"time"
29
30	"golang.org/x/sys/unix"
31)
32
33const (
34	dirName    = "nefercap"
35	sockName   = "record.sock"
36	lockName   = "record.lock"
37	maxPath    = 4096 // longest accepted runtime directory
38	maxRequest = 64
39	connLimit  = 4
40	ioTimeout  = time.Second
41	backlog    = connLimit
42
43	stopLine   = "stop\n"
44	statusLine = "status\n"
45	okLine     = "ok\n"
46	recLine    = "recording\n"
47)
48
49var (
50	// ErrAlreadyRecording means another live process owns the control socket.
51	ErrAlreadyRecording = errors.New("control: a recording is already active")
52	// ErrNoRuntimeDir means XDG_RUNTIME_DIR is unset or unusable.
53	ErrNoRuntimeDir = errors.New("control: XDG_RUNTIME_DIR is not an absolute path")
54	// ErrInsecure means a control path has the wrong type, owner or mode.
55	ErrInsecure = errors.New("control: insecure control path")
56	// ErrProtocol means the peer answered with something unexpected.
57	ErrProtocol = errors.New("control: protocol error")
58)
59
60// Request is a control request delivered to the recording owner. Status
61// requests are answered by the server itself and are never delivered.
62type Request uint8
63
64// RequestStop asks the recording owner to cancel and finalize.
65const RequestStop Request = iota + 1
66
67// Server is the listening side owned by the recording process.
68type Server struct {
69	ln      *net.UnixListener
70	dirFD   int
71	lockFD  int
72	dev     uint64
73	ino     uint64
74	peerUID uint32
75
76	reqs chan Request
77	sem  chan struct{}
78	done chan struct{}
79	wg   sync.WaitGroup
80
81	mu     sync.Mutex
82	closed bool
83	conns  map[net.Conn]struct{}
84
85	once     sync.Once
86	closeErr error
87}
88
89// cleanRuntimeDir validates a raw runtime directory string.
90func cleanRuntimeDir(rd string) (string, error) {
91	if !filepath.IsAbs(rd) || len(rd) > maxPath || strings.IndexByte(rd, 0) >= 0 {
92		return "", ErrNoRuntimeDir
93	}
94	return filepath.Clean(rd), nil
95}
96
97func runtimeDir() (string, error) { return cleanRuntimeDir(os.Getenv("XDG_RUNTIME_DIR")) }
98
99// openSecureDir opens runtime/nefercap without following symlinks and checks
100// that it is a directory owned by uid with mode exactly 0700. All later
101// operations are relative to the returned descriptor.
102func openSecureDir(runtime string, create bool, uid uint32) (int, error) {
103	dir := filepath.Join(runtime, dirName)
104	if create {
105		err := unix.Mkdir(dir, 0o700)
106		if err == nil {
107			err = unix.Chmod(dir, 0o700) // independent of the umask
108		}
109		if err != nil && err != unix.EEXIST {
110			return -1, fmt.Errorf("control: create %s: %w", dirName, err)
111		}
112	}
113	fd, err := unix.Open(dir, unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
114	if err != nil {
115		if err == unix.ELOOP || err == unix.ENOTDIR {
116			return -1, fmt.Errorf("%w: %s is not a directory", ErrInsecure, dirName)
117		}
118		return -1, fmt.Errorf("control: open %s: %w", dirName, err)
119	}
120	var st unix.Stat_t
121	if err := unix.Fstat(fd, &st); err != nil {
122		unix.Close(fd)
123		return -1, fmt.Errorf("control: stat %s: %w", dirName, err)
124	}
125	if st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Uid != uid || st.Mode&0o777 != 0o700 {
126		unix.Close(fd)
127		return -1, fmt.Errorf("%w: %s must be a 0700 directory owned by the current user", ErrInsecure, dirName)
128	}
129	return fd, nil
130}
131
132// Listen becomes the single control server. It returns ErrAlreadyRecording
133// when another live process holds the lock. The caller must Close the server.
134func Listen() (*Server, error) {
135	rd, err := runtimeDir()
136	if err != nil {
137		return nil, err
138	}
139	return listen(rd, uint32(os.Geteuid()))
140}
141
142// listen is Listen with an explicit accepted peer uid, so tests can exercise
143// the foreign-peer rejection with real sockets.
144func listen(runtime string, peerUID uint32) (s *Server, err error) {
145	if runtime, err = cleanRuntimeDir(runtime); err != nil {
146		return nil, err
147	}
148	euid := uint32(os.Geteuid())
149	dirFD, err := openSecureDir(runtime, true, euid)
150	if err != nil {
151		return nil, err
152	}
153	lockFD := -1
154	sockFD := -1
155	bound := false
156	var dev, ino uint64
157	defer func() {
158		if err == nil {
159			return
160		}
161		if sockFD >= 0 {
162			unix.Close(sockFD)
163		}
164		if bound && sameInode(dirFD, dev, ino) {
165			unix.Unlinkat(dirFD, sockName, 0)
166		}
167		if lockFD >= 0 {
168			unix.Close(lockFD)
169		}
170		unix.Close(dirFD)
171	}()
172
173	lockFD, err = unix.Openat(dirFD, lockName, unix.O_RDWR|unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
174	if err != nil {
175		return nil, fmt.Errorf("control: open lock: %w", err)
176	}
177	var st unix.Stat_t
178	if err = unix.Fstat(lockFD, &st); err != nil {
179		return nil, fmt.Errorf("control: stat lock: %w", err)
180	}
181	if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Uid != euid {
182		return nil, fmt.Errorf("%w: lock file", ErrInsecure)
183	}
184	if err = unix.Fchmod(lockFD, 0o600); err != nil {
185		return nil, fmt.Errorf("control: chmod lock: %w", err)
186	}
187	if err = unix.Flock(lockFD, unix.LOCK_EX|unix.LOCK_NB); err != nil {
188		if err == unix.EWOULDBLOCK {
189			err = ErrAlreadyRecording
190		} else {
191			err = fmt.Errorf("control: lock: %w", err)
192		}
193		return nil, err
194	}
195
196	// The lock is held, so no live server owns the socket path: a leftover
197	// socket of ours is stale. Anything else is left alone.
198	if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err == nil {
199		if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid {
200			err = fmt.Errorf("%w: %s is not a socket owned by the current user", ErrInsecure, sockName)
201			return nil, err
202		}
203		if err = unix.Unlinkat(dirFD, sockName, 0); err != nil {
204			return nil, fmt.Errorf("control: remove stale socket: %w", err)
205		}
206	} else if err != unix.ENOENT {
207		return nil, fmt.Errorf("control: stat socket: %w", err)
208	}
209
210	sockFD, err = unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, 0)
211	if err != nil {
212		return nil, fmt.Errorf("control: socket: %w", err)
213	}
214	if err = unix.Bind(sockFD, &unix.SockaddrUnix{Name: dirPath(dirFD)}); err != nil {
215		return nil, fmt.Errorf("control: bind: %w", err)
216	}
217	bound = true
218	if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
219		bound = false
220		return nil, fmt.Errorf("control: stat socket: %w", err)
221	}
222	dev, ino = uint64(st.Dev), uint64(st.Ino)
223	// Not yet listening, so nobody can connect while the mode is tightened.
224	if err = unix.Fchmodat(dirFD, sockName, 0o600, 0); err != nil {
225		return nil, fmt.Errorf("control: chmod socket: %w", err)
226	}
227	if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
228		return nil, fmt.Errorf("control: stat socket: %w", err)
229	}
230	if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid || st.Mode&0o777 != 0o600 ||
231		uint64(st.Dev) != dev || uint64(st.Ino) != ino {
232		err = fmt.Errorf("%w: created socket", ErrInsecure)
233		return nil, err
234	}
235	if err = unix.Listen(sockFD, backlog); err != nil {
236		return nil, fmt.Errorf("control: listen: %w", err)
237	}
238	f := os.NewFile(uintptr(sockFD), sockName)
239	sockFD = -1
240	l, lerr := net.FileListener(f)
241	f.Close()
242	if lerr != nil {
243		err = fmt.Errorf("control: listener: %w", lerr)
244		return nil, err
245	}
246	ln := l.(*net.UnixListener)
247	ln.SetUnlinkOnClose(false)
248
249	s = &Server{
250		ln:      ln,
251		dirFD:   dirFD,
252		lockFD:  lockFD,
253		dev:     dev,
254		ino:     ino,
255		peerUID: peerUID,
256		reqs:    make(chan Request, 1),
257		sem:     make(chan struct{}, connLimit),
258		done:    make(chan struct{}),
259		conns:   make(map[net.Conn]struct{}, connLimit),
260	}
261	s.wg.Add(1)
262	go s.acceptLoop()
263	return s, nil
264}
265
266// dirPath names the verified directory through its descriptor, so bind and
267// connect cannot be redirected by swapping path components afterwards. It is
268// short, which also avoids the sun_path length limit.
269func dirPath(dirFD int) string {
270	return "/proc/self/fd/" + strconv.Itoa(dirFD) + "/" + sockName
271}
272
273func sameInode(dirFD int, dev, ino uint64) bool {
274	var st unix.Stat_t
275	if unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW) != nil {
276		return false
277	}
278	return st.Mode&unix.S_IFMT == unix.S_IFSOCK && uint64(st.Dev) == dev && uint64(st.Ino) == ino
279}
280
281// Requests delivers RequestStop. Repeated stops coalesce into one pending
282// request. The channel is never closed; select on it together with the
283// recording's own completion.
284func (s *Server) Requests() <-chan Request { return s.reqs }
285
286// Close stops serving, removes the socket only if it is still the inode this
287// server created, then releases the lock. It is idempotent and safe for
288// concurrent use.
289func (s *Server) Close() error {
290	s.once.Do(func() {
291		s.mu.Lock()
292		s.closed = true
293		close(s.done)
294		for c := range s.conns {
295			c.Close()
296		}
297		s.mu.Unlock()
298		s.closeErr = s.ln.Close()
299		s.wg.Wait()
300		if sameInode(s.dirFD, s.dev, s.ino) {
301			if err := unix.Unlinkat(s.dirFD, sockName, 0); err != nil && err != unix.ENOENT && s.closeErr == nil {
302				s.closeErr = fmt.Errorf("control: remove socket: %w", err)
303			}
304		}
305		unix.Close(s.dirFD)
306		unix.Close(s.lockFD) // releases the flock last
307	})
308	return s.closeErr
309}
310
311func (s *Server) acceptLoop() {
312	defer s.wg.Done()
313	for {
314		select {
315		case s.sem <- struct{}{}:
316		case <-s.done:
317			return
318		}
319		c, err := s.ln.AcceptUnix()
320		if err != nil {
321			<-s.sem
322			select {
323			case <-s.done:
324				return
325			default:
326			}
327			// Transient failure (for example fd exhaustion): back off.
328			t := time.NewTimer(50 * time.Millisecond)
329			select {
330			case <-t.C:
331			case <-s.done:
332				t.Stop()
333				return
334			}
335			continue
336		}
337		s.wg.Add(1)
338		go s.serve(c)
339	}
340}
341
342func (s *Server) serve(c *net.UnixConn) {
343	defer s.wg.Done()
344	defer func() { <-s.sem }()
345	s.mu.Lock()
346	if s.closed {
347		s.mu.Unlock()
348		c.Close()
349		return
350	}
351	s.conns[c] = struct{}{}
352	s.mu.Unlock()
353	defer func() {
354		s.mu.Lock()
355		delete(s.conns, c)
356		s.mu.Unlock()
357		c.Close()
358	}()
359
360	if c.SetDeadline(time.Now().Add(ioTimeout)) != nil {
361		return
362	}
363	if uid, err := peerUID(c); err != nil || uid != s.peerUID {
364		return
365	}
366	line, ok := readLine(c)
367	if !ok {
368		return
369	}
370	switch {
371	case bytes.Equal(line, []byte(stopLine)):
372		// Ack before delivering: the owner may Close, and so drop this
373		// connection, as soon as it sees the request. The ack means the stop
374		// was requested, not that the capture file is finalized.
375		io.WriteString(c, okLine)
376		// Coalescing, non-blocking.
377		select {
378		case s.reqs <- RequestStop:
379		default:
380		}
381	case bytes.Equal(line, []byte(statusLine)):
382		io.WriteString(c, recLine)
383	}
384}
385
386// readLine reads one newline-terminated request of at most maxRequest bytes.
387func readLine(r io.Reader) ([]byte, bool) {
388	var buf [maxRequest]byte
389	b := buf[:]
390	n := 0
391	for n < len(b) {
392		m, err := r.Read(b[n:])
393		n += m
394		if i := bytes.IndexByte(b[:n], '\n'); i >= 0 {
395			// The request is exactly one line; trailing bytes are junk.
396			return b[:i+1], i+1 == n
397		}
398		if err != nil {
399			return nil, false
400		}
401	}
402	return nil, false
403}
404
405func peerUID(c *net.UnixConn) (uint32, error) {
406	rc, err := c.SyscallConn()
407	if err != nil {
408		return 0, err
409	}
410	var uc *unix.Ucred
411	var serr error
412	if err := rc.Control(func(fd uintptr) {
413		uc, serr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
414	}); err != nil {
415		return 0, err
416	}
417	if serr != nil {
418		return 0, serr
419	}
420	return uc.Uid, nil
421}
422
423// Stop asks the active recording to stop. It returns false with a nil error
424// when no recording is active. A true result means the request was accepted,
425// not that the capture has been finalized.
426func Stop(ctx context.Context) (bool, error) {
427	rd, err := runtimeDir()
428	if err != nil {
429		return false, err
430	}
431	return exchange(ctx, rd, stopLine, okLine)
432}
433
434// Query reports whether a recording is active.
435func Query(ctx context.Context) (bool, error) {
436	rd, err := runtimeDir()
437	if err != nil {
438		return false, err
439	}
440	return exchange(ctx, rd, statusLine, recLine)
441}
442
443func exchange(ctx context.Context, runtime, req, want string) (bool, error) {
444	euid := uint32(os.Geteuid())
445	dirFD, err := openSecureDir(runtime, false, euid)
446	if err != nil {
447		if errors.Is(err, unix.ENOENT) {
448			return false, nil
449		}
450		return false, err
451	}
452	defer unix.Close(dirFD)
453	var st unix.Stat_t
454	if err := unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
455		if err == unix.ENOENT {
456			return false, nil
457		}
458		return false, fmt.Errorf("control: stat socket: %w", err)
459	}
460	if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid || st.Mode&0o077 != 0 {
461		return false, fmt.Errorf("%w: %s", ErrInsecure, sockName)
462	}
463
464	ctx, cancel := context.WithTimeout(ctx, ioTimeout)
465	defer cancel()
466	conn, err := dialRetry(ctx, dirPath(dirFD))
467	if err != nil {
468		// A dead owner leaves the socket behind; only the next server, which
469		// holds the lock, removes it.
470		if errors.Is(err, unix.ECONNREFUSED) || errors.Is(err, unix.ENOENT) {
471			return false, nil
472		}
473		return false, err
474	}
475	defer conn.Close()
476	uc := conn.(*net.UnixConn)
477	stop := context.AfterFunc(ctx, func() { uc.Close() })
478	defer stop()
479	if dl, ok := ctx.Deadline(); ok {
480		uc.SetDeadline(dl)
481	}
482	if uid, err := peerUID(uc); err != nil {
483		return false, fmt.Errorf("control: peer credentials: %w", err)
484	} else if uid != euid {
485		return false, fmt.Errorf("%w: socket served by another user", ErrInsecure)
486	}
487	dev, ino := uint64(st.Dev), uint64(st.Ino)
488	if _, err := io.WriteString(uc, req); err != nil {
489		return false, droppedErr(ctx, dirFD, dev, ino, err)
490	}
491	var buf [len(recLine)]byte // the longest reply
492	n, err := io.ReadFull(uc, buf[:len(want)])
493	if n == 0 && err != nil {
494		return false, droppedErr(ctx, dirFD, dev, ino, err)
495	}
496	if err != nil {
497		return false, ctxErr(ctx, err)
498	}
499	if string(buf[:n]) != want {
500		return false, ErrProtocol
501	}
502	return true, nil
503}
504
505// dialRetry connects, retrying while the server's small accept backlog is
506// full (EAGAIN) until ctx ends.
507func dialRetry(ctx context.Context, path string) (net.Conn, error) {
508	var d net.Dialer
509	for {
510		conn, err := d.DialContext(ctx, "unix", path)
511		if !errors.Is(err, unix.EAGAIN) {
512			return conn, err
513		}
514		t := time.NewTimer(5 * time.Millisecond)
515		select {
516		case <-t.C:
517		case <-ctx.Done():
518			t.Stop()
519			return nil, ctx.Err()
520		}
521	}
522}
523
524// droppedErr handles a connection closed before any reply. Close drops open
525// connections, then removes the socket, so an owner shutting down is seen
526// as the socket inode going away: that is no recording, not an error. A
527// server that dropped us and keeps serving yields the original error once
528// ctx ends; a cancelled caller gets context.Canceled.
529func droppedErr(ctx context.Context, dirFD int, dev, ino uint64, err error) error {
530	if !errors.Is(err, io.EOF) && !errors.Is(err, unix.ECONNRESET) && !errors.Is(err, unix.EPIPE) {
531		return ctxErr(ctx, err)
532	}
533	for !socketGone(dirFD, dev, ino) {
534		t := time.NewTimer(5 * time.Millisecond)
535		select {
536		case <-t.C:
537		case <-ctx.Done():
538			t.Stop()
539			if errors.Is(ctx.Err(), context.Canceled) {
540				return ctx.Err()
541			}
542			return fmt.Errorf("control: server closed the connection: %w", err)
543		}
544	}
545	return nil
546}
547
548func socketGone(dirFD int, dev, ino uint64) bool {
549	var st unix.Stat_t
550	err := unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW)
551	return err == unix.ENOENT || err == nil && (uint64(st.Dev) != dev || uint64(st.Ino) != ino)
552}
553
554func ctxErr(ctx context.Context, err error) error {
555	if cerr := ctx.Err(); cerr != nil {
556		return cerr
557	}
558	return err
559}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close