captureauth.go RawHistoryFolder

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

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