| @@ -0,0 +1,6 @@ | |||
| .coverage | |||
| *.pyc | |||
| wsfwd.egg-info | |||
| p | |||
| @@ -0,0 +1,8 @@ | |||
| MODULES=wsfwd | |||
| VIRTUALENV?=virtualenv-3.8 | |||
| test: | |||
| (ls $(MODULES)/*.py | entr sh -c 'python -m coverage run -m unittest -f $(basename $(MODULES)) && coverage report --omit=p/\* -m -i') | |||
| env: | |||
| ($(VIRTUALENV) p && . ./p/bin/activate && pip install -r requirements.txt) | |||
| @@ -0,0 +1,62 @@ | |||
| WSFWD | |||
| ===== | |||
| WSFWD is a protocol for authentication (optional) and forwarding | |||
| command data over a WebSocket. This originally is for simple stdin | |||
| and stdout forwarding, for running a program like `sshd -i` to bypass | |||
| port blocking, or allow more custom routing and execution. | |||
| It is designed so that in the future, it could support forwarding | |||
| stderr separately, but also out of band messages, such as window | |||
| change information, so that a full tty could be forwarded over the | |||
| connection. | |||
| Protocol | |||
| -------- | |||
| All WebSocket messages much be initially treated as binary. This is | |||
| for simplicity and speed when doing large binary transfers. The | |||
| format of the messages are: | |||
| <cmd byte> <payload> | |||
| The `chan byte` value determins the meaning of payload. | |||
| If `chan byte` is zero (aka 00), then the payload is a JSON message | |||
| that contains a command, such as authentication, command to execute, | |||
| or out of band messages. | |||
| Other values for `chan byte` are dynamically allocated based upon the | |||
| commands sent. | |||
| Auth message: | |||
| { 'auth': { 'bearer': <token> } } | |||
| Error message in case of invalid auth: | |||
| { 'resp': 'auth', 'error': 'Invalid auth' } | |||
| Command to execute a program: | |||
| { 'cmd': 'exec', 'args': [ ... ], 'stdin': <chan>, 'stdout': <chan>, 'oob': <chan> } | |||
| Error if unable to exec the requested program: | |||
| { 'resp': 'exec', 'error': { 'type': 'exec', 'args': [ ... ], 'exec': 'Unable to exec' } } | |||
| Success: | |||
| { 'resp': 'exec' } | |||
| Close stdin or stdout or other channel: | |||
| { 'cmd': 'chanclose', 'chan': <chan> } | |||
| Success from above, or if stdout is closed: | |||
| { 'resp': 'chanclose', 'chan': <chan> } | |||
| oob messages are JSON as well, for the results of the program: | |||
| { 'exit': <code> } | |||
| S -> IDLE -- receive auth --> set AUTH token --> IDLE | |||
| FastAPI uses starlette: https://www.starlette.io/websockets/ | |||
| Client: https://github.com/aaugustin/websockets | |||
| @@ -0,0 +1,4 @@ | |||
| # use setup.py for dependancy info | |||
| -e . | |||
| -e .[dev] | |||
| @@ -0,0 +1,29 @@ | |||
| # python setup.py --dry-run --verbose install | |||
| import os.path | |||
| from setuptools import setup, find_packages | |||
| from distutils.core import setup | |||
| setup( | |||
| name='wsfwd', | |||
| version='0.1.0', | |||
| author='John-Mark Gurney', | |||
| author_email='jmg@funkthat.com', | |||
| packages=find_packages(), | |||
| #url='', | |||
| license='BSD', | |||
| description='WebSocket based command run/streaming system.', | |||
| #download_url='', | |||
| long_description=open('README.md').read(), | |||
| install_requires=[ | |||
| ], | |||
| extras_require = { | |||
| 'dev': [ 'coverage' ], | |||
| }, | |||
| entry_points={ | |||
| 'console_scripts': [ | |||
| ] | |||
| } | |||
| ) | |||
| @@ -0,0 +1,107 @@ | |||
| import asyncio | |||
| import functools | |||
| import json | |||
| import unittest | |||
| from unittest.mock import Mock, AsyncMock | |||
| def timeout(timeout): | |||
| def timeout_wrapper(fun): | |||
| @functools.wraps(fun) | |||
| async def wrapper(*args, **kwargs): | |||
| return await asyncio.wait_for(fun(*args, **kwargs), timeout) | |||
| return wrapper | |||
| return timeout_wrapper | |||
| class TestTimeout(unittest.IsolatedAsyncioTestCase): | |||
| async def test_timeout(self): | |||
| @timeout(.001) | |||
| async def somefun(): | |||
| await asyncio.sleep(1) | |||
| with self.assertRaises(asyncio.TimeoutError): | |||
| await somefun() | |||
| class WSFWDClient: | |||
| def __init__(self, reader, writer): | |||
| '''This is the client for doing command execution over | |||
| a datagram protocol, such as WebSockets. The two | |||
| arguments, each must be a coroutine. | |||
| In the case of reader, awaiting it MUST return a complete | |||
| datagram that was sent by the server. | |||
| In the case of writer, it will take a datagram that MUST | |||
| be sent to the server. | |||
| ''' | |||
| self._reader = reader | |||
| self._writer = writer | |||
| async def _sendcmd(self, cmd): | |||
| await self._writer(b'\x00' + json.dumps(cmd).encode('utf-8')) | |||
| async def auth(self, auth): | |||
| await self._sendcmd(dict(auth=auth)) | |||
| rsp = await self._reader() | |||
| rsp = json.loads(rsp[1:]) | |||
| if 'error' in rsp: | |||
| raise RuntimeError('Got auth error: %s' % repr(rsp['error'])) | |||
| class Test(unittest.IsolatedAsyncioTestCase): | |||
| @staticmethod | |||
| def _encodecmd(payload): | |||
| return b'\x00' + json.dumps(payload).encode('utf-8') | |||
| async def asyncSetUp(self): | |||
| self.toclient = asyncio.Queue() | |||
| self.toserver = asyncio.Queue() | |||
| def runClient(self): | |||
| return WSFWDClient(self.toclient.get, self.toserver.put) | |||
| def runServer(self, func): | |||
| return asyncio.create_task(func(self.toserver.get, self.toclient.put)) | |||
| @timeout(2) | |||
| async def test_authfail(self): | |||
| async def fake_server(reader, writer): | |||
| await reader() | |||
| await writer(self._encodecmd(dict(resp='auth', error='Invalid auth'))) | |||
| serv_task = self.runServer(fake_server) | |||
| a = self.runClient() | |||
| with self.assertRaises(RuntimeError): | |||
| await a.auth('randomtoken') | |||
| await serv_task | |||
| @timeout(2) | |||
| async def test_client(self): | |||
| toclient, toserver = self.toclient, self.toserver | |||
| token = 'sdlfkjsoidfjl' | |||
| authdict = dict(bearer=token) | |||
| authmsg = { 'auth': authdict } | |||
| async def fake_server(reader, writer): | |||
| auth = await reader() | |||
| self.assertEqual(auth, self._encodecmd(authmsg)) | |||
| await writer(self._encodecmd(dict(resp='auth'))) | |||
| serv_task = self.runServer(fake_server) | |||
| a = self.runClient() | |||
| await a.auth(authdict) | |||
| await serv_task | |||