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}