Bas van den Aakster 8d47424a82 fix: Remove authSecret reference from worker config
The authSecret variable was removed but still referenced in the config
object, causing the worker to crash on all requests.
2025-12-12 19:18:24 +01:00

Rondevu Server

npm version

🌐 Simple WebRTC signaling with username-based discovery

Scalable WebRTC signaling server with cryptographic username claiming, service publishing with semantic versioning, and efficient offer/answer exchange.

Related repositories:


Features

  • Username Claiming: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
  • Anonymous Users: Support for anon-* usernames for quick testing
  • Service Publishing: Service:version@username naming (e.g., chat:1.0.0@alice)
  • Service Discovery: Random and paginated discovery for finding services without knowing usernames
  • Semantic Versioning: Compatible version matching (chat:1.0.0 matches any 1.x.x)
  • Signature-Based Authentication: All authenticated requests use Ed25519 signatures
  • Complete WebRTC Signaling: Offer/answer exchange and ICE candidate relay
  • Efficient Batch Polling: Combined endpoint for answers and ICE candidates (50% fewer HTTP requests)
  • 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 chat:1.0.0@alice with offers
  ↓
bob queries chat:1.0.0@alice (direct) or chat:1.0.0 (discovery) → gets offer SDP
  ↓
bob posts answer SDP → WebRTC connection established
  ↓
ICE candidates exchanged via server relay

Quick Start

Node.js:

npm install && npm start

Docker:

docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu

Cloudflare Workers:

npx wrangler deploy

API Endpoints

Public Endpoints

GET /

Returns server version and info

Response:

{
  "version": "0.4.0",
  "name": "Rondevu",
  "description": "DNS-like WebRTC signaling with username claiming and service discovery"
}

GET /health

Health check endpoint with version

Response:

{
  "status": "ok",
  "timestamp": 1733404800000,
  "version": "0.4.0"
}

User Management

GET /users/:username

Check username availability and claim status

Response (Available):

{
  "username": "alice",
  "available": true
}

Response (Claimed):

{
  "username": "alice",
  "available": false,
  "claimedAt": 1733404800000,
  "expiresAt": 1765027200000,
  "publicKey": "base64-encoded-ed25519-public-key"
}

POST /users/:username

Claim a username with cryptographic proof

Request:

{
  "publicKey": "base64-encoded-ed25519-public-key",
  "signature": "base64-encoded-signature",
  "message": "claim:alice:1733404800000"
}

Response:

{
  "success": true,
  "username": "alice"
}

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

Service Management

POST /services

Publish a service with offers (requires username and signature)

Request:

{
  "username": "alice",
  "serviceFqn": "chat:1.0.0@alice",
  "offers": [
    { "sdp": "v=0..." },
    { "sdp": "v=0..." }
  ],
  "ttl": 300000,
  "signature": "base64-encoded-signature",
  "message": "publish:alice:chat:1.0.0@alice:1733404800000"
}

Response:

{
  "serviceId": "uuid-v4",
  "username": "alice",
  "serviceFqn": "chat:1.0.0@alice",
  "offers": [
    {
      "offerId": "offer-hash-1",
      "sdp": "v=0...",
      "createdAt": 1733404800000,
      "expiresAt": 1733405100000
    }
  ],
  "createdAt": 1733404800000,
  "expiresAt": 1733405100000
}

Service FQN Format:

  • Format: service:version@username
  • Service name: Lowercase alphanumeric + dash (e.g., chat, video-call)
  • Version: Semantic versioning (e.g., 1.0.0, 2.1.3)
  • Username: Claimed username
  • Example: chat:1.0.0@alice

Validation:

  • Service name pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$
  • Version pattern: ^[0-9]+\.[0-9]+\.[0-9]+$
  • Must include @username

GET /services/:fqn

Get service by FQN - Three modes:

1. Direct Lookup (with username):

GET /services/chat:1.0.0@alice

Returns first available offer from Alice's chat:1.0.0 service.

2. Random Discovery (without username):

GET /services/chat:1.0.0

Returns a random available offer from any user's chat:1.0.0 service.

3. Paginated Discovery (with query params):

GET /services/chat:1.0.0?limit=10&offset=0

Returns array of unique available offers from different users.

Semver Matching:

  • Requesting chat:1.0.0 matches any 1.x.x version
  • Major version must match exactly (chat:1.0.0 will NOT match chat:2.0.0)
  • For major version 0, minor must also match (0.1.0 will NOT match 0.2.0)
  • Returns the most recently published compatible version

Response (Single Offer):

{
  "serviceId": "uuid",
  "username": "alice",
  "serviceFqn": "chat:1.0.0@alice",
  "offerId": "offer-hash",
  "sdp": "v=0...",
  "createdAt": 1733404800000,
  "expiresAt": 1733405100000
}

Response (Paginated):

{
  "services": [
    {
      "serviceId": "uuid",
      "username": "alice",
      "serviceFqn": "chat:1.0.0@alice",
      "offerId": "offer-hash",
      "sdp": "v=0...",
      "createdAt": 1733404800000,
      "expiresAt": 1733405100000
    }
  ],
  "count": 1,
  "limit": 10,
  "offset": 0
}

DELETE /services/:fqn

Unpublish a service (requires username, signature, and ownership)

Request:

{
  "username": "alice",
  "signature": "base64-encoded-signature",
  "message": "deleteService:alice:chat:1.0.0@alice:1733404800000"
}

Response:

{
  "success": true
}

WebRTC Signaling

POST /services/:fqn/offers/:offerId/answer

Post answer SDP to specific offer

Request:

{
  "username": "bob",
  "sdp": "v=0...",
  "signature": "base64-encoded-signature",
  "message": "answerOffer:{username}:{offerId}:{timestamp}"
}

Response:

{
  "success": true,
  "offerId": "offer-hash"
}

GET /services/:fqn/offers/:offerId/answer

Get answer SDP (offerer polls this)

Query Parameters:

  • username - Your username
  • signature - Base64-encoded Ed25519 signature
  • message - Signed message (format: getAnswer:{username}:{offerId}:{timestamp})

Response:

{
  "sdp": "v=0...",
  "offerId": "offer-hash",
  "answererUsername": "bob",
  "answeredAt": 1733404800000
}

Returns 404 if not yet answered.

GET /poll

Combined polling endpoint for answers and ICE candidates

Query Parameters:

  • username - Your username
  • signature - Base64-encoded Ed25519 signature
  • message - Signed message (format: poll:{username}:{timestamp})
  • since - Optional timestamp to get only new data

Response:

{
  "answers": [
    {
      "offerId": "offer-hash",
      "serviceId": "service-uuid",
      "answererUsername": "bob",
      "sdp": "v=0...",
      "answeredAt": 1733404800000
    }
  ],
  "iceCandidates": {
    "offer-hash": [
      {
        "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 },
        "role": "answerer",
        "username": "bob",
        "createdAt": 1733404800000
      }
    ]
  }
}

POST /services/:fqn/offers/:offerId/ice-candidates

Add ICE candidates to specific offer

Request:

{
  "username": "alice",
  "candidates": [
    {
      "candidate": "candidate:...",
      "sdpMid": "0",
      "sdpMLineIndex": 0
    }
  ],
  "signature": "base64-encoded-signature",
  "message": "addIceCandidates:{username}:{offerId}:{timestamp}"
}

Response:

{
  "count": 1,
  "offerId": "offer-hash"
}

GET /services/:fqn/offers/:offerId/ice-candidates

Get ICE candidates for specific offer

Query Parameters:

  • username - Your username
  • signature - Base64-encoded Ed25519 signature
  • message - Signed message (format: getIceCandidates:{username}:{offerId}:{timestamp})
  • since - Optional timestamp to get only new candidates

Response:

{
  "candidates": [
    {
      "candidate": {
        "candidate": "candidate:...",
        "sdpMid": "0",
        "sdpMLineIndex": 0
      },
      "createdAt": 1733404800000
    }
  ],
  "offerId": "offer-hash"
}

Note: Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)

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 0.5.0 Server version (semver)
OFFER_DEFAULT_TTL 60000 Default offer TTL in ms (1 minute)
OFFER_MIN_TTL 60000 Minimum offer TTL in ms (1 minute)
OFFER_MAX_TTL 86400000 Maximum offer TTL in ms (24 hours)
CLEANUP_INTERVAL 60000 Cleanup interval in ms (1 minute)
MAX_OFFERS_PER_REQUEST 100 Maximum offers per create request

Database Schema

usernames

  • username (PK): Claimed username
  • public_key: Ed25519 public key (base64)
  • claimed_at: Claim timestamp
  • expires_at: Expiry timestamp (365 days)
  • last_used: Last activity timestamp
  • metadata: Optional JSON metadata

services

  • id (PK): Service ID (UUID)
  • username (FK): Owner username
  • service_fqn: Fully qualified name (chat:1.0.0@alice)
  • service_name: Service name component (chat)
  • version: Version component (1.0.0)
  • created_at, expires_at: Timestamps
  • UNIQUE constraint on (service_name, version, username)

offers

  • id (PK): Offer ID (hash of SDP)
  • username (FK): Owner username
  • service_id (FK): Link to service
  • service_fqn: Denormalized service FQN
  • sdp: WebRTC offer SDP
  • answerer_username: Username of answerer (null until answered)
  • answer_sdp: WebRTC answer SDP (null until answered)
  • answered_at: Timestamp when answered
  • created_at, expires_at, last_seen: Timestamps

ice_candidates

  • id (PK): Auto-increment ID
  • offer_id (FK): Link to offer
  • username: Username who sent the candidate
  • role: 'offerer' or 'answerer'
  • candidate: JSON-encoded candidate
  • created_at: Timestamp

Security

Ed25519 Signature Authentication

All authenticated requests require:

  • username: Your claimed username
  • signature: Base64-encoded Ed25519 signature of the message
  • message: Signed message with format-specific structure

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
  • Validity: 365 days, auto-renewed on use

Anonymous Users

  • Format: anon-{timestamp}-{random} (e.g., anon-lx2w34-a3f501)
  • Generation: Can be generated by client for testing
  • Behavior: Same as regular usernames, must be explicitly claimed like any username

Service Publishing

  • Ownership Verification: Every publish requires username signature
  • Message Format: publish:{username}:{serviceFqn}:{timestamp}
  • Auto-Renewal: Publishing a service extends username expiry

ICE Candidate Filtering

  • Server filters candidates by role to prevent peers from receiving their own candidates
  • Offerers receive only answerer candidates
  • Answerers receive only offerer candidates

Migration from v0.3.x

See MIGRATION.md for detailed migration guide.

Key Changes:

  • Service FQN format changed from service@version to service:version@username
  • Removed UUID privacy layer - direct FQN-based access
  • Removed public/private service distinction
  • Added service discovery (random and paginated)
  • Unified polling endpoint (/poll replaces /offers/answered and /offers/poll)
  • ICE candidate endpoints moved to offer-specific routes

License

MIT

Description
No description provided
Readme 642 KiB
Languages
TypeScript 97.9%
Dockerfile 1.3%
JavaScript 0.8%