1package uidriver
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net"
9 "os"
10 "path/filepath"
11 "sync"
12 "time"
13
14 "github.com/bnema/vev/pkg/safedir"
15)
16
17const (
18 unixSocketMode = 0o600
19 acceptRetryDelay = 10 * time.Millisecond
20)
21
22// UnixEndpoint exposes one already-bound attachment. It never creates or
23// owns a Runner; closing it only closes observer connections and its socket.
24type UnixEndpoint struct {
25 path string
26 ln *net.UnixListener
27 server *Server
28 ready func() Ready
29
30 ctx context.Context
31 cancel context.CancelFunc
32 done chan struct{}
33 wg sync.WaitGroup
34 once sync.Once
35 file os.FileInfo
36}
37
38// ListenUnix binds a private, per-client UI socket. Existing paths are always
39// rejected, including stale sockets, so cleanup can never remove a caller's
40// file.
41func ListenUnix(path string, server *Server, ready func() Ready) (*UnixEndpoint, error) {
42 if server == nil {
43 return nil, errors.New("uidriver: nil server")
44 }
45 if err := validateSocketPath(path); err != nil {
46 return nil, err
47 }
48 parent := filepath.Dir(path)
49 if err := secureSocketParent(parent); err != nil {
50 return nil, fmt.Errorf("uidriver: secure socket directory: %w", err)
51 }
52 if _, err := os.Lstat(path); err == nil {
53 return nil, errors.New("uidriver: socket path already exists")
54 } else if !errors.Is(err, os.ErrNotExist) {
55 return nil, fmt.Errorf("uidriver: inspect socket path: %w", err)
56 }
57 address := &net.UnixAddr{Name: path, Net: "unix"}
58 listener, err := net.ListenUnix("unix", address)
59 if err != nil {
60 return nil, fmt.Errorf("uidriver: listen on socket: %w", err)
61 }
62 // Never let net.UnixListener unlink the pathname behind our ownership
63 // check. A caller may replace the path after bind; cleanup must preserve
64 // that replacement rather than deleting it during Close.
65 listener.SetUnlinkOnClose(false)
66 boundInfo, infoErr := os.Lstat(path)
67 cleanup := func(cleanErr error) (*UnixEndpoint, error) {
68 _ = listener.Close()
69 if removeErr := removeOwnedSocket(path, boundInfo); removeErr != nil {
70 cleanErr = errors.Join(cleanErr, removeErr)
71 }
72 return nil, cleanErr
73 }
74 if infoErr != nil {
75 return cleanup(fmt.Errorf("uidriver: stat socket: %w", infoErr))
76 }
77 if err := os.Chmod(path, unixSocketMode); err != nil {
78 return cleanup(fmt.Errorf("uidriver: secure socket: %w", err))
79 }
80 info, err := os.Lstat(path)
81 if err != nil {
82 return cleanup(fmt.Errorf("uidriver: stat socket: %w", err))
83 }
84 ctx, cancel := context.WithCancel(context.Background())
85 endpoint := &UnixEndpoint{path: path, ln: listener, server: server, ready: ready, ctx: ctx, cancel: cancel, done: make(chan struct{}), file: info}
86 endpoint.wg.Add(1)
87 go endpoint.accept()
88 return endpoint, nil
89}
90
91func secureSocketParent(parent string) error {
92 for current := parent; ; current = filepath.Dir(current) {
93 info, err := os.Lstat(current)
94 if err == nil {
95 if info.Mode()&os.ModeSymlink != 0 {
96 return fmt.Errorf("safedir: %s is a symlink", current)
97 }
98 if !info.IsDir() {
99 return fmt.Errorf("safedir: %s is not a directory", current)
100 }
101 if current == filepath.Dir(current) {
102 break
103 }
104 continue
105 }
106 if !errors.Is(err, os.ErrNotExist) {
107 return err
108 }
109 next := filepath.Dir(current)
110 if next == current {
111 break
112 }
113 }
114 return safedir.EnsurePrivate(parent)
115}
116
117func validateSocketPath(path string) error {
118 if path == "" || len(path) > unixSocketMax || !filepath.IsAbs(path) {
119 return errors.New("uidriver: socket path must be an absolute path within Unix socket limits")
120 }
121 if filepath.Clean(path) != path || path == string(filepath.Separator) || filepath.Base(path) == "." || filepath.Base(path) == ".." {
122 return errors.New("uidriver: invalid socket path")
123 }
124 for i := 0; i < len(path); i++ {
125 if path[i] == 0 {
126 return errors.New("uidriver: socket path contains a NUL")
127 }
128 }
129 return nil
130}
131
132// DefaultSocketPath chooses a short private path under the current vev runtime
133// directory. The handle is already opaque and contains only hex characters.
134func DefaultSocketPath(runtimeDir, handle string) string {
135 return filepath.Join(runtimeDir, "ui", handle+".sock")
136}
137
138func (e *UnixEndpoint) accept() {
139 defer close(e.done)
140 defer e.wg.Done()
141 for {
142 conn, err := e.ln.AcceptUnix()
143 if err != nil {
144 if e.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
145 return
146 }
147 select {
148 case <-e.ctx.Done():
149 return
150 case <-time.After(acceptRetryDelay):
151 }
152 continue
153 }
154 if !peerIsCurrentUser(conn) {
155 _ = conn.Close()
156 continue
157 }
158 e.wg.Add(1)
159 go func() {
160 defer e.wg.Done()
161 ready := Ready{}
162 if e.ready != nil {
163 ready = e.ready()
164 }
165 _ = e.server.Serve(e.ctx, conn, ready)
166 }()
167 }
168}
169
170// Path is the bound filesystem path.
171func (e *UnixEndpoint) Path() string { return e.path }
172
173func removeOwnedSocket(path string, expected os.FileInfo) error {
174 if expected == nil {
175 return nil
176 }
177 info, err := os.Lstat(path)
178 if err != nil {
179 if errors.Is(err, os.ErrNotExist) {
180 return nil
181 }
182 return err
183 }
184 if !os.SameFile(info, expected) {
185 return nil
186 }
187 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
188 return err
189 }
190 return nil
191}
192
193// Close stops accepting connections and removes only the socket inode created
194// by this endpoint.
195func (e *UnixEndpoint) Close() error {
196 var closeErr error
197 e.once.Do(func() {
198 e.cancel()
199 if err := e.ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
200 closeErr = err
201 }
202 e.wg.Wait()
203 if err := removeOwnedSocket(e.path, e.file); err != nil && closeErr == nil {
204 closeErr = err
205 }
206 })
207 return closeErr
208}
209
210// Bridge connects stdio to an opted-in interactive UI socket without creating
211// another attachment.
212func Bridge(ctx context.Context, path string, input io.Reader, output io.Writer) error {
213 if err := validateSocketPath(path); err != nil {
214 return err
215 }
216 conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
217 if err != nil {
218 return fmt.Errorf("uidriver: connect socket: %w", err)
219 }
220 defer func() { _ = conn.Close() }()
221 ctx, cancel := context.WithCancel(ctx)
222 defer cancel()
223 inputDone := make(chan error, 1)
224 outputDone := make(chan error, 1)
225 go func() {
226 _, copyErr := io.Copy(conn, input)
227 inputDone <- copyErr
228 }()
229 go func() {
230 _, copyErr := io.Copy(output, conn)
231 outputDone <- copyErr
232 }()
233 select {
234 case inputErr := <-inputDone:
235 if inputErr != nil && !errors.Is(inputErr, io.EOF) {
236 _ = conn.Close()
237 return inputErr
238 }
239 if unixConn, ok := conn.(*net.UnixConn); ok {
240 _ = unixConn.CloseWrite()
241 } else {
242 _ = conn.Close()
243 }
244 select {
245 case outputErr := <-outputDone:
246 return bridgeCopyError(outputErr)
247 case <-ctx.Done():
248 return ctx.Err()
249 }
250 case err := <-outputDone:
251 _ = conn.Close()
252 return bridgeCopyError(err)
253 case <-ctx.Done():
254 _ = conn.Close()
255 return ctx.Err()
256 }
257}
258
259func bridgeCopyError(err error) error {
260 if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
261 return nil
262 }
263 return err
264}