v0.2.0 a558f49

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	var reply string
371	switch {
372	case bytes.Equal(line, []byte(stopLine)):
373		// Coalescing, non-blocking: the ack means the stop was requested,
374		// not that the capture file is finalized.
375		select {
376		case s.reqs <- RequestStop:
377		default:
378		}
379		reply = okLine
380	case bytes.Equal(line, []byte(statusLine)):
381		reply = recLine
382	default:
383		return
384	}
385	io.WriteString(c, reply)
386}
387
388// readLine reads one newline-terminated request of at most maxRequest bytes.
389func readLine(r io.Reader) ([]byte, bool) {
390	var buf [maxRequest]byte
391	b := buf[:]
392	n := 0
393	for n < len(b) {
394		m, err := r.Read(b[n:])
395		n += m
396		if i := bytes.IndexByte(b[:n], '\n'); i >= 0 {
397			// The request is exactly one line; trailing bytes are junk.
398			return b[:i+1], i+1 == n
399		}
400		if err != nil {
401			return nil, false
402		}
403	}
404	return nil, false
405}
406
407func peerUID(c *net.UnixConn) (uint32, error) {
408	rc, err := c.SyscallConn()
409	if err != nil {
410		return 0, err
411	}
412	var uc *unix.Ucred
413	var serr error
414	if err := rc.Control(func(fd uintptr) {
415		uc, serr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
416	}); err != nil {
417		return 0, err
418	}
419	if serr != nil {
420		return 0, serr
421	}
422	return uc.Uid, nil
423}
424
425// Stop asks the active recording to stop. It returns false with a nil error
426// when no recording is active. A true result means the request was accepted,
427// not that the capture has been finalized.
428func Stop(ctx context.Context) (bool, error) {
429	rd, err := runtimeDir()
430	if err != nil {
431		return false, err
432	}
433	return exchange(ctx, rd, stopLine, okLine)
434}
435
436// Query reports whether a recording is active.
437func Query(ctx context.Context) (bool, error) {
438	rd, err := runtimeDir()
439	if err != nil {
440		return false, err
441	}
442	return exchange(ctx, rd, statusLine, recLine)
443}
444
445func exchange(ctx context.Context, runtime, req, want string) (bool, error) {
446	euid := uint32(os.Geteuid())
447	dirFD, err := openSecureDir(runtime, false, euid)
448	if err != nil {
449		if errors.Is(err, unix.ENOENT) {
450			return false, nil
451		}
452		return false, err
453	}
454	defer unix.Close(dirFD)
455	var st unix.Stat_t
456	if err := unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
457		if err == unix.ENOENT {
458			return false, nil
459		}
460		return false, fmt.Errorf("control: stat socket: %w", err)
461	}
462	if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid || st.Mode&0o077 != 0 {
463		return false, fmt.Errorf("%w: %s", ErrInsecure, sockName)
464	}
465
466	ctx, cancel := context.WithTimeout(ctx, ioTimeout)
467	defer cancel()
468	conn, err := dialRetry(ctx, dirPath(dirFD))
469	if err != nil {
470		// A dead owner leaves the socket behind; only the next server, which
471		// holds the lock, removes it.
472		if errors.Is(err, unix.ECONNREFUSED) || errors.Is(err, unix.ENOENT) {
473			return false, nil
474		}
475		return false, err
476	}
477	defer conn.Close()
478	uc := conn.(*net.UnixConn)
479	stop := context.AfterFunc(ctx, func() { uc.Close() })
480	defer stop()
481	if dl, ok := ctx.Deadline(); ok {
482		uc.SetDeadline(dl)
483	}
484	if uid, err := peerUID(uc); err != nil {
485		return false, fmt.Errorf("control: peer credentials: %w", err)
486	} else if uid != euid {
487		return false, fmt.Errorf("%w: socket served by another user", ErrInsecure)
488	}
489	if _, err := io.WriteString(uc, req); err != nil {
490		return false, ctxErr(ctx, err)
491	}
492	var buf [len(recLine)]byte // the longest reply
493	n, err := io.ReadFull(uc, buf[:len(want)])
494	if err != nil {
495		return false, ctxErr(ctx, err)
496	}
497	if string(buf[:n]) != want {
498		return false, ErrProtocol
499	}
500	return true, nil
501}
502
503// dialRetry connects, retrying while the server's small accept backlog is
504// full (EAGAIN) until ctx ends.
505func dialRetry(ctx context.Context, path string) (net.Conn, error) {
506	var d net.Dialer
507	for {
508		conn, err := d.DialContext(ctx, "unix", path)
509		if !errors.Is(err, unix.EAGAIN) {
510			return conn, err
511		}
512		t := time.NewTimer(5 * time.Millisecond)
513		select {
514		case <-t.C:
515		case <-ctx.Done():
516			t.Stop()
517			return nil, ctx.Err()
518		}
519	}
520}
521
522func ctxErr(ctx context.Context, err error) error {
523	if cerr := ctx.Err(); cerr != nil {
524		return cerr
525	}
526	return err
527}

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