1// Package localadmin owns the owner-only Unix socket policy shared by the
2// Gordon daemon and the local CLI: runtime directory discovery, strict
3// filesystem validation, and safe listener lifecycle.
4//
5// It deliberately depends only on the standard library so the composition
6// root (internal/app) and the incoming CLI adapter can share it without
7// creating an adapter-to-application dependency.
8package localadmin
9
10import (
11 "errors"
12 "fmt"
13 "io/fs"
14 "net"
15 "os"
16 "path/filepath"
17 "syscall"
18 "time"
19)
20
21// SocketName is the fixed filename of the owner-only admin socket.
22const SocketName = "admin.sock"
23
24// DirPermissions is the required mode of the runtime directory.
25const DirPermissions fs.FileMode = 0o700
26
27// SocketPermissions is the required mode of the admin socket.
28const SocketPermissions fs.FileMode = 0o600
29
30// OtherPermissions is the union of permission bits that must never be set.
31const OtherPermissions fs.FileMode = 0o077
32
33// staleDialTimeout bounds the liveness probe performed before removing a
34// stale socket path.
35const staleDialTimeout = 500 * time.Millisecond
36
37// ErrUnsafePath marks a path that failed ownership, type, or mode validation.
38var ErrUnsafePath = errors.New("unsafe local admin path")
39
40// ErrActiveListener marks a socket that already has a live listener.
41var ErrActiveListener = errors.New("local admin socket already has an active listener")
42
43// SocketPath joins dir with the fixed socket filename.
44func SocketPath(dir string) string {
45 return filepath.Join(dir, SocketName)
46}
47
48// EnsureRuntimeDir returns, creates, and validates the daemon runtime
49// directory: $XDG_RUNTIME_DIR/gordon when set, otherwise $HOME/.gordon/run.
50// A set XDG_RUNTIME_DIR must be absolute; any failure to ensure the preferred
51// directory fails closed and never falls back to $HOME, since a silent
52// fallback would change daemon identity.
53func EnsureRuntimeDir() (string, error) {
54 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
55 if !filepath.IsAbs(xdg) {
56 return "", fmt.Errorf("%w: XDG_RUNTIME_DIR is not absolute: %q", ErrUnsafePath, xdg)
57 }
58 dir := filepath.Join(xdg, "gordon")
59 if err := EnsureDir(dir); err != nil {
60 return "", err
61 }
62 return dir, nil
63 }
64
65 home, err := os.UserHomeDir()
66 if err != nil {
67 return "", fmt.Errorf("resolve home directory: %w", err)
68 }
69 dir := filepath.Join(home, ".gordon", "run")
70 if err := EnsureDir(dir); err != nil {
71 return "", err
72 }
73 return dir, nil
74}
75
76// ClientDirCandidates lists CLI discovery candidates in daemon priority
77// order: $XDG_RUNTIME_DIR/gordon, /run/user/<uid>/gordon, then
78// $HOME/.gordon/run. Directories are not created.
79func ClientDirCandidates() []string {
80 var candidates []string
81
82 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
83 candidates = append(candidates, filepath.Join(xdg, "gordon"))
84 }
85
86 systemDir := filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), "gordon")
87 if len(candidates) == 0 || candidates[0] != systemDir {
88 candidates = append(candidates, systemDir)
89 }
90
91 if home, err := os.UserHomeDir(); err == nil {
92 candidates = append(candidates, filepath.Join(home, ".gordon", "run"))
93 }
94
95 return candidates
96}
97
98// EnsureDir creates dir with owner-only permissions when missing, tightens an
99// existing directory, and then validates it as an owner-owned real directory
100// without group/other permission bits.
101func EnsureDir(dir string) error {
102 // Validate the existing ancestor chain before creating anything so
103 // MkdirAll never creates directories through a symlinked, foreign, or
104 // otherwise unsafe ancestor.
105 if err := validateExistingAncestorChain(dir); err != nil {
106 return err
107 }
108 if err := os.MkdirAll(dir, DirPermissions); err != nil {
109 return fmt.Errorf("create local admin runtime directory: %w", err)
110 }
111
112 fi, err := os.Lstat(dir)
113 if err != nil {
114 return fmt.Errorf("inspect local admin runtime directory: %w", err)
115 }
116 // Refuse symlinks, non-directories, and foreign owners before any chmod so
117 // a hostile path never has its target modified through a link.
118 if err := validateDirIdentity(fi, dir); err != nil {
119 return err
120 }
121 if fi.Mode().Perm()&OtherPermissions != 0 {
122 if err := os.Chmod(dir, DirPermissions); err != nil {
123 return fmt.Errorf("restrict local admin runtime directory: %w", err)
124 }
125 }
126 return ValidateDir(dir)
127}
128
129// ValidateDir reports whether dir is an owner-owned real directory without
130// group/other permission bits.
131func ValidateDir(dir string) error {
132 if err := validateParentChain(dir); err != nil {
133 return err
134 }
135 fi, err := os.Lstat(dir)
136 if err != nil {
137 return fmt.Errorf("inspect local admin runtime directory: %w", err)
138 }
139 if err := validateDirIdentity(fi, dir); err != nil {
140 return err
141 }
142 if fi.Mode().Perm()&OtherPermissions != 0 {
143 return fmt.Errorf("%w: runtime directory %s has group/other permissions %04o", ErrUnsafePath, dir, fi.Mode().Perm())
144 }
145 return nil
146}
147
148// validateExistingAncestorChain validates every existing ancestor of dir
149// before MkdirAll runs, so missing directories are never created through a
150// symlinked, non-directory, or foreign-writable ancestor. Missing ancestors
151// are skipped: they will be created by MkdirAll once the existing chain is
152// known safe.
153func validateExistingAncestorChain(dir string) error {
154 for current := filepath.Dir(filepath.Clean(dir)); ; current = filepath.Dir(current) {
155 fi, err := os.Lstat(current)
156 if err != nil {
157 if errors.Is(err, fs.ErrNotExist) {
158 parent := filepath.Dir(current)
159 if parent == current {
160 return nil
161 }
162 continue
163 }
164 return fmt.Errorf("inspect local admin path component %s: %w", current, err)
165 }
166 if err := validatePathComponent(fi, current); err != nil {
167 return err
168 }
169 parent := filepath.Dir(current)
170 if parent == current {
171 return nil
172 }
173 }
174}
175
176func validatePathComponent(fi os.FileInfo, path string) error {
177 return validatePathComponentForUID(fi, path, uint32(os.Geteuid())) // #nosec G115 -- Unix effective UIDs fit uid_t.
178}
179
180func validatePathComponentForUID(fi os.FileInfo, path string, euid uint32) error {
181 if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
182 return fmt.Errorf("%w: path component %s is not a real directory", ErrUnsafePath, path)
183 }
184 st, ok := fi.Sys().(*syscall.Stat_t)
185 if !ok {
186 return fmt.Errorf("%w: cannot determine owner of %s", ErrUnsafePath, path)
187 }
188 if st.Uid != 0 && st.Uid != euid {
189 return fmt.Errorf("%w: path component %s is owned by uid %d, not root or %d", ErrUnsafePath, path, st.Uid, euid)
190 }
191 writable := fi.Mode().Perm()&0o022 != 0
192 stickyRoot := st.Uid == 0 && fi.Mode()&os.ModeSticky != 0
193 if writable && !stickyRoot {
194 return fmt.Errorf("%w: path component %s is replaceable", ErrUnsafePath, path)
195 }
196 return nil
197}
198
199// validateDirIdentity checks the properties that no chmod can repair: a symlink
200// target, a non-directory, or another user's path.
201func validateParentChain(dir string) error {
202 for current := filepath.Clean(dir); ; current = filepath.Dir(current) {
203 fi, err := os.Lstat(current)
204 if err != nil {
205 return fmt.Errorf("inspect local admin path component %s: %w", current, err)
206 }
207 if err := validatePathComponent(fi, current); err != nil {
208 return err
209 }
210 parent := filepath.Dir(current)
211 if parent == current {
212 return nil
213 }
214 }
215}
216
217func validateDirIdentity(fi os.FileInfo, dir string) error {
218 if fi.Mode()&os.ModeSymlink != 0 {
219 return fmt.Errorf("%w: runtime directory %s is a symlink", ErrUnsafePath, dir)
220 }
221 if !fi.IsDir() {
222 return fmt.Errorf("%w: runtime directory %s is not a directory", ErrUnsafePath, dir)
223 }
224 if err := validateOwner(fi); err != nil {
225 return fmt.Errorf("runtime directory %s: %w", dir, err)
226 }
227 return nil
228}
229
230// ValidateSocket reports whether path is an owner-owned Unix socket without
231// group/other permission bits and without a symlink at the final component.
232func ValidateSocket(path string) error {
233 if err := validateParentChain(filepath.Dir(path)); err != nil {
234 return err
235 }
236 fi, err := os.Lstat(path)
237 if err != nil {
238 return err
239 }
240 return validateSocketFile(fi, path)
241}
242
243// Listen binds the owner-only admin socket inside dir and returns the
244// listener plus the identity of the file it created.
245//
246// It fails closed: the directory must be owner-owned, a real directory, and
247// owner-only; an existing path must already be an owner-owned socket; and an
248// existing socket is removed only after a bounded dial proves no listener is
249// active. Any other existing path is never replaced.
250func Listen(dir string) (*net.UnixListener, os.FileInfo, error) {
251 if err := EnsureDir(dir); err != nil {
252 return nil, nil, err
253 }
254
255 path := SocketPath(dir)
256 if err := clearStale(path); err != nil {
257 return nil, nil, err
258 }
259
260 ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
261 if err != nil {
262 return nil, nil, fmt.Errorf("bind local admin socket: %w", err)
263 }
264 // Keep unlink-on-close under our control so shutdown removes the socket
265 // only after verifying it is still the file we bound.
266 ln.SetUnlinkOnClose(false)
267
268 bound, err := os.Lstat(path)
269 if err != nil {
270 _ = ln.Close()
271 return nil, nil, fmt.Errorf("inspect newly bound local admin socket: %w", err)
272 }
273 if err := os.Chmod(path, SocketPermissions); err != nil {
274 return nil, nil, cleanupListen(ln, path, bound, fmt.Errorf("restrict local admin socket: %w", err))
275 }
276
277 fi, err := os.Lstat(path)
278 if err != nil {
279 return nil, nil, cleanupListen(ln, path, bound, fmt.Errorf("inspect local admin socket: %w", err))
280 }
281 if !os.SameFile(bound, fi) {
282 return nil, nil, cleanupListen(ln, path, bound, fmt.Errorf("%w: local admin socket was replaced during setup", ErrUnsafePath))
283 }
284 if err := validateSocketFile(fi, path); err != nil {
285 return nil, nil, cleanupListen(ln, path, bound, err)
286 }
287
288 return ln, fi, nil
289}
290
291// RemoveOwnedSocket removes path only when it is still the same file that was
292// bound. A replaced path is left untouched and reported as unsafe.
293func RemoveOwnedSocket(path string, bound os.FileInfo) error {
294 if bound == nil {
295 return nil
296 }
297 current, err := os.Lstat(path)
298 if errors.Is(err, fs.ErrNotExist) {
299 return nil
300 }
301 if err != nil {
302 return fmt.Errorf("inspect local admin socket for removal: %w", err)
303 }
304 if !os.SameFile(bound, current) {
305 return fmt.Errorf("%w: local admin socket %s was replaced; not removing it", ErrUnsafePath, path)
306 }
307 if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
308 return fmt.Errorf("remove local admin socket: %w", err)
309 }
310 return nil
311}
312
313func cleanupListen(ln *net.UnixListener, path string, bound os.FileInfo, cause error) error {
314 _ = ln.Close()
315 _ = RemoveOwnedSocket(path, bound)
316 return cause
317}
318
319func validateSocketFile(fi os.FileInfo, path string) error {
320 if err := validateSocketIdentity(fi, path); err != nil {
321 return err
322 }
323 if fi.Mode().Perm()&OtherPermissions != 0 {
324 return fmt.Errorf("%w: %s has group/other permissions %04o", ErrUnsafePath, path, fi.Mode().Perm())
325 }
326 return nil
327}
328
329// validateSocketIdentity checks only the properties that must hold before a
330// stale path may be removed: a non-symlink Unix socket owned by this euid.
331// Callers that consume the socket additionally require mode 0600.
332func validateSocketIdentity(fi os.FileInfo, path string) error {
333 if fi.Mode()&os.ModeSymlink != 0 {
334 return fmt.Errorf("%w: %s is a symlink", ErrUnsafePath, path)
335 }
336 if fi.Mode()&os.ModeSocket == 0 {
337 return fmt.Errorf("%w: %s is not a Unix socket", ErrUnsafePath, path)
338 }
339 if err := validateOwner(fi); err != nil {
340 return fmt.Errorf("%s: %w", path, err)
341 }
342 return nil
343}
344
345func validateOwner(fi os.FileInfo) error {
346 st, ok := fi.Sys().(*syscall.Stat_t)
347 if !ok {
348 return fmt.Errorf("%w: cannot determine file owner", ErrUnsafePath)
349 }
350 if int64(st.Uid) != int64(os.Geteuid()) {
351 return fmt.Errorf("%w: owned by uid %d, not %d", ErrUnsafePath, st.Uid, os.Geteuid())
352 }
353 return nil
354}
355
356// clearStale removes an existing socket path only when it is an owner-owned
357// socket with no active listener. Everything else fails closed.
358func clearStale(path string) error {
359 fi, err := os.Lstat(path)
360 if errors.Is(err, fs.ErrNotExist) {
361 return nil
362 }
363 if err != nil {
364 return fmt.Errorf("inspect local admin socket: %w", err)
365 }
366 if err := validateSocketIdentity(fi, path); err != nil {
367 return fmt.Errorf("refusing to replace local admin path: %w", err)
368 }
369
370 live, err := listening(path)
371 if err != nil {
372 return err
373 }
374 if live {
375 return fmt.Errorf("%w at %s", ErrActiveListener, path)
376 }
377
378 current, err := os.Lstat(path)
379 if errors.Is(err, fs.ErrNotExist) {
380 return nil
381 }
382 if err != nil {
383 return fmt.Errorf("reinspect stale local admin socket: %w", err)
384 }
385 if !os.SameFile(fi, current) {
386 return fmt.Errorf("%w: local admin socket changed during stale check", ErrUnsafePath)
387 }
388 if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
389 return fmt.Errorf("remove stale local admin socket: %w", err)
390 }
391 return nil
392}
393
394// ProbeSocket reports whether a validated socket has a live listener.
395func ProbeSocket(path string) (bool, error) {
396 return listening(path)
397}
398
399// listening dials path with a bounded timeout. A live listener reports true;
400// an explicit no-listener error reports false. Any other dial failure is
401// returned so an unexpected condition never leads to removing a path.
402func listening(path string) (bool, error) {
403 conn, err := net.DialTimeout("unix", path, staleDialTimeout)
404 if err == nil {
405 _ = conn.Close()
406 return true, nil
407 }
408 if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) || errors.Is(err, fs.ErrNotExist) {
409 return false, nil
410 }
411 return false, fmt.Errorf("probe local admin socket %s: %w", path, err)
412}