main f2d166b

captureauth.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"os"
6
7	"github.com/bnema/neferwl/internal/adapters/captureallow"
8	"github.com/bnema/neferwl/internal/adapters/peerexe"
9	"github.com/bnema/purego-libwayland/server"
10)
11
12// mayCapture is the one place that decides whether a client may capture the
13// screen. Every capture path asks it: wlr-screencopy frames, ext-image-copy-
14// capture sessions and frames, whatever their source (output, output region
15// or workspace), and the attach of a layer surface to a capture exclusion. A
16// refused client gets the failure its protocol defines (failed, or stopped
17// for a session) and nothing is captured or flashed. The same executable
18// allowlist also gates the D-Bus screencast callers (adapters/screencast). In
19// order:
20//
21//  1. While the session is protected (ext-session-lock) no client may
22//     capture, so nothing of the locked session reaches a buffer.
23//  2. A sandboxed client (one that connected through a
24//     wp_security_context_v1 listener, securitycontext.go) never captures,
25//     whatever the allowlist says: it goes through the desktop portal. The
26//     display cannot hide globals per client, so the capture globals stay
27//     visible to it and its requests are refused here.
28//  3. The executable allowlist (captureallow; /etc/neferwl/capture-allow): no
29//     `*` line lets every client through, an invalid file lets none,
30//     otherwise the executable actually running behind the client's
31//     connection must be listed (in the file, or in the built-in list while
32//     the file is missing). captureexe.go resolves it afresh at every
33//     wlr-screencopy frame request and ext session creation, from a pidfd kept
34//     per client; the frames of an ext session use mayCaptureFrame. Without
35//     SO_PEERPIDFD nobody is identified, so nobody captures.
36//
37// Display goroutine only.
38func (s *Server) mayCapture(c server.Client) bool {
39	return s.mayCaptureWith(c, s.peerIdentity)
40}
41
42// mayCaptureFrame is mayCapture for the frames of an ext-image-copy-capture
43// session: a frame does not read /proc again but uses the executable resolved
44// at the client's latest check (the session's creation, or a later wlr request
45// or session on the same connection). The current policy and the lock are
46// still checked each time. Display goroutine only.
47func (s *Server) mayCaptureFrame(c server.Client) bool {
48	return s.mayCaptureWith(c, func(c server.Client) *peerID {
49		id := s.peerEntry(c)
50		if id.exe == "" && id.err == nil {
51			// Never resolved: the policy was off when the session began.
52			id = s.peerIdentity(c)
53		}
54		return id
55	})
56}
57
58// mayCaptureWith is the rule chain of mayCapture; resolve names the client's
59// executable once the policy needs it.
60func (s *Server) mayCaptureWith(c server.Client, resolve func(server.Client) *peerID) bool {
61	if s.protected() {
62		return false
63	}
64	if s.sandboxed[c] != nil {
65		s.refuseOnce(c, "sandboxed client", "", 0)
66		return false
67	}
68	if s.captureAllow == nil {
69		// No allowlist store: fail closed, never allow everyone.
70		s.refuseOnce(c, "allowlist invalid", "", 0)
71		return false
72	}
73	pol := s.captureAllow.Policy()
74	switch pol.Mode() {
75	case captureallow.ModeDisabled:
76		return true
77	case captureallow.ModeClosed:
78		s.refuseOnce(c, "allowlist invalid", "", 0)
79		return false
80	}
81	id := resolve(c)
82	if id.err != nil {
83		s.refuseOnce(c, "executable unknown: "+id.err.Error(), "", id.pid)
84		return false
85	}
86	if !pol.Allows(id.exe) {
87		s.refuseOnce(c, "executable not in allowlist", id.exe, id.pid)
88		return false
89	}
90	return true
91}
92
93// peerID is what is known of a client, dropped when it is destroyed. Only the
94// pidfd is kept across decisions: the executable is read again at each one,
95// so an exec after connecting is seen.
96type peerID struct {
97	pidfd *os.File // pins the peer process; closed with the client
98	exe   string   // the last resolution, for mayCaptureFrame
99	pid   int
100	err   error
101	// refused holds the refusals already logged (reason and executable), so
102	// a client is logged once per distinct executable; bounded.
103	refused map[string]struct{}
104}
105
106// maxRefusalsLogged bounds peerID.refused.
107const maxRefusalsLogged = 8
108
109// peerEntry is the entry of c, dropped when the client is destroyed.
110func (s *Server) peerEntry(c server.Client) *peerID {
111	if id := s.peers[c]; id != nil {
112		return id
113	}
114	id := &peerID{}
115	s.peers[c] = id
116	c.OnDestroy(func() {
117		if id.pidfd != nil {
118			_ = id.pidfd.Close()
119		}
120		delete(s.peers, c)
121	})
122	return id
123}
124
125// peerIdentity resolves the executable behind c now: the pidfd is opened on
126// the first call and kept, /proc/<pid>/exe and the pidfd's liveness are read
127// on every call. Failures are not cached.
128func (s *Server) peerIdentity(c server.Client) *peerID {
129	id := s.peerEntry(c)
130	id.exe, id.err = "", nil
131	cred, _ := c.Credentials()
132	id.pid = cred.PID
133	if id.pidfd == nil {
134		fd, err := c.FD()
135		if err != nil {
136			id.err = err
137			return id
138		}
139		id.pidfd, err = s.peer.Pidfd(fd)
140		if err != nil {
141			id.pidfd, id.err = nil, err
142			if errors.Is(err, peerexe.ErrNoPidfd) && !s.noPidfdWarned {
143				s.noPidfdWarned = true
144				s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")
145			}
146			return id
147		}
148	}
149	exe, pid, err := s.peer.Exe(id.pidfd)
150	id.exe, id.err = exe, err
151	if pid > 0 {
152		id.pid = pid
153	}
154	return id
155}
156
157// refuseOnce logs a refused capture, once per client and executable:
158// component, pid and executable path, never a token or anything a client
159// sent.
160func (s *Server) refuseOnce(c server.Client, reason, exe string, pid int) {
161	id := s.peerEntry(c)
162	key := reason + "\x00" + exe
163	if _, done := id.refused[key]; done || len(id.refused) >= maxRefusalsLogged {
164		return
165	}
166	if id.refused == nil {
167		id.refused = map[string]struct{}{}
168	}
169	id.refused[key] = struct{}{}
170	if pid == 0 {
171		if cred, err := c.Credentials(); err == nil {
172			pid = cred.PID
173		}
174	}
175	e := s.log.Info().Int("pid", pid)
176	if exe != "" {
177		e = e.Str("exe", exe)
178	}
179	e.Str("reason", reason).Msg("capture refused")
180}

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 25

Tags 10

↑↓ moveenter openesc close