v3.3.0 b164fe9

localadmin.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close