2026-07-25 15:53:31 +08:00
|
|
|
package browser
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-07-27 10:01:07 +08:00
|
|
|
"io"
|
2026-07-25 15:53:31 +08:00
|
|
|
"net"
|
|
|
|
|
"net/http"
|
2026-07-27 10:01:07 +08:00
|
|
|
"net/url"
|
2026-07-25 15:53:31 +08:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-07-27 10:01:07 +08:00
|
|
|
"unicode/utf8"
|
2026-07-25 15:53:31 +08:00
|
|
|
|
|
|
|
|
"chub/internal/domain"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
const (
|
|
|
|
|
remoteDebugPortAttempts = 100
|
|
|
|
|
remoteDebugTargetsMaxBytes = 1 << 20
|
|
|
|
|
remoteDebugTargetsMaxCount = 200
|
|
|
|
|
remoteDebugTargetTextMax = 4096
|
|
|
|
|
)
|
2026-07-25 15:53:31 +08:00
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
|
|
|
|
ErrRemoteDebugEndpointUnavailable = errors.New("remote debugging endpoint is unavailable")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type RemoteDebugEndpoint struct {
|
|
|
|
|
Port int
|
|
|
|
|
Browser string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
type CDPInspector struct {
|
|
|
|
|
readFile func(string) ([]byte, error)
|
|
|
|
|
client *http.Client
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewCDPInspector() *CDPInspector {
|
|
|
|
|
return &CDPInspector{
|
|
|
|
|
readFile: os.ReadFile,
|
2026-07-27 10:01:07 +08:00
|
|
|
client: &http.Client{
|
|
|
|
|
Transport: &http.Transport{Proxy: nil},
|
|
|
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
|
|
|
},
|
2026-07-25 15:53:31 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:01:07 +08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 15:53:31 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 10:01:07 +08:00
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
}
|