├── .gitignore ├── LICENSE ├── README.md ├── korv ├── __init__.py ├── client.py └── server.py ├── requirements.txt ├── sample_server.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cristian Medina 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Korv is an API framework that uses TCP sockets over SSH to exchange JSON data with a REST-like protocol. It's built on top of the `asyncssh` module, so it uses `asyncio` to manage the sockets and its callbacks. This allows you to build rich APIs with the session security of SSH and without the TCP overhead of HTTP. 2 | 3 | Communications over this framework requires SSH keys like logging into a normal SSH server: 4 | * The server itself has a private key and a set of public keys for the authorized clients. 5 | * The client has a private key and a set of public keys for the servers it can connect to. 6 | 7 | 8 | ## Verbs 9 | There are 4 main verbs that indicate the intent of your request: 10 | * `GET` for retrieving information. 11 | * `STORE` for creating new objects. 12 | * `UPDATE` for changing existing objects. 13 | * `DELETE` for removing objects. 14 | 15 | 16 | ## Keys 17 | As discussed previously, you establish an SSH session with the server, so it's possible to reuse existing keys or generate them through any standard mechanism like the one below: 18 | 19 | ```bash 20 | ssh-keygen -t rsa -b 4096 -C "your_email@example.com" 21 | ``` 22 | 23 | ## Server 24 | Getting a server up and running is very simple: 25 | 26 | ```python 27 | from korv import KorvServer 28 | 29 | 30 | def hello(request): 31 | """Callback for the /hello endpoint""" 32 | 33 | return 200, {'msg': 'Hello World!'} 34 | 35 | def echo(request): 36 | """Callback for the /echo endpoint""" 37 | 38 | return 200, {'msg': f'{request}'} 39 | 40 | 41 | # Create a server 42 | k = KorvServer(host_keys=['PATH_TO_YOUR_SERVER_PRIVATE_KEY'], authorized_client_keys='PATH_TO_YOUR_AUTHORIZED_PUBLIC_KEYS') 43 | 44 | # Register the callbacks 45 | k.add_callback('GET', '/hello', hello) 46 | k.add_callback('GET', '/echo', echo) 47 | 48 | # Start listening for requests 49 | k.start() 50 | ``` 51 | 52 | This will start a new SSH server with the specified private key that listens on port `8022` by default and will accept the clients listed in the authorized keys. 53 | 54 | ## Client 55 | Following is an example on how to communicate with this server. 56 | 57 | ```python 58 | >>> from korv import KorvClient 59 | >>> 60 | >>> # Create the client 61 | >>> k = KorvClient(client_keys=['PATH_TO_YOUR_CLIENTS_PRIVATE_KEY']) 62 | >>> 63 | >>> # Issue a GET request and print the output 64 | >>> k.get('/hello', callback=lambda response: print(response['body'])) 65 | >>> {'msg': 'Hello World!'} 66 | ``` 67 | 68 | ## Return Codes 69 | We're using standard HTTP response codes: 70 | * `200` = Success. 71 | * `400` = Malformed request or missing parameters. 72 | * `404` = NotFound 73 | * `500` = Internal error. 74 | 75 | Server exceptions map to a `500` return code ans will include a traceback in the response. 76 | -------------------------------------------------------------------------------- /korv/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import KorvServer 2 | from .client import KorvClient 3 | -------------------------------------------------------------------------------- /korv/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import asyncssh 3 | 4 | import sys 5 | from threading import Thread 6 | 7 | import time 8 | import json 9 | import logging 10 | import gzip 11 | 12 | 13 | class _SSHClient(asyncssh.SSHClient): 14 | def connection_made(self, conn): 15 | logging.debug(f"Connection made to conn.get_extra_info('peername')[0]") 16 | 17 | def auth_completed(self): 18 | logging.debug('Authentication successful') 19 | 20 | 21 | class _SSHClientSession(asyncssh.SSHTCPSession): 22 | 23 | def connection_made(self, chan): 24 | logging.debug("Session opened") 25 | self._chan = chan 26 | self._requests = dict() 27 | 28 | def connection_lost(self, exc): 29 | logging.debug("Connection lost") 30 | logging.debug(f"{exc}") 31 | 32 | def session_started(self): 33 | logging.debug("Session successful") 34 | 35 | def data_received(self, data, datatype): 36 | logging.debug(f"Received data: {data}") 37 | 38 | try: 39 | data = json.loads(gzip.decompress(data).decode('utf-8')) 40 | 41 | if data['request_id'] in self._requests: 42 | if callable(self._requests[data['request_id']]): 43 | self._requests[data['request_id']](data) 44 | 45 | if self._requests[data['request_id']] is None: 46 | self._requests[data['request_id']] = data 47 | else: 48 | del(self._requests[data['request_id']]) 49 | 50 | except Exception: 51 | logging.exception(f"There was an error processing the server response") 52 | 53 | def eof_received(self): 54 | logging.debug("Received EOF") 55 | self._chan.exit(0) 56 | 57 | async def send_request(self, verb, resource, body, callback): 58 | if verb not in ['GET', 'STORE', 'UPDATE', 'DELETE']: 59 | raise ValueError("Unknown verb") 60 | 61 | request = { 62 | 'id': time.time(), 63 | 'verb': verb, 64 | 'resource': resource, 65 | 'body': body 66 | } 67 | 68 | self._requests[request['id']] = callback 69 | self._chan.write(gzip.compress(json.dumps(request, separators=[',', ':']).encode('utf-8'))) 70 | logging.debug(f"{verb} {resource} {body}") 71 | 72 | return request['id'] 73 | 74 | 75 | class KorvClient: 76 | 77 | def __init__(self, host='localhost', port=8022, client_keys=None, known_hosts=None, max_packet_size=32768): 78 | self.max_packet_size = max_packet_size 79 | 80 | self._loop = asyncio.new_event_loop() 81 | asyncio.set_event_loop(self._loop) 82 | self._session = asyncio.get_event_loop().run_until_complete(self.__connect(host, port, known_hosts, client_keys)) 83 | 84 | try: 85 | t = Thread(target=self.__start_loop, args=(self._loop,)) 86 | t.start() 87 | # asyncio.run_coroutine_threadsafe(self.__connect(), self._loop) 88 | 89 | except (OSError, asyncssh.Error) as exc: 90 | sys.exit(f'SSH connection failed: {exc}') 91 | 92 | def __start_loop(self, loop): 93 | asyncio.set_event_loop(loop) 94 | loop.run_forever() 95 | 96 | async def __connect(self, host, port, known_hosts, client_keys): 97 | logging.info(f"Connecting to SSH Server {host}:{port}") 98 | conn, client = await asyncssh.create_connection( 99 | _SSHClient, 100 | host, 101 | port, 102 | client_keys=client_keys, 103 | known_hosts=known_hosts 104 | ) 105 | 106 | logging.debug("Opening Socket") 107 | chan, session = await conn.create_connection(_SSHClientSession, host, port, max_pktsize=self.max_packet_size) 108 | return session 109 | 110 | def get(self, resource, body=None, callback=None): 111 | if callback is None: 112 | request_id = asyncio.run_coroutine_threadsafe(self._session.send_request("GET", resource, body, None), self._loop).result() 113 | 114 | while self._session._requests[request_id] is None: 115 | time.sleep(0.1) 116 | 117 | response = self._session._requests[request_id] 118 | del(self._session._requests[request_id]) 119 | 120 | return response 121 | else: 122 | asyncio.run_coroutine_threadsafe(self._session.send_request("GET", resource, body, callback), self._loop) 123 | 124 | def store(self, resource, body, callback=None): 125 | asyncio.run_coroutine_threadsafe(self._session.send_request("STORE", resource, body, callback), self._loop) 126 | 127 | def update(self, resource, body, callback=None): 128 | asyncio.run_coroutine_threadsafe(self._session.send_request("UPDATE", resource, body, callback), self._loop) 129 | 130 | def delete(self, resource, body=None, callback=None): 131 | asyncio.run_coroutine_threadsafe(self._session.send_request("DELETE", resource, body, callback), self._loop) 132 | -------------------------------------------------------------------------------- /korv/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import asyncssh 3 | import logging 4 | 5 | import sys 6 | import traceback 7 | import time 8 | import json 9 | import gzip 10 | 11 | 12 | class _KorvServerSession(asyncssh.SSHTCPSession): 13 | def __init__(self, callbacks): 14 | self._callbacks = callbacks 15 | 16 | def connection_made(self, chan): 17 | """New connection established""" 18 | 19 | logging.info("Connection incoming") 20 | self._chan = chan 21 | 22 | def connection_lost(self, exc): 23 | """Lost the connection to the client""" 24 | 25 | logging.info("Connection lost") 26 | logging.info(f"{exc}") 27 | 28 | def session_started(self): 29 | """New session established succesfully""" 30 | 31 | logging.info("Connection successful") 32 | 33 | def data_received(self, data, datatype): 34 | """New data coming in""" 35 | 36 | logging.info(f"Received data: {data}") 37 | self._dispatch(data) 38 | 39 | def eof_received(self): 40 | """Got an EOF, close the channel""" 41 | 42 | logging.info("EOF") 43 | self._chan.exit(0) 44 | 45 | def _dispatch(self, data): 46 | try: 47 | request = json.loads(gzip.decompress(data).decode('utf-8')) 48 | 49 | if 'id' not in request: 50 | logging.info("Malformed request: missing 'id'") 51 | self._send_response(0, 400, {"message": "Missing 'id'"}) 52 | 53 | if 'verb' not in request: 54 | logging.info("Malformed request: missing 'request'") 55 | self._send_response(request['id'], 400, {"message": "Missing 'verb'"}) 56 | 57 | if 'resource' not in request: 58 | logging.info("Malformed request: missing 'resource'") 59 | self._send_response(request['id'], 400, {"message": "Missing 'resource'"}) 60 | 61 | if request['verb'] == 'STORE' and 'body' not in request['request']: 62 | logging.info("Malformed request: missing 'resource'") 63 | self._send_response(request['id'], 400, {"message": "Missing 'body'"}) 64 | 65 | elif request['verb'] == 'UPDATE' and 'body' not in request['request']: 66 | logging.info("Malformed request: missing 'resource'") 67 | self._send_response(request['id'], 400, {"message": "Missing 'body'"}) 68 | 69 | except Exception as e: 70 | logging.info("Unable to process request") 71 | self._send_response(0, 400, {"message": "Unable to process request"}) 72 | 73 | self.__process_request(request) 74 | 75 | def __process_request(self, request): 76 | if request['verb'] not in self._callbacks: 77 | logging.info(f"No callback found for {request['verb']}") 78 | self._send_response(request['id'], 404) 79 | return 80 | 81 | if request['resource'] not in self._callbacks[request['verb']]: 82 | logging.info(f"No callback found for {request['verb']} on {request['resource']}") 83 | self._send_response(request['id'], 404) 84 | return 85 | 86 | for callback in self._callbacks[request['verb']][request['resource']]: 87 | try: 88 | self._send_response(request['id'], *callback(request)) 89 | 90 | except Exception as e: 91 | logging.info(f"Internal error when executing {request['verb']} on {request['resource']}") 92 | self._send_response(request['id'], 500, {"message": str(e), "traceback": traceback.format_exc()}) 93 | 94 | def _send_response(self, request_id, code, body=None): 95 | """Send a response to the given client request""" 96 | 97 | cmd = { 98 | 'id': time.time(), 99 | 'request_id': request_id, 100 | 'code': code, 101 | 'body': body 102 | } 103 | 104 | logging.info(f"Sending response {cmd}") 105 | self._chan.write(gzip.compress(json.dumps(cmd, separators=[',', ':']).encode('utf-8'))) 106 | 107 | 108 | class KorvServer(asyncssh.SSHServer): 109 | VERBS = ('GET', 'STORE', 'UPDATE', 'DELETE') 110 | 111 | _callbacks = {verb: dict() for verb in VERBS} 112 | 113 | def __init__(self, port=8022, host_keys=['ssh_host_key'], authorized_client_keys='authorized_keys'): 114 | """Instatiate an SSH server that listens on the given port for clients that match the authorized keys""" 115 | 116 | self.port = port 117 | self._host_keys = host_keys 118 | self._authorized_client_keys = authorized_client_keys 119 | 120 | def connection_requested(self, dest_host, dest_port, orig_host, orig_port): 121 | """Run a new TCP session that handles an SSH client connection""" 122 | 123 | logging.info(f"Connection requested {dest_host} {dest_port} {orig_host} {orig_port}") 124 | return _KorvServerSession(KorvServer._callbacks) 125 | 126 | async def __create_server(self): 127 | """Creates an asynchronous SSH server""" 128 | 129 | await asyncssh.create_server( 130 | KorvServer, '', self.port, 131 | server_host_keys=self._host_keys, 132 | authorized_client_keys=self._authorized_client_keys 133 | ) 134 | 135 | def add_callback(self, verb, resource, callback): 136 | """Configure a callable to execute when receiving a request with the given verb and resource combination""" 137 | 138 | if verb not in KorvServer.VERBS: 139 | raise ValueError(f"Verb must be one of {KorvServer.VERBS}") 140 | 141 | if resource not in KorvServer._callbacks[verb]: 142 | KorvServer._callbacks[verb][resource] = list() 143 | 144 | KorvServer._callbacks[verb][resource].append(callback) 145 | 146 | def start(self): 147 | """Start the server""" 148 | 149 | logging.info(f"Listening on port {self.port}") 150 | 151 | loop = asyncio.get_event_loop() 152 | 153 | try: 154 | loop.run_until_complete(self.__create_server()) 155 | 156 | except (OSError, asyncssh.Error) as exc: 157 | sys.exit(f'Error starting server: {exc}') 158 | 159 | loop.run_forever() 160 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asyncssh==1.15.0 2 | -------------------------------------------------------------------------------- /sample_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from korv import KorvServer 4 | 5 | 6 | logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(asctime)s %(message)s') 7 | 8 | def callme(request): 9 | return 200, {'msg': 'Hello World!'} 10 | 11 | def echo(request): 12 | return 200, {'msg': f'{request}'} 13 | 14 | k = KorvServer() 15 | k.add_callback('GET', '/hello', callme) 16 | k.add_callback('GET', '/echo', echo) 17 | k.start() 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | 4 | 5 | # Get the long description from the README file 6 | with open(path.join('.', 'README.md'), encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | 10 | setup( 11 | author = "tryexceptpass", 12 | author_email = "cmedina@tryexceptpass.org", 13 | 14 | name = "korv", 15 | version = "0.2.1", 16 | description = "SSH API Framework", 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | 20 | url = "https://github.com/tryexceptpass/korv", 21 | 22 | packages = find_packages(), 23 | 24 | install_requires = ['asyncssh'], 25 | python_requires='>=3.6', 26 | setup_requires=['pytest-runner'], 27 | tests_require=['pytest'], 28 | 29 | license = "MIT", 30 | classifiers = [ 'License :: OSI Approved :: MIT License', 31 | 32 | 'Topic :: Communications', 33 | 'Topic :: Internet', 34 | 35 | 'Framework :: AsyncIO', 36 | 37 | 'Programming Language :: Python :: 3 :: Only', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Programming Language :: Python :: 3.7', 40 | 41 | 'Development Status :: 4 - Beta', 42 | ], 43 | 44 | keywords = 'ssh api framework', 45 | 46 | project_urls={ 47 | 'Gitter Chat': 'https://gitter.im/try-except-pass/korv', 48 | 'Say Thanks!': 'https://saythanks.io/to/tryexceptpass', 49 | 'Source': 'https://github.com/tryexceptpass/korv', 50 | # 'Documentation': 'http://korv.readthedocs.io/en/latest/', 51 | }, 52 | ) 53 | --------------------------------------------------------------------------------