6.8 KiB
PeerNet -- peer-to-peer communication without the network
PeerNet is a protocol and server software which let clients communicate directly with each other without required a lot of configuration or system permissions. It leverages WebRTC to create data channels between peers, and uses a simple protocol to let clients advertise services for other clients to connect with. Initially developed as an attempt to simplify a home security system, the reference client implementations offer:
- Security camera server and client
- Sensor server and client (i.e., to monitor external temperature and humidity)
- File server and client (i.e., to transfer security footage)
PeerNet protocol
PeerNet clients uses JSON or Protobuf to connect to the PeerNet server (signaling server, in WebRTC terms). They send messages advertising what they can do, and poll the server for clients who would like to establish connections. The server collects enough informaion for clients to identify the services, but no other information.
One minor goal in this API design is to minimize the number of messages that need to be sent to establish the connection. That means some operations violate REST API semantics; standard REST API features are also available for those who are pure of heart. We define these resources:
- Room: a logical grouping of servers, and the primary means of discovering a server
- Server: intended to represent a server, which may have multiple resources
- Service: a service on the server; a server may have multiple services
- Knock: a request to create a connection with the server; named to avoid stuttering and save keystrokes
Sample server flow:
- Server creates an auth token; this is secret, and is used to restrict actions related to the server such as updating/deleting it, or listing pending knocks
- Server send a POST to /v1/servers with the server object, including a list of services offered
- Server begins polling /v1/servers/{server}/services/{service}/knocks for client attempts to connect
- When a knock is recieved, a worker should be spawned to handle the connection
When a client connects, the standard WebRTC negotation commences. Restating from MDN:
- Peer connection is created in client (caller, in WebRTC parliance)
- Attach channels to it (i.e., a data channel)
- Client creates an offer; they set the 'local description' to the offer
- Client sends request to signaling server (peernet server)
- The peer recieves the request and sets remote description to the offer
- The peer creates an answer and sets it to 'local description'
- The peer sends the answer to the signaling server (peernet server)
- Client recieves the answer and sets it as remote description
In steps 3 and 6, the client and peer indicate a STUN/TURN server and begin generating ICE candidates. This is where the mgaic of bridging firewalls happens. The candidates will trickle from each system, and must be passed through the signaling server to the other system. The hope is that eventually both the client and peer find an ICE candidate they can use to exchange data without a middle man.
Once the ICE negotation has completed, data can flow according to the service protocol. Using the Python aiortc library, this might look like:
async def handle_offer(knock, peernetClient):
pc = RTCPeerConnection()
# Define our handlers
done = False
@pc.on("datachannel")
def on_datachannel(channel):
@channel.on("message")
def on_message(message):
# Process the message, and send a response
channel.send("pong" + message[4:])
@pc.on("connectionstatechange")
async def on_connectionstatechange():
if pc.connectionState == "failed":
done = True
await pc.close()
@pc.on("icecandidate")
async def on_icecandidate(candidate):
peernetClient.post("/v1/sessions/{knock.offer.name}/candidates", {
"candidate": candidate_to_sdp(obj),
"sdpMid": obj.sdpMid,
"sdpLineIndex": obj.sdpMLineIndex,
})
# Add the remote offer
offer = RTCSessionDescription(sdp=knock.offer.sdp, type=knock.offer.sdpType)
await pc.setRemoteDescription(offer)
# Create an answer
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
# Send the answer
session_id = uuid.new()
knock.answer = {
"name": session_id,
"sdp": answer.sdp,
"sdpType", answer.sdptype"
}
peernetClient.patch(knock.name, knock)
while not done:
candidates = peernetClient.get("/v1/sessions/{session_id}/claim/candidates")
for candidate in candidates:
ice_candidate = candidate_from_sdp(candidate.candidate)
ice_candidate.sdpMid = candidate.sdpMid
ice_candidate.sdpMLineIndex = candidate.sdpLineIndex
await pc.addIceCandidate(candidate)
server.py
import requests
import instance.serve
import uuid
url = 'https://myserver.local/v1'
auth_token = uuid.uuid4()
# This call creates a few things as a side effect:
# 1. The rooms 'the-good-place' and 'the-bad-place'
# 2. A server with the display_name "Chidi"
# 3. A service beloning to that server
create_server_response = requests.post(url + '/servers', '''
{
"unique_id": "my-public-name",
"rooms": [
"the-good-place",
"the-bad-place",
],
"auth_token": %s
"display_name": "Chidi",
"services": [
{
"protocol": "peernet.http",
"version": "1.1",
},
],
}
''' % auth_token)
# Response contains a token we use to authorize ourselves
auth_header = {"Authorization": auth_token}
# Poll for clients, and spin off a helper when one tries to connect
while True:
claim_knocks_response = request.get(
url + "/servers/%s/claim_knocks" % create_server_response.unique_id,
# The filter lets us ignore service we don't support
'''
"filter": "service.name=\"peernet.http\" AND service.version=\"1.1\""
'''
headers=auth_header,
)
list_knocks_json = list_knocks_response.json()
for knock in list_knocks_json["knocks"]:
# Spin off a process to handle the knock
p = Process(target=instance.serve, args=(knock))
p.start()
time.sleep(1)
instance.py
import asyncio
from multiprocessing import Process
from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription
async def serve(url, auth_header, knock):
peer_connection = RTCPeerConnection()
await peer_connection.set_remote_description(knock.client_session_description)
await pc.setLocalDescription(await peer_connection.createAnswer())
for ice_candidate in knock['ice_candidates']:
await peer_connection.addIceCandidate(ice_candidate)
# Gather local ICE candidates