🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Rondevu Server
🌐 DNS-like WebRTC signaling with username claiming and service discovery
Scalable WebRTC signaling server with cryptographic username claiming, service publishing, and privacy-preserving discovery.
Related repositories:
- @xtr-dev/rondevu-client - TypeScript client library (npm)
- @xtr-dev/rondevu-server - HTTP signaling server (npm, live)
- @xtr-dev/rondevu-demo - Interactive demo (live)
Features
- Username Claiming: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
- Service Publishing: Package-style naming with semantic versioning (com.example.chat@1.0.0)
- Privacy-Preserving Discovery: UUID-based service index prevents enumeration
- Public/Private Services: Control service visibility
- Stateless Authentication: AES-256-GCM encrypted credentials, no server-side sessions
- Complete WebRTC Signaling: Offer/answer exchange and ICE candidate relay
- Dual Storage: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
Architecture
Username Claiming → Service Publishing → Service Discovery → WebRTC Connection
alice claims "alice" with Ed25519 signature
↓
alice publishes com.example.chat@1.0.0 → receives UUID abc123
↓
bob queries alice's services → gets UUID abc123
↓
bob connects to UUID abc123 → WebRTC connection established
Quick Start
Node.js:
npm install && npm start
Docker:
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: -e AUTH_SECRET=$(openssl rand -hex 32) rondevu
Cloudflare Workers:
npx wrangler deploy
API Endpoints
Public Endpoints
GET /
Returns server version and info
GET /health
Health check endpoint with version
POST /register
Register a new peer and receive credentials (peerId + secret)
Generates a cryptographically random 128-bit peer ID.
Response:
{
"peerId": "f17c195f067255e357232e34cf0735d9",
"secret": "DdorTR8QgSn9yngn+4qqR8cs1aMijvX..."
}
Username Management
POST /usernames/claim
Claim a username with cryptographic proof
Request:
{
"username": "alice",
"publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000"
}
Response:
{
"username": "alice",
"claimedAt": 1733404800000,
"expiresAt": 1765027200000
}
Validation:
- Username format:
^[a-z0-9][a-z0-9-]*[a-z0-9]$(3-32 characters) - Signature must be valid Ed25519 signature
- Timestamp must be within 5 minutes (replay protection)
- Expires after 365 days, auto-renewed on use
GET /usernames/:username
Check username availability and claim status
Response:
{
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
GET /usernames/:username/services
List all services for a username (privacy-preserving)
Response:
{
"username": "alice",
"services": [
{
"uuid": "abc123",
"isPublic": false
},
{
"uuid": "def456",
"isPublic": true,
"serviceFqn": "com.example.public@1.0.0",
"metadata": { "description": "Public service" }
}
]
}
Service Management
POST /services
Publish a service (requires authentication and username signature)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"sdp": "v=0...",
"ttl": 300000,
"isPublic": false,
"metadata": { "description": "Chat service" },
"signature": "base64-encoded-signature",
"message": "publish:alice:com.example.chat@1.0.0:1733404800000"
}
Response:
{
"serviceId": "uuid-v4",
"uuid": "uuid-v4-for-index",
"offerId": "offer-hash-id",
"expiresAt": 1733405100000
}
Service FQN Format:
- Service name: Reverse domain notation (e.g.,
com.example.chat) - Version: Semantic versioning (e.g.,
1.0.0,2.1.3-beta) - Complete FQN:
service-name@version(e.g.,com.example.chat@1.0.0)
Validation:
- Service name pattern:
^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ - Length: 3-128 characters
- Version pattern:
^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$
GET /services/:uuid
Get service details by UUID
Response:
{
"serviceId": "...",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offerId": "...",
"sdp": "v=0...",
"isPublic": false,
"metadata": { ... },
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
DELETE /services/:serviceId
Unpublish a service (requires authentication and ownership)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"username": "alice"
}
Service Discovery
POST /index/:username/query
Query a service by FQN
Request:
{
"serviceFqn": "com.example.chat@1.0.0"
}
Response:
{
"uuid": "abc123",
"allowed": true
}
Offer Management (Low-level)
POST /offers
Create one or more offers (requires authentication)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"offers": [
{
"sdp": "v=0...",
"ttl": 300000
}
]
}
GET /offers/mine
List all offers owned by authenticated peer
PUT /offers/:offerId/heartbeat
Update last_seen timestamp for an offer
DELETE /offers/:offerId
Delete a specific offer
POST /offers/:offerId/answer
Answer an offer (locks it to answerer)
Request:
{
"sdp": "v=0..."
}
GET /offers/answers
Poll for answers to your offers
POST /offers/:offerId/ice-candidates
Post ICE candidates for an offer
Request:
{
"candidates": ["candidate:1 1 UDP..."]
}
GET /offers/:offerId/ice-candidates?since=1234567890
Get ICE candidates from the other peer
Configuration
Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port (Node.js/Docker) |
CORS_ORIGINS |
* |
Comma-separated allowed origins |
STORAGE_PATH |
./rondevu.db |
SQLite database path (use :memory: for in-memory) |
VERSION |
2.0.0 |
Server version (semver) |
AUTH_SECRET |
Random 32-byte hex | Secret key for credential encryption (required for production) |
OFFER_DEFAULT_TTL |
300000 |
Default offer TTL in ms (5 minutes) |
OFFER_MIN_TTL |
60000 |
Minimum offer TTL in ms (1 minute) |
OFFER_MAX_TTL |
3600000 |
Maximum offer TTL in ms (1 hour) |
MAX_OFFERS_PER_REQUEST |
10 |
Maximum offers per create request |
Database Schema
usernames
username(PK): Claimed usernamepublic_key: Ed25519 public key (base64)claimed_at: Claim timestampexpires_at: Expiry timestamp (365 days)last_used: Last activity timestampmetadata: Optional JSON metadata
services
id(PK): Service ID (UUID)username(FK): Owner usernameservice_fqn: Fully qualified name (com.example.chat@1.0.0)offer_id(FK): WebRTC offer IDis_public: Public/private flagmetadata: JSON metadatacreated_at,expires_at: Timestamps
service_index (privacy layer)
uuid(PK): Random UUID for discoveryservice_id(FK): Links to serviceusername,service_fqn: Denormalized for performance
Security
Username Claiming
- Algorithm: Ed25519 signatures
- Message Format:
claim:{username}:{timestamp} - Replay Protection: Timestamp must be within 5 minutes
- Key Management: Private keys never leave the client
Service Publishing
- Ownership Verification: Every publish requires username signature
- Message Format:
publish:{username}:{serviceFqn}:{timestamp} - Auto-Renewal: Publishing a service extends username expiry
Privacy
- Private Services: Only UUID exposed, FQN hidden
- Public Services: FQN and metadata visible
- No Enumeration: Cannot list all services without knowing FQN
Migration from V1
V2 is a breaking change that removes topic-based discovery. See MIGRATION.md for detailed migration guide.
Key Changes:
- ❌ Removed: Topic-based discovery, bloom filters, public peer listings
- ✅ Added: Username claiming, service publishing, UUID-based privacy
License
MIT