mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +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
270 lines
7.0 KiB
Go
270 lines
7.0 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"os/user"
|
|
"strings"
|
|
"syscall"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
rdpclient "github.com/netbirdio/netbird/client/rdp/client"
|
|
rdpserver "github.com/netbirdio/netbird/client/rdp/server"
|
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
var (
|
|
rdpUsername string
|
|
rdpHost string
|
|
rdpNoBrowser bool
|
|
rdpNoCache bool
|
|
)
|
|
|
|
func init() {
|
|
rdpCmd.PersistentFlags().StringVarP(&rdpUsername, "user", "u", "", "Windows username on remote peer")
|
|
rdpCmd.PersistentFlags().BoolVar(&rdpNoBrowser, noBrowserFlag, false, noBrowserDesc)
|
|
rdpCmd.PersistentFlags().BoolVar(&rdpNoCache, "no-cache", false, "Skip cached JWT token and force fresh authentication")
|
|
}
|
|
|
|
var rdpCmd = &cobra.Command{
|
|
Use: "rdp [flags] [user@]host",
|
|
Short: "Connect to a NetBird peer via RDP (passwordless)",
|
|
Long: `Connect to a NetBird peer using Remote Desktop Protocol with token-based
|
|
passwordless authentication. The target peer must have RDP passthrough enabled.
|
|
|
|
This command:
|
|
1. Obtains a JWT token via OIDC authentication
|
|
2. Sends the token to the target peer's sideband auth service
|
|
3. If authorized, launches mstsc.exe to connect
|
|
|
|
Examples:
|
|
netbird rdp peer-hostname
|
|
netbird rdp administrator@peer-hostname
|
|
netbird rdp --user admin peer-hostname`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: rdpFn,
|
|
}
|
|
|
|
func rdpFn(cmd *cobra.Command, args []string) error {
|
|
SetFlagsFromEnvVars(rootCmd)
|
|
SetFlagsFromEnvVars(cmd)
|
|
cmd.SetOut(cmd.OutOrStdout())
|
|
|
|
logOutput := "console"
|
|
if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile {
|
|
logOutput = firstLogFile
|
|
}
|
|
if err := util.InitLog(logLevel, logOutput); err != nil {
|
|
return fmt.Errorf("init log: %w", err)
|
|
}
|
|
|
|
// Parse user@host
|
|
if err := parseRDPHostArg(args[0]); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx := internal.CtxInitState(cmd.Context())
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
|
rdpCtx, cancel := context.WithCancel(ctx)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
if err := runRDP(rdpCtx, cmd); err != nil {
|
|
errCh <- err
|
|
}
|
|
cancel()
|
|
}()
|
|
|
|
select {
|
|
case <-sig:
|
|
cancel()
|
|
<-rdpCtx.Done()
|
|
return nil
|
|
case err := <-errCh:
|
|
return err
|
|
case <-rdpCtx.Done():
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseRDPHostArg(arg string) error {
|
|
if strings.Contains(arg, "@") {
|
|
parts := strings.SplitN(arg, "@", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return errors.New("invalid user@host format")
|
|
}
|
|
if rdpUsername == "" {
|
|
rdpUsername = parts[0]
|
|
}
|
|
rdpHost = parts[1]
|
|
} else {
|
|
rdpHost = arg
|
|
}
|
|
|
|
if rdpUsername == "" {
|
|
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
|
rdpUsername = sudoUser
|
|
} else if currentUser, err := user.Current(); err == nil {
|
|
rdpUsername = currentUser.Username
|
|
} else {
|
|
rdpUsername = "Administrator"
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runRDP(ctx context.Context, cmd *cobra.Command) error {
|
|
// Connect to daemon
|
|
grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
|
|
grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
return fmt.Errorf("connect to daemon: %w", err)
|
|
}
|
|
defer func() { _ = grpcConn.Close() }()
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(grpcConn)
|
|
|
|
// Resolve peer IP
|
|
peerIP, err := resolvePeerIP(ctx, daemonClient, rdpHost)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve peer %s: %w", rdpHost, err)
|
|
}
|
|
|
|
cmd.Printf("Connecting to %s@%s (%s)...\n", rdpUsername, rdpHost, peerIP)
|
|
|
|
// Obtain JWT token
|
|
hint := profilemanager.GetLoginHint()
|
|
var browserOpener func(string) error
|
|
if !rdpNoBrowser {
|
|
browserOpener = util.OpenBrowser
|
|
}
|
|
|
|
jwtToken, err := nbssh.RequestJWTToken(ctx, daemonClient, nil, cmd.ErrOrStderr(), !rdpNoCache, hint, browserOpener)
|
|
if err != nil {
|
|
return fmt.Errorf("JWT authentication: %w", err)
|
|
}
|
|
|
|
log.Debug("JWT authentication successful")
|
|
cmd.Println("Authenticated. Requesting RDP access...")
|
|
|
|
// Generate nonce for replay protection
|
|
nonce, err := rdpserver.GenerateNonce()
|
|
if err != nil {
|
|
return fmt.Errorf("generate nonce: %w", err)
|
|
}
|
|
|
|
// Send sideband auth request
|
|
authClient := rdpclient.New()
|
|
authAddr := net.JoinHostPort(peerIP, fmt.Sprintf("%d", rdpserver.DefaultRDPAuthPort))
|
|
|
|
resp, err := authClient.RequestAuth(ctx, authAddr, &rdpserver.AuthRequest{
|
|
JWTToken: jwtToken,
|
|
RequestedUser: rdpUsername,
|
|
ClientPeerIP: "", // will be filled by the server from the connection
|
|
Nonce: nonce,
|
|
})
|
|
if err != nil {
|
|
cmd.Printf("Failed to authorize RDP session with %s\n", rdpHost)
|
|
cmd.Printf("\nTroubleshooting:\n")
|
|
cmd.Printf(" 1. Check connectivity: netbird status -d\n")
|
|
cmd.Printf(" 2. Verify RDP passthrough is enabled on the target peer\n")
|
|
return fmt.Errorf("sideband auth: %w", err)
|
|
}
|
|
|
|
if resp.Status != rdpserver.StatusAuthorized {
|
|
return fmt.Errorf("RDP access denied: %s", resp.Reason)
|
|
}
|
|
|
|
cmd.Printf("RDP access authorized (session: %s, user: %s)\n", resp.SessionID, resp.OSUser)
|
|
cmd.Printf("Launching Remote Desktop client...\n")
|
|
|
|
// Launch mstsc.exe (platform-specific)
|
|
if err := launchRDPClient(peerIP); err != nil {
|
|
return fmt.Errorf("launch RDP client: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolvePeerIP resolves a peer hostname/FQDN to its WireGuard IP address
|
|
// by querying the daemon for the current peer status.
|
|
func resolvePeerIP(ctx context.Context, client proto.DaemonServiceClient, peerAddress string) (string, error) {
|
|
statusResp, err := client.Status(ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("get daemon status: %w", err)
|
|
}
|
|
|
|
if statusResp.GetFullStatus() == nil {
|
|
return "", errors.New("daemon returned empty status")
|
|
}
|
|
|
|
for _, peer := range statusResp.GetFullStatus().GetPeers() {
|
|
if matchesPeer(peer, peerAddress) {
|
|
ip := peer.GetIP()
|
|
if ip == "" {
|
|
continue
|
|
}
|
|
// Strip CIDR suffix if present
|
|
if idx := strings.Index(ip, "/"); idx != -1 {
|
|
ip = ip[:idx]
|
|
}
|
|
return ip, nil
|
|
}
|
|
}
|
|
|
|
// If not found as a peer name, try as a direct IP
|
|
if addr, err := net.ResolveIPAddr("ip", peerAddress); err == nil {
|
|
return addr.String(), nil
|
|
}
|
|
|
|
return "", fmt.Errorf("peer %q not found in network", peerAddress)
|
|
}
|
|
|
|
func matchesPeer(peer *proto.PeerState, address string) bool {
|
|
address = strings.ToLower(address)
|
|
|
|
if strings.EqualFold(peer.GetFqdn(), address) {
|
|
return true
|
|
}
|
|
|
|
// Match against FQDN without trailing dot
|
|
fqdn := strings.TrimSuffix(peer.GetFqdn(), ".")
|
|
if strings.EqualFold(fqdn, address) {
|
|
return true
|
|
}
|
|
|
|
// Match against short hostname (first part of FQDN)
|
|
if parts := strings.SplitN(fqdn, ".", 2); len(parts) > 0 {
|
|
if strings.EqualFold(parts[0], address) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Match against IP
|
|
ip := peer.GetIP()
|
|
if idx := strings.Index(ip, "/"); idx != -1 {
|
|
ip = ip[:idx]
|
|
}
|
|
if ip == address {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|