1package captureallow
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "slices"
11 "sync/atomic"
12 "syscall"
13 "time"
14 "unsafe"
15
16 "github.com/bnema/neferwl/internal/adapters/fdwait"
17 "github.com/bnema/zerowrap"
18 "golang.org/x/sys/unix"
19)
20
21// load reads the file at path, which must be trustworthy. owner is the uid
22// that must own the file, and may own its directories besides root.
23// Production passes 0; tests pass their own uid. A missing file gives the
24// built-in policy and an error wrapping fs.ErrNotExist; any other failure gives
25// the closed policy and the error, so a broken or tampered-with file never
26// opens capture.
27//
28// The file is opened without following a symlink and without blocking (a FIFO
29// must not hang the compositor), then checked on the descriptor: a regular
30// file, owned by owner, writable by nobody else. Every directory from / down
31// to the file's own must be a real directory (not a symlink), owned by root or
32// owner, and not group- or other-writable unless sticky. Anything else fails
33// closed. Only a missing file, or a missing directory that holds the file
34// (/etc/neferwl) under checked ancestors, gives the built-in list.
35func load(path string, owner uint32) (*Policy, error) {
36 path = filepath.Clean(path)
37 if !filepath.IsAbs(path) {
38 return Closed(), fmt.Errorf("%s: path is not absolute", path)
39 }
40 missing, err := checkDirs(path, owner)
41 if err != nil {
42 return Closed(), err
43 }
44 if missing {
45 return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
46 }
47 var fd int
48 for {
49 fd, err = unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
50 if err != unix.EINTR {
51 break
52 }
53 }
54 if err != nil {
55 if errors.Is(err, unix.ENOENT) {
56 return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
57 }
58 if errors.Is(err, unix.ELOOP) {
59 return Closed(), fmt.Errorf("%s: is a symbolic link", path)
60 }
61 return Closed(), &fs.PathError{Op: "open", Path: path, Err: err}
62 }
63 f := os.NewFile(uintptr(fd), path)
64 defer f.Close()
65 var st unix.Stat_t
66 if err := unix.Fstat(fd, &st); err != nil {
67 return Closed(), &fs.PathError{Op: "stat", Path: path, Err: err}
68 }
69 switch {
70 case st.Mode&unix.S_IFMT != unix.S_IFREG:
71 return Closed(), fmt.Errorf("%s: not a regular file", path)
72 case st.Uid != owner:
73 return Closed(), fmt.Errorf("%s: owned by uid %d, want %d", path, st.Uid, owner)
74 case st.Mode&0o022 != 0:
75 return Closed(), fmt.Errorf("%s: writable by group or others (mode %04o)", path, st.Mode&0o7777)
76 }
77 // Reads of a regular file do not block; leave non-blocking mode off so the
78 // os.File does not go through the poller.
79 _ = unix.SetNonblock(fd, false)
80 p, err := Parse(f)
81 if err != nil {
82 return Closed(), err
83 }
84 return p, nil
85}
86
87// checkDirs checks every directory from / to the one holding path. It reports
88// missing when that last directory does not exist.
89func checkDirs(path string, owner uint32) (missing bool, err error) {
90 var chain []string
91 for d := filepath.Dir(path); ; d = filepath.Dir(d) {
92 chain = append(chain, d)
93 if d == "/" {
94 break
95 }
96 }
97 slices.Reverse(chain)
98 for i, d := range chain {
99 fi, err := os.Lstat(d)
100 if err != nil {
101 if errors.Is(err, fs.ErrNotExist) && i == len(chain)-1 {
102 return true, nil
103 }
104 return false, err
105 }
106 st, ok := fi.Sys().(*syscall.Stat_t)
107 switch {
108 case !fi.IsDir():
109 return false, fmt.Errorf("%s: not a real directory (symbolic link?)", d)
110 case !ok:
111 return false, fmt.Errorf("%s: owner unknown", d)
112 case st.Uid != 0 && st.Uid != owner:
113 return false, fmt.Errorf("%s: owned by uid %d, want root", d, st.Uid)
114 case fi.Mode().Perm()&0o022 != 0 && fi.Mode()&os.ModeSticky == 0:
115 return false, fmt.Errorf("%s: writable by group or others (mode %04o)", d, fi.Mode().Perm())
116 }
117 }
118 return false, nil
119}
120
121// Store holds the current policy and reloads it when the file changes. Policy
122// is safe from any goroutine.
123type Store struct {
124 path string
125 owner uint32
126 log zerowrap.Logger
127 cur atomic.Pointer[Policy]
128 // retryInterval is the wait before watching a missing directory again.
129 retryInterval time.Duration
130}
131
132// NewStore loads path once and logs the outcome: an Info when the file is
133// missing (the built-in list is then in force), an Error naming the line when it is
134// invalid (no client may capture). Run keeps it current.
135func NewStore(path string, log zerowrap.Logger) *Store {
136 return NewStoreOwnedBy(path, 0, log)
137}
138
139// NewStoreOwnedBy is NewStore for a file owned by owner instead of root, which
140// then may also own its directories. For tests only, which cannot create
141// root-owned files: the compositor uses NewStore.
142func NewStoreOwnedBy(path string, owner uint32, log zerowrap.Logger) *Store {
143 s := &Store{path: path, owner: owner, log: log, retryInterval: 2 * time.Second}
144 s.reload(true, false)
145 return s
146}
147
148// Policy is the policy in force.
149func (s *Store) Policy() *Policy { return s.cur.Load() }
150
151// reload loads the file and logs the outcome. With quiet set it logs only
152// when the policy changed: the check that closes the gap between the first
153// load and the watch must not repeat the startup line.
154func (s *Store) reload(startup, quiet bool) {
155 p, err := load(s.path, s.owner)
156 old := s.cur.Swap(p)
157 if quiet && old != nil && old.equal(p) {
158 return
159 }
160 switch {
161 case errors.Is(err, fs.ErrNotExist):
162 s.log.Info().Str("path", s.path).Msg("capture allowlist file missing, using the built-in list")
163 case err != nil:
164 e := s.log.Error().Err(err).Str("path", s.path)
165 var pe *ParseError
166 if errors.As(err, &pe) {
167 e = e.Int("line", pe.Line)
168 }
169 e.Msg("capture allowlist invalid, no client may capture")
170 case p.Mode() == ModeDisabled:
171 s.log.Warn().Str("path", s.path).Msg("capture allowlist disables the check, every client may capture")
172 case startup:
173 s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist loaded")
174 default:
175 s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist reloaded")
176 }
177}
178
179// Run reloads the file whenever it changes, until ctx is done. It watches the
180// parent directory, so an atomic replacement is seen; a symlinked file's
181// target is not watched. While the directory is missing it retries every two
182// seconds. An error means the watch could not be set up: the policy then
183// stays as loaded ("restart to apply").
184func (s *Store) Run(ctx context.Context) error {
185 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
186 if err != nil {
187 return err
188 }
189 defer unix.Close(fd)
190 waiter, err := fdwait.New()
191 if err != nil {
192 return err
193 }
194 defer waiter.Close()
195 waiter.StopOn(ctx)
196 if err := waiter.Pin(int32(fd)); err != nil {
197 return err
198 }
199 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
200 dir, name := filepath.Dir(s.path), filepath.Base(s.path)
201 wd := -1
202 var retry, pending time.Time
203 quiet := false
204 buf := make([]byte, 4096)
205 for {
206 if ctx.Err() != nil {
207 return nil
208 }
209 if wd < 0 && (retry.IsZero() || !time.Now().Before(retry)) {
210 retry = time.Time{}
211 w, err := unix.InotifyAddWatch(fd, dir, watchMask)
212 switch {
213 case err == nil:
214 wd = w
215 // The file may have changed before the watch existed.
216 pending, quiet = time.Now(), true
217 case errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR):
218 retry = time.Now().Add(s.retryInterval)
219 default:
220 return err
221 }
222 }
223 // Sleep until an event, the shutdown, or the next deadline.
224 timeout := -1
225 if !retry.IsZero() {
226 timeout = max(0, int(time.Until(retry).Milliseconds())+1)
227 }
228 if !pending.IsZero() {
229 remaining := time.Until(pending)
230 if remaining <= 0 {
231 pending = time.Time{}
232 s.reload(false, quiet)
233 quiet = false
234 continue
235 }
236 if ms := int(remaining.Milliseconds()) + 1; timeout < 0 || ms < timeout {
237 timeout = ms
238 }
239 }
240 fds[0].Revents = 0
241 if _, err := waiter.Poll(fds, timeout); err != nil {
242 if err == unix.EINTR {
243 continue
244 }
245 return err
246 }
247 if fds[0].Revents&unix.POLLIN == 0 {
248 continue
249 }
250 for {
251 n, err := unix.Read(fd, buf)
252 if err == unix.EINTR {
253 continue
254 }
255 if err != nil || n == 0 {
256 break
257 }
258 for off := 0; off+unix.SizeofInotifyEvent <= n; {
259 ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
260 size := unix.SizeofInotifyEvent + int(ev.Len)
261 if off+size > n {
262 break
263 }
264 reload, lost := eventAction(ev.Mask, buf[off+unix.SizeofInotifyEvent:off+size], name)
265 if lost {
266 // The directory is gone: watch again once it is back.
267 wd = -1
268 retry = time.Now().Add(s.retryInterval)
269 }
270 if reload {
271 // Every write restarts the debounce: never read half a file.
272 pending, quiet = time.Now().Add(100*time.Millisecond), false
273 }
274 off += size
275 }
276 }
277 }
278}
279
280// watchMask is what the directory watch listens for.
281const watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_CREATE | unix.IN_DELETE | unix.IN_ATTRIB
282
283// eventAction says what one inotify event asks for: reload the file, and
284// watch the directory again because it was removed. A queue overflow means
285// events were lost, so the file is reloaded whatever it was.
286func eventAction(mask uint32, raw []byte, name string) (reload, lost bool) {
287 if mask&unix.IN_Q_OVERFLOW != 0 {
288 return true, false
289 }
290 if mask&unix.IN_IGNORED != 0 {
291 reload, lost = true, true
292 }
293 for len(raw) > 0 && raw[len(raw)-1] == 0 {
294 raw = raw[:len(raw)-1]
295 }
296 switch {
297 case len(raw) == 0 && mask&unix.IN_ATTRIB != 0:
298 reload = true // the directory itself was chmod'ed or chown'ed
299 case len(raw) > 0 && mask&watchMask != 0 && string(raw) == name:
300 reload = true
301 }
302 return reload, lost
303}