v0.5.0 131f96f

control.go RawHistoryFolder

1// Package control is the tiny private local control socket. Line protocol, one
2// request per connection:
3//
4//	-> "lock\n" | "status\n"
5//	<- "ok generation=N acquiring=B protected=B faded=B off=B\n" | "err <reason>\n"
6//
7// Only the same UID (SO_PEERCRED) is served, the directory is 0700 and the
8// socket 0600. There is deliberately no unlock command: unknown words, extra
9// arguments and oversize lines are rejected.
10package control
11
12import (
13	"bufio"
14	"context"
15	"errors"
16	"fmt"
17	"io"
18	"net"
19	"os"
20	"path/filepath"
21	"strings"
22	"syscall"
23	"time"
24
25	"github.com/bnema/neferafk/internal/ports"
26	"github.com/bnema/zerowrap"
27	"golang.org/x/sys/unix"
28)
29
30const (
31	maxLine       = 16
32	ioTimeout     = 2 * time.Second
33	replyTimeout  = 3 * time.Second
34	maxConcurrent = 8
35)
36
37// DefaultPath is $XDG_RUNTIME_DIR/neferafk/control.sock.
38func DefaultPath() (string, error) {
39	dir := os.Getenv("XDG_RUNTIME_DIR")
40	if dir == "" || !filepath.IsAbs(dir) {
41		return "", errors.New("XDG_RUNTIME_DIR is not set to an absolute path")
42	}
43	return filepath.Join(dir, "neferafk", "control.sock"), nil
44}
45
46// Server accepts control connections and forwards them to the policy owner.
47type Server struct {
48	ln       *net.UnixListener
49	path     string
50	uid      uint32
51	requests chan<- ports.ControlRequest
52	log      zerowrap.Logger
53}
54
55// Listen prepares the private directory (must be owned by this process's UID)
56// and socket. uid is the only peer UID served; production passes os.Getuid().
57func Listen(path string, uid uint32, requests chan<- ports.ControlRequest, log zerowrap.Logger) (*Server, error) {
58	dir := filepath.Dir(path)
59	if err := os.MkdirAll(dir, 0o700); err != nil {
60		return nil, err
61	}
62	fi, err := os.Lstat(dir)
63	if err != nil {
64		return nil, err
65	}
66	st, ok := fi.Sys().(*syscall.Stat_t)
67	if !fi.IsDir() || !ok || st.Uid != uint32(os.Getuid()) {
68		return nil, fmt.Errorf("control directory %s is not a directory owned by the current user", dir)
69	}
70	if fi.Mode().Perm() != 0o700 {
71		if err := os.Chmod(dir, 0o700); err != nil {
72			return nil, err
73		}
74	}
75	if fi, err := os.Lstat(path); err == nil {
76		if fi.Mode()&os.ModeSocket == 0 {
77			return nil, fmt.Errorf("%s exists and is not a socket", path)
78		}
79		if c, err := net.DialTimeout("unix", path, time.Second); err == nil {
80			c.Close()
81			return nil, errors.New("another neferafk daemon is already running")
82		}
83		if err := os.Remove(path); err != nil {
84			return nil, err
85		}
86	}
87	ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
88	if err != nil {
89		return nil, err
90	}
91	if err := os.Chmod(path, 0o600); err != nil {
92		ln.Close()
93		return nil, err
94	}
95	return &Server{ln: ln, path: path, uid: uid, requests: requests, log: log.WithField("component", "control")}, nil
96}
97
98// Close releases the listener and removes the socket without serving (startup
99// failure path). Serve does this itself on cancellation.
100func (s *Server) Close() error {
101	err := s.ln.Close()
102	if e := os.Remove(s.path); e != nil && !errors.Is(e, os.ErrNotExist) {
103		err = errors.Join(err, e)
104	}
105	return err
106}
107
108// Serve accepts until ctx ends, then removes the socket. It always returns nil
109// on cancellation.
110func (s *Server) Serve(ctx context.Context) error {
111	stop := context.AfterFunc(ctx, func() { s.ln.Close() })
112	defer stop()
113	defer os.Remove(s.path)
114	slots := make(chan struct{}, maxConcurrent)
115	for {
116		c, err := s.ln.AcceptUnix()
117		if err != nil {
118			if ctx.Err() != nil {
119				return nil
120			}
121			return err
122		}
123		select {
124		case slots <- struct{}{}:
125			go func() {
126				defer func() { <-slots }()
127				s.serve(ctx, c)
128			}()
129		default:
130			c.Close() // shed load instead of queueing
131		}
132	}
133}
134
135func peerUID(c *net.UnixConn) (uint32, error) {
136	raw, err := c.SyscallConn()
137	if err != nil {
138		return 0, err
139	}
140	var cred *unix.Ucred
141	var serr error
142	if err := raw.Control(func(fd uintptr) { cred, serr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) }); err != nil {
143		return 0, err
144	}
145	if serr != nil {
146		return 0, serr
147	}
148	return cred.Uid, nil
149}
150
151// parse maps a request line to a kind; anything else is rejected.
152func parse(line string) (ports.ControlKind, bool) {
153	switch line {
154	case "lock":
155		return ports.ControlLock, true
156	case "status":
157		return ports.ControlStatus, true
158	}
159	return 0, false
160}
161
162func (s *Server) serve(ctx context.Context, c *net.UnixConn) {
163	defer c.Close()
164	uid, err := peerUID(c)
165	if err != nil || uid != s.uid {
166		s.log.Warn().Msg("control peer rejected")
167		return // no reply for foreign peers
168	}
169	_ = c.SetDeadline(time.Now().Add(ioTimeout))
170	line, err := bufio.NewReaderSize(io.LimitReader(c, maxLine+1), maxLine+1).ReadString('\n')
171	line = strings.TrimSuffix(line, "\n")
172	kind, ok := parse(line)
173	if err != nil || len(line) > maxLine || !ok {
174		fmt.Fprint(c, "err unknown command\n")
175		return
176	}
177	reply := make(chan ports.ControlReply, 1)
178	select {
179	case s.requests <- ports.ControlRequest{Kind: kind, Reply: reply}:
180	case <-ctx.Done():
181		return
182	case <-time.After(replyTimeout):
183		fmt.Fprint(c, "err busy\n")
184		return
185	}
186	select {
187	case r := <-reply:
188		_ = c.SetWriteDeadline(time.Now().Add(ioTimeout))
189		fmt.Fprint(c, format(r))
190	case <-ctx.Done():
191	case <-time.After(replyTimeout):
192		fmt.Fprint(c, "err busy\n")
193	}
194}
195
196func format(r ports.ControlReply) string {
197	if r.Err != "" {
198		return "err " + strings.NewReplacer("\n", " ", "\r", " ").Replace(r.Err) + "\n"
199	}
200	return fmt.Sprintf("ok generation=%d acquiring=%t protected=%t faded=%t off=%t\n", r.Generation, r.Acquiring, r.Protected, r.Faded, r.Off)
201}
202
203// Do sends one command to the daemon and returns its reply line without the
204// trailing newline. An "err ..." reply is returned as an error.
205func Do(ctx context.Context, path, command string) (string, error) {
206	var d net.Dialer
207	ctx, cancel := context.WithTimeout(ctx, 2*replyTimeout)
208	defer cancel()
209	c, err := d.DialContext(ctx, "unix", path)
210	if err != nil {
211		return "", fmt.Errorf("daemon not reachable: %w", err)
212	}
213	defer c.Close()
214	if dl, ok := ctx.Deadline(); ok {
215		_ = c.SetDeadline(dl)
216	}
217	if _, err := fmt.Fprintf(c, "%s\n", command); err != nil {
218		return "", err
219	}
220	line, err := bufio.NewReaderSize(io.LimitReader(c, 256), 256).ReadString('\n')
221	if err != nil {
222		return "", fmt.Errorf("no reply from daemon: %w", err)
223	}
224	line = strings.TrimSuffix(line, "\n")
225	if rest, ok := strings.CutPrefix(line, "err "); ok {
226		return "", errors.New(rest)
227	}
228	return line, nil
229}

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 2

Tags 9

↑↓ moveenter openesc close