mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 05:39:07 +02:00
Replace VNC JWT auth with a Noise_IK handshake bound to ACL-pushed pubkeys
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
// noiseTestServer starts a VNC server with a freshly generated identity
|
||||
// key and returns the listener address, the server, and the server's
|
||||
// static public key for client-side handshake setup.
|
||||
func noiseTestServer(t *testing.T) (net.Addr, *Server, []byte) {
|
||||
t.Helper()
|
||||
|
||||
kp, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, kp.Private)
|
||||
srv.SetDisableAuth(false)
|
||||
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
network := netip.MustParsePrefix("127.0.0.0/8")
|
||||
require.NoError(t, srv.Start(t.Context(), addr, network))
|
||||
srv.localAddr = netip.MustParseAddr("10.99.99.1")
|
||||
t.Cleanup(func() { _ = srv.Stop() })
|
||||
|
||||
return srv.listener.Addr(), srv, kp.Public
|
||||
}
|
||||
|
||||
// registerSessionKey enrolls a fresh X25519 keypair under the given user
|
||||
// ID into the server's authorizer with the requested OS-user wildcard
|
||||
// mapping. Returns the keypair so the test can drive the handshake.
|
||||
func registerSessionKey(t *testing.T, srv *Server, userID string) noise.DHKey {
|
||||
t.Helper()
|
||||
|
||||
kp, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
userHash, err := sshuserhash.HashUserID(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
|
||||
MachineUsers: map[string][]uint32{sshauth.Wildcard: {0}},
|
||||
SessionPubKeys: []sshauth.SessionPubKey{
|
||||
{PubKey: kp.Public, UserIDHash: userHash},
|
||||
},
|
||||
})
|
||||
return kp
|
||||
}
|
||||
|
||||
// writeHeaderPrefix writes the mode + zero-length-username prefix that
|
||||
// precedes the optional Noise handshake in the NetBird VNC header.
|
||||
func writeHeaderPrefix(t *testing.T, conn net.Conn, mode byte) {
|
||||
t.Helper()
|
||||
prefix := []byte{mode, 0, 0}
|
||||
_, err := conn.Write(prefix)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// writeHeaderTail writes the sessionID/width/height fields that follow
|
||||
// either the Noise msg2 (auth path) or the prefix alone (no-auth path).
|
||||
func writeHeaderTail(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
tail := make([]byte, 8)
|
||||
_, err := conn.Write(tail)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// performInitiator drives the initiator side of Noise_IK against the
|
||||
// server's identity public key, returns the resulting state. The Noise
|
||||
// msg2 produced by the server is read and consumed.
|
||||
func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) {
|
||||
t.Helper()
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: serverPub,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, noiseInitiatorMsgLen, len(msg1))
|
||||
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
msg2 := make([]byte, noiseResponderMsgLen)
|
||||
_, err = io.ReadFull(conn, msg2)
|
||||
require.NoError(t, err)
|
||||
_, _, _, err = state.ReadMessage(nil, msg2)
|
||||
require.NoError(t, err, "server responder message must decrypt with the correct peer static")
|
||||
}
|
||||
|
||||
// readRFBFailure consumes the RFB version exchange and returns the
|
||||
// security-failure reason string. Fails the test if the server did not
|
||||
// send a failure (i.e. produced a non-zero security-types list).
|
||||
func readRFBFailure(t *testing.T, conn net.Conn) string {
|
||||
t.Helper()
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
var ver [12]byte
|
||||
_, err := io.ReadFull(conn, ver[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "RFB 003.008\n", string(ver[:]))
|
||||
|
||||
_, err = conn.Write(ver[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var n [1]byte
|
||||
_, err = io.ReadFull(conn, n[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byte(0), n[0], "expected security-failure (0 types)")
|
||||
|
||||
var rl [4]byte
|
||||
_, err = io.ReadFull(conn, rl[:])
|
||||
require.NoError(t, err)
|
||||
reason := make([]byte, binary.BigEndian.Uint32(rl[:]))
|
||||
_, err = io.ReadFull(conn, reason)
|
||||
require.NoError(t, err)
|
||||
return string(reason)
|
||||
}
|
||||
|
||||
// readRFBGreetingNoFailure asserts the server proceeded past auth: it
|
||||
// must offer at least one security type rather than a 0 failure.
|
||||
func readRFBGreetingNoFailure(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
var ver [12]byte
|
||||
_, err := io.ReadFull(conn, ver[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "RFB 003.008\n", string(ver[:]))
|
||||
|
||||
_, err = conn.Write(ver[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var n [1]byte
|
||||
_, err = io.ReadFull(conn, n[:])
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, byte(0), n[0], "server must offer security types after a valid handshake")
|
||||
}
|
||||
|
||||
// TestNoise_RegisteredKey_AccessGranted exercises the happy path: a
|
||||
// session key enrolled in the authorizer completes a Noise_IK handshake
|
||||
// and the server proceeds to the RFB greeting.
|
||||
func TestNoise_RegisteredKey_AccessGranted(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
performInitiator(t, conn, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
readRFBGreetingNoFailure(t, conn)
|
||||
}
|
||||
|
||||
// TestNoise_UnregisteredClientStatic_Rejected proves the authorizer is
|
||||
// consulted: a syntactically-valid handshake from a key the server has
|
||||
// never been told about must be rejected fail-closed.
|
||||
func TestNoise_UnregisteredClientStatic_Rejected(t *testing.T) {
|
||||
addr, _, serverPub := noiseTestServer(t)
|
||||
// Auth is enabled but the authorizer was not updated, so the lookup
|
||||
// path returns ErrSessionKeyNotKnown.
|
||||
attackerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
performInitiator(t, conn, attackerKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "session pubkey not registered")
|
||||
}
|
||||
|
||||
// TestNoise_WrongServerStatic_HandshakeFails proves the server's
|
||||
// identity is bound into the handshake: an initiator using the wrong
|
||||
// peer static encrypts msg1 under keys the real server can't derive, so
|
||||
// the server fails the handshake and closes without RFB output.
|
||||
func TestNoise_WrongServerStatic_HandshakeFails(t *testing.T) {
|
||||
addr, srv, _ := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
bogusServerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: bogusServerKey.Public,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "server must close without RFB greeting when msg1 is sealed for a different server identity")
|
||||
}
|
||||
|
||||
// TestNoise_MalformedMsg1_ClosesConnection covers the case where the
|
||||
// magic prefix is correct but the following 96 bytes are random: the
|
||||
// noise library fails ReadMessage and the server closes silently.
|
||||
func TestNoise_MalformedMsg1_ClosesConnection(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
junk := make([]byte, noiseInitiatorMsgLen)
|
||||
for i := range junk {
|
||||
junk[i] = byte(i)
|
||||
}
|
||||
_, err = conn.Write(append([]byte("NBV3"), junk...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "garbage msg1 must terminate the connection before any RFB output")
|
||||
}
|
||||
|
||||
// TestNoise_TruncatedMsg1_ClosesConnection sends fewer than the 96
|
||||
// bytes a Noise_IK msg1 must contain. The server's io.ReadFull short-
|
||||
// reads and closes; no RFB greeting must leak.
|
||||
func TestNoise_TruncatedMsg1_ClosesConnection(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
_, err = conn.Write([]byte("NBV3"))
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(make([]byte, 8))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
// Re-dial just to confirm the listener is alive (the previous
|
||||
// connection terminated server-side without affecting the listener).
|
||||
probe, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, probe.Close())
|
||||
}
|
||||
|
||||
// TestNoise_AuthEnabled_NoHandshake_Rejected proves that with auth on,
|
||||
// a connection that skips the Noise prefix (older client / VNC client)
|
||||
// is rejected with AUTH_FORBIDDEN: identity proof missing.
|
||||
func TestNoise_AuthEnabled_NoHandshake_Rejected(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "identity proof missing")
|
||||
}
|
||||
|
||||
// TestNoise_RevokedKey_RejectedAfterAuthUpdate verifies the authorizer
|
||||
// honors revocations: a key that worked before a UpdateVNCAuth call
|
||||
// must stop working as soon as the new config omits it.
|
||||
func TestNoise_RevokedKey_RejectedAfterAuthUpdate(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
// First connection succeeds.
|
||||
conn1, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn1.Close()
|
||||
writeHeaderPrefix(t, conn1, ModeAttach)
|
||||
performInitiator(t, conn1, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn1)
|
||||
readRFBGreetingNoFailure(t, conn1)
|
||||
|
||||
// Revoke by pushing a fresh config that drops the pubkey entry.
|
||||
srv.UpdateVNCAuth(&sshauth.Config{})
|
||||
|
||||
// Same client, same Noise key, should now be denied.
|
||||
conn2, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn2.Close()
|
||||
writeHeaderPrefix(t, conn2, ModeAttach)
|
||||
performInitiator(t, conn2, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn2)
|
||||
|
||||
reason := readRFBFailure(t, conn2)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "session pubkey not registered")
|
||||
}
|
||||
|
||||
// TestNoise_NoIdentityKey_FailsClosed ensures a server constructed
|
||||
// without a static private key still rejects authenticated connections
|
||||
// fail-closed; it must not silently accept the client.
|
||||
func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(false)
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
network := netip.MustParsePrefix("127.0.0.0/8")
|
||||
require.NoError(t, srv.Start(t.Context(), addr, network))
|
||||
srv.localAddr = netip.MustParseAddr("10.99.99.1")
|
||||
t.Cleanup(func() { _ = srv.Stop() })
|
||||
|
||||
clientKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
fakeServerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", srv.listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: fakeServerKey.Public,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "server without identity key must not write the RFB greeting")
|
||||
}
|
||||
|
||||
// TestNoise_DerivedIdentityPublicMatchesPrivate sanity-checks the
|
||||
// derivation done in New(): the identityPublic must be Curve25519.
|
||||
// Basepoint multiplied with identityKey.
|
||||
func TestNoise_DerivedIdentityPublicMatchesPrivate(t *testing.T) {
|
||||
priv := make([]byte, 32)
|
||||
for i := range priv {
|
||||
priv[i] = byte(i + 1)
|
||||
}
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, priv)
|
||||
|
||||
expected, err := curve25519.X25519(priv, curve25519.Basepoint)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, srv.identityPublic)
|
||||
}
|
||||
|
||||
// TestNoise_SessionMode_OSUserCheckRunsAfterHandshake verifies that a
|
||||
// successful Noise handshake doesn't bypass OS-user authorization: an
|
||||
// authenticated key whose user index isn't mapped to the requested OS
|
||||
// user must be rejected.
|
||||
func TestNoise_SessionMode_OSUserCheckRunsAfterHandshake(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
|
||||
clientKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
userHash, err := sshuserhash.HashUserID("alice@example")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Map Alice only to "alice" OS user, not the wildcard.
|
||||
srv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
|
||||
MachineUsers: map[string][]uint32{"alice": {0}},
|
||||
SessionPubKeys: []sshauth.SessionPubKey{
|
||||
{PubKey: clientKey.Public, UserIDHash: userHash},
|
||||
},
|
||||
})
|
||||
|
||||
// Request session for "bob" — Noise succeeds, OS-user check denies.
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
bob := []byte("bob")
|
||||
prefix := []byte{ModeSession, 0, byte(len(bob))}
|
||||
prefix = append(prefix, bob...)
|
||||
_, err = conn.Write(prefix)
|
||||
require.NoError(t, err)
|
||||
|
||||
performInitiator(t, conn, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "authorize OS user")
|
||||
}
|
||||
+292
-190
@@ -3,6 +3,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
@@ -13,16 +15,15 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/flynn/noise"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
)
|
||||
|
||||
// Connection modes sent by the client in the session header.
|
||||
@@ -35,11 +36,7 @@ const (
|
||||
// stable so clients can branch on them without parsing free text.
|
||||
// Format: "CODE: human message".
|
||||
const (
|
||||
RejectCodeJWTMissing = "AUTH_JWT_MISSING"
|
||||
RejectCodeJWTExpired = "AUTH_JWT_EXPIRED"
|
||||
RejectCodeJWTInvalid = "AUTH_JWT_INVALID"
|
||||
RejectCodeAuthForbidden = "AUTH_FORBIDDEN"
|
||||
RejectCodeAuthConfig = "AUTH_CONFIG"
|
||||
RejectCodeSessionError = "SESSION_ERROR"
|
||||
RejectCodeCapturerError = "CAPTURER_ERROR"
|
||||
RejectCodeUnsupportedOS = "UNSUPPORTED"
|
||||
@@ -56,6 +53,21 @@ const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE"
|
||||
// enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps.
|
||||
const freshWindow = 16 * time.Millisecond
|
||||
|
||||
// maxConcurrentVNCConns caps in-flight VNC connections. Each accepted
|
||||
// connection consumes a handler goroutine, a tracking entry, and (after
|
||||
// handshake) capturer/encoder resources, so an unauthenticated peer that
|
||||
// dials in a tight loop could otherwise grow memory without bound. The
|
||||
// limit covers the entire accept→handshake→session window; a slot is
|
||||
// released only when the handler returns.
|
||||
const maxConcurrentVNCConns = 64
|
||||
|
||||
// maxFramebufferDim caps the screen dimensions accepted from a capturer.
|
||||
// RFB serialises width/height as u16, and the encoder allocates per-frame
|
||||
// buffers proportional to width*height*4. 8192 keeps width*height*4 well
|
||||
// under 2^31 so int math doesn't overflow on 32-bit builds, and is large
|
||||
// enough to cover real-world multi-monitor desktops.
|
||||
const maxFramebufferDim = 8192
|
||||
|
||||
// ScreenCapturer grabs desktop frames for the VNC server.
|
||||
type ScreenCapturer interface {
|
||||
// Width returns the current screen width in pixels.
|
||||
@@ -120,26 +132,22 @@ type InputInjector interface {
|
||||
TypeText(text string)
|
||||
}
|
||||
|
||||
// JWTConfig holds JWT validation configuration for VNC auth.
|
||||
type JWTConfig struct {
|
||||
Issuer string
|
||||
KeysLocation string
|
||||
MaxTokenAge int64
|
||||
Audiences []string
|
||||
}
|
||||
|
||||
// connectionHeader is sent by the client before the RFB handshake to specify
|
||||
// the VNC session mode and authenticate.
|
||||
type connectionHeader struct {
|
||||
mode byte
|
||||
username string
|
||||
jwt string
|
||||
// clientStatic is the client's static X25519 public key learned from
|
||||
// the Noise handshake. Populated when identityVerified is true.
|
||||
clientStatic []byte
|
||||
// sessionID is the Windows session ID; 0 selects the console session.
|
||||
sessionID uint32
|
||||
// width and height request the virtual display geometry for session mode.
|
||||
// Zero means use the default.
|
||||
width uint16
|
||||
height uint16
|
||||
// identityVerified is true when the Noise_IK handshake completed.
|
||||
identityVerified bool
|
||||
}
|
||||
|
||||
// Server is the embedded VNC server that listens on the WireGuard interface.
|
||||
@@ -170,13 +178,16 @@ type Server struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
vmgr virtualSessionManager
|
||||
jwtConfig *JWTConfig
|
||||
jwtValidator *nbjwt.Validator
|
||||
jwtExtractor *nbjwt.ClaimsExtractor
|
||||
authorizer *sshauth.Authorizer
|
||||
netstackNet *netstack.Net
|
||||
authorizer *sshauth.Authorizer
|
||||
netstackNet *netstack.Net
|
||||
// agentToken holds the raw token bytes for agent-mode auth.
|
||||
agentToken []byte
|
||||
// identityKey is the daemon's static X25519 private key used in the
|
||||
// Noise_IK handshake. Nil disables the handshake.
|
||||
identityKey []byte
|
||||
// identityPublic is the matching X25519 public key, derived once at
|
||||
// construction to avoid recomputing per handshake.
|
||||
identityPublic []byte
|
||||
|
||||
sessionsMu sync.Mutex
|
||||
sessionSeq uint64
|
||||
@@ -188,6 +199,17 @@ type Server struct {
|
||||
// closeActiveSessions iterates this set so Stop() can interrupt
|
||||
// handshaking peers, not just post-handshake sessions.
|
||||
acceptedConns map[net.Conn]struct{}
|
||||
// connAuth holds the verified Noise_IK identity tied to each accepted
|
||||
// connection so a later UpdateVNCAuth call can revoke live sessions
|
||||
// whose authorization no longer holds. Populated by registerConnAuth
|
||||
// once authenticateSession succeeds; absent entries (e.g. disableAuth
|
||||
// or pre-handshake conns) are skipped at revocation time.
|
||||
connAuth map[net.Conn]connAuthInfo
|
||||
|
||||
// connSem caps concurrent accepted connections (handshake + session).
|
||||
// Buffered with maxConcurrentVNCConns slots; accept loops try-acquire
|
||||
// before spawning a handler and release on handler return.
|
||||
connSem chan struct{}
|
||||
|
||||
// sessionRecorder, when non-nil, receives a SessionTick periodically
|
||||
// during each VNC session and on session close. The engine wires
|
||||
@@ -195,12 +217,24 @@ type Server struct {
|
||||
sessionRecorder func(SessionTick)
|
||||
}
|
||||
|
||||
// connAuthInfo captures the Noise_IK-verified identity bound to a live
|
||||
// connection so policy updates can re-check it and close sessions whose
|
||||
// authorization was revoked. clientStatic is empty when auth was disabled
|
||||
// for this connection, which signals that revocation does not apply.
|
||||
type connAuthInfo struct {
|
||||
clientStatic []byte
|
||||
mode byte
|
||||
username string
|
||||
}
|
||||
|
||||
// ActiveSessionInfo describes a currently connected VNC client.
|
||||
type ActiveSessionInfo struct {
|
||||
RemoteAddress string
|
||||
Mode string
|
||||
Username string
|
||||
JWTUsername string
|
||||
// UserID is the authenticated session identity (hashed user ID from
|
||||
// the Noise_IK static-key registration), empty when auth is disabled.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// vncSession provides capturer and injector for a virtual display session.
|
||||
@@ -220,19 +254,31 @@ type virtualSessionManager interface {
|
||||
StopAll()
|
||||
}
|
||||
|
||||
// New creates a VNC server with the given screen capturer and input injector.
|
||||
// Authentication uses a JWT supplied by the client in the connection
|
||||
// header; the protocol-level VNC password scheme is not supported.
|
||||
func New(capturer ScreenCapturer, injector InputInjector) *Server {
|
||||
return &Server{
|
||||
// New creates a VNC server. identityKey is the 32-byte X25519 private
|
||||
// key used by the daemon in the Noise_IK handshake; nil disables auth.
|
||||
// The protocol-level VNC password scheme is not supported.
|
||||
func New(capturer ScreenCapturer, injector InputInjector, identityKey []byte) *Server {
|
||||
s := &Server{
|
||||
capturer: capturer,
|
||||
injector: injector,
|
||||
identityKey: identityKey,
|
||||
authorizer: sshauth.NewAuthorizer(),
|
||||
log: log.WithField("component", "vnc-server"),
|
||||
sessions: make(map[uint64]ActiveSessionInfo),
|
||||
sessionConns: make(map[uint64]net.Conn),
|
||||
acceptedConns: make(map[net.Conn]struct{}),
|
||||
connAuth: make(map[net.Conn]connAuthInfo),
|
||||
connSem: make(chan struct{}, maxConcurrentVNCConns),
|
||||
}
|
||||
if len(identityKey) == 32 {
|
||||
pub, err := curve25519.X25519(identityKey, curve25519.Basepoint)
|
||||
if err == nil {
|
||||
s.identityPublic = pub
|
||||
} else {
|
||||
s.log.Warnf("derive identity public key: %v", err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ActiveSessions returns a snapshot of currently connected VNC clients.
|
||||
@@ -292,9 +338,75 @@ func (s *Server) trackConn(c net.Conn) {
|
||||
func (s *Server) untrackConn(c net.Conn) {
|
||||
s.sessionsMu.Lock()
|
||||
delete(s.acceptedConns, c)
|
||||
delete(s.connAuth, c)
|
||||
s.sessionsMu.Unlock()
|
||||
}
|
||||
|
||||
// registerConnAuth records the verified Noise_IK identity for a live
|
||||
// connection so UpdateVNCAuth can later revoke it if policy changes.
|
||||
// No-op when auth is disabled (e.g. agent-mode loopback connections).
|
||||
func (s *Server) registerConnAuth(c net.Conn, header *connectionHeader) {
|
||||
if s.disableAuth || header == nil || len(header.clientStatic) != 32 {
|
||||
return
|
||||
}
|
||||
s.sessionsMu.Lock()
|
||||
s.connAuth[c] = connAuthInfo{
|
||||
clientStatic: append([]byte(nil), header.clientStatic...),
|
||||
mode: header.mode,
|
||||
username: header.username,
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
}
|
||||
|
||||
// tryAcquireConnSlot returns true when a connection slot was successfully
|
||||
// reserved. Releases must pair with releaseConnSlot. Returns false when
|
||||
// the cap is already saturated; callers must close the connection.
|
||||
func (s *Server) tryAcquireConnSlot() bool {
|
||||
select {
|
||||
case s.connSem <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) releaseConnSlot() {
|
||||
select {
|
||||
case <-s.connSem:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// revokeUnauthorizedSessions closes every live connection whose Noise-
|
||||
// verified identity no longer authenticates under the current authorizer
|
||||
// configuration. Called by UpdateVNCAuth after the new policy is applied.
|
||||
func (s *Server) revokeUnauthorizedSessions() {
|
||||
if s.disableAuth {
|
||||
return
|
||||
}
|
||||
s.sessionsMu.Lock()
|
||||
victims := make([]net.Conn, 0)
|
||||
for c, info := range s.connAuth {
|
||||
if len(info.clientStatic) != 32 {
|
||||
continue
|
||||
}
|
||||
hdr := &connectionHeader{
|
||||
identityVerified: true,
|
||||
clientStatic: info.clientStatic,
|
||||
mode: info.mode,
|
||||
username: info.username,
|
||||
}
|
||||
if _, err := s.authenticateSession(hdr); err != nil {
|
||||
victims = append(victims, c)
|
||||
s.log.Infof("revoking VNC session from %s: %v", c.RemoteAddr(), err)
|
||||
}
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
for _, c := range victims {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// SetServiceMode enables proxy-to-agent mode for Windows service operation.
|
||||
func (s *Server) SetServiceMode(enabled bool) {
|
||||
s.serviceMode = enabled
|
||||
@@ -308,16 +420,6 @@ func (s *Server) SetSessionRecorder(recorder func(SessionTick)) {
|
||||
s.sessionRecorder = recorder
|
||||
}
|
||||
|
||||
// SetJWTConfig configures JWT authentication for VNC connections.
|
||||
// Pass nil to disable JWT (public mode).
|
||||
func (s *Server) SetJWTConfig(config *JWTConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.jwtConfig = config
|
||||
s.jwtValidator = nil
|
||||
s.jwtExtractor = nil
|
||||
}
|
||||
|
||||
// SetDisableAuth disables authentication entirely.
|
||||
func (s *Server) SetDisableAuth(disable bool) {
|
||||
s.disableAuth = disable
|
||||
@@ -346,13 +448,14 @@ func (s *Server) SetNetstackNet(n *netstack.Net) {
|
||||
s.netstackNet = n
|
||||
}
|
||||
|
||||
// UpdateVNCAuth updates the fine-grained authorization configuration.
|
||||
// UpdateVNCAuth updates the fine-grained authorization configuration and
|
||||
// closes any live session whose identity no longer authenticates under
|
||||
// the new policy. Revocation is event-driven: there is no periodic
|
||||
// re-check, so a session stays open until either the next UpdateVNCAuth
|
||||
// call or normal disconnect.
|
||||
func (s *Server) UpdateVNCAuth(config *sshauth.Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.jwtValidator = nil
|
||||
s.jwtExtractor = nil
|
||||
s.authorizer.Update(config)
|
||||
s.revokeUnauthorizedSessions()
|
||||
}
|
||||
|
||||
// Start begins listening for VNC connections on the given address.
|
||||
@@ -463,9 +566,15 @@ func (s *Server) acceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.tryAcquireConnSlot() {
|
||||
s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns)
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
enableTCPKeepAlive(conn, s.log)
|
||||
s.trackConn(conn)
|
||||
go func(c net.Conn) {
|
||||
defer s.releaseConnSlot()
|
||||
defer s.untrackConn(c)
|
||||
s.handleConnection(c)
|
||||
}(conn)
|
||||
@@ -565,16 +674,17 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
if !s.verifyAgentToken(conn, connLog) {
|
||||
return
|
||||
}
|
||||
header, err := readConnectionHeader(conn)
|
||||
header, err := s.readConnectionHeader(conn)
|
||||
if err != nil {
|
||||
connLog.Warnf("read connection header: %v", err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog)
|
||||
connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.registerConnAuth(conn, header)
|
||||
|
||||
capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog)
|
||||
if !ok {
|
||||
@@ -586,7 +696,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
RemoteAddress: conn.RemoteAddr().String(),
|
||||
Mode: modeString(header.mode),
|
||||
Username: header.username,
|
||||
JWTUsername: jwtUserID,
|
||||
UserID: sessionUserID,
|
||||
}, conn)
|
||||
defer s.removeSession(sessionID)
|
||||
|
||||
@@ -596,13 +706,20 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
w, h := capturer.Width(), capturer.Height()
|
||||
if w <= 0 || h <= 0 || w > maxFramebufferDim || h > maxFramebufferDim {
|
||||
rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("framebuffer dimensions out of range: %dx%d", w, h)))
|
||||
connLog.Warnf("rejecting session: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim)
|
||||
return
|
||||
}
|
||||
|
||||
conn = newMetricsConn(conn, s.sessionRecorder)
|
||||
sess := &session{
|
||||
conn: conn,
|
||||
capturer: capturer,
|
||||
injector: injector,
|
||||
serverW: capturer.Width(),
|
||||
serverH: capturer.Height(),
|
||||
serverW: w,
|
||||
serverH: h,
|
||||
log: connLog,
|
||||
}
|
||||
sess.serve()
|
||||
@@ -615,25 +732,6 @@ func codeMessage(code, msg string) string {
|
||||
return code + ": " + msg
|
||||
}
|
||||
|
||||
// jwtErrorCode maps a JWT auth error to a stable reject code.
|
||||
func jwtErrorCode(err error) string {
|
||||
if err == nil {
|
||||
return RejectCodeJWTInvalid
|
||||
}
|
||||
if errors.Is(err, nbjwt.ErrTokenExpired) {
|
||||
return RejectCodeJWTExpired
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "JWT required but not provided"):
|
||||
return RejectCodeJWTMissing
|
||||
case strings.Contains(msg, "authorize") || strings.Contains(msg, "not authorized"):
|
||||
return RejectCodeAuthForbidden
|
||||
default:
|
||||
return RejectCodeJWTInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// rejectConnection sends a minimal RFB handshake with a security failure
|
||||
// reason, so VNC clients display the error message instead of a generic
|
||||
// "unexpected disconnect."
|
||||
@@ -658,105 +756,57 @@ func rejectConnection(conn net.Conn, reason string) {
|
||||
_, _ = conn.Write(buf)
|
||||
}
|
||||
|
||||
const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes
|
||||
|
||||
// authenticateJWT validates the JWT from the connection header and checks
|
||||
// authorization. For attach mode, just checks membership in the authorized
|
||||
// user list. For session mode, additionally validates the OS user mapping.
|
||||
func (s *Server) authenticateJWT(header *connectionHeader) (string, error) {
|
||||
if header.jwt == "" {
|
||||
return "", fmt.Errorf("JWT required but not provided")
|
||||
// authenticateSession resolves the Noise-verified client static public
|
||||
// key to a hashed user identity via the authorizer, and checks OS-user
|
||||
// mapping for session mode. Returns the hashed user identity on success.
|
||||
func (s *Server) authenticateSession(header *connectionHeader) (string, error) {
|
||||
if !header.identityVerified {
|
||||
return "", fmt.Errorf("identity proof missing")
|
||||
}
|
||||
if len(header.clientStatic) != 32 {
|
||||
return "", fmt.Errorf("client static key missing")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if err := s.ensureJWTValidator(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return "", fmt.Errorf("initialize JWT validator: %w", err)
|
||||
}
|
||||
validator := s.jwtValidator
|
||||
extractor := s.jwtExtractor
|
||||
s.mu.Unlock()
|
||||
|
||||
token, err := validator.ValidateAndParse(context.Background(), header.jwt)
|
||||
userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("validate JWT: %w", err)
|
||||
return "", fmt.Errorf("lookup session pubkey: %w", err)
|
||||
}
|
||||
|
||||
if err := s.checkTokenAge(token); err != nil {
|
||||
return "", err
|
||||
osUser := "*"
|
||||
if header.mode == ModeSession {
|
||||
osUser = header.username
|
||||
}
|
||||
|
||||
userAuth, err := extractor.ToUserAuth(token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extract user from JWT: %w", err)
|
||||
if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil {
|
||||
return "", fmt.Errorf("authorize OS user %q: %w", osUser, err)
|
||||
}
|
||||
if userAuth.UserId == "" {
|
||||
return "", fmt.Errorf("JWT has no user ID")
|
||||
}
|
||||
|
||||
switch header.mode {
|
||||
case ModeSession:
|
||||
// Session mode: check user + OS username mapping.
|
||||
if _, err := s.authorizer.Authorize(userAuth.UserId, header.username); err != nil {
|
||||
return "", fmt.Errorf("authorize session for %s: %w", header.username, err)
|
||||
}
|
||||
default:
|
||||
// Attach mode: just check user is in the authorized list (wildcard OS user).
|
||||
if _, err := s.authorizer.Authorize(userAuth.UserId, "*"); err != nil {
|
||||
return "", fmt.Errorf("user not authorized for VNC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return userAuth.UserId, nil
|
||||
return userIDHash.String(), nil
|
||||
}
|
||||
|
||||
// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held.
|
||||
func (s *Server) ensureJWTValidator() error {
|
||||
if s.jwtValidator != nil && s.jwtExtractor != nil {
|
||||
return nil
|
||||
}
|
||||
if s.jwtConfig == nil {
|
||||
return fmt.Errorf("no JWT config")
|
||||
}
|
||||
var vncIdentityMagic = []byte("NBV3")
|
||||
|
||||
// Enable IdP key refresh so JWKS rotations don't latch the validator
|
||||
// off until daemon restart.
|
||||
s.jwtValidator = nbjwt.NewValidator(
|
||||
s.jwtConfig.Issuer,
|
||||
s.jwtConfig.Audiences,
|
||||
s.jwtConfig.KeysLocation,
|
||||
true,
|
||||
)
|
||||
// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads).
|
||||
// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes
|
||||
// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes
|
||||
const (
|
||||
noiseInitiatorMsgLen = 96
|
||||
noiseResponderMsgLen = 48
|
||||
)
|
||||
|
||||
var opts []nbjwt.ClaimsExtractorOption
|
||||
if len(s.jwtConfig.Audiences) > 0 {
|
||||
opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0]))
|
||||
}
|
||||
if claim := s.authorizer.GetUserIDClaim(); claim != "" {
|
||||
opts = append(opts, nbjwt.WithUserIDClaim(claim))
|
||||
}
|
||||
s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...)
|
||||
// vncNoiseSuite pins the cipher suite for the VNC handshake. Changing
|
||||
// it requires bumping vncIdentityMagic so old clients fail closed.
|
||||
var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) checkTokenAge(token *gojwt.Token) error {
|
||||
maxAge := defaultJWTMaxTokenAge
|
||||
if s.jwtConfig != nil && s.jwtConfig.MaxTokenAge > 0 {
|
||||
maxAge = int(s.jwtConfig.MaxTokenAge)
|
||||
}
|
||||
return nbjwt.CheckTokenAge(token, time.Duration(maxAge)*time.Second)
|
||||
}
|
||||
|
||||
// readConnectionHeader reads the NetBird VNC session header from the connection.
|
||||
// Format: [mode: 1 byte] [username_len: 2 bytes BE] [username: N bytes]
|
||||
// readConnectionHeader reads the NetBird VNC session header. Format:
|
||||
//
|
||||
// [jwt_len: 2 bytes BE] [jwt: N bytes]
|
||||
// [mode: 1] [username_len: 2 BE] [username: N]
|
||||
// [opt magic "NBV3": 4] [noise_msg1: 96]
|
||||
// (server writes [noise_msg2: 48] here when the magic is present)
|
||||
// [session_id: 4 BE] [width: 2 BE] [height: 2 BE]
|
||||
//
|
||||
// Uses a short timeout: our WASM proxy sends the header immediately after
|
||||
// connecting. Standard VNC clients don't send anything first (server speaks
|
||||
// first in RFB), so they time out and get the default attach mode.
|
||||
func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
|
||||
// Standard VNC clients don't speak first, so they time out on the first
|
||||
// read and fall through to attach mode (which auth still rejects when
|
||||
// no Noise handshake completed).
|
||||
func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
@@ -764,11 +814,9 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
|
||||
|
||||
var hdr [3]byte
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
// Timeout or error: assume no header, use attach mode.
|
||||
return &connectionHeader{mode: ModeAttach}, nil
|
||||
}
|
||||
|
||||
// Restore a longer deadline for reading variable-length fields.
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
@@ -788,48 +836,93 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
|
||||
username = string(buf)
|
||||
}
|
||||
|
||||
// Read JWT token length and data.
|
||||
var jwtLenBuf [2]byte
|
||||
var jwtToken string
|
||||
if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil {
|
||||
jwtLen := binary.BigEndian.Uint16(jwtLenBuf[:])
|
||||
if jwtLen >= 8192 {
|
||||
return nil, fmt.Errorf("jwt too long: %d (max 8191)", jwtLen)
|
||||
}
|
||||
if jwtLen > 0 {
|
||||
buf := make([]byte, jwtLen)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("read JWT: %w", err)
|
||||
}
|
||||
jwtToken = string(buf)
|
||||
}
|
||||
br := bufio.NewReader(conn)
|
||||
clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto).
|
||||
var sessionID uint32
|
||||
var sidBuf [4]byte
|
||||
if _, err := io.ReadFull(conn, sidBuf[:]); err == nil {
|
||||
if _, err := io.ReadFull(br, sidBuf[:]); err == nil {
|
||||
sessionID = binary.BigEndian.Uint32(sidBuf[:])
|
||||
}
|
||||
|
||||
// Read optional requested viewport size (2x uint16 BE). Missing = 0 (default).
|
||||
var width, height uint16
|
||||
var geomBuf [4]byte
|
||||
if _, err := io.ReadFull(conn, geomBuf[:]); err == nil {
|
||||
if _, err := io.ReadFull(br, geomBuf[:]); err == nil {
|
||||
width = binary.BigEndian.Uint16(geomBuf[0:2])
|
||||
height = binary.BigEndian.Uint16(geomBuf[2:4])
|
||||
}
|
||||
|
||||
return &connectionHeader{
|
||||
mode: mode,
|
||||
username: username,
|
||||
jwt: jwtToken,
|
||||
sessionID: sessionID,
|
||||
width: width,
|
||||
height: height,
|
||||
mode: mode,
|
||||
username: username,
|
||||
clientStatic: clientStatic,
|
||||
sessionID: sessionID,
|
||||
width: width,
|
||||
height: height,
|
||||
identityVerified: identityVerified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeRunNoiseHandshake performs the responder side of a Noise_IK
|
||||
// handshake when the client sends the v3 magic. Returns the client static
|
||||
// public key learned from the handshake. Any handshake failure is fatal
|
||||
// (fail closed).
|
||||
func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) {
|
||||
peek, err := br.Peek(len(vncIdentityMagic))
|
||||
if err != nil || !bytes.Equal(peek, vncIdentityMagic) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if _, err := br.Discard(len(vncIdentityMagic)); err != nil {
|
||||
return nil, false, fmt.Errorf("discard identity magic: %w", err)
|
||||
}
|
||||
|
||||
msg1 := make([]byte, noiseInitiatorMsgLen)
|
||||
if _, err := io.ReadFull(br, msg1); err != nil {
|
||||
return nil, false, fmt.Errorf("read noise msg1: %w", err)
|
||||
}
|
||||
|
||||
// Agents on loopback authenticate via the agent token, not this
|
||||
// handshake. Consume the replayed bytes and skip the response.
|
||||
if s.disableAuth {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
if len(s.identityKey) != 32 || len(s.identityPublic) != 32 {
|
||||
return nil, false, errors.New("identity key not configured")
|
||||
}
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: false,
|
||||
StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("noise responder init: %w", err)
|
||||
}
|
||||
if _, _, _, err := state.ReadMessage(nil, msg1); err != nil {
|
||||
return nil, false, fmt.Errorf("noise read msg1: %w", err)
|
||||
}
|
||||
msg2, _, _, err := state.WriteMessage(nil, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("noise write msg2: %w", err)
|
||||
}
|
||||
if len(msg2) != noiseResponderMsgLen {
|
||||
return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen)
|
||||
}
|
||||
if _, err := conn.Write(msg2); err != nil {
|
||||
return nil, false, fmt.Errorf("write noise msg2: %w", err)
|
||||
}
|
||||
|
||||
clientStatic := state.PeerStatic()
|
||||
if len(clientStatic) != 32 {
|
||||
return nil, false, errors.New("noise peer static missing")
|
||||
}
|
||||
return clientStatic, true, nil
|
||||
}
|
||||
|
||||
// verifyAgentToken validates the agent token prefix when configured. Returns
|
||||
// false when the token is invalid or unreadable; the connection is closed.
|
||||
func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool {
|
||||
@@ -865,25 +958,34 @@ func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeJWT performs JWT validation when auth is enabled. Returns the
|
||||
// enriched log entry, jwt user ID (empty when auth disabled), and ok=false
|
||||
// if the connection was rejected.
|
||||
func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
|
||||
// authorizeSession runs the Noise_IK handshake when auth is enabled.
|
||||
// Returns the enriched log entry, user identity hash (empty when auth
|
||||
// disabled), and ok=false if the connection was rejected.
|
||||
func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
|
||||
if s.disableAuth {
|
||||
return connLog, "", true
|
||||
}
|
||||
if s.jwtConfig == nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured"))
|
||||
connLog.Warn("auth rejected: no identity provider configured")
|
||||
return connLog, "", false
|
||||
}
|
||||
jwtUserID, err := s.authenticateJWT(header)
|
||||
userID, err := s.authenticateSession(header)
|
||||
if err != nil {
|
||||
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
|
||||
connLog.Warnf("auth rejected: %v", err)
|
||||
return connLog, "", false
|
||||
}
|
||||
return connLog.WithField("jwt_user", jwtUserID), jwtUserID, true
|
||||
return connLog.WithFields(log.Fields{
|
||||
"session_user": userID,
|
||||
"session_key": sessionKeyFingerprint(header.clientStatic),
|
||||
}), userID, true
|
||||
}
|
||||
|
||||
// sessionKeyFingerprint returns a short hex fingerprint of a client
|
||||
// static key for log correlation. Distinct VNC sessions of the same
|
||||
// user end up with distinct fingerprints because each session mints a
|
||||
// fresh keypair, so this lets an operator tell parallel sessions apart.
|
||||
func sessionKeyFingerprint(clientStatic []byte) string {
|
||||
if len(clientStatic) < 4 {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(clientStatic[:4])
|
||||
}
|
||||
|
||||
// acquireSessionResources returns the capturer/injector to use for this
|
||||
|
||||
@@ -47,10 +47,16 @@ func (s *Server) serviceAcceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.tryAcquireConnSlot() {
|
||||
s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns)
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
enableTCPKeepAlive(conn, s.log)
|
||||
conn = newMetricsConn(conn, s.sessionRecorder)
|
||||
s.trackConn(conn)
|
||||
go func(c net.Conn) {
|
||||
defer s.releaseConnSlot()
|
||||
defer s.untrackConn(c)
|
||||
s.handleServiceConnectionDarwin(c, mgr)
|
||||
}(conn)
|
||||
@@ -69,7 +75,7 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa
|
||||
tee := io.TeeReader(conn, &headerBuf)
|
||||
teeConn := &darwinPrefixConn{Reader: tee, Conn: conn}
|
||||
|
||||
header, err := readConnectionHeader(teeConn)
|
||||
header, err := s.readConnectionHeader(teeConn)
|
||||
if err != nil {
|
||||
connLog.Debugf("read connection header: %v", err)
|
||||
conn.Close()
|
||||
@@ -77,17 +83,13 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa
|
||||
}
|
||||
|
||||
if !s.disableAuth {
|
||||
if s.jwtConfig == nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured"))
|
||||
connLog.Warn("auth rejected: no identity provider configured")
|
||||
return
|
||||
}
|
||||
if _, err := s.authenticateJWT(header); err != nil {
|
||||
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
|
||||
if _, err := s.authenticateSession(header); err != nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
|
||||
connLog.Warnf("auth rejected: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.registerConnAuth(conn, header)
|
||||
|
||||
token, err := mgr.ensure(s.ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,14 +25,11 @@ func (t *testCapturer) Capture() (*image.RGBA, error) {
|
||||
return image.NewRGBA(image.Rect(0, 0, 100, 100)), nil
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.Addr, *Server) {
|
||||
func startTestServer(t *testing.T, disableAuth bool) (net.Addr, *Server) {
|
||||
t.Helper()
|
||||
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(disableAuth)
|
||||
if jwtConfig != nil {
|
||||
srv.SetJWTConfig(jwtConfig)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
network := netip.MustParsePrefix("127.0.0.0/8")
|
||||
@@ -45,30 +41,28 @@ func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.
|
||||
return srv.listener.Addr(), srv
|
||||
}
|
||||
|
||||
func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) {
|
||||
addr, _ := startTestServer(t, false, nil)
|
||||
func TestAuthEnabled_NoSessionAuth_RejectsConnection(t *testing.T) {
|
||||
addr, _ := startTestServer(t, false)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
// Send session header: attach mode, no username, no JWT.
|
||||
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
|
||||
// Header with no Noise handshake. Auth-required servers must reject
|
||||
// because no client static was authenticated.
|
||||
header := make([]byte, 11) // mode + usernameLen + sessionID + w + h
|
||||
header[0] = ModeAttach
|
||||
_, err = conn.Write(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Server should send RFB version then security failure.
|
||||
var version [12]byte
|
||||
_, err = io.ReadFull(conn, version[:])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "RFB 003.008\n", string(version[:]))
|
||||
|
||||
// Write client version to proceed through handshake.
|
||||
_, err = conn.Write(version[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read security types: 0 means failure, followed by reason.
|
||||
var numTypes [1]byte
|
||||
_, err = io.ReadFull(conn, numTypes[:])
|
||||
require.NoError(t, err)
|
||||
@@ -81,18 +75,17 @@ func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) {
|
||||
reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:]))
|
||||
_, err = io.ReadFull(conn, reason)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(reason), "identity provider", "rejection reason should mention missing IdP config")
|
||||
assert.Contains(t, string(reason), "identity proof missing", "rejection reason should mention missing identity proof")
|
||||
}
|
||||
|
||||
func TestAuthDisabled_AllowsConnection(t *testing.T) {
|
||||
addr, _ := startTestServer(t, true, nil)
|
||||
addr, _ := startTestServer(t, true)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
// Send session header: attach mode, no username, no JWT.
|
||||
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
|
||||
header := make([]byte, 11) // mode + usernameLen + sessionID + w + h
|
||||
header[0] = ModeAttach
|
||||
_, err = conn.Write(header)
|
||||
require.NoError(t, err)
|
||||
@@ -114,70 +107,12 @@ func TestAuthDisabled_AllowsConnection(t *testing.T) {
|
||||
assert.NotEqual(t, byte(0), numTypes[0], "should have at least one security type (auth disabled)")
|
||||
}
|
||||
|
||||
// TestAuthEnabled_InvalidJWT_RejectedBeforeRFB confirms the VNC server itself
|
||||
// (not just the JWT library) wires authentication into handleConnection. A
|
||||
// well-formed JWT-shaped token must hit the server's validation path and be
|
||||
// rejected with an AUTH_JWT_* reason, never reaching the RFB handshake.
|
||||
func TestAuthEnabled_InvalidJWT_RejectedBeforeRFB(t *testing.T) {
|
||||
addr, _ := startTestServer(t, false, &JWTConfig{
|
||||
Issuer: "https://example.invalid",
|
||||
KeysLocation: "https://example.invalid/.well-known/jwks.json",
|
||||
Audiences: []string{"test"},
|
||||
})
|
||||
|
||||
// Three-segment "JWT" with bogus base64. The server's authenticateJWT path
|
||||
// must catch this regardless of the IdP being unreachable.
|
||||
bogusJWT := "abc.def.ghi"
|
||||
header := make([]byte, 3+2+len(bogusJWT)+4+4)
|
||||
header[0] = ModeAttach
|
||||
binary.BigEndian.PutUint16(header[1:3], 0) // username len
|
||||
binary.BigEndian.PutUint16(header[3:5], uint16(len(bogusJWT)))
|
||||
copy(header[5:5+len(bogusJWT)], bogusJWT)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
|
||||
|
||||
_, err = conn.Write(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
var version [12]byte
|
||||
_, err = io.ReadFull(conn, version[:])
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(version[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var numTypes [1]byte
|
||||
_, err = io.ReadFull(conn, numTypes[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byte(0), numTypes[0], "must fail security negotiation")
|
||||
|
||||
var reasonLen [4]byte
|
||||
_, err = io.ReadFull(conn, reasonLen[:])
|
||||
require.NoError(t, err)
|
||||
reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:]))
|
||||
_, err = io.ReadFull(conn, reason)
|
||||
require.NoError(t, err)
|
||||
// The reason must carry one of the server's AUTH_JWT_* codes, proving
|
||||
// the rejection came from authenticateJWT in handleConnection.
|
||||
r := string(reason)
|
||||
hasJWTReject := false
|
||||
for _, code := range []string{RejectCodeJWTInvalid, RejectCodeJWTExpired, RejectCodeAuthForbidden} {
|
||||
if strings.Contains(r, code) {
|
||||
hasJWTReject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasJWTReject, "reason %q must include an AUTH_JWT_* code", r)
|
||||
}
|
||||
|
||||
// TestAuth_NoUnauthBytesPastHeader proves the server does not send any RFB
|
||||
// content to a connection that fails source validation. Specifically, the
|
||||
// server must close immediately and the client must see EOF before any RFB
|
||||
// version greeting is written.
|
||||
func TestAuth_NoUnauthBytesPastHeader(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(true)
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
// Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so
|
||||
@@ -198,37 +133,6 @@ func TestAuth_NoUnauthBytesPastHeader(t *testing.T) {
|
||||
require.Error(t, err, "non-overlay client must see EOF, not an RFB greeting")
|
||||
}
|
||||
|
||||
func TestAuthEnabled_EmptyJWT_Rejected(t *testing.T) {
|
||||
// Auth enabled with a (bogus) JWT config: connections without JWT should be rejected.
|
||||
addr, _ := startTestServer(t, false, &JWTConfig{
|
||||
Issuer: "https://example.com",
|
||||
KeysLocation: "https://example.com/.well-known/jwks.json",
|
||||
Audiences: []string{"test"},
|
||||
})
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
// Send session header with empty JWT.
|
||||
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
|
||||
header[0] = ModeAttach
|
||||
_, err = conn.Write(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
var version [12]byte
|
||||
_, err = io.ReadFull(conn, version[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = conn.Write(version[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var numTypes [1]byte
|
||||
_, err = io.ReadFull(conn, numTypes[:])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0), numTypes[0], "should reject with 0 security types")
|
||||
}
|
||||
|
||||
func TestIsAllowedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -289,7 +193,7 @@ func TestIsAllowedSource(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.localAddr = tc.localAddr
|
||||
srv.network = tc.network
|
||||
assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote))
|
||||
@@ -298,7 +202,7 @@ func TestIsAllowedSource(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStart_InvalidNetworkRejected(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
err := srv.Start(t.Context(), addr, netip.Prefix{})
|
||||
require.Error(t, err, "Start must refuse an invalid overlay prefix")
|
||||
@@ -306,7 +210,7 @@ func TestStart_InvalidNetworkRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentToken_MismatchClosesConnection(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(true)
|
||||
srv.SetAgentToken("deadbeefcafebabe")
|
||||
|
||||
@@ -334,7 +238,7 @@ func TestAgentToken_MismatchClosesConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(true)
|
||||
const tokenHex = "deadbeefcafebabe"
|
||||
srv.SetAgentToken(tokenHex)
|
||||
@@ -356,7 +260,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send session header so handleConnection can proceed past readConnectionHeader.
|
||||
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
|
||||
header := make([]byte, 11) // ModeAttach + usernameLen=0 + sessionID=0 + width=0 + height=0
|
||||
header[0] = ModeAttach
|
||||
_, err = conn.Write(header)
|
||||
require.NoError(t, err)
|
||||
@@ -371,7 +275,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
|
||||
func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) {
|
||||
// Default platformSessionManager() on non-Linux returns nil, so ModeSession
|
||||
// must be rejected with the UNSUPPORTED reason rather than crashing.
|
||||
srv := New(&testCapturer{}, &StubInputInjector{})
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(true)
|
||||
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
@@ -387,7 +291,7 @@ func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) {
|
||||
defer conn.Close()
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
|
||||
|
||||
// ModeSession with no username/JWT, so we exit on the vmgr==nil branch
|
||||
// ModeSession with no username, so we exit on the vmgr==nil branch
|
||||
// before username validation runs.
|
||||
header := []byte{ModeSession, 0, 0, 0, 0}
|
||||
_, err = conn.Write(header)
|
||||
|
||||
@@ -233,8 +233,9 @@ func (s *Server) platformInit() {
|
||||
startSASListener(s.ctx)
|
||||
}
|
||||
|
||||
// serviceAcceptLoop runs in Session 0. It validates source IP and
|
||||
// authenticates via JWT before proxying connections to the user-session agent.
|
||||
// serviceAcceptLoop runs in Session 0. It validates the source IP and
|
||||
// hands accepted connections to handleServiceConnection, which runs the
|
||||
// Noise_IK handshake before proxying to the user-session agent.
|
||||
func (s *Server) serviceAcceptLoop() {
|
||||
|
||||
sm := newSessionManager(agentPort)
|
||||
@@ -255,18 +256,25 @@ func (s *Server) serviceAcceptLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.tryAcquireConnSlot() {
|
||||
s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns)
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
enableTCPKeepAlive(conn, s.log)
|
||||
conn = newMetricsConn(conn, s.sessionRecorder)
|
||||
s.trackConn(conn)
|
||||
go func(c net.Conn) {
|
||||
defer s.releaseConnSlot()
|
||||
defer s.untrackConn(c)
|
||||
s.handleServiceConnection(c, sm)
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleServiceConnection validates the source IP and JWT, then proxies
|
||||
// the connection (with header bytes replayed) to the agent.
|
||||
// handleServiceConnection runs the connection-header handshake (including
|
||||
// Noise_IK), then proxies the connection (with header bytes replayed) to
|
||||
// the agent listening on loopback.
|
||||
func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
|
||||
connLog := s.log.WithField("remote", conn.RemoteAddr().String())
|
||||
|
||||
@@ -279,7 +287,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
|
||||
tee := io.TeeReader(conn, &headerBuf)
|
||||
teeConn := &prefixConn{Reader: tee, Conn: conn}
|
||||
|
||||
header, err := readConnectionHeader(teeConn)
|
||||
header, err := s.readConnectionHeader(teeConn)
|
||||
if err != nil {
|
||||
connLog.Debugf("read connection header: %v", err)
|
||||
conn.Close()
|
||||
@@ -287,17 +295,13 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
|
||||
}
|
||||
|
||||
if !s.disableAuth {
|
||||
if s.jwtConfig == nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured"))
|
||||
connLog.Warn("auth rejected: no identity provider configured")
|
||||
return
|
||||
}
|
||||
if _, err := s.authenticateJWT(header); err != nil {
|
||||
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
|
||||
if _, err := s.authenticateSession(header); err != nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
|
||||
connLog.Warnf("auth rejected: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.registerConnAuth(conn, header)
|
||||
|
||||
// Replay buffered header bytes + remaining stream to the agent.
|
||||
replayConn := &prefixConn{
|
||||
|
||||
@@ -222,9 +222,9 @@ func (s *session) handshake() error {
|
||||
}
|
||||
|
||||
// sendSecurityTypes advertises only secNone. Authentication and access
|
||||
// control happen in the NetBird connection header (JWT, mode, username)
|
||||
// that precedes the RFB handshake, not via the protocol-level password
|
||||
// scheme.
|
||||
// control happen in the NetBird connection header (Noise_IK handshake,
|
||||
// mode, username) that precedes the RFB handshake; the protocol-level
|
||||
// password scheme is not supported.
|
||||
func (s *session) sendSecurityTypes() error {
|
||||
_, err := s.conn.Write([]byte{1, secNone})
|
||||
return err
|
||||
|
||||
@@ -225,6 +225,10 @@ func (s *session) handleResize() error {
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil
|
||||
}
|
||||
if w > maxFramebufferDim || h > maxFramebufferDim {
|
||||
s.log.Warnf("ignoring resize: %dx%d exceeds cap %d", w, h, maxFramebufferDim)
|
||||
return nil
|
||||
}
|
||||
if w == s.serverW && h == s.serverH {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user