Files
fn_registry/functions/browser/cdp_conn.go
T
egutierrez bf1efb2099 feat: externalize apps/analysis to Gitea repos, add analysis table
- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:23:51 +02:00

303 lines
8.0 KiB
Go

package browser
import (
"bufio"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
)
// CDPConn es una conexion activa al Chrome DevTools Protocol.
// Gestiona el WebSocket raw y el protocolo JSON-RPC de CDP.
type CDPConn struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID atomic.Int64
port int
pid int
pending map[int64]chan cdpResponse
pendMu sync.Mutex
closed bool
}
type cdpRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params,omitempty"`
}
type cdpResponse struct {
ID int64 `json:"id"`
Result map[string]any `json:"result"`
Error *cdpError `json:"error"`
Method string `json:"method"` // para eventos
Params map[string]any `json:"params"` // para eventos
}
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// cdpVersionResponse es la respuesta de /json/version del endpoint CDP.
type cdpVersionResponse struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
Browser string `json:"Browser"`
}
// wsHandshake realiza el handshake WebSocket RFC 6455 sobre una conexion TCP ya abierta.
func wsHandshake(conn net.Conn, host, path string) (*bufio.Reader, error) {
// Generar clave aleatoria de 16 bytes en base64
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("ws handshake: generar clave: %w", err)
}
key := base64.StdEncoding.EncodeToString(keyBytes)
// Enviar request HTTP upgrade
req := fmt.Sprintf(
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n",
path, host, key,
)
if _, err := fmt.Fprint(conn, req); err != nil {
return nil, fmt.Errorf("ws handshake: enviar upgrade: %w", err)
}
// Leer respuesta HTTP
reader := bufio.NewReaderSize(conn, 65536)
status, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer status: %w", err)
}
if !strings.Contains(status, "101") {
return nil, fmt.Errorf("ws handshake: status inesperado: %s", strings.TrimSpace(status))
}
// Consumir headers hasta linea vacia
var acceptKey string
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer headers: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(strings.ToLower(line), "sec-websocket-accept:") {
acceptKey = strings.TrimSpace(line[len("sec-websocket-accept:"):])
}
}
// Verificar Sec-WebSocket-Accept
magic := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
h := sha1.New()
h.Write([]byte(key + magic))
expected := base64.StdEncoding.EncodeToString(h.Sum(nil))
if acceptKey != expected {
return nil, fmt.Errorf("ws handshake: accept key invalida: got %q, want %q", acceptKey, expected)
}
return reader, nil
}
// wsReadMessage lee un frame WebSocket y retorna el payload.
// Solo soporta frames de texto/binario no fragmentados (suficiente para CDP).
func wsReadMessage(reader *bufio.Reader) ([]byte, error) {
// Leer primeros 2 bytes del frame
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, fmt.Errorf("ws read: header: %w", err)
}
// fin := (header[0] & 0x80) != 0 // ignoramos fragmentacion
opcode := header[0] & 0x0F
masked := (header[1] & 0x80) != 0
payloadLen := int64(header[1] & 0x7F)
if opcode == 8 {
return nil, fmt.Errorf("ws read: connection close frame")
}
// Leer longitud extendida
switch payloadLen {
case 126:
var ext uint16
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 16: %w", err)
}
payloadLen = int64(ext)
case 127:
var ext uint64
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 64: %w", err)
}
payloadLen = int64(ext)
}
// Leer mascara si aplica (servidor->cliente normalmente no tiene mascara)
var mask [4]byte
if masked {
if _, err := io.ReadFull(reader, mask[:]); err != nil {
return nil, fmt.Errorf("ws read: mask: %w", err)
}
}
// Leer payload
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(reader, payload); err != nil {
return nil, fmt.Errorf("ws read: payload: %w", err)
}
// Aplicar mascara si hay
if masked {
for i := range payload {
payload[i] ^= mask[i%4]
}
}
return payload, nil
}
// wsWriteMessage escribe un frame WebSocket enmascarado (cliente->servidor requiere mascara).
func wsWriteMessage(conn net.Conn, data []byte) error {
// Generar mascara aleatoria
var mask [4]byte
if _, err := rand.Read(mask[:]); err != nil {
return fmt.Errorf("ws write: generar mascara: %w", err)
}
// Construir frame
payloadLen := len(data)
var header []byte
header = append(header, 0x81) // FIN + opcode text
if payloadLen < 126 {
header = append(header, byte(payloadLen)|0x80) // masked
} else if payloadLen < 65536 {
header = append(header, 126|0x80)
header = append(header, byte(payloadLen>>8), byte(payloadLen))
} else {
header = append(header, 127|0x80)
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(payloadLen))
header = append(header, b...)
}
header = append(header, mask[:]...)
// Aplicar mascara al payload
masked := make([]byte, payloadLen)
for i, b := range data {
masked[i] = b ^ mask[i%4]
}
frame := append(header, masked...)
if _, err := conn.Write(frame); err != nil {
return fmt.Errorf("ws write: %w", err)
}
return nil
}
// sendCDP envia un comando CDP y espera la respuesta con el mismo ID.
func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any, error) {
id := c.nextID.Add(1)
req := cdpRequest{ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("cdp send: marshal: %w", err)
}
// Registrar canal para la respuesta
ch := make(chan cdpResponse, 1)
c.pendMu.Lock()
c.pending[id] = ch
c.pendMu.Unlock()
// Enviar frame WebSocket
c.mu.Lock()
err = wsWriteMessage(c.conn, data)
c.mu.Unlock()
if err != nil {
c.pendMu.Lock()
delete(c.pending, id)
c.pendMu.Unlock()
return nil, fmt.Errorf("cdp send %s: %w", method, err)
}
// Esperar respuesta
resp := <-ch
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
}
return resp.Result, nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes.
// Debe ejecutarse en una goroutine.
func (c *CDPConn) readLoop() {
for {
data, err := wsReadMessage(c.reader)
if err != nil {
// Conexion cerrada o error — notificar a todos los pendientes
c.pendMu.Lock()
for _, ch := range c.pending {
ch <- cdpResponse{Error: &cdpError{Message: err.Error()}}
}
c.pending = map[int64]chan cdpResponse{}
c.pendMu.Unlock()
return
}
var resp cdpResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
// Si tiene ID, es respuesta a un comando
if resp.ID > 0 {
c.pendMu.Lock()
ch, ok := c.pending[resp.ID]
if ok {
delete(c.pending, resp.ID)
}
c.pendMu.Unlock()
if ok {
ch <- resp
}
}
// Si no tiene ID, es un evento CDP — por ahora los ignoramos
// Las funciones que necesiten eventos usan polling o envian el comando y esperan
}
}
// cdpGetWSURL obtiene el webSocketDebuggerUrl del endpoint HTTP de CDP.
func cdpGetWSURL(port int) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port))
if err != nil {
return "", fmt.Errorf("cdp version: %w", err)
}
defer resp.Body.Close()
var info cdpVersionResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", fmt.Errorf("cdp version: decode: %w", err)
}
if info.WebSocketDebuggerURL == "" {
return "", fmt.Errorf("cdp version: webSocketDebuggerUrl vacio")
}
return info.WebSocketDebuggerURL, nil
}