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