mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-29 07:49:14 +01:00
Detect dead VNC peers on both ends and report session stats
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
@@ -102,6 +103,20 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error {
|
|||||||
netbirdIP := e.wgInterface.Address().IP
|
netbirdIP := e.wgInterface.Address().IP
|
||||||
|
|
||||||
srv := vncserver.New(capturer, injector)
|
srv := vncserver.New(capturer, injector)
|
||||||
|
if e.clientMetrics != nil {
|
||||||
|
srv.SetSessionRecorder(func(t vncserver.SessionTick) {
|
||||||
|
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
|
||||||
|
Period: t.Period,
|
||||||
|
BytesOut: t.BytesOut,
|
||||||
|
Writes: t.Writes,
|
||||||
|
FBUs: t.FBUs,
|
||||||
|
MaxFBUBytes: t.MaxFBUBytes,
|
||||||
|
MaxFBURects: t.MaxFBURects,
|
||||||
|
MaxWriteBytes: t.MaxWriteBytes,
|
||||||
|
WriteNanos: t.WriteNanos,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
if vncNeedsServiceMode() {
|
if vncNeedsServiceMode() {
|
||||||
log.Info("VNC: running in Session 0, enabling service mode (agent proxy)")
|
log.Info("VNC: running in Session 0, enabling service mode (agent proxy)")
|
||||||
srv.SetServiceMode(true)
|
srv.SetServiceMode(true)
|
||||||
|
|||||||
@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
|||||||
m.trimLocked()
|
m.trimLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
|
||||||
|
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||||
|
agentInfo.DeploymentType.String(),
|
||||||
|
agentInfo.Version,
|
||||||
|
agentInfo.OS,
|
||||||
|
agentInfo.Arch,
|
||||||
|
agentInfo.peerID,
|
||||||
|
)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "netbird_vnc_traffic",
|
||||||
|
tags: tags,
|
||||||
|
fields: map[string]float64{
|
||||||
|
"period_seconds": tick.Period.Seconds(),
|
||||||
|
"bytes_out": float64(tick.BytesOut),
|
||||||
|
"writes": float64(tick.Writes),
|
||||||
|
"fbus": float64(tick.FBUs),
|
||||||
|
"max_fbu_bytes": float64(tick.MaxFBUBytes),
|
||||||
|
"max_fbu_rects": float64(tick.MaxFBURects),
|
||||||
|
"max_write_bytes": float64(tick.MaxWriteBytes),
|
||||||
|
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
|
||||||
|
},
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||||
result := "success"
|
result := "success"
|
||||||
if !success {
|
if !success {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ type metricsImplementation interface {
|
|||||||
// RecordLoginDuration records how long the login to management took
|
// RecordLoginDuration records how long the login to management took
|
||||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||||
|
|
||||||
|
// RecordVNCSessionTick records a periodic snapshot of one VNC
|
||||||
|
// session's wire activity. Called once per metricsConn tick interval
|
||||||
|
// (and once at session close), only when the tick saw activity.
|
||||||
|
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
|
||||||
|
|
||||||
// Export exports metrics in InfluxDB line protocol format
|
// Export exports metrics in InfluxDB line protocol format
|
||||||
Export(w io.Writer) error
|
Export(w io.Writer) error
|
||||||
|
|
||||||
@@ -78,6 +83,21 @@ type ClientMetrics struct {
|
|||||||
pushCancel context.CancelFunc
|
pushCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
|
||||||
|
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
|
||||||
|
// tick; Max* fields are the high-water marks observed during the tick.
|
||||||
|
// Period is the wall-clock duration the deltas cover.
|
||||||
|
type VNCSessionTick struct {
|
||||||
|
Period time.Duration
|
||||||
|
BytesOut uint64
|
||||||
|
Writes uint64
|
||||||
|
FBUs uint64
|
||||||
|
MaxFBUBytes uint64
|
||||||
|
MaxFBURects uint64
|
||||||
|
MaxWriteBytes uint64
|
||||||
|
WriteNanos uint64
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||||
type ConnectionStageTimestamps struct {
|
type ConnectionStageTimestamps struct {
|
||||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||||
@@ -127,6 +147,17 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
|||||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
|
||||||
|
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
agentInfo := c.agentInfo
|
||||||
|
c.mu.RUnlock()
|
||||||
|
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLoginDuration records how long the login to management server took
|
// RecordLoginDuration records how long the login to management server took
|
||||||
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.
|
|||||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockMetrics) Export(w io.Writer) error {
|
func (m *mockMetrics) Export(w io.Writer) error {
|
||||||
if m.exportData != "" {
|
if m.exportData != "" {
|
||||||
_, err := w.Write([]byte(m.exportData))
|
_, err := w.Write([]byte(m.exportData))
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//go:build !js && !ios && !android
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionTick is one sampling slice of a VNC session's wire activity.
|
||||||
|
// BytesOut / Writes / FBUs are deltas observed during this tick;
|
||||||
|
// Max* fields are the high-water marks observed during this tick (reset
|
||||||
|
// at the start of the next). Period is the wall-clock duration covered
|
||||||
|
// (typically sessionTickInterval, shorter for the final flush).
|
||||||
|
type SessionTick struct {
|
||||||
|
Period time.Duration
|
||||||
|
BytesOut uint64
|
||||||
|
Writes uint64
|
||||||
|
FBUs uint64
|
||||||
|
MaxFBUBytes uint64
|
||||||
|
MaxFBURects uint64
|
||||||
|
MaxWriteBytes uint64
|
||||||
|
WriteNanos uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionTickInterval is how often metricsConn emits a SessionTick. One
|
||||||
|
// second matches noVNC's request cadence so each tick covers roughly one
|
||||||
|
// FBU round-trip during steady-state activity.
|
||||||
|
const sessionTickInterval = time.Second
|
||||||
|
|
||||||
|
// metricsConn wraps a net.Conn and tracks per-session byte / write / FBU
|
||||||
|
// counters. Updates are atomic so the cost is a few atomic ops per Write
|
||||||
|
// (well under 100 ns), negligible against the syscall itself, so the wrap
|
||||||
|
// is always installed. A goroutine emits a SessionTick to the recorder
|
||||||
|
// every sessionTickInterval (only when the tick has activity to report);
|
||||||
|
// a final partial-tick flush runs on Close.
|
||||||
|
type metricsConn struct {
|
||||||
|
net.Conn
|
||||||
|
|
||||||
|
recorder func(SessionTick)
|
||||||
|
|
||||||
|
bytesOut uint64
|
||||||
|
writes uint64
|
||||||
|
writeNanos uint64
|
||||||
|
largestPkt uint64
|
||||||
|
fbus uint64
|
||||||
|
fbuBytes uint64
|
||||||
|
fbuRects uint64
|
||||||
|
maxFBUBytes uint64
|
||||||
|
maxFBURects uint64
|
||||||
|
|
||||||
|
tickMu sync.Mutex
|
||||||
|
tickStart time.Time
|
||||||
|
tickPrevB uint64
|
||||||
|
tickPrevW uint64
|
||||||
|
tickPrevF uint64
|
||||||
|
tickPrevNS uint64
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricsConn(c net.Conn, recorder func(SessionTick)) net.Conn {
|
||||||
|
m := &metricsConn{
|
||||||
|
Conn: c,
|
||||||
|
recorder: recorder,
|
||||||
|
tickStart: time.Now(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if recorder != nil {
|
||||||
|
go m.tickLoop()
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickLoop emits a SessionTick every sessionTickInterval until done.
|
||||||
|
// Empty ticks (no writes since the last tick) are skipped.
|
||||||
|
func (m *metricsConn) tickLoop() {
|
||||||
|
t := time.NewTicker(sessionTickInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.done:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
m.flushTick(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushTick computes deltas since the last tick, resets the per-tick max
|
||||||
|
// trackers, and emits a SessionTick to the recorder. final=true forces
|
||||||
|
// emission even if no writes happened (used at session close to record
|
||||||
|
// the trailing partial period).
|
||||||
|
func (m *metricsConn) flushTick(final bool) {
|
||||||
|
m.tickMu.Lock()
|
||||||
|
defer m.tickMu.Unlock()
|
||||||
|
|
||||||
|
b := atomic.LoadUint64(&m.bytesOut)
|
||||||
|
w := atomic.LoadUint64(&m.writes)
|
||||||
|
f := atomic.LoadUint64(&m.fbus)
|
||||||
|
ns := atomic.LoadUint64(&m.writeNanos)
|
||||||
|
|
||||||
|
db := b - m.tickPrevB
|
||||||
|
dw := w - m.tickPrevW
|
||||||
|
df := f - m.tickPrevF
|
||||||
|
dns := ns - m.tickPrevNS
|
||||||
|
m.tickPrevB, m.tickPrevW, m.tickPrevF, m.tickPrevNS = b, w, f, ns
|
||||||
|
|
||||||
|
maxFBU := atomic.SwapUint64(&m.maxFBUBytes, 0)
|
||||||
|
maxRects := atomic.SwapUint64(&m.maxFBURects, 0)
|
||||||
|
maxPkt := atomic.SwapUint64(&m.largestPkt, 0)
|
||||||
|
|
||||||
|
period := time.Since(m.tickStart)
|
||||||
|
m.tickStart = time.Now()
|
||||||
|
|
||||||
|
if dw == 0 && !final {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.recorder(SessionTick{
|
||||||
|
Period: period,
|
||||||
|
BytesOut: db,
|
||||||
|
Writes: dw,
|
||||||
|
FBUs: df,
|
||||||
|
MaxFBUBytes: maxFBU,
|
||||||
|
MaxFBURects: maxRects,
|
||||||
|
MaxWriteBytes: maxPkt,
|
||||||
|
WriteNanos: dns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFBUHeader reports whether the given Write payload is the 4-byte
|
||||||
|
// FramebufferUpdate header (message type 0, padding 0, rect-count high
|
||||||
|
// byte). Rect bodies are written separately by sendDirtyAndMoves, so the
|
||||||
|
// FBU/rect boundary lines up with Write boundaries.
|
||||||
|
func isFBUHeader(p []byte) bool {
|
||||||
|
return len(p) == 4 && p[0] == serverFramebufferUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *metricsConn) Write(p []byte) (int, error) {
|
||||||
|
if isFBUHeader(p) {
|
||||||
|
if b := atomic.SwapUint64(&m.fbuBytes, 0); b > 0 {
|
||||||
|
if b > atomic.LoadUint64(&m.maxFBUBytes) {
|
||||||
|
atomic.StoreUint64(&m.maxFBUBytes, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := atomic.SwapUint64(&m.fbuRects, 0); r > 0 {
|
||||||
|
if r > atomic.LoadUint64(&m.maxFBURects) {
|
||||||
|
atomic.StoreUint64(&m.maxFBURects, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic.AddUint64(&m.fbus, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
n, err := m.Conn.Write(p)
|
||||||
|
atomic.AddUint64(&m.writeNanos, uint64(time.Since(t0).Nanoseconds()))
|
||||||
|
atomic.AddUint64(&m.bytesOut, uint64(n))
|
||||||
|
atomic.AddUint64(&m.writes, 1)
|
||||||
|
if !isFBUHeader(p) {
|
||||||
|
atomic.AddUint64(&m.fbuBytes, uint64(n))
|
||||||
|
atomic.AddUint64(&m.fbuRects, 1)
|
||||||
|
}
|
||||||
|
if uint64(n) > atomic.LoadUint64(&m.largestPkt) {
|
||||||
|
atomic.StoreUint64(&m.largestPkt, uint64(n))
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *metricsConn) Close() error {
|
||||||
|
m.closeOnce.Do(func() {
|
||||||
|
close(m.done)
|
||||||
|
if m.recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b := atomic.SwapUint64(&m.fbuBytes, 0); b > atomic.LoadUint64(&m.maxFBUBytes) {
|
||||||
|
atomic.StoreUint64(&m.maxFBUBytes, b)
|
||||||
|
}
|
||||||
|
if r := atomic.SwapUint64(&m.fbuRects, 0); r > atomic.LoadUint64(&m.maxFBURects) {
|
||||||
|
atomic.StoreUint64(&m.maxFBURects, r)
|
||||||
|
}
|
||||||
|
m.flushTick(true)
|
||||||
|
})
|
||||||
|
return m.Conn.Close()
|
||||||
|
}
|
||||||
@@ -156,9 +156,15 @@ type Server struct {
|
|||||||
netstackNet *netstack.Net
|
netstackNet *netstack.Net
|
||||||
agentToken []byte // raw token bytes for agent-mode auth
|
agentToken []byte // raw token bytes for agent-mode auth
|
||||||
|
|
||||||
sessionsMu sync.Mutex
|
sessionsMu sync.Mutex
|
||||||
sessionSeq uint64
|
sessionSeq uint64
|
||||||
sessions map[uint64]ActiveSessionInfo
|
sessions map[uint64]ActiveSessionInfo
|
||||||
|
sessionConns map[uint64]net.Conn
|
||||||
|
|
||||||
|
// sessionRecorder, when non-nil, receives a SessionTick periodically
|
||||||
|
// during each VNC session and on session close. The engine wires
|
||||||
|
// this to its metrics framework.
|
||||||
|
sessionRecorder func(SessionTick)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveSessionInfo describes a currently connected VNC client.
|
// ActiveSessionInfo describes a currently connected VNC client.
|
||||||
@@ -195,7 +201,8 @@ func New(capturer ScreenCapturer, injector InputInjector) *Server {
|
|||||||
injector: injector,
|
injector: injector,
|
||||||
authorizer: sshauth.NewAuthorizer(),
|
authorizer: sshauth.NewAuthorizer(),
|
||||||
log: log.WithField("component", "vnc-server"),
|
log: log.WithField("component", "vnc-server"),
|
||||||
sessions: make(map[uint64]ActiveSessionInfo),
|
sessions: make(map[uint64]ActiveSessionInfo),
|
||||||
|
sessionConns: make(map[uint64]net.Conn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +217,13 @@ func (s *Server) ActiveSessions() []ActiveSessionInfo {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) addSession(info ActiveSessionInfo) uint64 {
|
func (s *Server) addSession(info ActiveSessionInfo, conn net.Conn) uint64 {
|
||||||
s.sessionsMu.Lock()
|
s.sessionsMu.Lock()
|
||||||
defer s.sessionsMu.Unlock()
|
defer s.sessionsMu.Unlock()
|
||||||
s.sessionSeq++
|
s.sessionSeq++
|
||||||
id := s.sessionSeq
|
id := s.sessionSeq
|
||||||
s.sessions[id] = info
|
s.sessions[id] = info
|
||||||
|
s.sessionConns[id] = conn
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +231,24 @@ func (s *Server) removeSession(id uint64) {
|
|||||||
s.sessionsMu.Lock()
|
s.sessionsMu.Lock()
|
||||||
defer s.sessionsMu.Unlock()
|
defer s.sessionsMu.Unlock()
|
||||||
delete(s.sessions, id)
|
delete(s.sessions, id)
|
||||||
|
delete(s.sessionConns, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeActiveSessions closes every active session's connection so the
|
||||||
|
// per-session serve goroutines unblock from their Read loops and exit.
|
||||||
|
// Called from Stop to make sure clients see an immediate disconnect when
|
||||||
|
// the server is brought down, instead of waiting for the OS to reclaim
|
||||||
|
// the sockets after process exit.
|
||||||
|
func (s *Server) closeActiveSessions() {
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
conns := make([]net.Conn, 0, len(s.sessionConns))
|
||||||
|
for _, c := range s.sessionConns {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
for _, c := range conns {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetServiceMode enables proxy-to-agent mode for Windows service operation.
|
// SetServiceMode enables proxy-to-agent mode for Windows service operation.
|
||||||
@@ -230,6 +256,14 @@ func (s *Server) SetServiceMode(enabled bool) {
|
|||||||
s.serviceMode = enabled
|
s.serviceMode = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionRecorder installs a callback that receives a SessionTick
|
||||||
|
// each sessionTickInterval during a VNC session and one final tick on
|
||||||
|
// session close. Pass nil to disable. Empty ticks (no wire activity)
|
||||||
|
// are skipped.
|
||||||
|
func (s *Server) SetSessionRecorder(recorder func(SessionTick)) {
|
||||||
|
s.sessionRecorder = recorder
|
||||||
|
}
|
||||||
|
|
||||||
// SetJWTConfig configures JWT authentication for VNC connections.
|
// SetJWTConfig configures JWT authentication for VNC connections.
|
||||||
// Pass nil to disable JWT (public mode).
|
// Pass nil to disable JWT (public mode).
|
||||||
func (s *Server) SetJWTConfig(config *JWTConfig) {
|
func (s *Server) SetJWTConfig(config *JWTConfig) {
|
||||||
@@ -340,6 +374,13 @@ func (s *Server) Stop() error {
|
|||||||
s.cancel = nil
|
s.cancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close active client connections before tearing down capturers and
|
||||||
|
// listeners. The per-session serve goroutines unblock from their Read
|
||||||
|
// loop with an error and run their deferred conn.Close, which surfaces
|
||||||
|
// a clean disconnect on the client side instead of leaving the
|
||||||
|
// connection hanging until the OS reclaims it on process exit.
|
||||||
|
s.closeActiveSessions()
|
||||||
|
|
||||||
if s.vmgr != nil {
|
if s.vmgr != nil {
|
||||||
s.vmgr.StopAll()
|
s.vmgr.StopAll()
|
||||||
}
|
}
|
||||||
@@ -378,10 +419,36 @@ func (s *Server) acceptLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableTCPKeepAlive(conn, s.log)
|
||||||
go s.handleConnection(conn)
|
go s.handleConnection(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vncKeepAlivePeriod controls how often TCP layer probes are sent on an
|
||||||
|
// idle connection. Default OS settings (2 hours) are too long for an
|
||||||
|
// interactive session: when the server-side host dies without sending FIN
|
||||||
|
// (power loss, network partition, hung kernel), the client only learns of
|
||||||
|
// the dead connection when the OS gives up on a probe. 30 s here means
|
||||||
|
// most clients notice within ~3 minutes worst case.
|
||||||
|
const vncKeepAlivePeriod = 30 * time.Second
|
||||||
|
|
||||||
|
// enableTCPKeepAlive turns on SO_KEEPALIVE on the underlying TCP socket.
|
||||||
|
// Non-TCP conns (e.g. the netstack-backed listener) are skipped silently;
|
||||||
|
// keepalive there is the netstack's concern.
|
||||||
|
func enableTCPKeepAlive(c net.Conn, log *log.Entry) {
|
||||||
|
tc, ok := c.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tc.SetKeepAlive(true); err != nil {
|
||||||
|
log.Debugf("set keepalive: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tc.SetKeepAlivePeriod(vncKeepAlivePeriod); err != nil {
|
||||||
|
log.Debugf("set keepalive period: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) validateCapturer(capturer ScreenCapturer) error {
|
func (s *Server) validateCapturer(capturer ScreenCapturer) error {
|
||||||
// Quick check first: if already ready, return immediately.
|
// Quick check first: if already ready, return immediately.
|
||||||
if capturer.Width() > 0 && capturer.Height() > 0 {
|
if capturer.Width() > 0 && capturer.Height() > 0 {
|
||||||
@@ -472,7 +539,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
Mode: modeString(header.mode),
|
Mode: modeString(header.mode),
|
||||||
Username: header.username,
|
Username: header.username,
|
||||||
JWTUsername: jwtUserID,
|
JWTUsername: jwtUserID,
|
||||||
})
|
}, conn)
|
||||||
defer s.removeSession(sessionID)
|
defer s.removeSession(sessionID)
|
||||||
|
|
||||||
if err := s.validateCapturer(capturer); err != nil {
|
if err := s.validateCapturer(capturer); err != nil {
|
||||||
@@ -481,6 +548,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn = newMetricsConn(conn, s.sessionRecorder)
|
||||||
sess := &session{
|
sess := &session{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
capturer: capturer,
|
capturer: capturer,
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ func (s *Server) serviceAcceptLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableTCPKeepAlive(conn, s.log)
|
||||||
|
conn = newMetricsConn(conn, s.sessionRecorder)
|
||||||
go s.handleServiceConnection(conn, sm)
|
go s.handleServiceConnection(conn, sm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -691,10 +691,10 @@ func createStartCaptureMethod(client *netbird.Client) js.Func {
|
|||||||
//
|
//
|
||||||
// Usage from browser devtools console:
|
// Usage from browser devtools console:
|
||||||
//
|
//
|
||||||
// await client.capture() // capture all packets
|
// await netbird.capture() // capture all packets
|
||||||
// await client.capture("tcp") // capture with filter
|
// await netbird.capture("tcp") // capture with filter
|
||||||
// await client.capture({filter: "host 10.0.0.1", verbose: true})
|
// await netbird.capture({filter: "host 10.0.0.1", verbose: true})
|
||||||
// client.stopCapture() // stop and print stats
|
// netbird.stopCapture() // stop and print stats
|
||||||
func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
|
func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var active *wasmcapture.Handle
|
var active *wasmcapture.Handle
|
||||||
@@ -722,7 +722,7 @@ func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
|
|||||||
active = h
|
active = h
|
||||||
|
|
||||||
console := js.Global().Get("console")
|
console := js.Global().Get("console")
|
||||||
console.Call("log", "[capture] started, call client.stopCapture() to stop")
|
console.Call("log", "[capture] started, call netbird.stopCapture() to stop")
|
||||||
resolve.Invoke(js.Undefined())
|
resolve.Invoke(js.Undefined())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package vnc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -23,6 +24,15 @@ const (
|
|||||||
// Connection modes matching server/server.go constants.
|
// Connection modes matching server/server.go constants.
|
||||||
modeAttach byte = 0
|
modeAttach byte = 0
|
||||||
modeSession byte = 1
|
modeSession byte = 1
|
||||||
|
|
||||||
|
// WebSocket close codes the dashboard branches on. Codes 1000-1015
|
||||||
|
// are reserved by RFC 6455; 4000-4999 are application-defined.
|
||||||
|
wsCodeNormal = 1000
|
||||||
|
wsCodeAbnormal = 1006
|
||||||
|
wsCodeDialTimeout = 4001
|
||||||
|
wsCodeDialFailure = 4002
|
||||||
|
wsCodeSessionSetup = 4003
|
||||||
|
wsCodeTransport = 4004
|
||||||
)
|
)
|
||||||
|
|
||||||
// VNCProxy bridges WebSocket connections from noVNC in the browser
|
// VNCProxy bridges WebSocket connections from noVNC in the browser
|
||||||
@@ -245,8 +255,12 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("VNC connect to %s: %v", conn.destination.address, err)
|
log.Errorf("VNC connect to %s: %v", conn.destination.address, err)
|
||||||
// Close the WebSocket so noVNC fires a disconnect event.
|
// Close the WebSocket so noVNC fires a disconnect event.
|
||||||
|
code := wsCodeDialFailure
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
code = wsCodeDialTimeout
|
||||||
|
}
|
||||||
if conn.wsHandlers.Get("close").Truthy() {
|
if conn.wsHandlers.Get("close").Truthy() {
|
||||||
conn.wsHandlers.Call("close", 1006, fmt.Sprintf("connect to peer: %v", err))
|
conn.wsHandlers.Call("close", code, fmt.Sprintf("connect to peer: %v", err))
|
||||||
}
|
}
|
||||||
p.cleanupConnection(conn)
|
p.cleanupConnection(conn)
|
||||||
return
|
return
|
||||||
@@ -259,7 +273,7 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) {
|
|||||||
if err := p.sendSessionHeader(vncConn, conn.destination); err != nil {
|
if err := p.sendSessionHeader(vncConn, conn.destination); err != nil {
|
||||||
log.Errorf("send VNC session header: %v", err)
|
log.Errorf("send VNC session header: %v", err)
|
||||||
if conn.wsHandlers.Get("close").Truthy() {
|
if conn.wsHandlers.Get("close").Truthy() {
|
||||||
conn.wsHandlers.Call("close", 1006, fmt.Sprintf("send session header: %v", err))
|
conn.wsHandlers.Call("close", wsCodeSessionSetup, fmt.Sprintf("send session header: %v", err))
|
||||||
}
|
}
|
||||||
p.cleanupConnection(conn)
|
p.cleanupConnection(conn)
|
||||||
return
|
return
|
||||||
@@ -359,24 +373,23 @@ func (c *vncConnection) snapshotVNC() (net.Conn, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleConnReadError classifies an error from the VNC read loop. Returns
|
// handleConnReadError classifies an error from the VNC read loop. Returns
|
||||||
// true if the caller should exit; false to retry (transient timeout).
|
// true if the caller should exit and trigger the cleanup path. A read
|
||||||
|
// timeout counts as a fatal error: in a healthy session the server emits
|
||||||
|
// empty FramebufferUpdate responses several times per second, so a full
|
||||||
|
// idleReadDeadline of silence means the peer is dead (process gone,
|
||||||
|
// machine off, network partition) and the in-browser TCP stack will
|
||||||
|
// never surface that on its own.
|
||||||
func (p *VNCProxy) handleConnReadError(conn *vncConnection, err error) bool {
|
func (p *VNCProxy) handleConnReadError(conn *vncConnection, err error) bool {
|
||||||
if conn.ctx.Err() != nil {
|
if conn.ctx.Err() != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||||
// Read timeout: connection might be stale. The next iteration will
|
log.Debugf("VNC read deadline expired; treating peer as dead")
|
||||||
// fail too and trigger the close path.
|
} else if err != io.EOF {
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err != io.EOF {
|
|
||||||
log.Debugf("read from VNC connection: %v", err)
|
log.Debugf("read from VNC connection: %v", err)
|
||||||
}
|
}
|
||||||
// Close the WebSocket to notify noVNC, and cancel the local context so
|
|
||||||
// cleanupConnection isn't left waiting on the JS close callback that
|
|
||||||
// may never fire on hard errors.
|
|
||||||
if conn.wsHandlers.Get("close").Truthy() {
|
if conn.wsHandlers.Get("close").Truthy() {
|
||||||
conn.wsHandlers.Call("close", 1006, "VNC connection lost")
|
conn.wsHandlers.Call("close", wsCodeTransport, "VNC connection lost")
|
||||||
}
|
}
|
||||||
conn.cancel()
|
conn.cancel()
|
||||||
return true
|
return true
|
||||||
|
|||||||
Reference in New Issue
Block a user