Add scale testing harness and results

Bench suite: k6 load tests, Python Guacamole protocol client,
Vault population scripts, server metrics collector, xrdp target setup.

Results from 100 concurrent RDP session test on 16 GB server:
- rustguac: 45 MB RSS, 9 threads — not the bottleneck
- guacd/FreeRDP: 15.8 GB RSS (~158 MB/session) — primary bottleneck
- Zero errors, p95 session create 98ms, p95 WS connect 63ms
- Address book: 982 entries loads in 2.4s (sequential Vault reads)
This commit is contained in:
Dave Kempe
2026-03-21 12:38:03 +11:00
parent 35f7dd519c
commit 74f67d0ed9
10 changed files with 949 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
# rustguac Benchmarking
Scale testing harness for rustguac. Answers the question: will it handle 100 simultaneous users with 1000+ address book entries?
## Prerequisites
**Load generator machine** (your workstation):
- k6: `sudo apt-get install k6` or https://grafana.com/docs/k6/latest/set-up/install-k6/
- Python 3.10+: `pip install websockets httpx`
**rustguac server** (sol1-remoteconsole or similar):
- rustguac + guacd running
- `rdp_allowed_networks` must include the xrdp target IP
- Increase fd limit: add `LimitNOFILE=65535` to the `[Service]` section in the rustguac systemd unit
- An admin API key for test automation
**xrdp target VM**:
- Run `bench/xrdp-target/setup.sh` as root on a Debian 13 VM
- Creates 100 users (bench01-bench100, password: bench)
- Single VM handles many concurrent RDP sessions
## Test Scenarios
### 1. Single session baseline
```bash
python3 bench/guac-client.py \
--url https://RUSTGUAC:8089 \
--api-key rgu_xxx \
--rdp-host XRDP_IP \
--sessions 1 --duration 120
```
Run `collect-metrics.sh` on the server simultaneously.
### 2. Session ramp-up (10 → 25 → 50 → 75 → 100)
```bash
# On the rustguac server:
bash bench/collect-metrics.sh 5 metrics-ramp.csv &
# On the load generator:
k6 run --env API_KEY=rgu_xxx \
--env BASE_URL=https://RUSTGUAC:8089 \
--env XRDP_HOST=XRDP_IP \
bench/k6-session-ramp.js
```
Or use the Python client for deeper protocol simulation:
```bash
python3 bench/guac-client.py \
--url https://RUSTGUAC:8089 \
--api-key rgu_xxx \
--rdp-host XRDP_IP \
--sessions 50 --duration 300 --stagger 2
```
### 3. Address book scale (10 → 100 → 500 → 1000 entries)
```bash
# Populate
bash bench/populate-vault.sh 100 10 http://VAULT:8200 rgu_xxx https://RUSTGUAC:8089
# Measure
k6 run --env API_KEY=rgu_xxx --env BASE_URL=https://RUSTGUAC:8089 bench/k6-addressbook.js
# Cleanup
bash bench/cleanup-vault.sh rgu_xxx https://RUSTGUAC:8089
# Repeat for 500, 1000
bash bench/populate-vault.sh 500 20 http://VAULT:8200 rgu_xxx https://RUSTGUAC:8089
k6 run --env API_KEY=rgu_xxx --env BASE_URL=https://RUSTGUAC:8089 bench/k6-addressbook.js
bash bench/cleanup-vault.sh rgu_xxx https://RUSTGUAC:8089
```
### 4. Session creation throughput
```bash
k6 run --env API_KEY=rgu_xxx \
--env BASE_URL=https://RUSTGUAC:8089 \
--env XRDP_HOST=XRDP_IP \
bench/k6-session-burst.js
```
### 5. Combined (sessions + address book under load)
Run scenarios 2 and 3 simultaneously from two terminals.
## Metrics
`collect-metrics.sh` outputs CSV with columns:
- `timestamp` — ISO 8601
- `rg_rss_kb` — rustguac RSS memory (KB)
- `rg_threads` — rustguac thread count
- `rg_fds` — rustguac open file descriptors
- `rg_cpu_pct` — rustguac CPU usage (%)
- `gd_rss_kb` — guacd total RSS (all processes, KB)
- `gd_threads` — guacd total thread count
- `gd_fds` — guacd open file descriptors
- `gd_cpu_pct` — guacd CPU usage (%)
- `sys_mem_avail_mb` — system available memory (MB)
- `tcp_established` — established TCP connections
- `tcp_time_wait` — TIME_WAIT TCP connections
## Expected resource usage per RDP session
| Component | Memory | CPU (idle) | CPU (active) |
|-----------|--------|-----------|-------------|
| rustguac | ~0.5-2 MB | ~0% | <1% |
| guacd (FreeRDP) | ~30-50 MB | ~0% | 1-5% |
At 100 sessions: expect ~3-5 GB for guacd, ~200 MB for rustguac. The server needs at least 8 GB RAM.
## Known bottlenecks
1. **guacd memory** — 30-50 MB per RDP session (FreeRDP). This is the hard ceiling.
2. **Address book Vault reads** — O(folders + entries) sequential HTTP calls. 1000 entries ≈ 5-15s.
3. **guacd is single-threaded per session** — CPU-bound for screen encoding.
+106
View File
@@ -0,0 +1,106 @@
# rustguac Scale Test Results
**Date:** 2026-03-21
**Version:** 0.8.1
**Server:** 4 vCPU, 16 GB RAM, Debian 13 (sol1-remoteconsole, 10.10.50.51)
**xrdp target:** 4 vCPU, 16 GB RAM, Debian 13 + xrdp + Xfce (10.10.50.52)
**Load generator:** sacrifice (local workstation), k6 v0.56.0
**Address book:** 982 entries across 21 folders (Vault KV v2)
## Summary
rustguac comfortably handles **100 concurrent RDP sessions** with zero errors and flat latencies. The Rust proxy itself is negligible overhead — **guacd (FreeRDP) is the sole bottleneck**, consuming ~158 MB RAM per session.
## Test 1: Session Ramp (0 → 100 concurrent)
k6 ramping-vus: 0→10→25→50→75→100, hold 2 min, ramp down.
### k6 Results
| Metric | Value |
|--------|-------|
| Sessions created | 701 |
| HTTP failures | 0 (0.00%) |
| Session create latency (p95) | 98 ms |
| WebSocket connect (p95) | 63 ms |
| Session create (max) | 477 ms |
| Peak concurrent VUs | 100 |
### Server Resource Usage
| Component | Baseline | Peak (100 sessions) | Per-session |
|-----------|----------|---------------------|-------------|
| rustguac RSS | 18 MB | 45 MB | ~0.3 MB |
| guacd RSS | 19 MB | 15,854 MB | ~158 MB |
| rustguac threads | 5 | 9 | negligible |
| guacd threads | 1 | 4,962 | ~50 |
| rustguac FDs | 11 | 351 | ~3.4 |
| TCP connections | 3 | 665 | ~6.6 |
| Available RAM | 15,471 MB | 6,060 MB | — |
### Key Observations
- **rustguac is not the bottleneck.** 27 MB additional RSS for 100 sessions. The tokio runtime added only 4 threads.
- **guacd/FreeRDP is the bottleneck.** ~158 MB per RDP session, ~50 threads per session. This is FreeRDP's in-process RDP client + screen encoding.
- **Latencies stayed flat.** Session creation p95 remained under 100ms from 1 to 100 sessions — no degradation.
- **Zero errors.** Every session created and connected successfully at every concurrency level.
- **6 GB headroom remaining** at peak on a 16 GB machine. Estimated ceiling: ~130 concurrent RDP sessions on this hardware.
## Test 2: Address Book Scale
Sequential `GET /api/addressbook` response times with increasing Vault entry counts.
| Entries | Folders | Response time |
|---------|---------|---------------|
| 182 | 13 | 0.5s |
| 982 | 21 | 2.4s |
The address book endpoint performs O(folders + entries) sequential Vault HTTP calls. At 1000 entries this is functional but slow. Parallelising Vault reads or adding a short TTL cache would bring this under 500ms.
## Test 3: Earlier Run (75 sessions, 8 GB RAM)
An earlier test on 8 GB RAM reached 75 concurrent sessions before available memory dropped to 732 MB. This confirmed the linear ~158 MB/session scaling for guacd and validated that 16 GB was needed for 100 sessions.
## Bottleneck Analysis
### 1. guacd memory (primary bottleneck)
Each RDP session runs FreeRDP in-process within guacd, consuming ~158 MB. This is the hard ceiling on concurrent sessions. For SSH sessions (no FreeRDP), guacd uses ~2-5 MB per session — roughly 30x more efficient.
**Scaling formula:**
`max_rdp_sessions ≈ (total_ram - 2 GB for OS - 50 MB for rustguac) / 158 MB`
| Server RAM | Max RDP sessions (est.) |
|------------|------------------------|
| 8 GB | ~38 |
| 16 GB | ~88 |
| 32 GB | ~190 |
| 64 GB | ~392 |
### 2. Address book Vault reads (API bottleneck)
`GET /api/addressbook` performs sequential HTTP calls to Vault: 1 LIST + N folder GETs + N entry LISTs + M entry GETs. At 1000 entries this takes ~2.4s. Not a session bottleneck but affects page load time.
**Optimisation opportunities:**
- Parallel Vault fetches with `tokio::join_all`
- Short TTL cache (30-60s) for address book data
- Pagination for large folder listings
### 3. Non-bottlenecks
- **rustguac CPU/memory:** Negligible. The WebSocket proxy is two tokio tasks per session forwarding bytes through an 8 KB buffer.
- **rustguac file descriptors:** 351 at peak, well under default limits.
- **Session creation latency:** Flat at all concurrency levels. guacd handshake is fast.
- **Network:** ~10 KB/s per idle session. Active sessions with screen changes: ~100-500 KB/s typical for RDP.
- **SQLite:** Not in the hot path. Only used for auth/token validation.
- **tokio runtime:** 9 threads at peak for 200 async tasks (2 per session). No contention.
## Reproducing These Tests
See [README.md](README.md) for setup instructions. Key steps:
1. Set up an xrdp target VM: `bash bench/xrdp-target/setup.sh`
2. Create a rustguac API token
3. Populate address book: `bash bench/populate-vault.sh 1000 20 ...`
4. Run metrics collection: `bash bench/collect-metrics.sh 5 metrics.csv`
5. Run ramp test: `k6 run --env API_KEY=... --env XRDP_HOST=... bench/k6-session-ramp.js`
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Remove all bench-folder-* entries from the address book.
# Usage: ./cleanup-vault.sh [api_key] [rustguac_url]
set -e
API_KEY=${1:-""}
RUSTGUAC_URL=${2:-"https://localhost:8089"}
if [ -z "$API_KEY" ]; then
echo "Usage: $0 <api_key> [rustguac_url]"
exit 1
fi
CURL="curl -sk -H X-API-Key:${API_KEY}"
echo "Fetching folders..."
FOLDERS=$($CURL "$RUSTGUAC_URL/api/addressbook" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for f in data.get('folders', []):
if f['name'].startswith('bench-folder-'):
print(f['scope'] + '/' + f['name'])
" 2>/dev/null)
if [ -z "$FOLDERS" ]; then
echo "No bench folders found."
exit 0
fi
COUNT=$(echo "$FOLDERS" | wc -l)
echo "Deleting $COUNT bench folders..."
for folder in $FOLDERS; do
SCOPE=$(echo "$folder" | cut -d/ -f1)
NAME=$(echo "$folder" | cut -d/ -f2)
$CURL -X DELETE "$RUSTGUAC_URL/api/addressbook/folders/$SCOPE/$NAME" -o /dev/null 2>/dev/null
echo " Deleted $SCOPE/$NAME"
done
echo "Done."
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Collect system metrics for rustguac + guacd every N seconds.
# Usage: ./collect-metrics.sh [interval_secs] [output_file]
# Run on the rustguac server during benchmarks.
set -e
INTERVAL=${1:-5}
OUTPUT=${2:-"metrics-$(date +%Y%m%d-%H%M%S).csv"}
echo "Collecting metrics every ${INTERVAL}s → $OUTPUT"
echo "Press Ctrl+C to stop."
echo "timestamp,rg_rss_kb,rg_threads,rg_fds,rg_cpu_pct,gd_rss_kb,gd_threads,gd_fds,gd_cpu_pct,sys_mem_avail_mb,tcp_established,tcp_time_wait" > "$OUTPUT"
# Track CPU usage between samples
PREV_RG_UTIME=0; PREV_RG_STIME=0; PREV_GD_UTIME=0; PREV_GD_STIME=0
PREV_TS=$(date +%s%N)
CLK_TCK=$(getconf CLK_TCK)
while true; do
TS=$(date -Iseconds)
NOW=$(date +%s%N)
RG_PID=$(pgrep -x rustguac 2>/dev/null | head -1)
GD_PID=$(pgrep -x guacd 2>/dev/null | head -1)
# rustguac metrics
if [ -n "$RG_PID" ] && [ -d "/proc/$RG_PID" ]; then
RG_RSS=$(awk '/VmRSS/{print $2}' /proc/$RG_PID/status 2>/dev/null || echo 0)
RG_THR=$(awk '/Threads/{print $2}' /proc/$RG_PID/status 2>/dev/null || echo 0)
RG_FDS=$(ls /proc/$RG_PID/fd 2>/dev/null | wc -l)
read RG_UTIME RG_STIME < <(awk '{print $14, $15}' /proc/$RG_PID/stat 2>/dev/null || echo "0 0")
ELAPSED_NS=$((NOW - PREV_TS))
if [ "$ELAPSED_NS" -gt 0 ]; then
DTICKS=$(( (RG_UTIME + RG_STIME) - (PREV_RG_UTIME + PREV_RG_STIME) ))
RG_CPU=$(awk "BEGIN{printf \"%.1f\", $DTICKS / $CLK_TCK / ($ELAPSED_NS / 1000000000) * 100}")
else
RG_CPU="0.0"
fi
PREV_RG_UTIME=$RG_UTIME; PREV_RG_STIME=$RG_STIME
else
RG_RSS=0; RG_THR=0; RG_FDS=0; RG_CPU="0.0"
fi
# guacd metrics (sum all guacd child processes)
if [ -n "$GD_PID" ]; then
GD_RSS=$(awk '/VmRSS/{sum+=$2} END{print sum+0}' /proc/[0-9]*/status 2>/dev/null | head -1)
# More accurate: sum RSS of guacd parent + children
GD_RSS=$(ps -C guacd -o rss= 2>/dev/null | awk '{sum+=$1} END{print sum+0}')
GD_THR=$(ps -C guacd -o nlwp= 2>/dev/null | awk '{sum+=$1} END{print sum+0}')
GD_FDS=$(ls /proc/$GD_PID/fd 2>/dev/null | wc -l)
read GD_UTIME GD_STIME < <(awk '{print $14, $15}' /proc/$GD_PID/stat 2>/dev/null || echo "0 0")
if [ "$ELAPSED_NS" -gt 0 ]; then
DTICKS=$(( (GD_UTIME + GD_STIME) - (PREV_GD_UTIME + PREV_GD_STIME) ))
GD_CPU=$(awk "BEGIN{printf \"%.1f\", $DTICKS / $CLK_TCK / ($ELAPSED_NS / 1000000000) * 100}")
else
GD_CPU="0.0"
fi
PREV_GD_UTIME=$GD_UTIME; PREV_GD_STIME=$GD_STIME
else
GD_RSS=0; GD_THR=0; GD_FDS=0; GD_CPU="0.0"
fi
# System metrics
MEM_AVAIL=$(awk '/MemAvailable/{printf "%.0f", $2/1024}' /proc/meminfo)
TCP_EST=$(ss -tn state established 2>/dev/null | tail -n +2 | wc -l)
TCP_TW=$(ss -tn state time-wait 2>/dev/null | tail -n +2 | wc -l)
echo "$TS,$RG_RSS,$RG_THR,$RG_FDS,$RG_CPU,$GD_RSS,$GD_THR,$GD_FDS,$GD_CPU,$MEM_AVAIL,$TCP_EST,$TCP_TW" >> "$OUTPUT"
PREV_TS=$NOW
sleep "$INTERVAL"
done
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Minimal Guacamole WebSocket client for benchmarking.
Simulates realistic user sessions with mouse/keyboard activity.
Usage:
# Single session
python3 guac-client.py --url https://10.10.50.51:8089 --api-key rgu_xxx \
--rdp-host 10.10.50.52 --duration 120
# Multiple concurrent sessions
python3 guac-client.py --url https://10.10.50.51:8089 --api-key rgu_xxx \
--rdp-host 10.10.50.52 --sessions 50 --duration 300
Requirements: pip install websockets httpx
"""
import argparse
import asyncio
import json
import random
import ssl
import sys
import time
from dataclasses import dataclass, field
import httpx
import websockets
def encode_instruction(opcode: str, *args) -> str:
"""Encode a Guacamole protocol instruction."""
parts = [f"{len(opcode)}.{opcode}"]
for arg in args:
s = str(arg)
parts.append(f"{len(s)}.{s}")
return ",".join(parts) + ";"
@dataclass
class SessionStats:
session_id: str = ""
user: str = ""
create_ms: float = 0
connect_ms: float = 0
messages_in: int = 0
bytes_in: int = 0
messages_out: int = 0
duration_secs: float = 0
error: str = ""
async def run_session(
base_url: str,
api_key: str,
rdp_host: str,
rdp_port: int,
user_num: int,
duration_secs: int,
stats: SessionStats,
):
"""Create a session, connect via WebSocket, simulate activity, disconnect."""
username = f"bench{user_num:02d}"
stats.user = username
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
# Create session
t0 = time.monotonic()
async with httpx.AsyncClient(verify=False, timeout=30) as client:
try:
resp = await client.post(
f"{base_url}/api/sessions",
json={
"session_type": "rdp",
"hostname": rdp_host,
"port": rdp_port,
"username": username,
"password": "bench",
"width": 1024,
"height": 768,
"ignore_cert": True,
"security": "any",
},
headers=headers,
)
except Exception as e:
stats.error = f"create failed: {e}"
return
stats.create_ms = (time.monotonic() - t0) * 1000
if resp.status_code not in (200, 201):
stats.error = f"create returned {resp.status_code}: {resp.text}"
return
session = resp.json()
stats.session_id = session["session_id"]
# Connect WebSocket
ws_proto = "wss" if base_url.startswith("https") else "ws"
host = base_url.split("//", 1)[1]
ws_url = f"{ws_proto}://{host}/ws/{stats.session_id}"
t1 = time.monotonic()
try:
async with websockets.connect(
ws_url,
subprotocols=["guacamole"],
ssl=ssl_ctx,
additional_headers={"X-API-Key": api_key},
max_size=2**20,
open_timeout=15,
) as ws:
stats.connect_ms = (time.monotonic() - t1) * 1000
start = time.monotonic()
async def receive_loop():
try:
async for msg in ws:
stats.messages_in += 1
stats.bytes_in += len(msg)
except websockets.ConnectionClosed:
pass
async def send_loop():
while time.monotonic() - start < duration_secs:
# Mouse move
x = random.randint(0, 1023)
y = random.randint(0, 767)
await ws.send(encode_instruction("mouse", x, y, 0))
stats.messages_out += 1
# Occasional key press
if random.random() < 0.1:
key = random.randint(97, 122) # a-z
await ws.send(encode_instruction("key", key, 1))
await ws.send(encode_instruction("key", key, 0))
stats.messages_out += 2
await asyncio.sleep(0.2 + random.random() * 0.3)
try:
await asyncio.wait_for(
asyncio.gather(receive_loop(), send_loop()),
timeout=duration_secs + 5,
)
except asyncio.TimeoutError:
pass
stats.duration_secs = time.monotonic() - start
except Exception as e:
stats.error = f"ws error: {e}"
stats.duration_secs = time.monotonic() - t1
# Cleanup
async with httpx.AsyncClient(verify=False, timeout=10) as client:
try:
await client.delete(
f"{base_url}/api/sessions/{stats.session_id}",
headers=headers,
)
except Exception:
pass
async def main():
parser = argparse.ArgumentParser(description="Guacamole WebSocket benchmark client")
parser.add_argument("--url", required=True, help="rustguac base URL")
parser.add_argument("--api-key", required=True, help="Admin API key")
parser.add_argument("--rdp-host", required=True, help="xrdp target IP")
parser.add_argument("--rdp-port", type=int, default=3389, help="xrdp port")
parser.add_argument("--sessions", type=int, default=1, help="Concurrent sessions")
parser.add_argument("--duration", type=int, default=120, help="Session duration (secs)")
parser.add_argument("--stagger", type=float, default=1.0, help="Delay between session starts (secs)")
args = parser.parse_args()
print(f"Starting {args.sessions} sessions to {args.rdp_host}:{args.rdp_port} for {args.duration}s")
print(f"rustguac: {args.url}")
print()
all_stats = [SessionStats() for _ in range(args.sessions)]
async def start_session(i):
await asyncio.sleep(i * args.stagger)
user_num = (i % 100) + 1
await run_session(
args.url, args.api_key, args.rdp_host, args.rdp_port,
user_num, args.duration, all_stats[i],
)
await asyncio.gather(*[start_session(i) for i in range(args.sessions)])
# Print results
print()
print(f"{'User':<10} {'Create ms':>10} {'WS ms':>8} {'Msgs In':>10} {'MB In':>8} {'Msgs Out':>10} {'Secs':>6} {'Error'}")
print("-" * 90)
errors = 0
total_bytes = 0
total_msgs = 0
create_times = []
connect_times = []
for s in all_stats:
err = s.error[:30] if s.error else ""
if s.error:
errors += 1
mb = s.bytes_in / 1024 / 1024
total_bytes += s.bytes_in
total_msgs += s.messages_in
if s.create_ms > 0:
create_times.append(s.create_ms)
if s.connect_ms > 0:
connect_times.append(s.connect_ms)
print(f"{s.user:<10} {s.create_ms:>10.0f} {s.connect_ms:>8.0f} {s.messages_in:>10} {mb:>8.1f} {s.messages_out:>10} {s.duration_secs:>6.0f} {err}")
print()
print(f"Sessions: {args.sessions}, Errors: {errors}")
if create_times:
create_times.sort()
print(f"Create latency: p50={create_times[len(create_times)//2]:.0f}ms p95={create_times[int(len(create_times)*0.95)]:.0f}ms max={create_times[-1]:.0f}ms")
if connect_times:
connect_times.sort()
print(f"WS connect: p50={connect_times[len(connect_times)//2]:.0f}ms p95={connect_times[int(len(connect_times)*0.95)]:.0f}ms max={connect_times[-1]:.0f}ms")
print(f"Total data in: {total_bytes/1024/1024:.1f} MB, {total_msgs} messages")
if __name__ == "__main__":
asyncio.run(main())
+69
View File
@@ -0,0 +1,69 @@
// k6 address book latency test.
// Measures GET /api/addressbook response time at various entry counts.
//
// Usage:
// k6 run --env API_KEY=rgu_xxx \
// --env BASE_URL=https://10.10.50.51:8089 \
// bench/k6-addressbook.js
//
// Run after populating Vault with populate-vault.sh at different counts
// to measure how response time scales with entry count.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
const abListDuration = new Trend('ab_list_all_ms');
const abEntryCount = new Counter('ab_total_entries');
const abFolderCount = new Counter('ab_total_folders');
export let options = {
scenarios: {
// Single user, repeated requests
ab_serial: {
executor: 'constant-vus',
vus: 1,
duration: '60s',
},
},
insecureSkipTLSVerify: true,
};
const API_KEY = __ENV.API_KEY;
const BASE_URL = __ENV.BASE_URL || 'https://localhost:8089';
export default function () {
const res = http.get(`${BASE_URL}/api/addressbook`, {
headers: { 'X-API-Key': API_KEY },
tags: { name: 'list_all' },
});
check(res, { 'status 200': (r) => r.status === 200 });
abListDuration.add(res.timings.duration);
if (res.status === 200) {
try {
const data = JSON.parse(res.body);
const folders = data.folders || [];
let totalEntries = 0;
folders.forEach(function (f) {
totalEntries += (f.entries || []).length;
});
abFolderCount.add(folders.length);
abEntryCount.add(totalEntries);
} catch (e) {}
}
sleep(1);
}
// Also run a concurrent-user variant
export function concurrent() {
const res = http.get(`${BASE_URL}/api/addressbook`, {
headers: { 'X-API-Key': API_KEY },
tags: { name: 'list_all_concurrent' },
});
check(res, { 'status 200': (r) => r.status === 200 });
abListDuration.add(res.timings.duration);
sleep(0.5);
}
+72
View File
@@ -0,0 +1,72 @@
// k6 session creation throughput test.
// Measures how many sessions/sec rustguac can create.
//
// Usage:
// k6 run --env API_KEY=rgu_xxx \
// --env BASE_URL=https://10.10.50.51:8089 \
// --env XRDP_HOST=10.10.50.52 \
// bench/k6-session-burst.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';
const createDuration = new Trend('session_create_ms');
const createSuccess = new Rate('session_create_success');
const sessionsCreated = new Counter('total_sessions_created');
export let options = {
scenarios: {
burst: {
executor: 'constant-arrival-rate',
rate: 5, // 5 sessions/sec
timeUnit: '1s',
duration: '60s',
preAllocatedVUs: 30,
maxVUs: 60,
},
},
insecureSkipTLSVerify: true,
};
const API_KEY = __ENV.API_KEY;
const BASE_URL = __ENV.BASE_URL || 'https://localhost:8089';
const XRDP_HOST = __ENV.XRDP_HOST || '127.0.0.1';
const params = {
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
};
export default function () {
const userNum = ((__ITER % 100) + 1);
const username = `bench${String(userNum).padStart(2, '0')}`;
const start = Date.now();
const res = http.post(`${BASE_URL}/api/sessions`, JSON.stringify({
session_type: 'rdp',
hostname: XRDP_HOST,
port: 3389,
username: username,
password: 'bench',
width: 1024,
height: 768,
ignore_cert: true,
security: 'any',
}), params);
createDuration.add(Date.now() - start);
const ok = res.status === 200 || res.status === 201;
createSuccess.add(ok);
if (ok) {
sessionsCreated.add(1);
const session = JSON.parse(res.body);
// Immediately delete — we're measuring creation throughput, not holding sessions
http.del(`${BASE_URL}/api/sessions/${session.session_id}`, null, params);
} else {
console.error(`Create failed: ${res.status} ${res.body}`);
}
}
+139
View File
@@ -0,0 +1,139 @@
// k6 session ramp-up test for rustguac.
// Gradually increases concurrent RDP sessions from 0 to 100.
//
// Usage:
// k6 run --env API_KEY=rgu_xxx \
// --env BASE_URL=https://10.10.50.51:8089 \
// --env XRDP_HOST=10.10.50.52 \
// bench/k6-session-ramp.js
//
// Env vars:
// API_KEY - rustguac admin API key
// BASE_URL - rustguac base URL
// XRDP_HOST - xrdp target IP (single VM with multiple users)
// MAX_VUS - max concurrent sessions (default 100)
// HOLD_SECS - seconds to hold at max (default 300)
import http from 'k6/http';
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const sessionCreateTime = new Trend('session_create_ms');
const wsConnectTime = new Trend('ws_connect_ms');
const sessionsCreated = new Counter('sessions_created');
const sessionsFailed = new Counter('sessions_failed');
const wsMessages = new Counter('ws_messages_received');
const wsBytesIn = new Counter('ws_bytes_received');
const MAX_VUS = parseInt(__ENV.MAX_VUS || '100');
const HOLD = parseInt(__ENV.HOLD_SECS || '300');
export let options = {
scenarios: {
ramp_sessions: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 10 },
{ duration: '2m', target: 25 },
{ duration: '2m', target: 50 },
{ duration: '2m', target: 75 },
{ duration: '2m', target: MAX_VUS },
{ duration: `${HOLD}s`, target: MAX_VUS },
{ duration: '1m', target: 0 },
],
},
},
insecureSkipTLSVerify: true,
thresholds: {
'session_create_ms': ['p(95)<5000'],
'ws_connect_ms': ['p(95)<2000'],
},
};
const API_KEY = __ENV.API_KEY;
const BASE_URL = __ENV.BASE_URL || 'https://localhost:8089';
const XRDP_HOST = __ENV.XRDP_HOST || '127.0.0.1';
const params = {
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
};
export default function () {
// Use a unique bench user per VU to avoid xrdp session conflicts
const userNum = ((__VU - 1) % 100) + 1;
const username = `bench${String(userNum).padStart(2, '0')}`;
// Create session
const createStart = Date.now();
const createRes = http.post(`${BASE_URL}/api/sessions`, JSON.stringify({
session_type: 'rdp',
hostname: XRDP_HOST,
port: 3389,
username: username,
password: 'bench',
width: 1024,
height: 768,
ignore_cert: true,
security: 'any',
}), params);
sessionCreateTime.add(Date.now() - createStart);
if (createRes.status !== 200 && createRes.status !== 201) {
sessionsFailed.add(1);
console.error(`VU ${__VU}: session create failed: ${createRes.status} ${createRes.body}`);
sleep(5);
return;
}
sessionsCreated.add(1);
const session = JSON.parse(createRes.body);
const sessionId = session.session_id;
// Connect WebSocket
const wsProto = BASE_URL.startsWith('https') ? 'wss' : 'ws';
const host = BASE_URL.replace(/^https?:\/\//, '');
const wsUrl = `${wsProto}://${host}/ws/${sessionId}`;
const wsStart = Date.now();
const res = ws.connect(wsUrl, {
headers: { 'X-API-Key': API_KEY },
}, function (socket) {
wsConnectTime.add(Date.now() - wsStart);
// Simulate mouse movement every 2s
socket.setInterval(function () {
const x = Math.floor(Math.random() * 1024);
const y = Math.floor(Math.random() * 768);
const xStr = String(x);
const yStr = String(y);
socket.send(`5.mouse,${xStr.length}.${xStr},${yStr.length}.${yStr},1.0;`);
}, 2000);
// Simulate typing every 10s
socket.setInterval(function () {
// Press and release 'a' (keysym 97)
socket.send('3.key,2.97,1.1;');
socket.send('3.key,2.97,1.0;');
}, 10000);
socket.on('message', function (msg) {
wsMessages.add(1);
wsBytesIn.add(msg.length);
});
// Hold for iteration duration, then close
socket.setTimeout(function () {
socket.close();
}, 60000);
});
// Cleanup
http.del(`${BASE_URL}/api/sessions/${sessionId}`, null, params);
sleep(1);
}
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Populate Vault with address book entries for benchmarking.
# Usage: ./populate-vault.sh [total_entries] [folders] [vault_addr] [api_key] [rustguac_url]
#
# Defaults: 1000 entries across 20 folders.
# Uses the rustguac API (not Vault directly) so entries are properly formatted.
set -e
TOTAL=${1:-1000}
FOLDERS=${2:-20}
VAULT_ADDR=${3:-"http://127.0.0.1:8200"}
API_KEY=${4:-""}
RUSTGUAC_URL=${5:-"https://localhost:8089"}
ENTRIES_PER_FOLDER=$((TOTAL / FOLDERS))
if [ -z "$API_KEY" ]; then
echo "Usage: $0 [entries] [folders] [vault_addr] [api_key] [rustguac_url]"
echo " api_key is required (admin API key for rustguac)"
exit 1
fi
CURL="curl -sk -H X-API-Key:${API_KEY} -H Content-Type:application/json"
echo "Creating $TOTAL entries across $FOLDERS folders ($ENTRIES_PER_FOLDER per folder)"
echo "rustguac: $RUSTGUAC_URL"
for f in $(seq 1 $FOLDERS); do
FOLDER="bench-folder-$(printf '%02d' $f)"
# Create folder
$CURL -X POST "$RUSTGUAC_URL/api/addressbook/folders" \
-d "{\"name\":\"$FOLDER\",\"scope\":\"shared\",\"description\":\"Benchmark folder $f\",\"allowed_groups\":[]}" \
-o /dev/null 2>/dev/null
for e in $(seq 1 $ENTRIES_PER_FOLDER); do
ENTRY="rdp-host-$(printf '%02d' $f)-$(printf '%03d' $e)"
OCTET3=$((f % 256))
OCTET4=$((e % 256))
$CURL -X PUT "$RUSTGUAC_URL/api/addressbook/folders/shared/$FOLDER/entries/$ENTRY" \
-d "{
\"type\":\"rdp\",
\"hostname\":\"10.99.${OCTET3}.${OCTET4}\",
\"port\":3389,
\"username\":\"bench$(printf '%02d' $e)\",
\"password\":\"bench\",
\"display_name\":\"RDP Host $f-$e\",
\"ignore_cert\":true,
\"security\":\"any\"
}" -o /dev/null 2>/dev/null
done
echo " $FOLDER: $ENTRIES_PER_FOLDER entries"
done
echo "Done: $TOTAL entries in $FOLDERS folders"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Setup xrdp on a Debian 13 VM for benchmarking rustguac.
# Run as root on the target VM.
# Creates 100 bench users (bench01-bench100) with password "bench".
set -e
echo "=== Installing xrdp ==="
apt-get update
apt-get install -y xrdp xfce4 xfce4-terminal dbus-x11
echo "=== Configuring xrdp ==="
# Use Xvnc backend (lighter than Xorg)
sed -i 's/^port=3389/port=3389/' /etc/xrdp/xrdp.ini
# Allow multiple sessions per user
sed -i 's/^Policy=Default/Policy=Default/' /etc/xrdp/sesman.ini
# Set session defaults to Xfce (lightweight)
cat > /etc/xrdp/startwm.sh << 'STARTWM'
#!/bin/sh
if [ -r /etc/default/locale ]; then
. /etc/default/locale
export LANG LANGUAGE
fi
exec startxfce4
STARTWM
chmod +x /etc/xrdp/startwm.sh
echo "=== Creating bench users ==="
for i in $(seq -w 1 100); do
USER="bench${i}"
if ! id "$USER" &>/dev/null; then
useradd -m -s /bin/bash "$USER"
echo "${USER}:bench" | chpasswd
fi
done
echo "=== Starting xrdp ==="
systemctl enable xrdp
systemctl restart xrdp
echo "=== Done ==="
echo "xrdp listening on port 3389"
echo "Users: bench01-bench100, password: bench"
echo "Test with: xfreerdp /v:$(hostname -I | awk '{print $1}'):3389 /u:bench01 /p:bench"