package browser import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" "chub/internal/domain" ) const ( remoteDebugPortAttempts = 100 remoteDebugTargetsMaxBytes = 1 << 20 remoteDebugTargetsMaxCount = 200 remoteDebugTargetTextMax = 4096 ) var ( ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable") ErrRemoteDebugEndpointUnavailable = errors.New("remote debugging endpoint is unavailable") ) type RemoteDebugEndpoint struct { Port int Browser string } // RemoteDebugTarget is a read-only summary of one target returned by CDP // /json/list. URL is safe to display but must never be treated as a control // endpoint; its userinfo, query, and fragment have been removed. type RemoteDebugTarget struct { ID string Type string Title string URL string } type RemoteDebugEndpointInspector interface { InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error) WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error) InspectRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error) WaitForRemoteDebugPort(context.Context, domain.BrowserKind, int) (RemoteDebugEndpoint, error) } type RemoteDebugPortAllocator interface { FindAvailableRemoteDebugPort(context.Context, int) (int, error) } // RemoteDebugTargetInspector reads CDP targets only from a verified loopback // endpoint. It intentionally exposes no WebSocket URL or browser-control API. type RemoteDebugTargetInspector interface { ListRemoteDebugTargets(context.Context, domain.BrowserKind, int) ([]RemoteDebugTarget, error) } type CDPInspector struct { readFile func(string) ([]byte, error) client *http.Client } func NewCDPInspector() *CDPInspector { return &CDPInspector{ readFile: os.ReadFile, client: &http.Client{ Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, } } func (i *CDPInspector) InspectRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) { if err := ctx.Err(); err != nil { return RemoteDebugEndpoint{}, err } if i == nil || i.readFile == nil || i.client == nil || !kind.Valid() { return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable } directory := strings.TrimSpace(userDataDir) if directory == "" || !filepath.IsAbs(directory) { return RemoteDebugEndpoint{}, fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec) } data, err := i.readFile(filepath.Join(filepath.Clean(directory), "DevToolsActivePort")) if err != nil { return RemoteDebugEndpoint{}, fmt.Errorf("%w: read active port", ErrRemoteDebugEndpointUnavailable) } port, err := parseDevToolsActivePort(data) if err != nil { return RemoteDebugEndpoint{}, err } return i.InspectRemoteDebugPort(ctx, kind, port) } func (i *CDPInspector) InspectRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) { if err := ctx.Err(); err != nil { return RemoteDebugEndpoint{}, err } if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) { return RemoteDebugEndpoint{}, ErrRemoteDebugEndpointUnavailable } request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/version", nil) if err != nil { return RemoteDebugEndpoint{}, fmt.Errorf("%w: build version request", ErrRemoteDebugEndpointUnavailable) } response, err := i.client.Do(request) if err != nil { return RemoteDebugEndpoint{}, fmt.Errorf("%w: query version", ErrRemoteDebugEndpointUnavailable) } defer response.Body.Close() if response.StatusCode != http.StatusOK { return RemoteDebugEndpoint{}, fmt.Errorf("%w: version status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode) } var version struct { Browser string `json:"Browser"` } if err := json.NewDecoder(response.Body).Decode(&version); err != nil { return RemoteDebugEndpoint{}, fmt.Errorf("%w: decode version", ErrRemoteDebugEndpointUnavailable) } if !browserVersionMatchesKind(version.Browser, kind) { return RemoteDebugEndpoint{}, fmt.Errorf("%w: browser kind mismatch", ErrRemoteDebugEndpointUnavailable) } return RemoteDebugEndpoint{Port: port, Browser: version.Browser}, nil } func (i *CDPInspector) WaitForRemoteDebugEndpoint(ctx context.Context, kind domain.BrowserKind, userDataDir string) (RemoteDebugEndpoint, error) { return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) { return i.InspectRemoteDebugEndpoint(ctx, kind, userDataDir) }) } func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.BrowserKind, port int) (RemoteDebugEndpoint, error) { return waitForRemoteDebugEndpoint(ctx, func(ctx context.Context) (RemoteDebugEndpoint, error) { return i.InspectRemoteDebugPort(ctx, kind, port) }) } // ListRemoteDebugTargets first validates the browser kind at the supplied // loopback port, then reads its bounded /json/list response. It never follows // redirects or exposes the response's webSocketDebuggerUrl field. func (i *CDPInspector) ListRemoteDebugTargets(ctx context.Context, kind domain.BrowserKind, port int) ([]RemoteDebugTarget, error) { if err := ctx.Err(); err != nil { return nil, err } if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) { return nil, ErrRemoteDebugEndpointUnavailable } if _, err := i.InspectRemoteDebugPort(ctx, kind, port); err != nil { return nil, err } request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/list", nil) if err != nil { return nil, fmt.Errorf("%w: build target request", ErrRemoteDebugEndpointUnavailable) } response, err := i.client.Do(request) if err != nil { return nil, fmt.Errorf("%w: query targets", ErrRemoteDebugEndpointUnavailable) } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("%w: target status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode) } data, err := io.ReadAll(io.LimitReader(response.Body, remoteDebugTargetsMaxBytes+1)) if err != nil { return nil, fmt.Errorf("%w: read targets", ErrRemoteDebugEndpointUnavailable) } if len(data) > remoteDebugTargetsMaxBytes { return nil, fmt.Errorf("%w: target response exceeds %d bytes", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxBytes) } var rawTargets []struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` URL string `json:"url"` } if err := json.Unmarshal(data, &rawTargets); err != nil { return nil, fmt.Errorf("%w: decode targets", ErrRemoteDebugEndpointUnavailable) } if len(rawTargets) > remoteDebugTargetsMaxCount { return nil, fmt.Errorf("%w: target count exceeds %d", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxCount) } targets := make([]RemoteDebugTarget, 0, len(rawTargets)) for _, raw := range rawTargets { id := strings.TrimSpace(raw.ID) if id == "" { continue } targets = append(targets, RemoteDebugTarget{ ID: truncateRemoteDebugTargetText(id), Type: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Type)), Title: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Title)), URL: sanitizeRemoteDebugTargetURL(raw.URL), }) } return targets, nil } func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) { const pollInterval = 50 * time.Millisecond for { endpoint, err := inspect(ctx) if err == nil { return endpoint, nil } if !errors.Is(err, ErrRemoteDebugEndpointUnavailable) { return RemoteDebugEndpoint{}, err } timer := time.NewTimer(pollInterval) select { case <-ctx.Done(): timer.Stop() return RemoteDebugEndpoint{}, fmt.Errorf("%w: %v", ErrRemoteDebugEndpointUnavailable, ctx.Err()) case <-timer.C: } } } func (i *CDPInspector) FindAvailableRemoteDebugPort(ctx context.Context, start int) (int, error) { if i == nil || !domain.ValidRemoteDebugPort(start) { return 0, fmt.Errorf("%w: start port must be between %d and %d", ErrRemoteDebugPortUnavailable, domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort) } for offset := 0; offset < remoteDebugPortAttempts && start+offset <= domain.MaxRemoteDebugPort; offset++ { if err := ctx.Err(); err != nil { return 0, err } port := start + offset listener, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) if err != nil { continue } _ = listener.Close() return port, nil } return 0, fmt.Errorf("%w: no free port in %d candidates starting at %d", ErrRemoteDebugPortUnavailable, remoteDebugPortAttempts, start) } func parseDevToolsActivePort(data []byte) (int, error) { lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") if len(lines) < 2 { return 0, fmt.Errorf("%w: malformed active port file", ErrRemoteDebugEndpointUnavailable) } port, err := strconv.Atoi(strings.TrimSpace(lines[0])) if err != nil || !domain.ValidRemoteDebugPort(port) { return 0, fmt.Errorf("%w: malformed active port", ErrRemoteDebugEndpointUnavailable) } if strings.TrimSpace(lines[1]) == "" { return 0, fmt.Errorf("%w: missing browser endpoint", ErrRemoteDebugEndpointUnavailable) } return port, nil } func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool { switch kind { case domain.BrowserChrome: return strings.HasPrefix(browser, "Chrome/") || strings.HasPrefix(browser, "HeadlessChrome/") case domain.BrowserEdge: return strings.HasPrefix(browser, "Edg/") || strings.HasPrefix(browser, "HeadlessEdg/") default: return false } } func sanitizeRemoteDebugTargetURL(value string) string { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || parsed == nil { return "" } parsed.User = nil parsed.RawQuery = "" parsed.ForceQuery = false parsed.Fragment = "" return truncateRemoteDebugTargetText(parsed.String()) } func truncateRemoteDebugTargetText(value string) string { if len(value) <= remoteDebugTargetTextMax { return value } cut := remoteDebugTargetTextMax for cut > 0 && !utf8.RuneStart(value[cut]) { cut-- } return value[:cut] }