Bas van den Aakster 3efed6e9d2 Fix service reconnection: return available offer from pool
Modified /services/:uuid endpoint to return an available (unanswered)
offer from the service's offer pool instead of always returning the
initial offer. This fixes reconnection failures where clients would
try to answer already-consumed offers.

Changes:
- Query all offers from the service's peer ID
- Return first unanswered offer
- Return 503 if no offers available

Fixes: "Offer already answered" errors on reconnection attempts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:47:00 +01:00

Rondevu Server

npm version

🌐 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:


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 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 (com.example.chat@1.0.0)
  • offer_id (FK): WebRTC offer ID
  • is_public: Public/private flag
  • metadata: JSON metadata
  • created_at, expires_at: Timestamps

service_index (privacy layer)

  • uuid (PK): Random UUID for discovery
  • service_id (FK): Links to service
  • username, 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

Description
No description provided
Readme 252 KiB
Languages
TypeScript 97.8%
Dockerfile 1.4%
JavaScript 0.8%