1package desktop
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 neturl "net/url"
13 "os"
14 "path/filepath"
15 "sync"
16 "syscall"
17 "time"
18
19 "github.com/bnema/dumber/internal/application/port"
20 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
21 "github.com/bnema/dumber/internal/logging"
22)
23
24const browserLaunchSocketName = "browser-launch.sock"
25
26// Local browser-launch handoff needs enough headroom for a busy browser
27// process to accept and acknowledge the request before callers classify the
28// handoff as ambiguous.
29const browserLaunchIOTimeout = 250 * time.Millisecond
30
31const browserLaunchDirPerm = 0o700
32
33// ErrBrowserLaunchRelayUnconfirmed reports that the relay accepted a launch
34// request but the caller did not receive a confirmation response in time.
35var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
36
37type browserLaunchRelay struct {
38 ipc runtimeprofile.IPCPaths
39}
40
41type browserLaunchRequest struct {
42 RequestID string `json:"request_id,omitempty"`
43 URL string `json:"url"`
44}
45
46type browserLaunchResponse struct {
47 RequestID string `json:"request_id,omitempty"`
48 Accepted bool `json:"accepted,omitempty"`
49 Error string `json:"error,omitempty"`
50}
51
52type browserLaunchRelayListener struct {
53 listener *net.UnixListener
54 socketPath string
55 once sync.Once
56 err error
57}
58
59var newBrowserLaunchRequestID = func() string {
60 var buf [8]byte
61 if _, err := rand.Read(buf[:]); err == nil {
62 return "blr-" + hex.EncodeToString(buf[:])
63 }
64 return fmt.Sprintf("blr-%d", time.Now().UnixNano())
65}
66
67func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
68 return &browserLaunchRelay{ipc: ipc}
69}
70
71func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
72 socketPath, err := r.socketPath()
73 if err != nil {
74 return false, err
75 }
76
77 conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
78 if err != nil {
79 if isMissingRelayListener(err) {
80 return false, nil
81 }
82 return false, err
83 }
84 defer func() { _ = conn.Close() }()
85
86 requestID := newBrowserLaunchRequestID()
87 log := logging.FromContext(ctx)
88 log.Debug().
89 Str("request_id", requestID).
90 Str("url_host", safeURLHost(url)).
91 Msg("browser launch relay delivery started")
92
93 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
94 return false, err
95 }
96 if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url}); err != nil {
97 return false, err
98 }
99
100 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
101 return false, err
102 }
103
104 var response browserLaunchResponse
105 decoder := json.NewDecoder(conn)
106 for {
107 if decodeErr := decoder.Decode(&response); decodeErr != nil {
108 if isBrowserLaunchReadTimeout(decodeErr) {
109 if ctxErr := ctx.Err(); ctxErr != nil {
110 return false, ctxErr
111 }
112 if _, ok := ctx.Deadline(); !ok {
113 log.Warn().
114 Str("request_id", requestID).
115 Str("url_host", safeURLHost(url)).
116 Dur("timeout", browserLaunchIOTimeout).
117 Msg("browser launch relay response timed out without caller deadline; delivery is unconfirmed")
118 return true, ErrBrowserLaunchRelayUnconfirmed
119 }
120 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
121 return false, deadlineErr
122 }
123 continue
124 }
125 return false, decodeErr
126 }
127 break
128 }
129 if response.RequestID != "" && response.RequestID != requestID {
130 return true, fmt.Errorf("mismatched browser launch relay response request id: got %q, want %q", response.RequestID, requestID)
131 }
132 if response.Error != "" {
133 log.Warn().
134 Str("request_id", requestID).
135 Str("url_host", safeURLHost(url)).
136 Str("relay_error", response.Error).
137 Msg("browser launch relay delivery rejected")
138 return true, errors.New(response.Error)
139 }
140
141 log.Debug().
142 Str("request_id", requestID).
143 Str("url_host", safeURLHost(url)).
144 Bool("accepted", response.Accepted || response.RequestID == "").
145 Msg("browser launch relay delivery acknowledged")
146 return true, nil
147}
148
149func isMissingRelayListener(err error) bool {
150 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
151}
152
153func safeURLHost(raw string) string {
154 parsed, err := neturl.Parse(raw)
155 if err != nil {
156 return ""
157 }
158 return parsed.Host
159}
160
161func isBrowserLaunchReadTimeout(err error) bool {
162 var netErr net.Error
163 return errors.As(err, &netErr) && netErr.Timeout()
164}
165
166func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
167 conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
168 if err == nil {
169 _ = conn.Close()
170 return true, nil
171 }
172
173 if isMissingRelayListener(err) {
174 return false, nil
175 }
176
177 return false, fmt.Errorf("check browser launch socket: %w", err)
178}
179
180func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
181 deadline := time.Now().Add(browserLaunchIOTimeout)
182 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
183 deadline = ctxDeadline
184 }
185
186 return conn.SetDeadline(deadline)
187}
188
189func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error {
190 socketDir := filepath.Dir(socketPath)
191 for dir := socketDir; ; dir = filepath.Dir(dir) {
192 info, err := os.Stat(dir)
193 if err != nil {
194 return fmt.Errorf("stat browser launch dir: %w", err)
195 }
196 stat, ok := info.Sys().(*syscall.Stat_t)
197 if !ok {
198 return nil
199 }
200
201 isSocketDir := dir == socketDir
202 if stat.Uid != expectedUID {
203 if isSocketDir || stat.Uid != 0 {
204 return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID)
205 }
206 }
207 if info.Mode().Perm()&0o022 != 0 && (isSocketDir || info.Mode()&os.ModeSticky == 0) {
208 return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm())
209 }
210 if parent := filepath.Dir(dir); parent == dir {
211 return nil
212 }
213 }
214}
215
216func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
217 socketPath, err := r.socketPath()
218 if err != nil {
219 return nil, err
220 }
221
222 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
223 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
224 }
225 if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
226 return nil, ownerErr
227 }
228 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
229 if err != nil {
230 if !errors.Is(err, syscall.EADDRINUSE) {
231 return nil, fmt.Errorf("listen browser launch socket: %w", err)
232 }
233
234 live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
235 if liveErr != nil {
236 return nil, liveErr
237 }
238 if live {
239 return nil, errors.New("browser launch relay already running")
240 }
241
242 if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
243 return nil, ownerErr
244 }
245
246 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
247 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
248 }
249
250 listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
251 if err != nil {
252 return nil, fmt.Errorf("listen browser launch socket: %w", err)
253 }
254 }
255
256 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
257 go relayListener.serve(ctx, opener)
258
259 return relayListener, nil
260}
261
262func (r *browserLaunchRelay) socketPath() (string, error) {
263 if r == nil {
264 return "", errors.New("browser launch relay missing IPC paths")
265 }
266 if r.ipc.BrowserLaunchSocket == "" {
267 return "", errors.New("browser launch relay missing browser launch socket path")
268 }
269 return r.ipc.BrowserLaunchSocket, nil
270}
271
272func (l *browserLaunchRelayListener) Close() error {
273 l.once.Do(func() {
274 if l.listener != nil {
275 l.err = l.listener.Close()
276 }
277 _ = os.Remove(l.socketPath)
278 })
279
280 return l.err
281}
282
283func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
284 defer func() { _ = l.Close() }()
285
286 for {
287 if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
288 return
289 }
290 conn, err := l.listener.AcceptUnix()
291 if err != nil {
292 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
293 return
294 }
295 var netErr net.Error
296 if errors.As(err, &netErr) && netErr.Timeout() {
297 if ctx.Err() != nil {
298 return
299 }
300 continue
301 }
302 continue
303 }
304
305 go l.handleConnection(ctx, conn, opener)
306 }
307}
308
309func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
310 defer func() { _ = conn.Close() }()
311 log := logging.FromContext(ctx)
312 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
313 return
314 }
315
316 var request browserLaunchRequest
317 if err := json.NewDecoder(conn).Decode(&request); err != nil {
318 return
319 }
320 requestID := request.RequestID
321 if requestID == "" {
322 requestID = newBrowserLaunchRequestID()
323 }
324 log.Debug().
325 Str("request_id", requestID).
326 Str("url_host", safeURLHost(request.URL)).
327 Msg("browser launch relay request received")
328
329 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
330 return
331 }
332 if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {
333 log.Warn().Err(err).
334 Str("request_id", requestID).
335 Str("url_host", safeURLHost(request.URL)).
336 Msg("failed to encode browser launch response")
337 return
338 }
339 log.Debug().
340 Str("request_id", requestID).
341 Str("url_host", safeURLHost(request.URL)).
342 Msg("browser launch relay request accepted")
343
344 go func() {
345 if opener == nil {
346 log.Warn().
347 Str("request_id", requestID).
348 Str("url_host", safeURLHost(request.URL)).
349 Msg("browser launch relay accepted request without opener")
350 return
351 }
352 started := time.Now()
353 log.Debug().
354 Str("request_id", requestID).
355 Str("url_host", safeURLHost(request.URL)).
356 Msg("browser launch relay calling browser window opener")
357 if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
358 log.Warn().Err(err).
359 Str("request_id", requestID).
360 Str("url_host", safeURLHost(request.URL)).
361 Dur("elapsed", time.Since(started)).
362 Msg("browser launch relay opener failed")
363 return
364 }
365 log.Debug().
366 Str("request_id", requestID).
367 Str("url_host", safeURLHost(request.URL)).
368 Dur("elapsed", time.Since(started)).
369 Msg("browser launch relay opener returned success")
370 }()
371}
372
373var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)