Files
Ronni Skansing 404ecd205b fix test runner does not keepalive
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-05-14 11:14:15 +02:00

684 lines
21 KiB
Go

package remotebrowser
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// Config holds browser connection and execution settings configurable by platform admins.
type Config struct {
Mode string `json:"mode"` // "local" or "remote"
Remote string `json:"remote"` // DevTools WS URL (mode=remote)
Proxy string `json:"proxy"` // socks5:// or http:// (mode=local)
Headless bool `json:"headless"` // run Chrome in headless mode (mode=local)
Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout
}
const DefaultTimeout = 60_000 // ms
// DefaultConfig returns a sensible default config.
func DefaultConfig() Config {
return Config{
Mode: "local",
Timeout: DefaultTimeout,
}
}
// ParseConfig parses a JSON config string, returning defaults for empty input.
func ParseConfig(raw string) (Config, error) {
cfg := DefaultConfig()
if raw == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return cfg, err
}
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultTimeout
}
if cfg.Mode != "remote" {
cfg.Remote = ""
}
return cfg, nil
}
// resolveBrowserRootDir returns the directory Rod uses to cache its auto-downloaded
// Chromium. We use a path next to the running binary rather than $HOME/.cache
// because the systemd unit sets ProtectHome=true, making /home inaccessible to
// the service regardless of what /etc/passwd says.
func resolveBrowserRootDir() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("cannot locate browser cache dir: %w", err)
}
return filepath.Join(filepath.Dir(execPath), "data", "browser"), nil
}
// Runner executes a JS script against a Chrome instance and streams events
// to the Events channel. The caller must close or drain Events after Run returns.
type Runner struct {
Script string
Config Config
// ExecPath is the server-configured Chrome binary path (from config.json,
// not user-supplied). Empty = Rod auto-download.
ExecPath string
// NoKeepAlive disables the keepAlive() parking behaviour. When true,
// keepAlive() returns immediately so the script exits normally. Used by
// the test runner to prevent leaking browsers when the test panel closes.
NoKeepAlive bool
Events chan RunEvent // server → client
Incoming chan IncomingMsg // client → script (victim events / test injections)
// BrowserCh receives the *rod.Page as soon as newSession() spawns the browser.
// The caller reads this once to obtain the page for streaming.
BrowserCh chan *rod.Page
// LiveCh receives the *rod.Page when s.keepAlive() is called (kept for compat).
LiveCh chan *rod.Page
// StreamCh receives commands from s.stream(selector, name) / stop().
StreamCh chan StreamCmd
}
// IncomingMsg is an event sent from the client into the running script.
// Wire format: {"event": "credentials", "data": {"username": "...", "password": "..."}}
type IncomingMsg struct {
Event string `json:"event"`
Data interface{} `json:"data"`
}
// StreamCmd is sent on Runner.StreamCh when the script calls s.stream() or the returned stop().
type StreamCmd struct {
Op string // "start" | "stop"
Selector string // CSS selector (Op=start)
Name string // stream name
Page *rod.Page // rod page (Op=start)
MaxFps int // 0 = unlimited
Quality int // JPEG re-encode quality 1-100; 0 = default (92)
}
// NewRunner creates a Runner with buffered event channels.
func NewRunner(script string, cfg Config) *Runner {
return &Runner{
Script: script,
Config: cfg,
Events: make(chan RunEvent, 256),
Incoming: make(chan IncomingMsg, 256),
BrowserCh: make(chan *rod.Page, 1),
LiveCh: make(chan *rod.Page, 1),
StreamCh: make(chan StreamCmd, 16),
}
}
// Run executes the script. It blocks until the script finishes, the context is
// cancelled, or the global timeout fires. The Events channel is closed when Run
// returns.
func (r *Runner) Run(ctx context.Context) error {
defer close(r.Events)
defer close(r.StreamCh)
emitter := newChannelEmitter(r.Events)
timeout := time.Duration(r.Config.Timeout) * time.Millisecond
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
vm := goja.New()
// vmArgStr returns "" for undefined/null instead of the string "undefined".
vmArgStr := func(v goja.Value) string {
if goja.IsUndefined(v) || goja.IsNull(v) {
return ""
}
return v.String()
}
vm.Set("emit", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
value := call.Argument(1).Export()
emitter.emit(key, value)
return goja.Undefined()
})
vm.Set("log", func(call goja.FunctionCall) goja.Value {
msg := vmArgStr(call.Argument(0))
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) {
emitter.log(msg, call.Argument(1).Export())
} else {
emitter.log(msg)
}
return goja.Undefined()
})
vm.Set("waitFor", func(call goja.FunctionCall) goja.Value {
key := vmArgStr(call.Argument(0))
emitter.log(fmt.Sprintf("[waitFor] waiting for %q", key))
for {
select {
case <-ctx.Done():
panic(vm.NewGoError(ctx.Err()))
case msg, ok := <-r.Incoming:
if !ok {
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
}
if msg.Event == key {
emitter.log(fmt.Sprintf("[waitFor] received %q", key))
return vm.ToValue(msg.Data)
}
}
}
})
vm.Set("waitForAny", func(call goja.FunctionCall) goja.Value {
// waitForAny(["password", "username"]) or waitForAny("password", "username")
// Returns {event: "...", data: ...} for whichever arrives first.
keySet := make(map[string]bool)
if len(call.Arguments) == 1 {
if arr, ok := call.Argument(0).Export().([]interface{}); ok {
for _, v := range arr {
keySet[fmt.Sprintf("%v", v)] = true
}
} else {
keySet[vmArgStr(call.Argument(0))] = true
}
} else {
for _, a := range call.Arguments {
keySet[vmArgStr(a)] = true
}
}
for {
select {
case <-ctx.Done():
panic(vm.NewGoError(ctx.Err()))
case msg, ok := <-r.Incoming:
if !ok {
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
}
if keySet[msg.Event] {
result := vm.NewObject()
result.Set("event", msg.Event)
result.Set("data", vm.ToValue(msg.Data))
return result
}
}
}
})
vm.Set("withTimeout", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
fn, ok := goja.AssertFunction(call.Argument(1))
if !ok {
panic(vm.NewTypeError("withTimeout: second argument must be a function"))
}
tCtx, tCancel := context.WithTimeout(ctx, time.Duration(ms)*time.Millisecond)
defer tCancel()
_ = tCtx
_, err := fn(goja.Undefined())
if err != nil {
panic(err)
}
return goja.Undefined()
})
vm.Set("newSession", func(call goja.FunctionCall) goja.Value {
type sessionOpts struct {
Proxy string
Remote string
Headless bool
IdleTimeout int // ms; 0 = disabled
Debug bool
QueryTimeout int // ms; 0 = no timeout on read ops
}
opts := sessionOpts{
Headless: r.Config.Headless,
}
if r.Config.Proxy != "" {
opts.Proxy = r.Config.Proxy
}
if r.Config.Mode == "remote" && r.Config.Remote != "" {
opts.Remote = r.Config.Remote
}
if len(call.Arguments) > 0 {
obj := call.Argument(0).ToObject(vm)
if obj != nil {
if v := obj.Get("proxy"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Proxy = v.String()
}
if v := obj.Get("remote"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Remote = v.String()
}
if v := obj.Get("headless"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Headless = v.ToBoolean()
}
if v := obj.Get("idleTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.IdleTimeout = int(v.ToInteger())
}
if v := obj.Get("debug"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.Debug = v.ToBoolean()
}
if v := obj.Get("queryTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
opts.QueryTimeout = int(v.ToInteger())
}
}
}
var browser *rod.Browser
var page *rod.Page
if opts.Remote != "" {
if opts.Proxy != "" {
emitter.log(fmt.Sprintf("[session] warning: proxy %q ignored for remote browser", opts.Proxy))
}
emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", opts.Remote))
browser = rod.New().ControlURL(opts.Remote).Context(ctx)
if err := browser.Connect(); err != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
return goja.Undefined()
}
var err error
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
browser.Close() //nolint:errcheck
return goja.Undefined()
}
} else {
rootDir, err := resolveBrowserRootDir()
if err != nil {
emitter.errorf(err.Error())
return goja.Undefined()
}
// Newer Chromium requires writable XDG dirs and a crash-dumps-dir at
// startup (see go-rod#1126). Use subdirs of the browser root so all
// Chrome-related data lives in one place.
crashDir := filepath.Join(rootDir, "crashes")
os.MkdirAll(crashDir, 0755) //nolint:errcheck
os.MkdirAll(filepath.Join(rootDir, "config"), 0755) //nolint:errcheck
os.MkdirAll(filepath.Join(rootDir, "cache"), 0755) //nolint:errcheck
l := launcher.New().
Headless(opts.Headless).
Set("disable-crash-reporter").
Set("crash-dumps-dir", crashDir).
Env(append(os.Environ(),
"XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"),
"XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"),
)...)
if r.ExecPath != "" {
l = l.Bin(r.ExecPath)
} else {
b := launcher.NewBrowser()
b.RootDir = rootDir
binPath := b.BinPath()
if _, err := os.Stat(binPath); os.IsNotExist(err) {
if err := b.Download(); err != nil {
emitter.errorf(fmt.Sprintf("browser download failed: %v", err))
return goja.Undefined()
}
}
l = l.Bin(binPath)
}
if opts.Proxy != "" {
emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy))
l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "")
}
u, err := l.Launch()
if err != nil {
emitter.errorf(fmt.Sprintf("browser launch failed: %v", err))
return goja.Undefined()
}
browser = rod.New().ControlURL(u).Context(ctx)
if err := browser.Connect(); err != nil {
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
return goja.Undefined()
}
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
browser.Close() //nolint:errcheck
return goja.Undefined()
}
}
// Signal the controller that a page is ready for streaming.
select {
case r.BrowserCh <- page:
default:
}
session := vm.NewObject()
RegisterBrowserBindings(vm, session, page, emitter, opts.Debug, opts.QueryTimeout)
session.Set("withTimeout", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
fn, ok := goja.AssertFunction(call.Argument(1))
if !ok {
panic(vm.NewTypeError("withTimeout: second argument must be a function"))
}
tCtx, tCancel := context.WithTimeout(page.GetContext(), time.Duration(ms)*time.Millisecond)
defer tCancel()
tPage := page.Context(tCtx)
tmpSession := vm.NewObject()
RegisterBrowserBindings(vm, tmpSession, tPage, nil, opts.Debug, opts.QueryTimeout)
_, err := fn(goja.Undefined(), tmpSession)
if err != nil {
panic(err)
}
return goja.Undefined()
})
session.Set("close", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] closing")
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
return goja.Undefined()
})
// keepAlive parks the script and signals the caller that the page
// is available for streaming. It blocks until the runner context
// is cancelled (e.g. the admin closes the live session).
session.Set("keepAlive", func(call goja.FunctionCall) goja.Value {
if r.NoKeepAlive {
emitter.log("[session] keepAlive skipped (test mode)")
return goja.Undefined()
}
emitter.log("[session] keeping alive for remote takeover")
select {
case r.LiveCh <- page:
default:
}
emitter.send(RunEvent{
Type: "keep_alive",
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
<-ctx.Done()
emitter.log("[session] keep-alive ended")
return goja.Undefined()
})
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
handlers := map[string]goja.Callable{}
listenDone := make(chan struct{}, 1)
session.Set("on", func(call goja.FunctionCall) goja.Value {
event := call.Argument(0).String()
fn, ok := goja.AssertFunction(call.Argument(1))
if !ok {
panic(vm.NewTypeError("on: second argument must be a function"))
}
handlers[event] = fn
return goja.Undefined()
})
session.Set("done", func(call goja.FunctionCall) goja.Value {
select {
case listenDone <- struct{}{}:
default:
}
return goja.Undefined()
})
session.Set("listen", func(call goja.FunctionCall) goja.Value {
emitter.log("[session] listening for events")
var idleCh <-chan time.Time
var idleTimer *time.Timer
resetIdle := func() {
if opts.IdleTimeout > 0 {
if idleTimer != nil {
idleTimer.Stop()
}
idleTimer = time.NewTimer(time.Duration(opts.IdleTimeout) * time.Millisecond)
idleCh = idleTimer.C
}
}
resetIdle()
for {
select {
case <-ctx.Done():
return goja.Undefined()
case <-listenDone:
return goja.Undefined()
case <-idleCh:
emitter.log(fmt.Sprintf("[session] idle timeout (%dms), closing", opts.IdleTimeout))
if opts.Remote != "" {
page.Close() //nolint:errcheck
} else {
browser.Close() //nolint:errcheck
}
return goja.Undefined()
case msg, ok := <-r.Incoming:
if !ok {
return goja.Undefined()
}
resetIdle()
fn, exists := handlers[msg.Event]
if !exists {
emitter.log(fmt.Sprintf("[session] no handler for %q, ignoring", msg.Event))
continue
}
if _, err := fn(goja.Undefined(), vm.ToValue(msg.Data)); err != nil {
panic(err)
}
}
}
})
// s.stream(selector, name) — non-blocking; returns {stop()} to end the stream.
// The caller (controller) watches StreamCh to start/stop cropped frame forwarding.
streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern
session.Set("stream", func(call goja.FunctionCall) goja.Value {
selector := vmArgStr(call.Argument(0))
name := vmArgStr(call.Argument(1))
if selector == "" || name == "" {
panic(vm.NewTypeError("stream: selector and name are required"))
}
maxFps := 0
quality := 0
if len(call.Arguments) > 2 {
if obj := call.Argument(2).ToObject(vm); obj != nil {
if v := obj.Get("maxFps"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
maxFps = int(v.ToInteger())
}
if v := obj.Get("quality"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
quality = int(v.ToInteger())
}
}
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] → stream %s as %q (maxFps=%d, quality=%d)", selector, name, maxFps, quality))
}
select {
case r.StreamCh <- StreamCmd{Op: "start", Selector: selector, Name: name, Page: page, MaxFps: maxFps, Quality: quality}:
default:
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] ✓ stream %s as %q started", selector, name))
}
stopObj := vm.NewObject()
stopped := false
stopObj.Set("stop", func(call goja.FunctionCall) goja.Value {
if !stopped {
stopped = true
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] → stream stop %q", name))
}
select {
case r.StreamCh <- StreamCmd{Op: "stop", Name: name}:
default:
}
if streamDebug {
emitter.log(fmt.Sprintf("[dbg] ✓ stream stop %q", name))
}
}
return goja.Undefined()
})
return stopObj
})
// s.capture() / s.capture({cookies,localStorage,sessionStorage})
// Grabs cookies via CDP + localStorage/sessionStorage via JS, emits a "capture" event,
// and also returns the data so scripts can inspect it directly.
session.Set("capture", func(call goja.FunctionCall) goja.Value {
// Options:
// domains []string - filter cookies to these domains via CDP (e.g. ["google.com"])
// cookieNames []string - only keep cookies with these names
// localStorage bool - include localStorage (default true when no domains given)
// sessionStorage bool - include sessionStorage (default true when no domains given)
var domains []string
var cookieNames []string
lsExplicit, ssExplicit := false, false
capLS := true
capSS := true
if len(call.Arguments) > 0 {
obj := call.Argument(0).ToObject(vm)
if obj != nil {
if v := obj.Get("domains"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if arr, ok := v.Export().([]interface{}); ok {
for _, d := range arr {
domains = append(domains, fmt.Sprintf("%v", d))
}
}
}
if v := obj.Get("cookieNames"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if arr, ok := v.Export().([]interface{}); ok {
for _, n := range arr {
cookieNames = append(cookieNames, fmt.Sprintf("%v", n))
}
}
}
if v := obj.Get("localStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
capLS = v.ToBoolean()
lsExplicit = true
}
if v := obj.Get("sessionStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
capSS = v.ToBoolean()
ssExplicit = true
}
}
}
// When domains are specified, skip storage by default unless the caller explicitly opted in.
if len(domains) > 0 {
if !lsExplicit {
capLS = false
}
if !ssExplicit {
capSS = false
}
}
result := map[string]interface{}{}
// Cookie capture: domain-scoped via network.GetCookies (CDP) or all cookies.
if len(domains) > 0 {
urls := make([]string, len(domains))
for i, d := range domains {
d = strings.TrimPrefix(d, ".")
if !strings.HasPrefix(d, "http") {
d = "https://" + d
}
urls[i] = d
}
res, err := proto.NetworkGetCookies{Urls: urls}.Call(page)
if err == nil {
cookies := res.Cookies
// Filter by name if requested.
if len(cookieNames) > 0 {
nameSet := make(map[string]bool, len(cookieNames))
for _, n := range cookieNames {
nameSet[n] = true
}
filtered := cookies[:0]
for _, c := range cookies {
if nameSet[c.Name] {
filtered = append(filtered, c)
}
}
cookies = filtered
}
result["cookies"] = cookies
} else {
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
}
} else {
// StorageGetCookies returns all browser cookies (all domains),
// equivalent to the CDP Storage.getCookies command.
res, err := proto.StorageGetCookies{}.Call(page)
if err == nil {
cookies := res.Cookies
// Filter by name if requested.
if len(cookieNames) > 0 {
nameSet := make(map[string]bool, len(cookieNames))
for _, n := range cookieNames {
nameSet[n] = true
}
filtered := cookies[:0]
for _, c := range cookies {
if nameSet[c.Name] {
filtered = append(filtered, c)
}
}
cookies = filtered
}
result["cookies"] = cookies
} else {
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
}
}
evalStorage := func(key string, storeName string) {
script := fmt.Sprintf(`() => (function(){try{var s=window[%q],o={};for(var i=0;i<s.length;i++){var k=s.key(i);o[k]=s.getItem(k);}return JSON.stringify(o);}catch(e){return "{}"}})()`, storeName)
res, err := page.Eval(script)
if err != nil {
emitter.log(fmt.Sprintf("[capture] %s: %s", key, err))
return
}
var data interface{}
if json.Unmarshal([]byte(res.Value.Str()), &data) == nil {
result[key] = data
}
}
if capLS {
evalStorage("localStorage", "localStorage")
}
if capSS {
evalStorage("sessionStorage", "sessionStorage")
}
emitter.capture(result)
return vm.ToValue(result)
})
return session
})
_, err := vm.RunString(r.Script)
if err != nil {
if ex, ok := err.(*goja.Exception); ok {
emitter.errorf(ex.String())
return fmt.Errorf("script error: %s", ex.String())
}
emitter.errorf(err.Error())
return err
}
emitter.done()
return nil
}