Last updated: 2026-04-13
This repository contains a set of server services for PeerLink:
relay— HTTP relay and blob API (store/fetch/ack + group fan-out + blob upload)signal— bootstrap signaling servercoturn— TURN/TLS server for WebRTChaproxy— reverse proxy and TLS termination
This project is authored by AI agents; I act only as a coordinator and development lead. No character of code was written by hand.
Russian documentation is available in
README_RU.md.
The project is deployed with Docker Compose and works as a single system:
relayforwards messages between WebRTC peersrelaystores/fetches signed envelopes and serves blob payload APIsignalregisters and authenticates stablepeerId(v2) with Ed25519 proofcoturnprovides TURN/TLS access by IPhaproxyaccepts HTTP/HTTPS and proxiesrelayandsignal
A self-signed certificate is used for the server IP instead of a public domain.
- Debian/Ubuntu-like system for
deploy.sh bash,curl,sudo- Docker and Docker Compose (installed by the script)
- OpenSSL
File: relay.js
This service stores signed relay envelopes and allows clients to:
- store/fetch/ack message envelopes,
- fan-out one signed group envelope to recipient list (
/relay/group/store), - upload/fetch encrypted blobs (
/relay/blob/*), including chunked upload.
It does not handle peer registration or signaling.
File: signal.js
This is the bootstrap signaling server with:
- stable
peerId(v2) registration over WebSocket - signature-based registration authentication
- backward-compatible signature verification for legacy registration payloads (v1)
- session takeover support when a peer reconnects
- relaying
signalmessages between peers ping/pongheartbeat- stable
peers_requestsnapshots (online peers only) - server-side
lastSeenMsin peers snapshots - push
presence_updateevents foronline/offlinetransitions
The instrumentisto/coturn service runs in network_mode: host and exposes:
- TURN: 3478
- TURNS: 5349
- Relay ports:
49152-51819(UDP/TCP) for TURN media relay candidates.
It uses a self-signed certificate for the IP configured in turnserver.conf.
HAProxy accepts HTTP/HTTPS and routes:
wss://<IP>:443->signal:3000https://<IP>:444->relay:4000
It runs in network_mode: host and uses selfsigned.pem.
TURN/TURNS is not proxied through HAProxy:
turn:<IP>:3478andturns:<IP>:5349are served directly bycoturn,- relay ports
49152-51819are also opened directly for TURN media relay candidates.
deploy.shtargets Debian/Ubuntu and usesapt,sudo, Docker, and OpenSSL.
File: docker-compose.yml
This file defines all four services. coturn and haproxy mount the certificate files from the host.
File: turnserver.conf
The file is generated automatically by deploy.sh with a detected IP address:
external-ip=<CERT_IP>realm=<CERT_IP>cert=/etc/coturn/certs/fullchain.pempkey=/etc/coturn/private/privkey.pem
Note:
deploy.shuses sample TURN credentials by default (TURN_USER/TURN_PASSWORD). Replace them with secure values and use real certificates in production.
File: haproxy.cfg
Configures HTTPS termination only for signal and relay.
To bootstrap automatically:
wget -qO- https://raw.githubusercontent.com/simplegear-org/peerlink_servers/main/bootstrap.sh | bashOr clone and run manually:
git clone https://github.com/simplegear-org/peerlink_servers.git
cd peerlink_servers
./deploy.shTo start the servers without Docker:
npm install
npm run start:signal
npm run start:relaysignal listens on localhost:3000, relay listens on localhost:4000.
File: deploy.sh
The script automatically:
- updates the system
- installs Docker and Docker Compose
- detects the server IP address (using
ip route,hostname -I, or an external service) - generates a self-signed certificate for the detected IP
- generates
turnserver.confwith the correctexternal-ipandrealm - starts the containers
Run:
./deploy.shBy default the script detects IP automatically; if detection fails it uses 127.0.0.1.
To use custom TURN credentials, set
TURN_USERandTURN_PASSWORDbefore running./deploy.sh.Example:
TURN_USER=myuser TURN_PASSWORD=strongpass ./deploy.shIf you need another operating system,
deploy.shmust be adapted.
Clients communicate using JSON frames:
{
"v": "1",
"id": "string",
"type": "string",
"payload": {}
}Supported frame types:
registerregister_acksignalpingpongpeers_requestpeerspresence_updateerror
A register frame requires cryptographic authentication:
{
"v": "1",
"id": "1741590000123456",
"type": "register",
"payload": {
"peerId": "PEER_ID",
"client": {
"name": "peerlink",
"protocol": "1"
},
"capabilities": ["webrtc", "signal-relay"],
"auth": {
"scheme": "peerlink-ed25519-v1",
"peerId": "PEER_ID",
"timestampMs": 1741590002123,
"nonce": "1741590002123456",
"signingPublicKey": "BASE64",
"signature": "BASE64",
"legacyPeerId": "OPTIONAL_LEGACY_ID",
"identityProfile": {
"stableUserId": "PEER_ID",
"endpointId": "OPTIONAL_ENDPOINT_ID",
"fcmTokenHash": "OPTIONAL_HASH"
}
}
}
}The server validates:
auth.scheme == peerlink-ed25519-v1auth.peerId == payload.peerIdtimestampMsis within the allowed skew windownoncewas not used beforesigningPublicKeyis a valid Ed25519 keysignatureis valid for the canonical payload- if
identityProfile.stableUserIdis present, it must matchpayload.peerId
Canonical payload for signature verification (v2):
{
"purpose": "bootstrap-register",
"protocol": "1",
"peerId": "PEER_ID",
"timestampMs": 1741590002123,
"nonce": "1741590002123456",
"signingPublicKey": "BASE64",
"legacyPeerId": "OPTIONAL_LEGACY_ID",
"identityProfile": {
"stableUserId": "PEER_ID",
"endpointId": "OPTIONAL_ENDPOINT_ID",
"fcmTokenHash": "OPTIONAL_HASH"
}
}Legacy canonical payload (v1 fallback):
{
"purpose": "bootstrap-register",
"protocol": "1",
"peerId": "PEER_ID",
"timestampMs": 1741590002123,
"nonce": "1741590002123456",
"signingPublicKey": "BASE64"
}The server tries v2 verification first and then v1 for backward compatibility.
If a valid register arrives for an already connected peerId:
- the old session is closed
- the new session becomes active
- the client receives
register_ack
This supports network changes and reconnection.
Relay endpoints used by current client:
GET /healthPOST /relay/storePOST /relay/group/storePOST /relay/group/members/updateGET /relay/fetch?to=<peerId>&limit=<n>&cursor=<optional>POST /relay/ackPOST /relay/blob/uploadPOST /relay/blob/upload/chunkPOST /relay/blob/upload/completeGET /relay/blob/:blobId
For full payload fields and signature formats, see README.relay.md.
Example error frame:
{
"v": "1",
"id": "srv-error-1",
"type": "error",
"payload": {
"code": "INVALID_REGISTER_AUTH",
"message": "signature verification failed"
}
}Common error codes:
INVALID_JSONINVALID_VERSIONINVALID_REGISTERINVALID_REGISTER_AUTHNOT_REGISTEREDINVALID_SIGNALPEER_NOT_FOUNDSESSION_REPLACEDUNKNOWN_TYPE
The relay service exposes the following endpoints:
GET /health- returns service status
POST /relay/store- store an envelope for a recipient
- request body must include:
id,from,to,ts,ttl,payload,sig,signingPub
POST /relay/group/store- server-side fan-out to multiple recipients
- request body must include:
id,from,groupId,recipients[],ts,ttl,payload,sig,signingPub
POST /relay/group/members/update- updates authoritative membership for a group on relay
- request body must include:
id,from,groupId,ownerPeerId,memberPeerIds[],ts,ttl,sig,signingPub frommust equalownerPeerId
GET /relay/fetch?to=<recipient>&cursor=<id>&limit=<n>- fetch pending envelopes for a recipient
- supports pagination with
cursor
POST /relay/ack- acknowledge envelope delivery
- body must include:
id,from,to,ts,sig,signingPub
The relay API validates Ed25519 signatures for both stored envelopes and delivery acknowledgements. For group operations relay also enforces server-side membership:
POST /relay/group/store: sender and all recipients must be current membersPOST /relay/blob/upload,/relay/blob/upload/chunk,/relay/blob/upload/complete: sender must be a current member (when membership is known)
Relay signature payloads:
- store:
id|from|to|ts|ttl|+ decodedpayloadbytes - group-store:
id|from|groupId|recipient1,recipient2,...|ts|ttl|+ decodedpayloadbytes - group-members-update:
id|from|groupId|ownerPeerId|member1,member2,...|ts|ttl - ack:
id|from|to|ts
Relay validation behavior:
- malformed body/fields ->
400 - invalid signature ->
401({"error":"invalid signature"}) - membership violation ->
403 - owner mismatch in
/relay/group/members/update->409
- Use HAProxy for HTTPS termination and proxying.
- For IP certificates, self-signed certificates with IP SAN are acceptable in this setup.
- Clients must trust the certificate manually.
- In production, store
peerstate andnoncestate outside the process memory. - Consider adding metrics for
register_ack,INVALID_REGISTER_AUTH,SESSION_REPLACED, and recovery time.