mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 08:29:06 +02:00
[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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user