Merge remote-tracking branch 'origin/main' into wasm-websocket-dial

# Conflicts:
#	go.mod
#	go.sum
This commit is contained in:
Viktor Liu
2026-05-11 10:37:30 +02:00
409 changed files with 24726 additions and 10958 deletions

View File

@@ -5,6 +5,9 @@ package main
import (
"context"
"fmt"
"net"
"strconv"
"sync"
"syscall/js"
"time"
@@ -14,6 +17,7 @@ import (
netbird "github.com/netbirdio/netbird/client/embed"
sshdetection "github.com/netbirdio/netbird/client/ssh/detection"
nbstatus "github.com/netbirdio/netbird/client/status"
wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture"
"github.com/netbirdio/netbird/client/wasm/internal/http"
"github.com/netbirdio/netbird/client/wasm/internal/rdp"
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
@@ -83,6 +87,10 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) {
options.DeviceName = deviceName.String()
}
if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() {
options.DisableIPv6 = disableIPv6.Bool()
}
return options, nil
}
@@ -163,39 +171,58 @@ func createSSHMethod(client *netbird.Client) js.Func {
})
}
var jwtToken string
if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() {
jwtToken = args[3].String()
}
jwtToken, ipVersion := parseSSHOptions(args)
return createPromise(func(resolve, reject js.Value) {
sshClient := ssh.NewClient(client)
if err := sshClient.Connect(host, port, username, jwtToken); err != nil {
jsInterface, err := connectSSH(client, host, port, username, jwtToken, ipVersion)
if err != nil {
reject.Invoke(err.Error())
return
}
if err := sshClient.StartSession(80, 24); err != nil {
if closeErr := sshClient.Close(); closeErr != nil {
log.Errorf("Error closing SSH client: %v", closeErr)
}
reject.Invoke(err.Error())
return
}
jsInterface := ssh.CreateJSInterface(sshClient)
resolve.Invoke(jsInterface)
})
})
}
func performPing(client *netbird.Client, hostname string) {
func parseSSHOptions(args []js.Value) (jwtToken string, ipVersion int) {
if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() {
jwtToken = args[3].String()
}
if len(args) > 4 {
ipVersion = jsIPVersion(args[4])
}
return
}
func connectSSH(client *netbird.Client, host string, port int, username, jwtToken string, ipVersion int) (js.Value, error) {
sshClient := ssh.NewClient(client)
if err := sshClient.Connect(host, port, username, jwtToken, ipVersion); err != nil {
return js.Undefined(), err
}
if err := sshClient.StartSession(80, 24); err != nil {
if closeErr := sshClient.Close(); closeErr != nil {
log.Errorf("Error closing SSH client: %v", closeErr)
}
return js.Undefined(), err
}
return ssh.CreateJSInterface(sshClient), nil
}
func performPing(client *netbird.Client, hostname string, ipVersion int) {
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
defer cancel()
// Default to ping4 to avoid dual-stack ICMP endpoint issues in wireguard-go netstack.
network := "ping4"
if ipVersion == 6 {
network = "ping6"
}
start := time.Now()
conn, err := client.Dial(ctx, "ping", hostname)
conn, err := client.Dial(ctx, network, hostname)
if err != nil {
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err))
return
@@ -222,27 +249,39 @@ func performPing(client *netbird.Client, hostname string) {
}
latency := time.Since(start)
js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds()))
remote := conn.RemoteAddr().String()
msg := fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds())
if remote != hostname {
msg += fmt.Sprintf(" (via %s)", remote)
}
js.Global().Get("console").Call("log", msg)
}
func performPingTCP(client *netbird.Client, hostname string, port int) {
func performPingTCP(client *netbird.Client, hostname string, port, ipVersion int) {
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
defer cancel()
address := fmt.Sprintf("%s:%d", hostname, port)
network := ipVersionNetwork("tcp", ipVersion)
address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
start := time.Now()
conn, err := client.Dial(ctx, "tcp", address)
conn, err := client.Dial(ctx, network, address)
if err != nil {
js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s failed: %v", address, err))
return
}
latency := time.Since(start)
remote := conn.RemoteAddr().String()
if err := conn.Close(); err != nil {
log.Debugf("failed to close TCP connection: %v", err)
}
js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds()))
msg := fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds())
if remote != address {
msg += fmt.Sprintf(" (via %s)", remote)
}
js.Global().Get("console").Call("log", msg)
}
// createPingMethod creates the ping method
@@ -259,8 +298,12 @@ func createPingMethod(client *netbird.Client) js.Func {
}
hostname := args[0].String()
var ipVersion int
if len(args) > 1 {
ipVersion = jsIPVersion(args[1])
}
return createPromise(func(resolve, reject js.Value) {
performPing(client, hostname)
performPing(client, hostname, ipVersion)
resolve.Invoke(js.Undefined())
})
})
@@ -287,8 +330,12 @@ func createPingTCPMethod(client *netbird.Client) js.Func {
hostname := args[0].String()
port := args[1].Int()
var ipVersion int
if len(args) > 2 {
ipVersion = jsIPVersion(args[2])
}
return createPromise(func(resolve, reject js.Value) {
performPingTCP(client, hostname, port)
performPingTCP(client, hostname, port, ipVersion)
resolve.Invoke(js.Undefined())
})
})
@@ -461,6 +508,120 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func {
})
}
// ipVersionNetwork appends "4" or "6" to a base network string (e.g. "tcp" -> "tcp4").
func ipVersionNetwork(base string, ipVersion int) string {
switch ipVersion {
case 4:
return base + "4"
case 6:
return base + "6"
default:
return base
}
}
// jsIPVersion extracts an IP version (4 or 6) from a JS string or number.
func jsIPVersion(v js.Value) int {
switch v.Type() {
case js.TypeNumber:
return v.Int()
case js.TypeString:
n, _ := strconv.Atoi(v.String())
return n
default:
return 0
}
}
// createStartCaptureMethod creates the programmable packet capture method.
// Returns a JS interface with onpacket callback and stop() method.
//
// Usage from JavaScript:
//
// const cap = await client.startCapture({ filter: "tcp port 443", verbose: true })
// cap.onpacket = (line) => console.log(line)
// const stats = cap.stop()
func createStartCaptureMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(_ js.Value, args []js.Value) any {
var opts js.Value
if len(args) > 0 {
opts = args[0]
}
return createPromise(func(resolve, reject js.Value) {
iface, err := wasmcapture.Start(client, opts)
if err != nil {
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
return
}
resolve.Invoke(iface)
})
})
}
// captureMethods returns capture() and stopCapture() that share state for
// the console-log shortcut. capture() logs packets to the browser console
// and stopCapture() ends it, like Ctrl+C on the CLI.
//
// Usage from browser devtools console:
//
// await client.capture() // capture all packets
// await client.capture("tcp") // capture with filter
// await client.capture({filter: "host 10.0.0.1", verbose: true})
// client.stopCapture() // stop and print stats
func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
var mu sync.Mutex
var active *wasmcapture.Handle
startFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
var opts js.Value
if len(args) > 0 {
opts = args[0]
}
return createPromise(func(resolve, reject js.Value) {
mu.Lock()
defer mu.Unlock()
if active != nil {
active.Stop()
active = nil
}
h, err := wasmcapture.StartConsole(client, opts)
if err != nil {
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
return
}
active = h
console := js.Global().Get("console")
console.Call("log", "[capture] started, call client.stopCapture() to stop")
resolve.Invoke(js.Undefined())
})
})
stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
mu.Lock()
defer mu.Unlock()
if active == nil {
js.Global().Get("console").Call("log", "[capture] no active capture")
return js.Undefined()
}
stats := active.Stop()
active = nil
console := js.Global().Get("console")
console.Call("log", fmt.Sprintf("[capture] stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped))
return js.Undefined()
})
return startFn, stopFn
}
// createPromise is a helper to create JavaScript promises
func createPromise(handler func(resolve, reject js.Value)) js.Value {
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
@@ -524,6 +685,11 @@ func createClientObject(client *netbird.Client) js.Value {
obj["statusDetail"] = createStatusDetailMethod(client)
obj["getSyncResponse"] = createGetSyncResponseMethod(client)
obj["setLogLevel"] = createSetLogLevelMethod(client)
obj["startCapture"] = createStartCaptureMethod(client)
capStart, capStop := captureMethods(client)
obj["capture"] = capStart
obj["stopCapture"] = capStop
return js.ValueOf(obj)
}

View File

@@ -0,0 +1,176 @@
//go:build js
// Package capture bridges the util/capture package to JavaScript via syscall/js.
package capture
import (
"strings"
"sync"
"syscall/js"
netbird "github.com/netbirdio/netbird/client/embed"
)
// Handle holds a running capture session so it can be stopped later.
type Handle struct {
cs *netbird.CaptureSession
stopFn js.Func
stopped bool
}
// Stop ends the capture and returns stats.
func (h *Handle) Stop() netbird.CaptureStats {
if h.stopped {
return h.cs.Stats()
}
h.stopped = true
h.stopFn.Release()
h.cs.Stop()
return h.cs.Stats()
}
func statsToJS(s netbird.CaptureStats) 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
}
// 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)
cb := &jsCallbackWriter{}
cs, err := client.StartCapture(netbird.CaptureOptions{
TextOutput: cb,
Filter: filter,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return js.Undefined(), err
}
handle := &Handle{cs: cs}
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)
cb := &jsCallbackWriter{}
cs, err := client.StartCapture(netbird.CaptureOptions{
TextOutput: cb,
Filter: filter,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return nil, err
}
handle := &Handle{cs: cs}
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
}

View File

@@ -82,7 +82,7 @@ func NewRDCleanPathProxy(client interface {
// CreateProxy creates a new proxy endpoint for the given destination
func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
destination := fmt.Sprintf("%s:%s", hostname, port)
destination := net.JoinHostPort(hostname, port)
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, args []js.Value) any {
resolve := args[0]

View File

@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
@@ -45,9 +46,10 @@ func NewClient(nbClient *netbird.Client) *Client {
}
}
// Connect establishes an SSH connection through NetBird network
func (c *Client) Connect(host string, port int, username, jwtToken string) error {
addr := fmt.Sprintf("%s:%d", host, port)
// Connect establishes an SSH connection through NetBird network.
// ipVersion may be 4, 6, or 0 for automatic selection.
func (c *Client) Connect(host string, port int, username, jwtToken string, ipVersion int) error {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
logrus.Infof("SSH: Connecting to %s as %s", addr, username)
authMethods, err := c.getAuthMethods(jwtToken)
@@ -62,10 +64,18 @@ func (c *Client) Connect(host string, port int, username, jwtToken string) error
Timeout: sshDialTimeout,
}
network := "tcp"
switch ipVersion {
case 4:
network = "tcp4"
case 6:
network = "tcp6"
}
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
defer cancel()
conn, err := c.nbClient.Dial(ctx, "tcp", addr)
conn, err := c.nbClient.Dial(ctx, network, addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}