add: demo server and client

This commit is contained in:
Charles
2024-04-06 20:45:15 -07:00
parent 5a149bc922
commit 72effb5b2a
100 changed files with 11603 additions and 62 deletions

192
README.md
View File

@@ -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;
}
```
```