Files
netbird/client/wasm/internal/capture/capture.go
2026-04-15 19:19:09 +02:00

212 lines
4.8 KiB
Go

//go:build js
// Package capture bridges the util/capture package to JavaScript via syscall/js.
package capture
import (
"strings"
"sync"
"syscall/js"
log "github.com/sirupsen/logrus"
netbird "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/util/capture"
)
// Handle holds a running capture session and the embedded client reference
// so it can be stopped later.
type Handle struct {
client *netbird.Client
sess *capture.Session
stopFn js.Func
stopped bool
}
// Stop ends the capture and returns stats.
func (h *Handle) Stop() capture.Stats {
if h.stopped {
return h.sess.Stats()
}
h.stopped = true
h.stopFn.Release()
if err := h.client.SetCapture(nil); err != nil {
log.Debugf("clear capture: %v", err)
}
h.sess.Stop()
return h.sess.Stats()
}
func statsToJS(s capture.Stats) js.Value {
obj := js.Global().Get("Object").Call("create", js.Null())
obj.Set("packets", js.ValueOf(s.Packets))
obj.Set("bytes", js.ValueOf(s.Bytes))
obj.Set("dropped", js.ValueOf(s.Dropped))
return obj
}
// parseOpts extracts filter/verbose/ascii from a JS options value.
func parseOpts(jsOpts js.Value) (filter string, verbose, ascii bool) {
if jsOpts.IsNull() || jsOpts.IsUndefined() {
return
}
if jsOpts.Type() == js.TypeString {
filter = jsOpts.String()
return
}
if jsOpts.Type() != js.TypeObject {
return
}
if f := jsOpts.Get("filter"); !f.IsUndefined() && !f.IsNull() {
filter = f.String()
}
if v := jsOpts.Get("verbose"); !v.IsUndefined() {
verbose = v.Truthy()
}
if a := jsOpts.Get("ascii"); !a.IsUndefined() {
ascii = a.Truthy()
}
return
}
func buildMatcher(filter string) (capture.Matcher, error) {
if filter == "" {
return nil, nil
}
return capture.ParseFilter(filter)
}
// Start creates a capture session and returns a JS interface for streaming text
// output. The returned object exposes:
//
// onpacket(callback) - set callback(string) for each text line
// stop() - stop capture and return stats { packets, bytes, dropped }
//
// Options: { filter: string, verbose: bool, ascii: bool } or just a filter string.
func Start(client *netbird.Client, jsOpts js.Value) (js.Value, error) {
filter, verbose, ascii := parseOpts(jsOpts)
matcher, err := buildMatcher(filter)
if err != nil {
return js.Undefined(), err
}
cb := &jsCallbackWriter{}
sess, err := capture.NewSession(capture.Options{
TextOutput: cb,
Matcher: matcher,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return js.Undefined(), err
}
if err := client.SetCapture(sess); err != nil {
sess.Stop()
return js.Undefined(), err
}
handle := &Handle{client: client, sess: sess}
iface := js.Global().Get("Object").Call("create", js.Null())
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return statsToJS(handle.Stop())
})
iface.Set("stop", handle.stopFn)
iface.Set("onpacket", js.Undefined())
cb.setInterface(iface)
return iface, nil
}
// StartConsole starts a capture that logs every packet line to console.log.
// Returns a Handle so the caller can stop it later.
func StartConsole(client *netbird.Client, jsOpts js.Value) (*Handle, error) {
filter, verbose, ascii := parseOpts(jsOpts)
matcher, err := buildMatcher(filter)
if err != nil {
return nil, err
}
cb := &jsCallbackWriter{}
sess, err := capture.NewSession(capture.Options{
TextOutput: cb,
Matcher: matcher,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return nil, err
}
if err := client.SetCapture(sess); err != nil {
sess.Stop()
return nil, err
}
handle := &Handle{client: client, sess: sess}
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return statsToJS(handle.Stop())
})
iface := js.Global().Get("Object").Call("create", js.Null())
console := js.Global().Get("console")
iface.Set("onpacket", console.Get("log").Call("bind", console, js.ValueOf("[capture]")))
cb.setInterface(iface)
return handle, nil
}
// jsCallbackWriter is an io.Writer that buffers text until a newline, then
// invokes the JS onpacket callback with each complete line.
type jsCallbackWriter struct {
mu sync.Mutex
iface js.Value
buf strings.Builder
}
func (w *jsCallbackWriter) setInterface(iface js.Value) {
w.mu.Lock()
defer w.mu.Unlock()
w.iface = iface
}
func (w *jsCallbackWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
var lines []string
for {
str := w.buf.String()
idx := strings.IndexByte(str, '\n')
if idx < 0 {
break
}
lines = append(lines, str[:idx])
w.buf.Reset()
if idx+1 < len(str) {
w.buf.WriteString(str[idx+1:])
}
}
iface := w.iface
w.mu.Unlock()
if iface.IsUndefined() {
return len(p), nil
}
cb := iface.Get("onpacket")
if cb.IsUndefined() || cb.IsNull() {
return len(p), nil
}
for _, line := range lines {
cb.Invoke(js.ValueOf(line))
}
return len(p), nil
}