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}