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