├── .gitignore ├── README.md ├── setup.py └── wssh ├── __init__.py ├── client.py ├── common.py └── server.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.pyc 2 | *.egg-info 3 | .DS_Store 4 | build 5 | dist 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wssh - websocket shell 2 | 3 | wssh ("wish") is a command-line utility/shell for WebSocket inpsired by netcat. 4 | 5 | ## Install 6 | 7 | - Assumes Python 2.7 8 | 9 | It uses currently uses gevent 0.13, so you may need to install libevent. This is because it uses the great work in [ws4py](https://github.com/Lawouach/WebSocket-for-Python). My gevent websocket server+client in there could probably be generalized to work with Eventlet; then this could be trivially ported to Eventlet to drop the libevent dependency. 10 | 11 | If you don't have libevent installed already, install it prior to running setup.py. You can install libevent using `apt-get` on Ubuntu or `brew` on a Mac. 12 | 13 | git clone git://github.com/progrium/wssh.git 14 | cd wssh 15 | python setup.py install 16 | 17 | ## Usage 18 | 19 | Listen for WebSocket connections on a particular path and print messages to STDOUT: 20 | 21 | wssh -l localhost:8000/websocket 22 | 23 | Once connected you can use STDIN to send messages. Each line is a message. You can just as well open a peristent client connection that prints incoming messages to STDOUT and sends messages from STDIN interactively: 24 | 25 | wssh localhost:8000/websocket 26 | 27 | ## Contributing 28 | 29 | Feel free to fork and improve. 30 | 31 | ## License 32 | 33 | MIT 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | setup( 5 | name='wssh', 6 | version='0.1.0', 7 | author='Jeff Lindsay', 8 | author_email='jeff.lindsay@twilio.com', 9 | description='command-line websocket client+server shell', 10 | packages=find_packages(), 11 | install_requires=['ws4py==0.2.4','gevent==0.13.6'], 12 | data_files=[], 13 | entry_points={ 14 | 'console_scripts': [ 15 | 'wssh = wssh:main',]}, 16 | ) 17 | -------------------------------------------------------------------------------- /wssh/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | if 'threading' in sys.modules: 3 | raise Exception('threading module loaded before patching!') 4 | import gevent.monkey; gevent.monkey.patch_thread() 5 | from urlparse import urlparse 6 | import argparse 7 | 8 | from . import client 9 | from . import server 10 | 11 | def main(): 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument('url', metavar='URL', type=str, 14 | help='URL of a WebSocket endpoint with or without ws:// or wss://') 15 | parser.add_argument('-l', dest='listen', action='store_true', 16 | help='start in listen mode, creating a server') 17 | parser.add_argument('-m', dest='text_mode', type=str, default='auto', 18 | choices=['text', 'binary', 'auto'], 19 | help='specify the message transmit mode (default: auto)') 20 | parser.add_argument('-n', dest='new_lines', action='store_true', 21 | help='separate each received message with a newline') 22 | parser.add_argument('-q', dest='quit_on_eof', metavar='secs', type=int, 23 | help='quit after EOF on stdin and delay of secs (0 allowed)') 24 | parser.add_argument('-v', dest='verbosity', action='count', 25 | help='verbose (use up to 3 times to be more verbose)') 26 | args = parser.parse_args() 27 | 28 | url = args.url 29 | # Keep track of whether we are artificially assuming non-wss 30 | noscheme = False 31 | if not url.startswith("ws://") and not url.startswith("wss://"): 32 | noscheme = True 33 | url = "ws://{0}".format(url) 34 | url = urlparse(url) 35 | 36 | # If we added non-wss ourselves but the user picked port 443, they almost certainly wanted wss 37 | if noscheme and url.port == 443: 38 | url = urlparse("wss://{0}:443{1}".format(url.hostname, url.path)) 39 | 40 | port = url.port 41 | # Apply an appropriate default port. 42 | if port == None: 43 | if url.scheme == "wss": 44 | port = 443 45 | else: 46 | port = 80 47 | 48 | # Translate an empty path to None, triggering the default behaviour for 49 | # both client and server (which differ in their treatment of the default 50 | # case). 51 | path = url.path 52 | if url.path == '': 53 | path = None 54 | elif url.query: 55 | path = "{0}?{1}".format(path, url.query) 56 | 57 | try: 58 | if args.listen: 59 | server.listen(args, port, path) 60 | else: 61 | client.connect(args, url.scheme, url.hostname, port, path) 62 | except KeyboardInterrupt: 63 | pass 64 | -------------------------------------------------------------------------------- /wssh/client.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import gevent 4 | from gevent.event import Event 5 | 6 | from ws4py.exc import HandshakeError 7 | from ws4py.client.geventclient import WebSocketClient 8 | 9 | from . import common 10 | 11 | # Handles the WebSocket once it has been upgraded by the HTTP layer. 12 | class StdioPipedWebSocketClient(WebSocketClient): 13 | 14 | def __init__(self, scheme, host, port, path, opts): 15 | url = "{0}://{1}:{2}{3}".format(scheme, host, port, path) 16 | WebSocketClient.__init__(self, url) 17 | 18 | self.path = path 19 | self.shutdown_cond = Event() 20 | self.opts = opts 21 | self.iohelper = common.StdioPipedWebSocketHelper(self.shutdown_cond, opts) 22 | 23 | def received_message(self, m): 24 | self.iohelper.received_message(self, m) 25 | 26 | def opened(self): 27 | if self.opts.verbosity >= 1: 28 | peername, peerport = self.sock.getpeername() 29 | print >> sys.stderr, "[%s] %d open for path '%s'" % (peername, peerport, self.path) 30 | self.iohelper.opened(self) 31 | 32 | def closed(self, code, reason): 33 | self.shutdown_cond.set() 34 | 35 | def connect_and_wait(self): 36 | self.connect() 37 | self.shutdown_cond.wait() 38 | 39 | def connect(args, scheme, host, port, path): 40 | if path == None: 41 | path = '/' 42 | client = StdioPipedWebSocketClient(scheme, host, port, path, args) 43 | try: 44 | client.connect_and_wait() 45 | except (IOError, HandshakeError), e: 46 | print >> sys.stderr, e 47 | -------------------------------------------------------------------------------- /wssh/common.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import fcntl 4 | 5 | import string 6 | 7 | import gevent 8 | from gevent.socket import wait_read 9 | 10 | from ws4py.websocket import WebSocket 11 | 12 | # Common stdio piping behaviour for the WebSocket handler. Unfortunately due 13 | # to ws4py OO failures, it's not possible to just share a common WebSocket 14 | # class for this (WebSocketClient extends WebSocket, rather than simply 15 | # delegating to one as WebSocketServer can). 16 | class StdioPipedWebSocketHelper: 17 | def __init__(self, shutdown_cond, opts): 18 | self.shutdown_cond = shutdown_cond 19 | self.opts = opts 20 | if self.opts.text_mode == 'auto': 21 | # This represents all printable, ASCII characters. Only these 22 | # characters can pass through as a WebSocket text frame. 23 | self.textset = set(c for c in string.printable if ord(c) < 128) 24 | 25 | def received_message(self, websocket, m): 26 | if self.opts.verbosity >= 3: 27 | mode_msg = 'binary' if m.is_binary else 'text' 28 | print >> sys.stderr, "[received payload of length %d as %s]" % (len(m.data), mode_msg) 29 | sys.stdout.write(m.data) 30 | if self.opts.new_lines: 31 | sys.stdout.write("\n") 32 | sys.stdout.flush() 33 | 34 | def should_send_binary_frame(self, buf): 35 | if self.opts.text_mode == 'auto': 36 | return not set(buf).issubset(self.textset) 37 | elif self.opts.text_mode == 'text': 38 | return False 39 | else: 40 | return True 41 | 42 | def opened(self, websocket): 43 | def connect_stdin(): 44 | fcntl.fcntl(sys.stdin, fcntl.F_SETFL, os.O_NONBLOCK) 45 | while True: 46 | wait_read(sys.stdin.fileno()) 47 | buf = sys.stdin.read(4096) 48 | if len(buf) == 0: 49 | break 50 | binary=self.should_send_binary_frame(buf) 51 | if self.opts.verbosity >= 3: 52 | mode_msg = 'binary' if binary else 'text' 53 | print >> sys.stderr, "[sending payload of length %d as %s]" % (len(buf), mode_msg) 54 | websocket.send(buf, binary) 55 | 56 | if self.opts.verbosity >= 2: 57 | print >> sys.stderr, '[EOF on stdin, shutting down input]' 58 | 59 | # If -q was passed, shutdown the program after EOF and the 60 | # specified delay. Otherwise, keep the socket open even with no 61 | # more input flowing (consistent with netcat's behaviour). 62 | if self.opts.quit_on_eof is not None: 63 | if self.opts.quit_on_eof > 0: 64 | gevent.sleep(self.opts.quit_on_eof) 65 | self.shutdown_cond.set() 66 | 67 | # XXX: We wait for the socket to open before reading stdin so that we 68 | # support behaviour like: echo foo | wssh -l ... 69 | gevent.spawn(connect_stdin) 70 | -------------------------------------------------------------------------------- /wssh/server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import gevent 4 | from gevent.event import Event 5 | 6 | from ws4py.server.geventserver import UpgradableWSGIHandler 7 | from ws4py.server.wsgi.middleware import WebSocketUpgradeMiddleware 8 | from ws4py.websocket import WebSocket 9 | 10 | from . import common 11 | 12 | # Handles the WebSocket once it has been upgraded by the HTTP layer. 13 | class StdioPipedWebSocket(WebSocket): 14 | def my_setup(self, helper, opts): 15 | self.iohelper = helper 16 | self.opts = opts 17 | 18 | def received_message(self, m): 19 | self.iohelper.received_message(self, m) 20 | 21 | def opened(self): 22 | if self.opts.verbosity >= 1: 23 | peername, peerport = self.sock.getpeername() 24 | print >> sys.stderr, "connect from [%s] %d" % (peername, peerport) 25 | self.iohelper.opened(self) 26 | 27 | def closed(self, code, reason): 28 | pass 29 | 30 | # Simple HTTP server implementing only one endpoint which upgrades to the 31 | # stdin/stdout connected WebSocket. 32 | class SimpleWebSocketServer(gevent.pywsgi.WSGIServer): 33 | handler_class = UpgradableWSGIHandler 34 | 35 | def __init__(self, host, port, path, opts): 36 | gevent.pywsgi.WSGIServer.__init__(self, (host, port), log=None) 37 | 38 | self.host = host 39 | self.port = port 40 | self.path = path 41 | self.application = self 42 | 43 | self.shutdown_cond = Event() 44 | self.opts = opts 45 | self.iohelper = common.StdioPipedWebSocketHelper(self.shutdown_cond, opts) 46 | 47 | self.ws_upgrade = WebSocketUpgradeMiddleware(app=self.ws_handler, 48 | websocket_class=StdioPipedWebSocket) 49 | 50 | def __call__(self, environ, start_response): 51 | request_path = environ['PATH_INFO'] 52 | if self.path and request_path != self.path: 53 | if self.opts.verbosity >= 2: 54 | print "refusing to serve request for path '%s'" % request_path 55 | start_response('400 Not Found', []) 56 | return [''] 57 | else: 58 | # Hand-off the WebSocket upgrade negotiation to ws4py... 59 | return self.ws_upgrade(environ, start_response) 60 | 61 | def ws_handler(self, websocket): 62 | # Stop accepting new connections after we receive our first one (a la 63 | # netcat). 64 | self.stop_accepting() 65 | 66 | # Pass custom arguments over to our WebSocket instance. The design of 67 | # gevent's pywsgi layer leaves a lot to be desired in terms of proper 68 | # dependency injection patterns... 69 | websocket.my_setup(self.iohelper, self.opts) 70 | 71 | # Transfer control to the websocket_class. 72 | g = gevent.spawn(websocket.run) 73 | g.join() 74 | 75 | # WebSocket connection terminated, exit program. 76 | self.shutdown_cond.set() 77 | 78 | def handle_one_websocket(self): 79 | self.start() 80 | if self.opts.verbosity >= 1: 81 | if self.path: 82 | path_stmt = "path '%s'" % (self.path) 83 | else: 84 | path_stmt = 'all paths' 85 | print >> sys.stderr, 'listening on [any] %d for %s...' % (self.port, path_stmt) 86 | self.shutdown_cond.wait() 87 | 88 | def listen(args, port, path): 89 | # XXX: Should add support to limit the listening interface. 90 | server = SimpleWebSocketServer('', port, path, args) 91 | try: 92 | server.handle_one_websocket() 93 | except IOError, e: 94 | print >> sys.stderr, e 95 | --------------------------------------------------------------------------------