[client] Add RDP token passthrough for passwordless Windows Remote Desktop

Implement sideband authorization and credential provider architecture for
passwordless RDP access to Windows peers via NetBird.

Go components:
- Sideband RDP auth server (TCP on WG interface, port 3390/22023)
- Pending session store with TTL expiry and replay protection
- Named pipe IPC server (\\.\pipe\netbird-rdp-auth) for credential provider
- Sideband client for connecting peer to request authorization
- CLI command `netbird rdp [user@]host` with JWT auth flow
- Engine integration with DNAT port redirection

Rust credential provider DLL (client/rdp/credprov/):
- COM DLL implementing ICredentialProvider + ICredentialProviderCredential
- Loaded by Windows LogonUI.exe at the RDP login screen
- Queries NetBird agent via named pipe for pending sessions
- Performs S4U logon (LsaLogonUser) for passwordless Windows token creation
- Self-registration via regsvr32 (DllRegisterServer/DllUnregisterServer)

https://claude.ai/code/session_01C38bCDyYzLgxYLVwJkcUng
This commit is contained in:
Claude
2026-04-11 17:15:42 +00:00
parent 5259e5df51
commit c5186f1483
21 changed files with 2883 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
package server
import (
"fmt"
"net/netip"
)
// parseAddr parses a string into a netip.Addr, stripping any port or zone.
func parseAddr(s string) (netip.Addr, error) {
// Try as plain IP first
if addr, err := netip.ParseAddr(s); err == nil {
return addr, nil
}
// Try as IP:port
if addrPort, err := netip.ParseAddrPort(s); err == nil {
return addrPort.Addr(), nil
}
return netip.Addr{}, fmt.Errorf("invalid IP address: %s", s)
}
+184
View File
@@ -0,0 +1,184 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/netip"
"sync"
"time"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
const (
// DefaultSessionTTL is the default time-to-live for pending RDP sessions.
DefaultSessionTTL = 60 * time.Second
// cleanupInterval is how often the store checks for expired sessions.
cleanupInterval = 10 * time.Second
// nonceLength is the length of the nonce in bytes.
nonceLength = 32
)
// PendingRDPSession represents an authorized but not yet consumed RDP session.
type PendingRDPSession struct {
SessionID string
PeerIP netip.Addr
OSUsername string
Domain string
JWTUserID string // for audit trail
Nonce string // replay protection
CreatedAt time.Time
ExpiresAt time.Time
consumed bool
}
// PendingStore manages pending RDP session entries with automatic expiration.
type PendingStore struct {
mu sync.RWMutex
sessions map[string]*PendingRDPSession // keyed by SessionID
nonces map[string]struct{} // seen nonces for replay protection
ttl time.Duration
}
// NewPendingStore creates a new pending session store with the given TTL.
func NewPendingStore(ttl time.Duration) *PendingStore {
if ttl <= 0 {
ttl = DefaultSessionTTL
}
return &PendingStore{
sessions: make(map[string]*PendingRDPSession),
nonces: make(map[string]struct{}),
ttl: ttl,
}
}
// Add creates a new pending RDP session and returns it.
func (ps *PendingStore) Add(peerIP netip.Addr, osUsername, domain, jwtUserID, nonce string) (*PendingRDPSession, error) {
ps.mu.Lock()
defer ps.mu.Unlock()
// Check nonce for replay protection
if _, seen := ps.nonces[nonce]; seen {
return nil, fmt.Errorf("duplicate nonce: replay detected")
}
ps.nonces[nonce] = struct{}{}
now := time.Now()
session := &PendingRDPSession{
SessionID: uuid.New().String(),
PeerIP: peerIP,
OSUsername: osUsername,
Domain: domain,
JWTUserID: jwtUserID,
Nonce: nonce,
CreatedAt: now,
ExpiresAt: now.Add(ps.ttl),
}
ps.sessions[session.SessionID] = session
log.Debugf("RDP pending session created: id=%s peer=%s user=%s domain=%s expires=%s",
session.SessionID, peerIP, osUsername, domain, session.ExpiresAt.Format(time.RFC3339))
return session, nil
}
// QueryByPeerIP finds the first non-consumed, non-expired pending session for the given peer IP.
func (ps *PendingStore) QueryByPeerIP(peerIP netip.Addr) (*PendingRDPSession, bool) {
ps.mu.RLock()
defer ps.mu.RUnlock()
now := time.Now()
for _, session := range ps.sessions {
if session.PeerIP == peerIP && !session.consumed && now.Before(session.ExpiresAt) {
return session, true
}
}
return nil, false
}
// Consume marks a session as consumed (single-use). Returns true if the session
// was found and successfully consumed, false if it was already consumed, expired, or not found.
func (ps *PendingStore) Consume(sessionID string) bool {
ps.mu.Lock()
defer ps.mu.Unlock()
session, exists := ps.sessions[sessionID]
if !exists {
return false
}
if session.consumed {
log.Debugf("RDP pending session already consumed: id=%s", sessionID)
return false
}
if time.Now().After(session.ExpiresAt) {
log.Debugf("RDP pending session expired: id=%s", sessionID)
return false
}
session.consumed = true
log.Debugf("RDP pending session consumed: id=%s peer=%s user=%s",
sessionID, session.PeerIP, session.OSUsername)
return true
}
// StartCleanup runs a background goroutine that periodically removes expired sessions.
func (ps *PendingStore) StartCleanup(ctx context.Context) {
go func() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ps.cleanup()
}
}
}()
}
// cleanup removes expired and consumed sessions.
func (ps *PendingStore) cleanup() {
ps.mu.Lock()
defer ps.mu.Unlock()
now := time.Now()
for id, session := range ps.sessions {
if now.After(session.ExpiresAt) || session.consumed {
delete(ps.sessions, id)
delete(ps.nonces, session.Nonce)
}
}
}
// Count returns the number of active (non-expired, non-consumed) sessions.
func (ps *PendingStore) Count() int {
ps.mu.RLock()
defer ps.mu.RUnlock()
count := 0
now := time.Now()
for _, session := range ps.sessions {
if !session.consumed && now.Before(session.ExpiresAt) {
count++
}
}
return count
}
// GenerateNonce creates a cryptographically random nonce for replay protection.
func GenerateNonce() (string, error) {
b := make([]byte, nonceLength)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
return hex.EncodeToString(b), nil
}
+268
View File
@@ -0,0 +1,268 @@
package server
import (
"context"
"net/netip"
"sync"
"testing"
"time"
)
func TestPendingStore_AddAndQuery(t *testing.T) {
store := NewPendingStore(DefaultSessionTTL)
peerIP := netip.MustParseAddr("100.64.0.1")
session, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-1")
if err != nil {
t.Fatalf("Add failed: %v", err)
}
if session.SessionID == "" {
t.Fatal("expected non-empty session ID")
}
if session.PeerIP != peerIP {
t.Errorf("expected peer IP %s, got %s", peerIP, session.PeerIP)
}
if session.OSUsername != "admin" {
t.Errorf("expected username admin, got %s", session.OSUsername)
}
// Query should find the session
found, ok := store.QueryByPeerIP(peerIP)
if !ok {
t.Fatal("expected to find pending session")
}
if found.SessionID != session.SessionID {
t.Errorf("expected session %s, got %s", session.SessionID, found.SessionID)
}
// Query for different IP should not find anything
_, ok = store.QueryByPeerIP(netip.MustParseAddr("100.64.0.2"))
if ok {
t.Fatal("expected no session for different IP")
}
}
func TestPendingStore_Consume(t *testing.T) {
store := NewPendingStore(DefaultSessionTTL)
peerIP := netip.MustParseAddr("100.64.0.1")
session, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-2")
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// First consume should succeed
if !store.Consume(session.SessionID) {
t.Fatal("expected first consume to succeed")
}
// Second consume should fail (already consumed)
if store.Consume(session.SessionID) {
t.Fatal("expected second consume to fail")
}
// Query should no longer find consumed session
_, ok := store.QueryByPeerIP(peerIP)
if ok {
t.Fatal("expected consumed session to not be found by query")
}
}
func TestPendingStore_Expiry(t *testing.T) {
store := NewPendingStore(50 * time.Millisecond)
peerIP := netip.MustParseAddr("100.64.0.1")
session, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-3")
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// Should be found immediately
_, ok := store.QueryByPeerIP(peerIP)
if !ok {
t.Fatal("expected to find session before expiry")
}
// Wait for expiry
time.Sleep(100 * time.Millisecond)
// Should not be found after expiry
_, ok = store.QueryByPeerIP(peerIP)
if ok {
t.Fatal("expected session to be expired")
}
// Consume should also fail
if store.Consume(session.SessionID) {
t.Fatal("expected consume of expired session to fail")
}
}
func TestPendingStore_ReplayProtection(t *testing.T) {
store := NewPendingStore(DefaultSessionTTL)
peerIP := netip.MustParseAddr("100.64.0.1")
_, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-same")
if err != nil {
t.Fatalf("first Add failed: %v", err)
}
// Same nonce should be rejected
_, err = store.Add(peerIP, "admin", ".", "user@example.com", "nonce-same")
if err == nil {
t.Fatal("expected duplicate nonce to be rejected")
}
}
func TestPendingStore_Cleanup(t *testing.T) {
store := NewPendingStore(50 * time.Millisecond)
peerIP := netip.MustParseAddr("100.64.0.1")
_, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-cleanup")
if err != nil {
t.Fatalf("Add failed: %v", err)
}
if store.Count() != 1 {
t.Fatalf("expected count 1, got %d", store.Count())
}
// Wait for expiry then trigger cleanup
time.Sleep(100 * time.Millisecond)
store.cleanup()
if store.Count() != 0 {
t.Fatalf("expected count 0 after cleanup, got %d", store.Count())
}
}
func TestPendingStore_CleanupBackground(t *testing.T) {
store := NewPendingStore(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store.StartCleanup(ctx)
peerIP := netip.MustParseAddr("100.64.0.1")
_, err := store.Add(peerIP, "admin", ".", "user@example.com", "nonce-bg-cleanup")
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// Wait for expiry + cleanup interval
time.Sleep(200 * time.Millisecond)
_, ok := store.QueryByPeerIP(peerIP)
if ok {
t.Fatal("expected session to be cleaned up")
}
}
func TestPendingStore_ConcurrentAccess(t *testing.T) {
store := NewPendingStore(DefaultSessionTTL)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
ip := netip.AddrFrom4([4]byte{100, 64, byte(i / 256), byte(i % 256)})
nonce := "nonce-" + string(rune(i+'A'))
if i >= 26 {
nonce = "nonce-" + string(rune(i-26+'a'))
}
session, err := store.Add(ip, "admin", ".", "user", nonce)
if err != nil {
return // nonce collision in test is expected
}
store.QueryByPeerIP(ip)
store.Consume(session.SessionID)
}(i)
}
wg.Wait()
}
func TestPendingStore_MultipleSessions(t *testing.T) {
store := NewPendingStore(DefaultSessionTTL)
ip1 := netip.MustParseAddr("100.64.0.1")
ip2 := netip.MustParseAddr("100.64.0.2")
s1, err := store.Add(ip1, "admin", ".", "user1", "nonce-a")
if err != nil {
t.Fatalf("Add s1 failed: %v", err)
}
s2, err := store.Add(ip2, "jdoe", "DOMAIN", "user2", "nonce-b")
if err != nil {
t.Fatalf("Add s2 failed: %v", err)
}
// Query each
found1, ok := store.QueryByPeerIP(ip1)
if !ok || found1.SessionID != s1.SessionID {
t.Fatal("expected to find s1")
}
found2, ok := store.QueryByPeerIP(ip2)
if !ok || found2.SessionID != s2.SessionID {
t.Fatal("expected to find s2")
}
if found2.Domain != "DOMAIN" {
t.Errorf("expected domain DOMAIN, got %s", found2.Domain)
}
if store.Count() != 2 {
t.Errorf("expected count 2, got %d", store.Count())
}
}
func TestGenerateNonce(t *testing.T) {
nonce1, err := GenerateNonce()
if err != nil {
t.Fatalf("GenerateNonce failed: %v", err)
}
nonce2, err := GenerateNonce()
if err != nil {
t.Fatalf("GenerateNonce failed: %v", err)
}
if len(nonce1) != nonceLength*2 { // hex encoding doubles the length
t.Errorf("expected nonce length %d, got %d", nonceLength*2, len(nonce1))
}
if nonce1 == nonce2 {
t.Error("expected unique nonces")
}
}
func TestParseWindowsUsername(t *testing.T) {
tests := []struct {
input string
expectedUser string
expectedDomain string
}{
{"admin", "admin", "."},
{"DOMAIN\\admin", "admin", "DOMAIN"},
{"admin@domain.com", "admin", "domain.com"},
{".\\localuser", "localuser", "."},
}
for _, tt := range tests {
user, domain := parseWindowsUsername(tt.input)
if user != tt.expectedUser {
t.Errorf("parseWindowsUsername(%q) user = %q, want %q", tt.input, user, tt.expectedUser)
}
if domain != tt.expectedDomain {
t.Errorf("parseWindowsUsername(%q) domain = %q, want %q", tt.input, domain, tt.expectedDomain)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package server
import "context"
type stubPipeServer struct{}
func newPipeServer(_ *PendingStore) PipeServer {
return &stubPipeServer{}
}
func (s *stubPipeServer) Start(_ context.Context) error {
return nil
}
func (s *stubPipeServer) Stop() error {
return nil
}
+164
View File
@@ -0,0 +1,164 @@
//go:build windows
package server
import (
"context"
"encoding/json"
"io"
"net"
"sync"
log "github.com/sirupsen/logrus"
"github.com/Microsoft/go-winio"
)
const (
// PipeName is the named pipe path used for IPC between the NetBird agent and
// the Credential Provider DLL.
PipeName = `\\.\pipe\netbird-rdp-auth`
// pipeSDDL restricts access to LOCAL_SYSTEM (SY) and Administrators (BA).
pipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)"
// maxPipeRequestSize is the maximum size of a pipe request in bytes.
maxPipeRequestSize = 4096
)
// windowsPipeServer implements the PipeServer interface for Windows.
type windowsPipeServer struct {
pending *PendingStore
listener net.Listener
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
func newPipeServer(pending *PendingStore) PipeServer {
return &windowsPipeServer{
pending: pending,
}
}
func (s *windowsPipeServer) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.ctx, s.cancel = context.WithCancel(ctx)
cfg := &winio.PipeConfig{
SecurityDescriptor: pipeSDDL,
}
listener, err := winio.ListenPipe(PipeName, cfg)
if err != nil {
return err
}
s.listener = listener
go s.acceptLoop()
log.Infof("RDP named pipe server started on %s", PipeName)
return nil
}
func (s *windowsPipeServer) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cancel != nil {
s.cancel()
}
if s.listener != nil {
err := s.listener.Close()
s.listener = nil
return err
}
return nil
}
func (s *windowsPipeServer) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
if s.ctx.Err() != nil {
return
}
log.Debugf("RDP pipe accept error: %v", err)
continue
}
go s.handlePipeConnection(conn)
}
}
func (s *windowsPipeServer) handlePipeConnection(conn net.Conn) {
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("RDP pipe close: %v", err)
}
}()
data, err := io.ReadAll(io.LimitReader(conn, maxPipeRequestSize))
if err != nil {
log.Debugf("RDP pipe read: %v", err)
return
}
var req PipeRequest
if err := json.Unmarshal(data, &req); err != nil {
log.Debugf("RDP pipe unmarshal: %v", err)
return
}
var resp PipeResponse
switch req.Action {
case PipeActionQuery:
resp = s.handleQuery(req.RemoteIP)
case PipeActionConsume:
resp = s.handleConsume(req.SessionID)
default:
log.Debugf("RDP pipe unknown action: %s", req.Action)
return
}
respData, err := json.Marshal(resp)
if err != nil {
log.Debugf("RDP pipe marshal response: %v", err)
return
}
if _, err := conn.Write(respData); err != nil {
log.Debugf("RDP pipe write response: %v", err)
}
}
func (s *windowsPipeServer) handleQuery(remoteIP string) PipeResponse {
peerIP, err := parseAddr(remoteIP)
if err != nil {
log.Debugf("RDP pipe invalid remote IP: %s", remoteIP)
return PipeResponse{Found: false}
}
session, found := s.pending.QueryByPeerIP(peerIP)
if !found {
return PipeResponse{Found: false}
}
return PipeResponse{
Found: true,
SessionID: session.SessionID,
OSUser: session.OSUsername,
Domain: session.Domain,
}
}
func (s *windowsPipeServer) handleConsume(sessionID string) PipeResponse {
if s.pending.Consume(sessionID) {
return PipeResponse{Found: true, SessionID: sessionID}
}
return PipeResponse{Found: false}
}
+48
View File
@@ -0,0 +1,48 @@
package server
// AuthRequest is the sideband authorization request sent by the connecting peer
// to the target peer's RDP auth server over the WireGuard tunnel.
type AuthRequest struct {
JWTToken string `json:"jwt_token"`
RequestedUser string `json:"requested_user"`
ClientPeerIP string `json:"client_peer_ip"`
Nonce string `json:"nonce"`
}
// AuthResponse is the sideband authorization response sent by the target peer
// back to the connecting peer.
type AuthResponse struct {
Status string `json:"status"` // "authorized" or "denied"
SessionID string `json:"session_id,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"` // unix timestamp
OSUser string `json:"os_user,omitempty"`
Reason string `json:"reason,omitempty"`
}
// PipeRequest is the IPC request from the Credential Provider DLL to the NetBird agent
// via the named pipe.
type PipeRequest struct {
Action string `json:"action"` // "query_pending" or "consume"
RemoteIP string `json:"remote_ip"` // connecting peer's WG IP
SessionID string `json:"session_id,omitempty"` // for consume action
}
// PipeResponse is the IPC response from the NetBird agent to the Credential Provider DLL.
type PipeResponse struct {
Found bool `json:"found"`
SessionID string `json:"session_id,omitempty"`
OSUser string `json:"os_user,omitempty"`
Domain string `json:"domain,omitempty"`
}
const (
// StatusAuthorized indicates the RDP session was authorized.
StatusAuthorized = "authorized"
// StatusDenied indicates the RDP session was denied.
StatusDenied = "denied"
// PipeActionQuery queries for a pending session by remote IP.
PipeActionQuery = "query_pending"
// PipeActionConsume marks a pending session as consumed.
PipeActionConsume = "consume"
)
+286
View File
@@ -0,0 +1,286 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/netip"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const (
// InternalRDPAuthPort is the internal port the sideband auth server listens on.
InternalRDPAuthPort = 22023
// DefaultRDPAuthPort is the external port on the WireGuard interface (DNAT target).
DefaultRDPAuthPort = 3390
// maxRequestSize is the maximum size of an auth request in bytes.
maxRequestSize = 64 * 1024
// connectionTimeout is the timeout for a single auth connection.
connectionTimeout = 30 * time.Second
)
// JWTValidator validates JWT tokens and extracts user identity.
type JWTValidator interface {
ValidateAndExtract(token string) (userID string, err error)
}
// Authorizer checks if a user is authorized for RDP access.
type Authorizer interface {
Authorize(jwtUserID, osUsername string) (string, error)
}
// Server is the sideband RDP authorization server that listens on the WireGuard interface.
type Server struct {
listener net.Listener
pending *PendingStore
pipeServer PipeServer
jwtValidator JWTValidator
authorizer Authorizer
networkAddr netip.Prefix // WireGuard network for source IP validation
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// PipeServer is the interface for the named pipe IPC server (platform-specific).
type PipeServer interface {
Start(ctx context.Context) error
Stop() error
}
// Config holds the configuration for the RDP auth server.
type Config struct {
JWTValidator JWTValidator
Authorizer Authorizer
NetworkAddr netip.Prefix
SessionTTL time.Duration
}
// New creates a new RDP sideband auth server.
func New(cfg *Config) *Server {
ttl := cfg.SessionTTL
if ttl <= 0 {
ttl = DefaultSessionTTL
}
pending := NewPendingStore(ttl)
return &Server{
pending: pending,
pipeServer: newPipeServer(pending),
jwtValidator: cfg.JWTValidator,
authorizer: cfg.Authorizer,
networkAddr: cfg.NetworkAddr,
}
}
// Start begins listening for sideband auth requests on the given address.
func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.listener != nil {
return errors.New("RDP auth server already running")
}
s.ctx, s.cancel = context.WithCancel(ctx)
listenAddr := net.TCPAddrFromAddrPort(addr)
listener, err := net.ListenTCP("tcp", listenAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", addr, err)
}
s.listener = listener
s.pending.StartCleanup(s.ctx)
if s.pipeServer != nil {
if err := s.pipeServer.Start(s.ctx); err != nil {
log.Warnf("failed to start RDP named pipe server: %v", err)
}
}
go s.acceptLoop()
log.Infof("RDP sideband auth server started on %s", addr)
return nil
}
// Stop shuts down the server and cleans up resources.
func (s *Server) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cancel != nil {
s.cancel()
}
if s.pipeServer != nil {
if err := s.pipeServer.Stop(); err != nil {
log.Warnf("failed to stop RDP named pipe server: %v", err)
}
}
if s.listener != nil {
err := s.listener.Close()
s.listener = nil
if err != nil {
return fmt.Errorf("close listener: %w", err)
}
}
log.Info("RDP sideband auth server stopped")
return nil
}
// GetPendingStore returns the pending session store (for testing/named pipe access).
func (s *Server) GetPendingStore() *PendingStore {
return s.pending
}
func (s *Server) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
if s.ctx.Err() != nil {
return
}
log.Debugf("RDP auth accept error: %v", err)
continue
}
go s.handleConnection(conn)
}
}
func (s *Server) handleConnection(conn net.Conn) {
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("RDP auth close connection: %v", err)
}
}()
if err := conn.SetDeadline(time.Now().Add(connectionTimeout)); err != nil {
log.Debugf("RDP auth set deadline: %v", err)
return
}
// Validate source IP is from WireGuard network
remoteAddr, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
log.Debugf("RDP auth parse remote addr: %v", err)
return
}
if !s.networkAddr.Contains(remoteAddr.Addr()) {
log.Warnf("RDP auth rejected connection from non-WG address: %s", remoteAddr.Addr())
return
}
// Read request
data, err := io.ReadAll(io.LimitReader(conn, maxRequestSize))
if err != nil {
log.Debugf("RDP auth read request: %v", err)
return
}
var req AuthRequest
if err := json.Unmarshal(data, &req); err != nil {
log.Debugf("RDP auth unmarshal request: %v", err)
s.sendResponse(conn, &AuthResponse{Status: StatusDenied, Reason: "invalid request format"})
return
}
response := s.processAuthRequest(remoteAddr.Addr(), &req)
s.sendResponse(conn, response)
}
func (s *Server) processAuthRequest(peerIP netip.Addr, req *AuthRequest) *AuthResponse {
// Validate JWT
if s.jwtValidator == nil {
// No JWT validation configured - for POC, accept all requests from WG peers
log.Warnf("RDP auth: no JWT validator configured, accepting request from %s", peerIP)
return s.createSession(peerIP, req, "no-jwt-validation")
}
userID, err := s.jwtValidator.ValidateAndExtract(req.JWTToken)
if err != nil {
log.Warnf("RDP auth JWT validation failed for %s: %v", peerIP, err)
return &AuthResponse{Status: StatusDenied, Reason: "JWT validation failed"}
}
// Check authorization
if s.authorizer != nil {
if _, err := s.authorizer.Authorize(userID, req.RequestedUser); err != nil {
log.Warnf("RDP auth denied for user %s -> %s: %v", userID, req.RequestedUser, err)
return &AuthResponse{Status: StatusDenied, Reason: "not authorized for this user"}
}
}
return s.createSession(peerIP, req, userID)
}
func (s *Server) createSession(peerIP netip.Addr, req *AuthRequest, jwtUserID string) *AuthResponse {
// Parse domain from requested user (DOMAIN\user or user@domain)
osUser, domain := parseWindowsUsername(req.RequestedUser)
session, err := s.pending.Add(peerIP, osUser, domain, jwtUserID, req.Nonce)
if err != nil {
log.Warnf("RDP auth create session failed: %v", err)
return &AuthResponse{Status: StatusDenied, Reason: err.Error()}
}
return &AuthResponse{
Status: StatusAuthorized,
SessionID: session.SessionID,
ExpiresAt: session.ExpiresAt.Unix(),
OSUser: session.OSUsername,
}
}
func (s *Server) sendResponse(conn net.Conn, resp *AuthResponse) {
data, err := json.Marshal(resp)
if err != nil {
log.Debugf("RDP auth marshal response: %v", err)
return
}
if _, err := conn.Write(data); err != nil {
log.Debugf("RDP auth write response: %v", err)
}
}
// parseWindowsUsername extracts username and domain from Windows username formats.
// Supports DOMAIN\username, username@domain, and plain username.
func parseWindowsUsername(fullUsername string) (username, domain string) {
for i := len(fullUsername) - 1; i >= 0; i-- {
if fullUsername[i] == '\\' {
return fullUsername[i+1:], fullUsername[:i]
}
}
if idx := indexOf(fullUsername, '@'); idx != -1 {
return fullUsername[:idx], fullUsername[idx+1:]
}
return fullUsername, "."
}
func indexOf(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}