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}