1// Private daemonmux Unix carriage.
2//
3// This file exposes the explicit, mux-specific raw Unix carriage that the
4// daemonmux multiplexer (internal/adapters/daemonmux) runs over. It is
5// deliberately separate from the daemon's public Listen/DialContext pair: the
6// mux carriage is bound at a caller-supplied, isolated path, never the live
7// production daemon.sock, so the two endpoints cannot be confused and the
8// daemon's public socket is never reused or inspected here.
9//
10// Both ends of the carriage are authenticated before they are handed out. A
11// listener and a dialer verify that the connected AF_UNIX peer is the current
12// effective user from the kernel's own peer credentials (SO_PEERCRED on
13// Linux), not from filesystem permissions or an X11-style "the directory is
14// private so the peer is trusted" assumption. On a platform or build without a
15// same-user peer-credential check this file fails closed: it refuses the
16// connection rather than admitting an unverified peer (peer_other.go).
17//
18// The carriage reuses the existing IPC framing and transport (NewTransport,
19// streamframe, RecvBounded) and the shared race-safe stale-socket recovery
20// (listenUnixStaleSafe). Listener setup creates or validates an owner-only
21// (0700) parent directory with safedir.EnsurePrivate and tightens the bound
22// socket to 0600; a foreign path that is not a socket is refused without being
23// removed. Close stops Accept, unlinks only the socket inode this listener
24// created, and leaves a caller replacement alone.
25
26package ipc
27
28import (
29 "context"
30 "errors"
31 "fmt"
32 "net"
33 "os"
34 "path/filepath"
35 "sync"
36
37 "github.com/bnema/vev/internal/protocol/wire"
38 "github.com/bnema/vev/pkg/safedir"
39)
40
41const (
42 // muxSocketMode is the owner-only permission a bound mux socket is
43 // tightened to after bind.
44 muxSocketMode = 0o600
45 // muxSocketPathMax is the worst-case AF_UNIX pathname length observed
46 // across the supported platforms (BSD/macOS 103 < Linux 107). A mux path
47 // longer than this is refused on every platform, so a path that binds on
48 // Linux can never be silently truncated on macOS.
49 muxSocketPathMax = 103
50)
51
52// Mux carriage sentinels. They are typed so a caller can classify a path,
53// platform, or peer-credential outcome without matching on message text.
54var (
55 // ErrMuxPath reports an invalid caller-supplied carriage path: empty,
56 // relative, unclean, too long, containing a NUL, or naming a connection
57 // that is not a Unix socket.
58 ErrMuxPath = errors.New("ipc: invalid mux socket path")
59 // ErrMuxForeignPath reports a mux path that already exists and is not a
60 // socket. ListenMux refuses it without removing the caller's file.
61 ErrMuxForeignPath = errors.New("ipc: mux socket path exists and is not a socket")
62 // ErrMuxUnsupported reports a same-user peer-credential check on a
63 // platform or build that has none. The carriage fails closed: it never
64 // admits an unverified peer.
65 ErrMuxUnsupported = errors.New("ipc: same-user peer credential verification is unsupported on this platform")
66 // ErrMuxPeerRejected reports a connected AF_UNIX peer whose kernel
67 // credentials are not the current effective user, or could not be read.
68 ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification")
69)
70
71// PeerVerifier checks one connected AF_UNIX peer's kernel credentials. It
72// returns nil only for a peer proven to be the current effective user. It is a
73// function type so a caller (or test) can inject an observable refusal without
74// root; SameUserPeerVerifier returns the platform default.
75type PeerVerifier func(conn *net.UnixConn) error
76
77// muxPeerVerifier is the in-package spelling of PeerVerifier.
78type muxPeerVerifier = PeerVerifier
79
80// SameUserPeerVerifier returns this build's default same-user peer verifier:
81// Linux SO_PEERCRED admission, or a fail-closed refusal on a platform or
82// build without it.
83func SameUserPeerVerifier() PeerVerifier { return verifySameUserUnixPeer }
84
85// MuxListener is the listener half of a private daemonmux Unix carriage. Accept
86// returns a raw bounded transport (Send/RecvBounded/Close) ready for the
87// daemonmux FramedCarrierBridge, and verifies same-user peer credentials before
88// handing it out. Close unblocks a blocked Accept and removes only the socket
89// inode this listener created.
90type MuxListener interface {
91 Accept() (wire.BoundedTransport, error)
92 Close() error
93 Addr() string
94}
95
96// muxListener implements MuxListener over one bound AF_UNIX SOCK_STREAM socket.
97type muxListener struct {
98 ln *net.UnixListener
99 path string
100 file os.FileInfo
101 opts []Option
102 verify muxPeerVerifier
103
104 closeOnce sync.Once
105 closeErr error
106}
107
108var _ MuxListener = (*muxListener)(nil)
109
110// ListenMux binds the private daemonmux carriage at exactly path and returns a
111// listener that verifies same-user peer credentials before handing out a raw
112// bounded transport.
113//
114// path is caller-supplied and isolation is the caller's responsibility: it must
115// be an absolute, cleaned path inside an owner-only directory and must not be
116// the daemon's public daemon.sock. ListenMux creates or validates the parent
117// directory as 0700 (safedir.EnsurePrivate), refuses a path that already exists
118// and is not a socket without removing it, recovers race-safely from a stale
119// socket left by a dead owner, and tightens the bound socket to 0600. A live
120// owner already bound at path is reported as ErrDaemonRunning.
121func ListenMux(path string, opts ...Option) (MuxListener, error) {
122 return listenMux(path, verifySameUserUnixPeer, opts...)
123}
124
125// ListenMuxWithPeerVerifier binds the private carriage exactly like ListenMux
126// but admits peers with an injected verifier instead of the platform default.
127// It exists so a caller or test can supply an explicit admission policy (or
128// observe a deterministic refusal without root); a nil verifier falls back to
129// the platform default. Every other security property - owner-only parent,
130// 0600 socket, race-safe stale recovery, foreign-path refusal, inode-scoped
131// unlink on Close - is unchanged.
132func ListenMuxWithPeerVerifier(path string, verify PeerVerifier, opts ...Option) (MuxListener, error) {
133 return listenMux(path, verify, opts...)
134}
135
136// listenMux is ListenMux with an injected peer verifier, so a test can observe
137// a credential refusal deterministically without root. A nil verifier falls
138// back to the platform default.
139func listenMux(path string, verify muxPeerVerifier, opts ...Option) (MuxListener, error) {
140 if err := validateMuxSocketPath(path); err != nil {
141 return nil, err
142 }
143 if verify == nil {
144 verify = verifySameUserUnixPeer
145 }
146 if err := prepareMuxParent(path); err != nil {
147 return nil, err
148 }
149 if err := inspectMuxSocketPath(path); err != nil {
150 return nil, err
151 }
152
153 ln, err := listenUnixStaleSafe(path)
154 if err != nil {
155 return nil, fmt.Errorf("ipc: listen on mux socket %s: %w", path, err)
156 }
157 // The listener must never unlink a pathname it did not create. A caller may
158 // replace the path after bind; Close preserves that replacement.
159 ln.SetUnlinkOnClose(false)
160
161 cleanup := func(cause error) (MuxListener, error) {
162 _ = ln.Close()
163 return nil, cause
164 }
165 if err := os.Chmod(path, muxSocketMode); err != nil {
166 return cleanup(fmt.Errorf("ipc: securing mux socket %s: %w", path, err))
167 }
168 info, err := os.Lstat(path)
169 if err != nil {
170 return cleanup(fmt.Errorf("ipc: stat mux socket %s: %w", path, err))
171 }
172 if info.Mode()&os.ModeSocket == 0 {
173 return cleanup(fmt.Errorf("%w: %s", ErrMuxForeignPath, path))
174 }
175 return &muxListener{ln: ln, path: path, file: info, opts: opts, verify: verify}, nil
176}
177
178// DialMuxContext connects to the private daemonmux carriage at exactly path and
179// returns a raw bounded transport (Send/RecvBounded/Close) ready for the
180// daemonmux FramedCarrierBridge. The peer's kernel credentials are verified as
181// the current effective user before the transport is returned; a peer that
182// cannot be proven same-user is refused and its connection closed.
183//
184// A dial failure (no carriage bound, or a stale path) is wrapped so callers can
185// distinguish it; cancellation and deadline errors are preserved for
186// classification.
187func DialMuxContext(ctx context.Context, path string, opts ...Option) (wire.BoundedTransport, error) {
188 return dialMux(ctx, path, verifySameUserUnixPeer, opts...)
189}
190
191// DialMuxContextWithPeerVerifier connects to the private carriage exactly like
192// DialMuxContext but verifies the accepted peer with an injected verifier
193// instead of the platform default. A nil verifier falls back to the platform
194// default; a peer the verifier refuses is closed without being handed out.
195func DialMuxContextWithPeerVerifier(ctx context.Context, path string, verify PeerVerifier, opts ...Option) (wire.BoundedTransport, error) {
196 return dialMux(ctx, path, verify, opts...)
197}
198
199// dialMux is DialMuxContext with an injected peer verifier, so a test can
200// observe a credential refusal deterministically without root. A nil verifier
201// falls back to the platform default.
202func dialMux(ctx context.Context, path string, verify muxPeerVerifier, opts ...Option) (wire.BoundedTransport, error) {
203 if err := validateMuxSocketPath(path); err != nil {
204 return nil, err
205 }
206 if verify == nil {
207 verify = verifySameUserUnixPeer
208 }
209 var dialer net.Dialer
210 conn, err := dialer.DialContext(ctx, "unix", path)
211 if err != nil {
212 return nil, fmt.Errorf("ipc: dial mux socket %s: %w", path, err)
213 }
214 unixConn, ok := conn.(*net.UnixConn)
215 if !ok {
216 _ = conn.Close()
217 return nil, fmt.Errorf("%w: %s is not a Unix connection", ErrMuxPath, path)
218 }
219 if err := verify(unixConn); err != nil {
220 _ = conn.Close()
221 return nil, err
222 }
223 transport, ok := NewTransport(conn, opts...).(wire.BoundedTransport)
224 if !ok {
225 _ = conn.Close()
226 return nil, fmt.Errorf("%w: %s transport is not bounded", ErrMuxPath, path)
227 }
228 return transport, nil
229}
230
231// Accept waits for the next connection on the bound socket. Before handing a
232// transport out it verifies the peer's same-user kernel credentials; a refused
233// peer is closed and the refusal returned, so an unverified carriage is never
234// admitted. Accept fails once the listener is closed (Close unblocks it).
235func (l *muxListener) Accept() (wire.BoundedTransport, error) {
236 conn, err := l.ln.AcceptUnix()
237 if err != nil {
238 return nil, err
239 }
240 if err := l.verify(conn); err != nil {
241 _ = conn.Close()
242 return nil, err
243 }
244 transport, ok := NewTransport(conn, l.opts...).(wire.BoundedTransport)
245 if !ok {
246 _ = conn.Close()
247 return nil, fmt.Errorf("%w: %s transport is not bounded", ErrMuxPath, l.path)
248 }
249 return transport, nil
250}
251
252// Addr returns the bound filesystem path.
253func (l *muxListener) Addr() string { return l.path }
254
255// Close stops accepting, unblocking a blocked Accept, and removes the socket
256// only if the pathname still names the inode this listener created. A caller
257// replacement is preserved. Close is idempotent and concurrent-safe.
258func (l *muxListener) Close() error {
259 if l == nil {
260 return nil
261 }
262 l.closeOnce.Do(func() {
263 l.closeErr = l.ln.Close()
264 if err := removeMuxSocket(l.path, l.file); err != nil {
265 l.closeErr = errors.Join(l.closeErr, err)
266 }
267 })
268 return l.closeErr
269}
270
271// validateMuxSocketPath refuses a caller path that cannot be a portable private
272// carriage endpoint: it must be absolute, already cleaned, within the
273// cross-platform AF_UNIX pathname limit, free of NUL, and name a real base.
274func validateMuxSocketPath(path string) error {
275 if path == "" || !filepath.IsAbs(path) {
276 return fmt.Errorf("%w: path must be absolute", ErrMuxPath)
277 }
278 if filepath.Clean(path) != path || path == string(filepath.Separator) {
279 return fmt.Errorf("%w: path must be cleaned", ErrMuxPath)
280 }
281 base := filepath.Base(path)
282 if base == "." || base == ".." || base == string(filepath.Separator) {
283 return fmt.Errorf("%w: invalid base name", ErrMuxPath)
284 }
285 if len(path) > muxSocketPathMax {
286 return fmt.Errorf("%w: path exceeds %d bytes", ErrMuxPath, muxSocketPathMax)
287 }
288 for i := 0; i < len(path); i++ {
289 if path[i] == 0 {
290 return fmt.Errorf("%w: path contains a NUL", ErrMuxPath)
291 }
292 }
293 return nil
294}
295
296// prepareMuxParent creates or validates the carriage's parent directory as an
297// owner-only (0700) directory owned by the current user.
298func prepareMuxParent(path string) error {
299 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
300 return fmt.Errorf("ipc: securing mux socket directory: %w", err)
301 }
302 return nil
303}
304
305// inspectMuxSocketPath refuses an existing path that is not a socket before any
306// bind or liveness probe, so a caller's regular file, directory, or symlink is
307// never mistaken for a stale endpoint and never removed.
308func inspectMuxSocketPath(path string) error {
309 info, err := os.Lstat(path)
310 if err != nil {
311 if errors.Is(err, os.ErrNotExist) {
312 return nil
313 }
314 return fmt.Errorf("ipc: inspecting mux socket %s: %w", path, err)
315 }
316 if info.Mode()&os.ModeSocket == 0 {
317 return fmt.Errorf("%w: %s", ErrMuxForeignPath, path)
318 }
319 return nil
320}
321
322// removeMuxSocket removes path only when it still names expected, so a socket
323// a caller replaced during Close is preserved.
324func removeMuxSocket(path string, expected os.FileInfo) error {
325 if expected == nil {
326 return nil
327 }
328 info, err := os.Lstat(path)
329 if err != nil {
330 if errors.Is(err, os.ErrNotExist) {
331 return nil
332 }
333 return err
334 }
335 if !os.SameFile(info, expected) {
336 return nil
337 }
338 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
339 return err
340 }
341 return nil
342}