mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 00:06:38 +00:00
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
185 lines
4.6 KiB
Go
185 lines
4.6 KiB
Go
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
|
|
}
|