Azure / azure-relay

☁️ Azure Relay service issue tracking and samples
https://azure.microsoft.com/services/service-bus
MIT License
86 stars 85 forks source link

Python Example #28

Open andrewkittredge opened 6 years ago

andrewkittredge commented 6 years ago

Can you write an example of a Relay server for a Python?

dlstucki commented 6 years ago

Here's a quick run at a Python Relay HybridConnection Listener which can process HTTP Requests. I'm not accustomed to working in Python so things like error handling and class/module encapsulation are lacking.

Program.py

import asyncio
import json
import relaylib
import urllib
import websockets

async def run_application():
    serviceNamespace = 'contoso-sb.servicebus.windows.net'
    entityPath = 'unauthenticated'
    sasKeyName = 'RootManageSharedAccessKey'
    sasKey = 'GgRm12TgutmThtiRHo6DZqUpeZMbjQh0fNytng+oAcM='

    token = relaylib.createSasToken(serviceNamespace, entityPath, sasKeyName, sasKey)
    wssUri = relaylib.createListenUrl(serviceNamespace, entityPath, token)

    async with websockets.connect(wssUri) as websocket:
        keepGoing = True
        while keepGoing:
            commandStr = await websocket.recv()

            command = json.loads(commandStr)
            request = command['request']
            operationwebsocket = websocket
            rendezvouswebsocket = None
            if request is not None:
                print("\nRequest:\n{}".format(commandStr))
                if 'method' not in request:
                    print('Need to rendezvous to accept the actual request')
                    rendezvouswebsocket = await websockets.connect(request['address'])
                    operationwebsocket = rendezvouswebsocket
                    commandStr = await rendezvouswebsocket.recv()
                    print(commandStr)
                    command = json.loads(commandStr)
                    request = command['request']

                if request['body']:
                    #request has a body, read it from the websocket from which the request was read
                    requestBody = await operationwebsocket.recv()
                    print('RequestBody is {0} bytes'.format(len(requestBody)))

                response = {}
                response['requestId'] = request['id']
                response['body'] = True
                response['statusCode'] = 200
                response['responseHeaders'] = {}
                response['responseHeaders']['Content-Type'] = 'text/html'
                responseStr = json.dumps({'response' : response })
                print("\nResponse:\n{}".format(responseStr))
                await operationwebsocket.send(responseStr)

                #Response body, if present, must be binary(bytes)
                #TODO: If the response body is > 64kb it must be sent over the rendezvouswebsocket (which may need to be created for the response)
                responseBodyStr = "<html><body><H1>Hello World</H1><p>From a Python Listener</p><body></html>"
                print("ResponseBody:{}".format(responseBodyStr))
                responseBodyBytes = responseBodyStr.encode('utf-8')
                await operationwebsocket.send(responseBodyBytes)

                if rendezvouswebsocket is not None:
                    await rendezvouswebsocket.close()

asyncio.get_event_loop().run_until_complete(run_application())

relaylib.py

import base64
import hashlib
import hmac
import math
import time
import urllib

def hmac_sha256(key, msg):
    hash_obj = hmac.new(key=key, msg=msg, digestmod=hashlib._hashlib.openssl_sha256)
    return hash_obj.digest()

def createListenUrl(serviceNamespace, entityPath, token = None):
    url = 'wss://' + serviceNamespace + '/$hc/' + entityPath + '?sb-hc-action=listen'
    if token is not None:
        url = url + '&sb-hc-token=' + urllib.parse.quote(token)
    return url

# Function which creates the Service Bus SAS token. 
def createSasToken(serviceNamespace, entityPath, sasKeyName, sasKey):
    uri = "http://" + serviceNamespace + "/" + entityPath
    encodedResourceUri = urllib.parse.quote(uri, safe = '')

    tokenValidTimeInSeconds = 60 * 60 # One Hour
    unixSeconds = math.floor(time.time())
    expiryInSeconds = unixSeconds + tokenValidTimeInSeconds

    plainSignature = encodedResourceUri + "\n" + str(expiryInSeconds)
    sasKeyBytes = sasKey.encode("utf-8")
    plainSignatureBytes = plainSignature.encode("utf-8")
    hashBytes = hmac_sha256(sasKeyBytes, plainSignatureBytes)
    base64HashValue = base64.b64encode(hashBytes)

    token = "SharedAccessSignature sr=" + encodedResourceUri + "&sig=" +  urllib.parse.quote(base64HashValue) + "&se=" + str(expiryInSeconds) + "&skn=" + sasKeyName
    return token
ghost commented 5 years ago

I have two questions in this python example 1) The above python example doesn't handle websockets through the service-bus-relay right? Can you please give us a pseudo code or steps regarding how to handle the websocket case? I am trying to write the azure service bus relay library in python, as we need to use that in python programs. I may also be able to contribute that code back to the repository so that there is a python client for azure service bus relay.
2) In what case "if 'method' not in request:" condition is relevant in your python example? I thought, http case will always have http method in the request.

dlstucki commented 5 years ago

Any help you could contribute would be wonderful.

The entire protocol is documented here: https://docs.microsoft.com/en-us/azure/service-bus-relay/relay-hybrid-connections-protocol I wish it had numbers so I could refer to, say, "3.2.3" in the below discussion.

  1. Correct, this example does not support websocket (duplex) clients at this time. To do so would mean implementing handling of the Accept JSON message:

    Accept message When a sender opens a new connection on the service, the service chooses and notifies one of the active listeners on the Hybrid Connection. This notification is sent to the listener over the open control channel as a JSON message. The message contains the URL of the WebSocket endpoint that the listener must connect to for accepting the connection. [...] As soon as the WebSocket connection with the rendezvous URL is established, all further activity on this WebSocket is relayed from and to the sender.

If the sender were a websocket instead of normal HTTP this Accept message would arrive much like the Request command does:

    command = json.loads(commandStr)
    request = command['request']      <==== Instead of 'request' this would be 'accept' 

The pseudo-code would be something similar to the following:

    # command['request'] would be empty, instead check for accept command
    accept = command['accept']
    if accept is not None:
        print("\nAccept:\n{}".format(commandStr))
        print('Need to rendezvous to accept the websocket client')
        rendezvouswebsocket = await websockets.connect(accept['address'])
        # All bytes over rendezvouswebsocket would now be the application data (sender and listener).
  1. The if 'method' not in request: check is to determine whether the listener MUST rendezvous to read the HTTP request as described in this part of the protocol doc:

For requests, the service decides whether to route requests over the control channel. This includes, but may not be limited to cases where a request exceeds 64 kB (headers plus body) outright, or if the request is sent with "chunked" transfer-encoding [...]. If the service chooses to deliver the request over rendezvous, it only passes the rendezvous address to the listener. The listener then MUST establish the rendezvous WebSocket and the service promptly delivers the full request including bodies over the rendezvous WebSocket. The response MUST also use the rendezvous WebSocket.

ghost commented 5 years ago

Thanks for the detailed information 👍

dlstucki commented 5 years ago

If you can even lay out the scaffolding for a good python library with some unit tests it would help greatly. I think this should probably end up getting checked in at https://github.com/Azure/azure-relay/tree/master/samples/hybrid-connections in a new subfolder, "python". Heck, if you've even made any improvements on this and are willing please submit a PR adding these at that path.

ghost commented 5 years ago

Yeah sure. I am also following a lot of naming conventions from the dotnet library. I have something basic working for http and will add support for websockets. I will also need to optimize on the performance like threads and locks before issuing a PR.

jfggdl commented 4 years ago

@adyada, would you please let us know if you are planning to have an improved sample for Python (PR) ready soon?

matthewhampton commented 4 years ago

@dlstucki Does the following imply that you'd be happy for this code snippet to be treated as MIT licensed like the rest of the samples in the project? (I'd like to start from it in a commercial project - that will hopefully result in something generic that we can contribute back to this project - but I'm not exactly sure where it will go)

If you can even lay out the scaffolding for a good python library with some unit tests it would help greatly. I think this should probably end up getting checked in at https://github.com/Azure/azure-relay/tree/master/samples/hybrid-connections in a new subfolder, "python". Heck, if you've even made any improvements on this and are willing please submit a PR adding these at that path.