feat: add read-only CDP target viewing

This commit is contained in:
QiuSW
2026-07-27 10:01:07 +08:00
parent 7816be2a3d
commit 519d3ef636
11 changed files with 668 additions and 18 deletions
+112 -2
View File
@@ -5,18 +5,26 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"chub/internal/domain"
)
const remoteDebugPortAttempts = 100
const (
remoteDebugPortAttempts = 100
remoteDebugTargetsMaxBytes = 1 << 20
remoteDebugTargetsMaxCount = 200
remoteDebugTargetTextMax = 4096
)
var (
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
@@ -28,6 +36,16 @@ type RemoteDebugEndpoint struct {
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)
@@ -39,6 +57,12 @@ 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
@@ -47,7 +71,10 @@ type CDPInspector struct {
func NewCDPInspector() *CDPInspector {
return &CDPInspector{
readFile: os.ReadFile,
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
client: &http.Client{
Transport: &http.Transport{Proxy: nil},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
}
}
@@ -116,6 +143,66 @@ func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.B
})
}
// 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 {
@@ -180,3 +267,26 @@ func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
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]
}