Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/run-cli-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,25 @@ jobs:
go-version: "1.25.9"
- name: Install dependencies
run: go get .
- name: Cache cargo registry + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
packages/pam/handlers/rdp/native/target
key: rdp-bridge-cargo-${{ runner.os }}-${{ hashFiles('packages/pam/handlers/rdp/native/Cargo.lock') }}
restore-keys: rdp-bridge-cargo-${{ runner.os }}-
- name: Install pinned Rust toolchain
working-directory: packages/pam/handlers/rdp/native
run: rustup show active-toolchain
- name: Build Rust RDP bridge
working-directory: packages/pam/handlers/rdp/native
run: cargo build --release
- name: Build the CLI
run: go build -o infisical-cli
run: CGO_ENABLED=1 go build -tags rdp -o infisical-cli
- name: Install RDP test dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends freerdp2-x11 xvfb
- name: Checkout infisical repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
2 changes: 2 additions & 0 deletions e2e/openapi-cfg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ output-options:
- createSshPamResource
- createSshPamAccount
- createRedisPamAccount
- createWindowsPamResource
- createWindowsPamAccount
318 changes: 318 additions & 0 deletions e2e/pam/rdp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
package pam

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os/exec"
"strings"
"sync"
"testing"
"time"

"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/google/uuid"
helpers "github.com/infisical/cli/e2e-tests/util"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

const (
rdpUser = "testuser"
rdpPassword = "testpass"
)

func startRDPContainer(t *testing.T, ctx context.Context) (testcontainers.Container, int) {
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: "testdata/rdp-server",
Dockerfile: "Dockerfile",
},
ExposedPorts: []string{"3389/tcp"},
HostConfigModifier: func(hc *container.HostConfig) {
hc.ExtraHosts = append(hc.ExtraHosts, "host.docker.internal:host-gateway")
},
WaitingFor: wait.ForListeningPort("3389/tcp").WithStartupTimeout(60 * time.Second),
},
Started: true,
})
require.NoError(t, err)
t.Cleanup(func() {
if err := ctr.Terminate(ctx); err != nil {
t.Logf("Failed to terminate RDP container: %v", err)
}
})

port, err := ctr.MappedPort(ctx, "3389")
require.NoError(t, err)
return ctr, port.Int()
}

func pamAPIRequest(t *testing.T, infra *PAMTestInfra, method, path string, body interface{}) (int, []byte) {
jsonBody, err := json.Marshal(body)
require.NoError(t, err)

url := infra.Infisical.ApiUrl(t) + path
req, err := http.NewRequest(method, url, bytes.NewReader(jsonBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+infra.ProvisionResult.Token)

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, respBody
}

func setupRecordingConfig(t *testing.T, ctx context.Context, infra *PAMTestInfra) {
dbContainer, err := infra.Infisical.Compose().ServiceContainer(ctx, "db")
require.NoError(t, err)
dbPort, err := dbContainer.MappedPort(ctx, nat.Port("5432"))
require.NoError(t, err)

connStr := fmt.Sprintf("postgres://infisical:infisical@localhost:%s/infisical", dbPort.Port())
conn, err := pgx.Connect(ctx, connStr)
require.NoError(t, err)
defer conn.Close(ctx)

_, err = conn.Exec(ctx, `SET session_replication_role = 'replica'`)
require.NoError(t, err)
defer func() {
_, _ = conn.Exec(ctx, `SET session_replication_role = 'origin'`)
}()

_, err = conn.Exec(ctx, `
INSERT INTO pam_project_recording_configs (id, "projectId", "storageBackend", "connectionId", bucket, region)
VALUES ($1, $2, 'aws-s3', $3, 'e2e-test-bucket', 'us-east-1')
ON CONFLICT ("projectId") DO NOTHING`,
uuid.New().String(), infra.ProjectId, uuid.New().String(),
)
require.NoError(t, err)
slog.Info("Inserted recording config for project", "projectId", infra.ProjectId)
}

func createRDPPamResource(t *testing.T, ctx context.Context, infra *PAMTestInfra, name, host string, port int) uuid.UUID {
status, respBody := pamAPIRequest(t, infra, "POST", "/api/v1/pam/resources/windows", map[string]interface{}{
"projectId": infra.ProjectId,
"gatewayId": infra.GatewayId,
"name": name,
"connectionDetails": map[string]interface{}{
"protocol": "rdp",
"hostname": host,
"port": port,
"winrmPort": 5985,
"useWinrmHttps": false,
"winrmRejectUnauthorized": false,
},
})
require.Equal(t, http.StatusOK, status, "create Windows resource: %s", string(respBody))

var result struct {
Resource struct {
Id uuid.UUID `json:"id"`
} `json:"resource"`
}
require.NoError(t, json.Unmarshal(respBody, &result))
slog.Info("Created Windows PAM resource", "resourceId", result.Resource.Id, "name", name)
return result.Resource.Id
}

func createRDPPamAccount(t *testing.T, ctx context.Context, infra *PAMTestInfra, resourceId uuid.UUID, name, username, password string) {
body := map[string]interface{}{
"resourceId": resourceId.String(),
"name": name,
"credentials": map[string]interface{}{
"username": username,
"password": password,
},
"internalMetadata": map[string]interface{}{
"accountType": "user",
},
}

result := helpers.WaitFor(t, helpers.WaitForOptions{
Timeout: 90 * time.Second,
Interval: 3 * time.Second,
Condition: func() helpers.ConditionResult {
status, respBody := pamAPIRequest(t, infra, "POST", "/api/v1/pam/accounts/windows", body)
if status != http.StatusOK {
slog.Warn("Windows PAM account creation returned non-200, retrying...", "status", status, "body", string(respBody))
return helpers.ConditionWait
}
return helpers.ConditionSuccess
},
})
require.Equal(t, helpers.WaitSuccess, result, "Windows PAM account creation should succeed for %s", name)
slog.Info("Created Windows PAM account", "name", name)
}

func startRDPProxy(t *testing.T, ctx context.Context, infra *PAMTestInfra, resourceName, accountName, duration string, port int) (int, *helpers.Command) {
pamCmd := helpers.Command{
Test: t,
RunMethod: helpers.RunMethodSubprocess,
DisableTempHomeDir: true,
Args: []string{
"pam", "rdp", "access",
"--resource", resourceName,
"--account", accountName,
"--project-id", infra.ProjectId,
"--duration", duration,
"--port", fmt.Sprintf("%d", port),
"--no-launch",
},
Env: map[string]string{
"HOME": infra.SharedHomeDir,
"INFISICAL_API_URL": infra.Infisical.ApiUrl(t),
},
}
pamCmd.Start(ctx)
t.Cleanup(pamCmd.Stop)

result := helpers.WaitFor(t, helpers.WaitForOptions{
EnsureCmdRunning: &pamCmd,
Condition: func() helpers.ConditionResult {
if strings.Contains(pamCmd.Stderr(), "RDP Proxy Session Started") {
return helpers.ConditionSuccess
}
return helpers.ConditionWait
},
})
if result != helpers.WaitSuccess {
pamCmd.DumpOutput()
}
require.Equal(t, helpers.WaitSuccess, result, "RDP proxy should start successfully")

return port, &pamCmd
}

func findFreeRDPBinary(t *testing.T) string {
for _, name := range []string{"xfreerdp3", "xfreerdp"} {
if path, err := exec.LookPath(name); err == nil {
return path
}
}
t.Skip("xfreerdp not found; install freerdp2-x11 or freerdp3-x11")
return ""
}

func authOnlyFreeRDP(t *testing.T, ctx context.Context, binary string, proxyPort int, timeout time.Duration) error {
args := []string{
binary,
fmt.Sprintf("/v:127.0.0.1:%d", proxyPort),
"/u:testuser",
"/p:",
"/cert:ignore",
"/auth-only",
fmt.Sprintf("/timeout:%d", int(timeout.Milliseconds())),
}

cmdCtx, cancel := context.WithTimeout(ctx, timeout+10*time.Second)
defer cancel()

cmd := exec.CommandContext(cmdCtx, args[0], args[1:]...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("xfreerdp /auth-only failed (exit %v): %s", err, string(output))
}
return nil
}

func TestPAM_RDP(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

infra := SetupPAMInfra(t, ctx)
LoginUser(t, ctx, infra)
setupRecordingConfig(t, ctx, infra)

rdpBinary := findFreeRDPBinary(t)
resourceHost := getOutboundIP(t)

t.Run("connection", func(t *testing.T) {
_, rdpPort := startRDPContainer(t, ctx)
slog.Info("RDP container started", "host", resourceHost, "port", rdpPort)

resourceName := "rdp-connection-resource"
resourceId := createRDPPamResource(t, ctx, infra, resourceName, resourceHost, rdpPort)
createRDPPamAccount(t, ctx, infra, resourceId, "rdp-connection-account", rdpUser, rdpPassword)

proxyPort := helpers.GetFreePort()
startRDPProxy(t, ctx, infra, resourceName, "rdp-connection-account", "5m", proxyPort)

err := authOnlyFreeRDP(t, ctx, rdpBinary, proxyPort, 30*time.Second)
require.NoError(t, err, "NLA authentication through proxy should succeed")
slog.Info("RDP connection test passed")
})

t.Run("bad-credentials", func(t *testing.T) {
_, rdpPort := startRDPContainer(t, ctx)

resourceName := "rdp-badcreds-resource"
resourceId := createRDPPamResource(t, ctx, infra, resourceName, resourceHost, rdpPort)
createRDPPamAccount(t, ctx, infra, resourceId, "rdp-badcreds-account", rdpUser, "wrong-password")

proxyPort := helpers.GetFreePort()
startRDPProxy(t, ctx, infra, resourceName, "rdp-badcreds-account", "5m", proxyPort)

err := authOnlyFreeRDP(t, ctx, rdpBinary, proxyPort, 30*time.Second)
require.Error(t, err, "NLA authentication should fail with bad credentials")
slog.Info("Bad credentials test passed", "error", err)
})

t.Run("unreachable-target", func(t *testing.T) {
ctr, rdpPort := startRDPContainer(t, ctx)

resourceName := "rdp-unreachable-resource"
resourceId := createRDPPamResource(t, ctx, infra, resourceName, resourceHost, rdpPort)
createRDPPamAccount(t, ctx, infra, resourceId, "rdp-unreachable-account", rdpUser, rdpPassword)

require.NoError(t, ctr.Terminate(ctx))

proxyPort := helpers.GetFreePort()
startRDPProxy(t, ctx, infra, resourceName, "rdp-unreachable-account", "5m", proxyPort)

err := authOnlyFreeRDP(t, ctx, rdpBinary, proxyPort, 30*time.Second)
require.Error(t, err, "NLA authentication should fail when target is down")
slog.Info("Unreachable target test passed", "error", err)
})

t.Run("concurrent-connections", func(t *testing.T) {
_, rdpPort := startRDPContainer(t, ctx)

resourceName := "rdp-concurrent-resource"
resourceId := createRDPPamResource(t, ctx, infra, resourceName, resourceHost, rdpPort)
createRDPPamAccount(t, ctx, infra, resourceId, "rdp-concurrent-account", rdpUser, rdpPassword)

proxyPort := helpers.GetFreePort()
startRDPProxy(t, ctx, infra, resourceName, "rdp-concurrent-account", "5m", proxyPort)

const numClients = 3
var wg sync.WaitGroup
errs := make([]error, numClients)

for i := 0; i < numClients; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errs[idx] = authOnlyFreeRDP(t, ctx, rdpBinary, proxyPort, 30*time.Second)
}(i)
}

wg.Wait()
for i, err := range errs {
require.NoError(t, err, "concurrent RDP client %d NLA auth should succeed", i)
}
slog.Info("All concurrent RDP connections succeeded", "numClients", numClients)
})
}
16 changes: 16 additions & 0 deletions e2e/pam/testdata/rdp-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
apt-get install -y --no-install-recommends \
xrdp xorgxrdp openbox dbus-x11 xterm && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 3389

ENTRYPOINT ["/entrypoint.sh"]
20 changes: 20 additions & 0 deletions e2e/pam/testdata/rdp-server/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/bin/bash
set -e

useradd -m -s /bin/bash testuser
echo "testuser:testpass" | chpasswd

mkdir -p /home/testuser
echo "openbox-session" > /home/testuser/.xsession
chown testuser:testuser /home/testuser/.xsession

if [ ! -f /etc/xrdp/rsakeys.ini ]; then
xrdp-keygen xrdp auto
fi

mkdir -p /run/dbus
dbus-daemon --system --fork

xrdp-sesman --nodaemon &

exec xrdp --nodaemon
Loading