add: demo server and client
This commit is contained in:
192
README.md
192
README.md
@@ -1,4 +1,4 @@
|
||||
*/# PeerNet -- peer-to-peer communication without the network
|
||||
# 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:
|
||||
|
||||
@@ -19,79 +19,147 @@ One minor goal in this API design is to minimize the number of messages that nee
|
||||
|
||||
Sample server flow:
|
||||
|
||||
```pyton
|
||||
import requests
|
||||
1. 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
|
||||
2. Server send a POST to /v1/servers with the server object, including a list of services offered
|
||||
3. Server begins polling /v1/servers/{server}/services/{service}/knocks for client attempts to connect
|
||||
4. When a knock is recieved, a worker should be spawned to handle the connection
|
||||
|
||||
url = 'https://myserver.local'
|
||||
When a client connects, the standard WebRTC negotation commences. Restating from [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity#session_descriptions):
|
||||
|
||||
1. Peer connection is created in client (caller, in WebRTC parliance)
|
||||
2. Attach channels to it (i.e., a data channel)
|
||||
3. Client creates an offer; they set the 'local description' to the offer
|
||||
4. Client sends request to signaling server (peernet server)
|
||||
5. The peer recieves the request and sets remote description to the offer
|
||||
6. The peer creates an answer and sets it to 'local description'
|
||||
7. The peer sends the answer to the signaling server (peernet server)
|
||||
8. 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)
|
||||
|
||||
response = requests.post(url + '/server', '''
|
||||
{
|
||||
"unique_id": "
|
||||
}
|
||||
''')
|
||||
```
|
||||
|
||||
The protocol is define below in Protobuf, but is also availabe [here] in OpenAPI format.
|
||||
|
||||
```proto2
|
||||
syntax = "proto2";
|
||||
package peernet
|
||||
server.py
|
||||
```pyton
|
||||
import requests
|
||||
import instance.serve
|
||||
import uuid
|
||||
|
||||
service PeerNetService {
|
||||
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
|
||||
rpc DeleteRoom(DeleteRoomRequest) return (DeleteRoomResponse);
|
||||
url = 'https://myserver.local/v1'
|
||||
auth_token = uuid.uuid4()
|
||||
|
||||
rpc CreateServer(CreateServerRequest) returns (CreateServerResponse);
|
||||
rpc DeleteServer(DeleteServerRequest) returns (DeleteServerResponse);
|
||||
rpc ListServers(ListServersRequest) returns (ListServersResponse);
|
||||
|
||||
rpc CreateService(CreateServiceRequest) returns (CreateServiceResponse);
|
||||
rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse);
|
||||
rpc UpdateService(UpdateServiceRequest) returns (UpdateServiceResponse);
|
||||
|
||||
rpc CreateKnock(CreateKnockRequest) returns (CreateKnockResponse);
|
||||
rpc DeleteKnock(DeleteKnockRequest) returns (DeleteKnockResponse);
|
||||
rpc GetKnock(GetKnockRequest) returns (Knock);
|
||||
rpc ListKnocks(ListKnocksRequest) returns (ListKnocksResponse);
|
||||
# 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)
|
||||
|
||||
message Room {
|
||||
string unique_id = 1;
|
||||
string display_name = 2;
|
||||
}
|
||||
# Response contains a token we use to authorize ourselves
|
||||
auth_header = {"Authorization": auth_token}
|
||||
|
||||
message Server {
|
||||
string unique_id = 1;
|
||||
# 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()
|
||||
|
||||
string room = 2;
|
||||
|
||||
string display_name = 2;
|
||||
}
|
||||
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)
|
||||
|
||||
message Service {
|
||||
string protocol = 1;
|
||||
string version = 2;
|
||||
```
|
||||
|
||||
// Used to define custom fields per-protocol/version.
|
||||
// We use unverified extensions because this system is meant
|
||||
// to be distributed, with no central owner, hence, no singular
|
||||
// authority to hand out field numbers. Instead, implementations
|
||||
// should use the protocol/version to scope what values are expected.
|
||||
extensions 100 to max [verification = UNVERIFIED];
|
||||
}
|
||||
instance.py
|
||||
```
|
||||
import asyncio
|
||||
from multiprocessing import Process
|
||||
from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription
|
||||
|
||||
message Knock {
|
||||
|
||||
repeated IceCandidate ice_candidates = 1;
|
||||
}
|
||||
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
|
||||
|
||||
message IceCandidate {
|
||||
// Copied from https://pkg.go.dev/github.com/pion/webrtc/v4#ICECandidateInit
|
||||
string candidate = 1;
|
||||
optional string sdp_mid = 2;
|
||||
optional int32 sdp_line_index = 3;
|
||||
optional string username_fragment = 4;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user