Browse Source

add first cut of wsfwd, only implements auth.. rest to follow..

main
John-Mark Gurney 5 years ago
commit
3b41828482
7 changed files with 216 additions and 0 deletions
  1. +6
    -0
      .gitignore
  2. +8
    -0
      Makefile
  3. +0
    -0
      README.md
  4. +62
    -0
      WSFWD.md
  5. +4
    -0
      requirements.txt
  6. +29
    -0
      setup.py
  7. +107
    -0
      wsfwd/__init__.py

+ 6
- 0
.gitignore View File

@@ -0,0 +1,6 @@
.coverage

*.pyc

wsfwd.egg-info
p

+ 8
- 0
Makefile View File

@@ -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
README.md View File


+ 62
- 0
WSFWD.md View File

@@ -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

+ 4
- 0
requirements.txt View File

@@ -0,0 +1,4 @@
# use setup.py for dependancy info
-e .

-e .[dev]

+ 29
- 0
setup.py View File

@@ -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': [
]
}
)

+ 107
- 0
wsfwd/__init__.py View File

@@ -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

Loading…
Cancel
Save