├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── README.md ├── conf ├── client.yml └── server.yml ├── requirements.txt ├── requirements_windows.txt ├── setup.cfg ├── setup.py └── wstunnel ├── __init__.py ├── client.py ├── daemon ├── __init__.py ├── wstuncltd.py └── wstunsrvd.py ├── exception.py ├── factory.py ├── filters.py ├── server.py ├── svc ├── __init__.py ├── registry.py ├── wstuncltd.py └── wstunsrvd.py ├── test ├── __init__.py ├── fixture │ ├── localhost.key │ ├── localhost.pem │ ├── wstuncltd.yml │ └── wstunsrvd.yml ├── test_daemon.py ├── test_toolbox.py └── test_wstunnel.py └── toolbox.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = wstunnel 3 | 4 | [report] 5 | omit = 6 | */svc/* 7 | wstunnel/registry.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | *.egg-info 3 | *.pyc 4 | .tox 5 | logs 6 | coverage 7 | dist 8 | .idea 9 | .coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | - "3.3" 5 | - "2.7" 6 | # - "2.6" 7 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 8 | install: 9 | - pip install -r requirements.txt --use-mirrors 10 | - pip install coveralls 11 | # command to run tests, e.g. python setup.py test 12 | script: 13 | coverage run --source=wstunnel setup.py test 14 | after_success: 15 | coveralls -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Fabio Falcinelli 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wstunnel 2 | ======== 3 | [![Build Status](https://travis-ci.org/ffalcinelli/wstunnel.png)](https://travis-ci.org/ffalcinelli/wstunnel)[![Coverage Status](https://coveralls.io/repos/ffalcinelli/wstunnel/badge.png)](https://coveralls.io/r/ffalcinelli/wstunnel)[![PyPI Version](https://pypip.in/v/wstunnel/badge.png)](https://crate.io/packages/wstunnel) 4 | 5 | A WebSocket tunneling software written in python on top of [tornado](http://www.tornadoweb.org/) web framework for asynchronous I/O. 6 | 7 | Currently works and tested on 8 | 9 | * python 2.7 10 | * python 3.3 11 | 12 | both on unix (at least Fedora 18 and OSX) and Windows 7. 13 | 14 | 15 | Warnings 16 | ======== 17 | 18 | On windows the server tunnel endpoint may perform not so well. There's a limit on `select()` call that impacts Tornado 19 | loop for asynchronous I/O. 20 | 21 | You may want to read [this conversation](https://groups.google.com/forum/?fromgroups#!topic/python-tornado/oSbxI9X28MM) for more details. 22 | 23 | Quick start 24 | =========== 25 | 26 | Installation 27 | ------------ 28 | 29 | You can install `wstunnel` with 30 | 31 | ``` 32 | $ python setup.py install 33 | ``` 34 | 35 | This will install the packages and two execution scripts, `wstuncltd` and `wstunsrvd` for the client and server endpoints respectively. 36 | 37 | The scripts act like daemons on unix system and services on windows. 38 | 39 | On the former platform you can provide configuration with the -c option 40 | 41 | ``` 42 | $ wstuncltd -c conf/client.yml start 43 | ``` 44 | 45 | while on the latter platform a regitry key is expected 46 | 47 | ``` 48 | Windows Registry Editor Version 5.00 49 | 50 | [HKEY_LOCAL_MACHINE\SOFTWARE\wstunneld] 51 | "install_dir"="C:\\Users\\Fabio\\Documents\\GitHub\\wstunnel" 52 | 53 | [HKEY_LOCAL_MACHINE\SOFTWARE\wstunneld\client] 54 | "config"="C:\\Users\\Fabio\\Documents\\GitHub\\wstunnel\\conf\\client.yml" 55 | 56 | [HKEY_LOCAL_MACHINE\SOFTWARE\wstunneld\server] 57 | "config"="C:\\Users\\Fabio\\Documents\\GitHub\\wstunnel\\conf\\server.yml" 58 | ``` 59 | 60 | On windows you can get a binary distribution by running 61 | 62 | ``` 63 | $ python setup.py py2exe 64 | ``` 65 | 66 | in the `dist` folder a `wstuncltd.exe` and `wstunsrvd.exe` will be generated. 67 | 68 | 69 | The standalone way 70 | ------------------ 71 | 72 | The command arguments are exactly the same for the client and server endpoints. 73 | Anyway, options differs from unix and windows as you can see by invoking the `help` 74 | 75 | ``` 76 | $ wstuncltd --help 77 | usage: wstuncltd [-h] [-c CONF_FILE] {start,stop,restart} 78 | 79 | WebSocket tunnel client endpoint 80 | 81 | positional arguments: 82 | {start,stop,restart} Command to execute 83 | 84 | optional arguments: 85 | -h, --help show this help message and exit 86 | -c CONF_FILE, --config CONF_FILE 87 | path to a configuration file 88 | ``` 89 | 90 | whereas on windows 91 | ``` 92 | C:\Users\Fabio\Documents\GitHub\wstunnel>wstuncltd.exe 93 | Usage: 'wstuncltd-script.py [options] install|update|remove|start [...]|stop|restart [...]|debug [...]' 94 | Options for 'install' and 'update' commands only: 95 | --username domain\username : The Username the service is to run under 96 | --password password : The password for the username 97 | --startup [manual|auto|disabled|delayed] : How the service starts, default = manual 98 | --interactive : Allow the service to interact with the desktop. 99 | --perfmonini file: .ini file to use for registering performance monitor data 100 | --perfmondll file: .dll file to use when querying the service for 101 | performance data, default = perfmondata.dll 102 | Options for 'start' and 'stop' commands only: 103 | --wait seconds: Wait for the service to actually start or stop. 104 | If you specify --wait with the 'stop' option, the service 105 | and all dependent services will be stopped, each waiting 106 | the specified period. 107 | 108 | C:\Users\Fabio\Documents\GitHub\wstunnel> 109 | ``` 110 | 111 | The same applies on .exe binaries. 112 | 113 | Configuration 114 | ------------- 115 | 116 | The configuration file is in YAML syntax. The following is an example of telnet mapping 117 | 118 | Tunnel Client side 119 | 120 | ```yaml 121 | endpoint: client 122 | ws_url: ws://localhost:9000/ 123 | 124 | pid_file: /tmp/wstuncltd.pid 125 | user: null 126 | workdir: null 127 | 128 | proxies: 129 | /telnet: 130 | port: 50023 131 | filters: [] 132 | ``` 133 | 134 | Tunnel Server side 135 | 136 | ```yaml 137 | endpoint: server 138 | listen: 9000 139 | ssl: no 140 | ssl_options: 141 | certfile: null 142 | keyfile: null 143 | 144 | pid_file: /tmp/wstunsrvd.pid 145 | user: null 146 | workdir: null 147 | 148 | proxies: 149 | /telnet: 150 | address: 192.168.1.2:23 151 | filters: [wstunnel.filters.DumpFilter] 152 | ``` 153 | 154 | As a warm up you can edit the provided `conf/client.yml` and `conf/server.yml` and run each side separately 155 | 156 | 157 | The API way 158 | ----------- 159 | 160 | You can use the tunneling endpoints in your code. Check the test suite for examples. 161 | By default, a `DumpFilter` class is provided to hex dump all network traffic. 162 | I'm planning to extend the plugin feature so this will change very soon. 163 | 164 | ### Tunnel endpoints example 165 | 166 | The following are examples of usage of the client and server endpoints. 167 | 168 | 169 | ```python 170 | clt_tun = WSTunnelClient(proxies={50023: "wss://localhost:9000/telnet", 171 | 80: "wss://localhost:9000/http"}, 172 | family=socket.AF_INET) 173 | clt_tun.install_filter(DumpFilter(handler={"filename": "/tmp/clt_log"})) 174 | clt_tun.start() 175 | IOLoop.instance().start() 176 | ``` 177 | 178 | ```python 179 | srv_tun = WSTunnelServer(9000, 180 | proxies={"/telnet": ("192.168.1.2", 23), 181 | "/http": ("192.168.1.2", 80)}, 182 | ssl_options={ 183 | "certfile": "certs/wstunsrv.pem", 184 | "keyfile": "certs/wstunsrv.key", 185 | }) 186 | 187 | srv_tun.install_filter(DumpFilter(handler={"filename": "/tmp/srv_log"})) 188 | srv_tun.start() 189 | IOLoop.instance().start() 190 | ``` 191 | 192 | Pay attention to the `IOLoop` instance. Until not started, the requests will not be served by the tunnel. 193 | 194 | 195 | The developer way 196 | ------------------- 197 | 198 | If you want to help me and contribute, start by cloning the repo 199 | 200 | ``` 201 | $ git clone https://github.com/ffalcinelli/wstunnel wstunnel 202 | ``` 203 | 204 | Create a `virtualenv`, it's a recommended practice, and install the dependencies using `pip` 205 | 206 | ``` 207 | $ pip install -r requirements.txt 208 | ``` 209 | 210 | ### Windows requirements 211 | 212 | ``` 213 | $ pip install -r requirements_windows.txt 214 | ``` 215 | 216 | Anyway, `pywin32` and `py2exe` have to be installed using their installers. 217 | 218 | For `py2exe` I've successfully got binary distribution on python 2.7 but no luck with python 3.3 219 | 220 | Happy hacking :-) 221 | 222 | TODOs 223 | ===== 224 | 225 | - ~~"Daemonize" the standalone way on unix~~ 226 | - ~~A Windows Service would be nice for the Microsoft's platform~~ 227 | - ~~Create 2 different executables for client and server tunnels (maybe `wstuncltd` and `wstunsrvd`?). Explicit is better than implicit~~ 228 | - Enhance the `filter` support with custom configuration from yaml files 229 | - Test, test, test... Expecially on Windows 230 | - Provide an NSIS installer and a nicer way to customize on windows 231 | 232 | License 233 | ======= 234 | 235 | LGPLv3 236 | 237 | Copyright (c) 2014 Fabio Falcinelli 238 | 239 | > This program is free software: you can redistribute it and/or modify 240 | > it under the terms of the GNU Lesser General Public License as published by 241 | > the Free Software Foundation, either version 3 of the License, or 242 | > (at your option) any later version. 243 | > 244 | > This program is distributed in the hope that it will be useful, 245 | > but WITHOUT ANY WARRANTY; without even the implied warranty of 246 | > MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 247 | > GNU Lesser General Public License for more details. 248 | > 249 | > You should have received a copy of the GNU Lesser General Public License 250 | > along with this program. If not, see . 251 | 252 | 253 | This file was modified by PyCharm 2.7.2 for binding GitHub repository -------------------------------------------------------------------------------- /conf/client.yml: -------------------------------------------------------------------------------- 1 | # The WebSocket server endpoint url 2 | # use wss:// schema to connect in SSL mode 3 | endpoint: client 4 | ws_url: ws://localhost:9000/ 5 | 6 | # use this section to provide options for the websocket connection, in particular 7 | # on SSL connection 8 | # e.g. turn off certificate validation using 9 | # validate_cert: no 10 | ws_options: 11 | #ciphers: "AES128" 12 | #validate_cert: false 13 | 14 | # The pid file used to recognize if there's already a running instance 15 | pid_file: /tmp/wstuncltd.pid 16 | 17 | # If a user is provided the daemon will be demoted to run as this user 18 | user: null 19 | 20 | # Change the working directory. If not provided, the current working directory 21 | # will be used 22 | workdir: null 23 | 24 | # This is the set of proxy services. 25 | # For each service you can specify 26 | # the port where to listen for connections 27 | # and the remote resource mapped to the service 28 | # Additionally you can provide a list of filters 29 | # to intercept data before being send to WebSocket 30 | # or before being sent back to client 31 | proxies: 32 | /telnet: 33 | port: 50023 34 | filters: [] 35 | /ftp: 36 | port: 50021 37 | filters: [] 38 | /ssh: 39 | port: 50022 40 | filters: [] 41 | 42 | # Logging configuration 43 | logging: 44 | version: 1 45 | disable_existing_loggers: False 46 | formatters: 47 | simple: 48 | format: "[%(asctime)s] %(name)s - %(levelname)-7s - %(message)s" 49 | 50 | handlers: 51 | console: 52 | class: logging.StreamHandler 53 | level: DEBUG 54 | formatter: simple 55 | stream: ext://sys.stdout 56 | 57 | file_handler: 58 | class: logging.handlers.RotatingFileHandler 59 | level: INFO 60 | formatter: simple 61 | filename: logs/wstunclt.log 62 | maxBytes: 10485760 # 10MB 63 | backupCount: 20 64 | encoding: utf8 65 | 66 | loggers: 67 | wstunnel.client: 68 | level: INFO 69 | handlers: [file_handler] 70 | propagate: yes 71 | tornado.general: 72 | level: WARNING 73 | handlers: [file_handler] 74 | propagate: yes 75 | 76 | root: 77 | level: INFO 78 | handlers: [file_handler] 79 | -------------------------------------------------------------------------------- /conf/server.yml: -------------------------------------------------------------------------------- 1 | # The WebSocket server endpoint will listen on 2 | # this host:port pair. If host is omitted then connections 3 | # will be accepted from any interface (both ipv4 and ipv6) 4 | endpoint: server 5 | listen: 9000 6 | # The SSL parameter enables/disables the criptography on channel. 7 | # When enabled, the ssl_options section is required 8 | ssl: false 9 | 10 | # These options should include at least the server certificate file. 11 | # If only this one is provided, the file must contain both the public 12 | # and the private keys 13 | ssl_options: 14 | certfile: null 15 | keyfile: null 16 | # ciphers: "AES128" 17 | 18 | # The pid file used to recognize if there's already a running instance 19 | pid_file: /tmp/wstunsrvd.pid 20 | 21 | # If a user is provided the daemon will be demoted to run as this user 22 | user: null 23 | 24 | # Change the working directory. If not provided, the current working directory 25 | # will be used 26 | workdir: null 27 | 28 | # This the resource/service mapping. 29 | # For each resource you can map a destination host:port 30 | # and a list of filters to be applied before sending data to the 31 | # service and before sending it back to the client. 32 | proxies: 33 | /telnet: 34 | address: 192.168.1.2:13131 35 | filters: [wstunnel.filters.DumpFilter] 36 | 37 | /ftp: 38 | address: 192.168.1.2:21 39 | filters: [] 40 | 41 | /ssh: 42 | address: 192.168.1.2:22 43 | filters: [] 44 | 45 | # WSTunnel logging configuration 46 | logging: 47 | version: 1 48 | disable_existing_loggers: True 49 | formatters: 50 | simple: 51 | format: "[%(asctime)s] %(name)s - %(levelname)-7s - %(message)s" 52 | 53 | handlers: 54 | console: 55 | class: logging.StreamHandler 56 | level: DEBUG 57 | formatter: simple 58 | stream: ext://sys.stdout 59 | 60 | file_handler: 61 | class: logging.handlers.RotatingFileHandler 62 | level: INFO 63 | formatter: simple 64 | filename: logs/wstunsrv.log 65 | maxBytes: 10485760 # 10MB 66 | backupCount: 20 67 | encoding: utf8 68 | 69 | loggers: 70 | wstunnel.server: 71 | level: INFO 72 | handlers: [file_handler] 73 | propagate: yes 74 | tornado.general: 75 | level: WARNING 76 | handlers: [file_handler] 77 | propagate: yes 78 | 79 | root: 80 | level: INFO 81 | handlers: [file_handler] 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML>=3.10 2 | nose>=1.3.0 3 | mock>=1.0.1 4 | tornado>=3.0.2 5 | 6 | -------------------------------------------------------------------------------- /requirements_windows.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pywin32==218 3 | py2exe==0.6.9 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright (C) 2013 Fabio Falcinelli 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | import sys 18 | from setuptools import setup, find_packages 19 | import wstunnel 20 | 21 | __author__ = 'fabio' 22 | 23 | kwargs = dict(name='wstunnel', 24 | version='0.0.6', 25 | description='A Python WebSocket Tunnel', 26 | author='Fabio Falcinelli', 27 | author_email='fabio.falcinelli@gmail.com', 28 | url='https://github.com/ffalcinelli/wstunnel', 29 | keywords=['tunneling', 'websocket', 'ssl'], 30 | packages=find_packages(), 31 | classifiers=[ 32 | 'Development Status :: 2 - Pre-Alpha', 33 | 'Intended Audience :: Developers', 34 | 'Intended Audience :: System Administrators', 35 | 'Intended Audience :: Telecommunications Industry', 36 | 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2.7', 39 | 'Programming Language :: Python :: 3.3', 40 | 'Programming Language :: Python :: 3.4', 41 | 'Topic :: Software Development :: Libraries :: Python Modules', 42 | 'Topic :: Utilities', 43 | ], 44 | setup_requires=['nose'], 45 | test_suite='nose.collector') 46 | 47 | kwargs["download_url"] = 'https://github.com/ffalcinelli/wstunnel/tarball/{0}'.format(kwargs.get("version")) 48 | 49 | install_requires = ["PyYAML>=3.10", 50 | "tornado>=3.0.2", 51 | "nose>=1.3.0", 52 | "mock>=1.0.1"] 53 | 54 | if not sys.platform.startswith("win"): 55 | 56 | kwargs["install_requires"] = install_requires 57 | kwargs["entry_points"] = { 58 | "console_scripts": [ 59 | "wstuncltd = wstunnel.daemon.wstuncltd:main", 60 | "wstunsrvd = wstunnel.daemon.wstunsrvd:main", 61 | ] 62 | } 63 | else: 64 | 65 | install_requires.extend(["pywin32>=218", 66 | "py2exe>=0.6.9", ]) 67 | 68 | if "py2exe" in sys.argv: 69 | if wstunnel.PY2: 70 | from wstunnel.svc import wstunsrvd, wstuncltd 71 | import py2exe 72 | 73 | class Target: 74 | def __init__(self, **kw): 75 | self.__dict__.update(kw) 76 | # for the versioninfo resources 77 | self.version = kwargs["version"] 78 | self.company_name = "N.A." 79 | self.copyright = "Copyright (c) 2014 Fabio Falcinelli" 80 | self.name = kwargs["name"] 81 | 82 | tunclt_svc = Target( 83 | # used for the versioninfo resource 84 | description=wstuncltd.wstuncltd._svc_description_, 85 | # what to build. For a service, the module name (not the 86 | # filename) must be specified! 87 | modules=["wstunnel.svc.wstuncltd"], 88 | cmdline_style='pywin32', 89 | ) 90 | 91 | tunsrv_svc = Target( 92 | # used for the versioninfo resource 93 | description=wstunsrvd.wstunsrvd._svc_description_, 94 | # what to build. For a service, the module name (not the 95 | # filename) must be specified! 96 | modules=["wstunnel.svc.wstunsrvd"], 97 | cmdline_style='pywin32', 98 | ) 99 | 100 | kwargs["service"] = [tunclt_svc, tunsrv_svc] 101 | kwargs["options"] = { 102 | "py2exe": { 103 | "compressed": 1, 104 | "optimize": 2, 105 | } 106 | } 107 | else: 108 | sys.stderr.write("Warning: you're using python {0}.{1}.{2} " 109 | "which is not supported yet by py2exe.\n".format(sys.version_info[0], 110 | sys.version_info[1], 111 | sys.version_info[2])) 112 | sys.exit(-1) 113 | else: 114 | kwargs["entry_points"] = { 115 | "console_scripts": [ 116 | "wstuncltd = wstunnel.svc.wstuncltd:main", 117 | "wstunsrvd = wstunnel.svc.wstunsrvd:main", 118 | ] 119 | } 120 | 121 | setup(**kwargs) -------------------------------------------------------------------------------- /wstunnel/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import logging 17 | import os 18 | import sys 19 | from logging.handlers import RotatingFileHandler 20 | 21 | try: 22 | import urlparse 23 | except ImportError: 24 | import urllib.parse as urlparse 25 | 26 | __author__ = 'fabio' 27 | 28 | #monkey patch scheme to support ws and wss 29 | ws_scheme = ["ws", "wss"] 30 | for scheme in (urlparse.uses_relative, 31 | urlparse.uses_netloc, 32 | urlparse.non_hierarchical, 33 | urlparse.uses_params, 34 | urlparse.uses_query, 35 | urlparse.uses_fragment): 36 | scheme.extend(ws_scheme) 37 | 38 | parse_url = urlparse.urlparse 39 | join_url = urlparse.urljoin 40 | 41 | PY2 = sys.version_info[0] == 2 42 | if not PY2: 43 | unichr = chr 44 | else: 45 | unichr = unichr 46 | 47 | if sys.platform.startswith("win"): 48 | #_winreg has been renamed in python3 to winreg 49 | if PY2: 50 | import _winreg as winreg 51 | else: 52 | import winreg 53 | winreg = winreg 54 | 55 | bytes_type = type(b"") 56 | string_type = type(u"") 57 | 58 | 59 | 60 | #monkey patch RotatingFileHandler 61 | class EnhancedRotatingFileHandler(RotatingFileHandler): 62 | """ 63 | Same as the standard RotatingFileHandler, but creates directories containing filename if not existent. 64 | """ 65 | 66 | def __init__(self, filename, mode='a', maxBytes=0, backupCount=0, encoding=None, delay=False): 67 | log_dir = os.path.dirname(filename) 68 | if not os.path.exists(log_dir): 69 | os.makedirs(log_dir) 70 | super(EnhancedRotatingFileHandler, self).__init__(filename, 71 | mode=mode, 72 | maxBytes=maxBytes, 73 | backupCount=backupCount, 74 | encoding=encoding, 75 | delay=delay) 76 | 77 | 78 | logging.handlers.RotatingFileHandler = EnhancedRotatingFileHandler -------------------------------------------------------------------------------- /wstunnel/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import logging 17 | import socket 18 | from tornado import httpclient 19 | from tornado.ioloop import IOLoop 20 | 21 | from tornado.tcpserver import TCPServer 22 | from tornado.websocket import WebSocketClientConnection 23 | from wstunnel.toolbox import tuple_to_address 24 | from wstunnel.exception import EndpointNotAvailableException 25 | from wstunnel.filters import FilterException 26 | 27 | __author__ = "fabio" 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | def websocket_connect(url, io_loop=None, callback=None, connect_timeout=None, **kwargs): 32 | """Client-side websocket support. 33 | 34 | Takes a url and returns a Future whose result is a 35 | `WebSocketClientConnection`. 36 | """ 37 | options = httpclient.HTTPRequest._DEFAULTS.copy() 38 | options.update(kwargs) 39 | 40 | if io_loop is None: 41 | io_loop = IOLoop.current() 42 | request = httpclient.HTTPRequest(url, connect_timeout=connect_timeout, 43 | validate_cert=kwargs.get("validate_cert", True)) 44 | request = httpclient._RequestProxy(request, options) 45 | conn = WebSocketClientConnection(io_loop, request) 46 | if callback is not None: 47 | io_loop.add_future(conn.connect_future, callback) 48 | return conn.connect_future 49 | 50 | 51 | class WebSocketProxy(TCPServer): 52 | """ 53 | Listen on a port and delegate the accepted connection to a WebSocketLocalProxyHandler 54 | """ 55 | 56 | def __init__(self, port, ws_url, **kwargs): 57 | super(WebSocketProxy, self).__init__(kwargs.get("io_loop"), 58 | kwargs.get("ssl_options")) 59 | self.bind(port, 60 | kwargs.get("address", ''), 61 | kwargs.get("family", socket.AF_UNSPEC), 62 | kwargs.get("backlog", 128)) 63 | 64 | self.ws_url = ws_url 65 | self.ws_options = kwargs.get("ws_options", {}) 66 | self.filters = kwargs.get("filters", []) 67 | self.serving = False 68 | self.ws_conn = None 69 | self._address_list = [] 70 | 71 | @property 72 | def address_list(self): 73 | return self._address_list 74 | 75 | def handle_stream(self, stream, address): 76 | """ 77 | Handle a new client connection with a proxy over websocket 78 | """ 79 | logger.info("Got connection from %s on %s" % (tuple_to_address(stream.socket.getpeername()), 80 | tuple_to_address(stream.socket.getsockname()))) 81 | self.ws_conn = WebSocketProxyConnection(self.ws_url, stream, address, 82 | filters=self.filters, 83 | ws_options=self.ws_options) 84 | self.ws_conn.connect() 85 | 86 | def start(self, num_processes=1): 87 | super(WebSocketProxy, self).start(num_processes) 88 | self._address_list = [(s.getsockname()[0], s.getsockname()[1]) for s in self._sockets.values()] 89 | self.serving = True 90 | 91 | def stop(self): 92 | super(WebSocketProxy, self).stop() 93 | self.serving = False 94 | 95 | def __str__(self): 96 | return "WebSocketProxy %s" % (" | ".join(["%s --> %s" % 97 | ("%s:%d" % (a, p), self.ws_url) for (a, p) in self.address_list])) 98 | 99 | 100 | class WebSocketProxyConnection(object): 101 | """ 102 | Handles the client connection and works as a proxy over a websocket connection 103 | """ 104 | 105 | def __init__(self, url, io_stream, address, ws_options=None, **kwargs): 106 | self.url = url 107 | self.io_loop = kwargs.get("io_loop") 108 | self.connect_timeout = kwargs.get("connect_timeout", None) 109 | self.keep_alive = kwargs.get("keep_alive", None) 110 | self.ws_options = ws_options 111 | self.io_stream, self.address = io_stream, address 112 | self.filters = kwargs.get("filters", []) 113 | self.io_stream.set_close_callback(self.on_close) 114 | self.ws_conn = None 115 | 116 | def connect(self): 117 | logger.info("Connecting WebSocket at url %s" % self.url) 118 | websocket_connect(self.url, 119 | self.io_loop, 120 | callback=self.on_open, 121 | connect_timeout=self.connect_timeout, 122 | **self.ws_options) 123 | 124 | def on_open(self, ws_conn): 125 | """ 126 | When the websocket connection is handshaked, start reading for data over the client socket 127 | connection 128 | """ 129 | try: 130 | self.ws_conn = ws_conn.result() 131 | except httpclient.HTTPError as e: 132 | #TODO: change with raise EndpointNotAvailableException(message="The server endpoint is not available") from e 133 | raise EndpointNotAvailableException("The server endpoint is not available", cause=e) 134 | self.ws_conn.on_message = self.on_message 135 | self.ws_conn.release_callback = self.on_close 136 | self.io_stream.read_until_close(self.on_close, streaming_callback=self.on_peer_message) 137 | 138 | def on_message(self, message): 139 | """ 140 | On a message received from websocket, send back to client peer 141 | """ 142 | try: 143 | data = None if message is None else bytes(message) 144 | for filtr in self.filters: 145 | data = filtr.ws_to_socket(data=data) 146 | if data: 147 | self.io_stream.write(data) 148 | except FilterException as e: 149 | logger.exception(e) 150 | self.on_close() 151 | 152 | def on_close(self, *args, **kwargs): 153 | """ 154 | Handles the close event from the client socket 155 | """ 156 | logger.info("Closing connection with client at {0}:{1}".format(*self.address)) 157 | logger.debug("Received args %s and %s", args, kwargs) 158 | if not self.io_stream.closed(): 159 | self.io_stream.close() 160 | 161 | def on_peer_message(self, message): 162 | """ 163 | On data received from client peer, forward through WebSocket 164 | """ 165 | try: 166 | data = None if message is None else bytes(message) 167 | for filtr in self.filters: 168 | data = filtr.socket_to_ws(data=data) 169 | if data: 170 | self.ws_conn.write_message(data, binary=True) 171 | except FilterException as e: 172 | logger.exception(e) 173 | self.on_close() 174 | 175 | 176 | class WSTunnelClient(object): 177 | """ 178 | Manages redirects from local ports to remote websocket servers 179 | """ 180 | 181 | def __init__(self, proxies=None, address='', family=socket.AF_UNSPEC, io_loop=None, ssl_options=None, 182 | ws_options=None): 183 | 184 | self.stream_options = { 185 | "address": address, 186 | "family": family, 187 | "io_loop": io_loop, 188 | "ssl_options": ssl_options, 189 | } 190 | self.ws_options = ws_options or {} 191 | self.proxies = proxies or {} 192 | self.serving = False 193 | self._num_proc = 1 194 | if proxies: 195 | for port, ws_url in proxies.items(): 196 | self.add_proxy(port, WebSocketProxy(port=port, 197 | ws_url=ws_url, 198 | ws_options=self.ws_options, 199 | **self.stream_options)) 200 | 201 | def add_proxy(self, key, ws_proxy): 202 | """ 203 | Adds a proxy to the list. 204 | If the tunnel is serving connection, the proxy it gets started. 205 | """ 206 | self.proxies[key] = ws_proxy 207 | if self.serving: 208 | ws_proxy.start(self._num_proc) 209 | logger.info("Started %s" % ws_proxy) 210 | 211 | def remove_proxy(self, key): 212 | """ 213 | Removes a proxy from the list. 214 | If the tunnel is serving connection, the proxy it gets stopped. 215 | """ 216 | ws_proxy = self.proxies.get(key) 217 | if ws_proxy: 218 | if self.serving: 219 | ws_proxy.stop() 220 | logger.info("Removing %s" % ws_proxy) 221 | del self.proxies[key] 222 | 223 | def get_proxy(self, key): 224 | """ 225 | Return the proxy associated to the given name. 226 | """ 227 | return self.proxies.get(key) 228 | 229 | @property 230 | def address_list(self): 231 | """ 232 | Returns the address (, tuple) list of all the addresses used 233 | """ 234 | l = [] 235 | for service in self.proxies.values(): 236 | l.extend(service.address_list) 237 | return l 238 | 239 | def install_filter(self, filtr): 240 | """ 241 | Install the given filter to all the current mapped services 242 | """ 243 | for ws_proxy in self.proxies.values(): 244 | ws_proxy.filters.append(filtr) 245 | 246 | def uninstall_filter(self, filtr): 247 | """ 248 | Uninstall the given filter from all the current mapped services 249 | """ 250 | for ws_proxy in self.proxies.values(): 251 | ws_proxy.filters.remove(filtr) 252 | 253 | def start(self, num_processes=1): 254 | """ 255 | Start the client tunnel service by starting each configured proxy 256 | """ 257 | logger.info("Starting %d %s processes" % (num_processes, self.__class__.__name__)) 258 | self._num_processes = num_processes 259 | for key, ws_proxy in self.proxies.items(): 260 | ws_proxy.start(num_processes) 261 | logger.info("Started %s" % ws_proxy) 262 | self.serving = True 263 | 264 | def stop(self): 265 | """ 266 | Stop the client tunnel service by stopping each configured proxy 267 | """ 268 | logger.info("Stopping {}".format(self.__class__.__name__)) 269 | for key, ws_proxy in self.proxies.items(): 270 | ws_proxy.stop() 271 | logger.info("Stopped %s" % ws_proxy) 272 | self.serving = False 273 | -------------------------------------------------------------------------------- /wstunnel/daemon/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import sys 17 | import atexit 18 | import signal 19 | import logging 20 | import time 21 | import os 22 | from tornado.ioloop import IOLoop 23 | from wstunnel.factory import create_ws_client_endpoint, create_ws_server_endpoint 24 | 25 | __author__ = 'fabio' 26 | SIG_NAMES = dict((k, v) for v, k in signal.__dict__.items() if v.startswith('SIG')) 27 | SHUTDOWN_POLL = 0.2 28 | 29 | 30 | class Daemon(object): 31 | """ 32 | Handles common daemon operations: pid file, start/stop/restart commands 33 | """ 34 | 35 | def __init__(self, pid_file, user=None, workdir=".", umask=0): 36 | self.pid_file = pid_file 37 | self.user = user 38 | self.workdir = workdir if workdir else os.getcwd() 39 | self.umask = umask 40 | self.name = type(self).__name__ 41 | self.dev_null = None 42 | 43 | def switch_user(self): 44 | """ 45 | Changes the user running the daemon 46 | """ 47 | if self.user: 48 | self.uid = self.user 49 | 50 | @property 51 | def uid(self): 52 | return os.getuid() 53 | 54 | @uid.setter 55 | def uid(self, value): 56 | import pwd 57 | 58 | if isinstance(value, int): 59 | self.user = pwd.getpwuid(value).pw_name 60 | else: 61 | value = pwd.getpwnam(self.user).pw_uid 62 | os.setuid(value) 63 | 64 | def hush(self, **kwargs): 65 | """ 66 | Redirects standard fds to the /dev/null if no one is provided 67 | """ 68 | sys.stdout.flush() 69 | sys.stderr.flush() 70 | 71 | for name in "stdin", "stdout", "stderr": 72 | if not kwargs.get(name): 73 | if not self.dev_null: 74 | self.dev_null = open(os.devnull, "r+b") 75 | kwargs[name] = self.dev_null 76 | 77 | os.dup2(kwargs["stdin"].fileno(), sys.stdin.fileno()) 78 | os.dup2(kwargs["stdout"].fileno(), sys.stdout.fileno()) 79 | os.dup2(kwargs["stderr"].fileno(), sys.stderr.fileno()) 80 | 81 | def delete_pid_file(self): 82 | """ 83 | If no daemon running, deletes the pid file 84 | """ 85 | if os.path.exists(self.pid_file) and not self.is_running(): 86 | os.remove(self.pid_file) 87 | 88 | def register_shutdown(self): 89 | """ 90 | Registers the shutdown hook when trapping SIGTERM 91 | """ 92 | signal.signal(signal.SIGTERM, self._gracefully_terminate) 93 | 94 | def _write_pid_file(self): 95 | """ 96 | Writes Process ID into pid file 97 | """ 98 | if not os.path.exists(os.path.dirname(self.pid_file)): 99 | os.makedirs(os.path.dirname(self.pid_file)) 100 | 101 | with open(self.pid_file, 'w+') as f: 102 | f.write(str(os.getpid()) + '\n') 103 | 104 | def daemonize(self): 105 | """ 106 | Daemonizes the process with the double fork hack 107 | """ 108 | try: 109 | pid = os.fork() 110 | if pid > 0: 111 | sys.exit(0) 112 | 113 | os.chdir(self.workdir) 114 | os.setsid() 115 | os.umask(self.umask) 116 | 117 | pid = os.fork() 118 | if pid > 0: 119 | sys.exit(0) 120 | 121 | self.hush() 122 | 123 | atexit.register(self.delete_pid_file) 124 | 125 | self.register_shutdown() 126 | 127 | self._write_pid_file() 128 | 129 | except OSError as oserr: 130 | sys.stderr.write("Daemonize {0} failed: {1}\n".format(self.name, oserr)) 131 | sys.exit(oserr.errno) 132 | 133 | def read_pid_file(self): 134 | """ 135 | Reads the pid from the pid file, if available 136 | """ 137 | try: 138 | with open(self.pid_file, 'r') as pf: 139 | pid = int(pf.read().strip()) 140 | return pid 141 | except IOError: 142 | return None 143 | 144 | def is_running(self, pid=None): 145 | """ 146 | Checks if the given pid corresponds to an alive process. If no pid is given, tries to read from pid file 147 | """ 148 | pid = pid if pid else self.read_pid_file() 149 | try: 150 | if pid: 151 | os.kill(pid, 0) 152 | else: 153 | return False 154 | except OSError: 155 | return False 156 | else: 157 | return True 158 | 159 | def start(self): 160 | """ 161 | Starts the daemon. If a user have been setup, demotes to that user 162 | """ 163 | pid = self.read_pid_file() 164 | if not pid: 165 | self.daemonize() 166 | self.run() 167 | else: 168 | sys.stderr.write("{0} is already running [pid: {1}]\n".format(self.name, pid)) 169 | sys.exit(-1) 170 | 171 | def stop(self): 172 | """ 173 | Stops the daemon. Invokes the shutdown hook to gracefully terminate the service 174 | """ 175 | try: 176 | pid = self.read_pid_file() 177 | if pid: 178 | while self.is_running(pid): 179 | os.kill(pid, signal.SIGTERM) 180 | time.sleep(SHUTDOWN_POLL) 181 | except OSError as oserr: 182 | msg = "Could not stop {0} daemon: {1}\n" 183 | sys.stderr.write(msg.format(self.name, oserr)) 184 | sys.exit(oserr.errno) 185 | else: 186 | self.delete_pid_file() 187 | 188 | def restart(self): 189 | """ 190 | Restarts the daemon by simply stopping and starting again. 191 | """ 192 | self.stop() 193 | time.sleep(SHUTDOWN_POLL) 194 | self.start() 195 | 196 | def run(self, *args): 197 | """ 198 | Hook to call the business service. Override to launch your service 199 | """ 200 | pass 201 | 202 | def _gracefully_terminate(self, *args): 203 | self.shutdown(*args) 204 | if self.dev_null: 205 | self.dev_null.close() 206 | 207 | def shutdown(self, *args): 208 | """ 209 | Hook called when a shutdown is requested. Override to add your graceful service termination 210 | """ 211 | pass 212 | 213 | 214 | class WSTunnelDaemon(Daemon): 215 | """ 216 | WebSocket Tunnel Daemon 217 | """ 218 | 219 | def __init__(self, config): 220 | super(WSTunnelDaemon, self).__init__(pid_file=config.get("pid_file"), 221 | user=config.get("user"), 222 | workdir=config.get("workdir")) 223 | self.config = config 224 | logging.config.dictConfig(self.config["logging"]) 225 | self._srv = None 226 | 227 | def run(self): 228 | """ 229 | Called when daemon starts 230 | """ 231 | for logger_name in self.config["logging"]["loggers"].keys(): 232 | logging.getLogger(logger_name).disabled = False 233 | self._srv.start() 234 | self.switch_user() 235 | IOLoop.instance().start() 236 | 237 | def shutdown(self, *args): 238 | """ 239 | This will be called when daemon will be stopped 240 | """ 241 | if self._srv: 242 | self._srv.stop() 243 | for logger_name in self.config["logging"]["loggers"].keys(): 244 | logger = logging.getLogger(logger_name) 245 | for h in list(logger.handlers): 246 | logger.removeHandler(h) 247 | h.flush() 248 | h.close() 249 | IOLoop.instance().stop() 250 | 251 | 252 | class WSTunnelClientDaemon(WSTunnelDaemon): 253 | """ 254 | Shortcut to have a wstunnel client endpoint 255 | """ 256 | 257 | def run(self): 258 | self._srv = create_ws_client_endpoint(self.config) 259 | super(WSTunnelClientDaemon, self).run() 260 | 261 | 262 | class WSTunnelServerDaemon(WSTunnelDaemon): 263 | """ 264 | Shortcut to have a wstunnel server endpoint 265 | """ 266 | 267 | def run(self): 268 | self._srv = create_ws_server_endpoint(self.config) 269 | super(WSTunnelServerDaemon, self).run() 270 | 271 | -------------------------------------------------------------------------------- /wstunnel/daemon/wstuncltd.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright (C) 2013 Fabio Falcinelli 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | import argparse 18 | import sys 19 | import yaml 20 | from wstunnel.daemon import WSTunnelClientDaemon 21 | from wstunnel.toolbox import get_config 22 | 23 | __author__ = "fabio" 24 | 25 | 26 | def main(): 27 | """ 28 | Entry point for the WebSocket client tunnel daemon endpoint 29 | """ 30 | parser = argparse.ArgumentParser(description='WebSocket tunnel client endpoint') 31 | parser.add_argument("-c", "--config", 32 | metavar="CONF_FILE", 33 | help="path to a configuration file", 34 | default=get_config("wstunneld", "wstuncltd.yml")) 35 | # parser.add_argument("-p", "--pid-file", 36 | # metavar="PID_FILE", 37 | # help="path to a pid file") 38 | parser.add_argument("command", 39 | help="Command to execute", choices=["start", "stop", "restart"]) 40 | options = parser.parse_args() 41 | 42 | if not options.config: 43 | parser.error("No configuration file found. Try using --config option.") 44 | 45 | with open(options.config, 'rt') as f: 46 | conf = yaml.load(f.read()) 47 | 48 | if conf["endpoint"] == "client": 49 | wstund = WSTunnelClientDaemon(conf) 50 | else: 51 | raise ValueError("Wrong name for endpoint") 52 | 53 | getattr(wstund, options.command)() 54 | sys.exit(0) 55 | 56 | 57 | if __name__ == "__main__": 58 | main() -------------------------------------------------------------------------------- /wstunnel/daemon/wstunsrvd.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright (C) 2013 Fabio Falcinelli 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | import argparse 18 | import sys 19 | import yaml 20 | from wstunnel.daemon import WSTunnelServerDaemon 21 | from wstunnel.toolbox import get_config 22 | 23 | __author__ = "fabio" 24 | 25 | 26 | def main(): 27 | """ 28 | Entry point for the WebSocket server tunnel daemon endpoint 29 | """ 30 | parser = argparse.ArgumentParser(description='WebSocket tunnel server endpoint') 31 | parser.add_argument("-c", "--config", 32 | metavar="CONF_FILE", 33 | help="path to a configuration file", 34 | default=get_config("wstunneld", "wstunsrvd.yml")) 35 | # parser.add_argument("-p", "--pid-file", 36 | # metavar="PID_FILE", 37 | # help="path to a pid file") 38 | parser.add_argument("command", 39 | help="Command to execute", choices=["start", "stop", "restart"]) 40 | options = parser.parse_args() 41 | 42 | if not options.config: 43 | parser.error("No configuration file found. Try using --config option.") 44 | 45 | with open(options.config, 'rt') as f: 46 | conf = yaml.load(f.read()) 47 | 48 | if conf["endpoint"] == "server": 49 | wstund = WSTunnelServerDaemon(conf) 50 | else: 51 | raise ValueError("Wrong name for endpoint") 52 | 53 | getattr(wstund, options.command)() 54 | sys.exit(0) 55 | 56 | 57 | if __name__ == "__main__": 58 | main() -------------------------------------------------------------------------------- /wstunnel/exception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | 17 | __author__ = 'fabio' 18 | 19 | 20 | class ChainedException(Exception): 21 | """ 22 | An exception which could be caused by another one 23 | """ 24 | 25 | def __init__(self, message, *args, **kwargs): 26 | cause = kwargs.get("cause", None) 27 | if cause: 28 | message = "%s, caused by %s" % (message, repr(cause)) 29 | 30 | super(ChainedException, self).__init__(message) 31 | 32 | 33 | class EndpointNotAvailableException(ChainedException): 34 | """ 35 | Exception raised when the endpoint is not available (most likely tunnel server side is down) 36 | """ 37 | 38 | def __init__(self, message="Endpoint is not available", *args, **kwargs): 39 | super(EndpointNotAvailableException, self).__init__(message, *args, **kwargs) 40 | 41 | 42 | class MappedServiceNotAvailableException(ChainedException): 43 | """ 44 | Exception raised when the destination service is not available (most likely tunnel server side is up but the 45 | service is trying to remap does not respond) 46 | """ 47 | 48 | def __init__(self, message="Mapped service is not available", *args, **kwargs): 49 | super(MappedServiceNotAvailableException, self).__init__(message, *args, **kwargs) 50 | -------------------------------------------------------------------------------- /wstunnel/factory.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | from wstunnel import join_url 17 | from wstunnel.client import WSTunnelClient, WebSocketProxy 18 | from wstunnel.server import WSTunnelServer 19 | from wstunnel.toolbox import address_to_tuple 20 | 21 | __author__ = 'fabio' 22 | 23 | 24 | def load_filter(clazz, args=None): 25 | """ 26 | Load a filter by its fully qualified class name 27 | """ 28 | import importlib 29 | 30 | path = clazz.split(".") 31 | mod = importlib.import_module(".".join(path[:-1])) 32 | Filter = getattr(mod, path[-1]) 33 | return Filter(*args) if args else Filter() 34 | 35 | 36 | def create_ws_client_endpoint(config): 37 | """ 38 | Create a client endpoint parsing the configuration file options 39 | """ 40 | ws_url = config["ws_url"] 41 | srv = WSTunnelClient(ws_options=config.get("ws_options", {})) 42 | proxies = config["proxies"] 43 | for resource, settings in proxies.items(): 44 | filters = [load_filter(clazz) for clazz in config.get("filters", [])] 45 | 46 | srv.add_proxy(key=settings["port"], 47 | ws_proxy=WebSocketProxy( #address=settings.get("address", ''), 48 | port=int(settings.get("port", 0)), 49 | ws_url=join_url(ws_url, resource), 50 | filters=filters, 51 | ws_options=config.get("ws_options", {}))) 52 | return srv 53 | 54 | 55 | def create_ws_server_endpoint(config): 56 | """ 57 | Create a server endpoint parsing the configuration file options 58 | """ 59 | address, port = address_to_tuple(config["listen"]) 60 | 61 | ssl_options = None 62 | if config["ssl"]: 63 | ssl_options = config["ssl_options"] 64 | 65 | srv = WSTunnelServer(port=port, 66 | address=address, 67 | ssl_options=ssl_options) 68 | proxies = config["proxies"] 69 | for resource, settings in proxies.items(): 70 | filters = [load_filter(clazz) for clazz in settings.get("filters", [])] 71 | 72 | srv.add_proxy(key=resource, 73 | ws_proxy={"address": address_to_tuple(settings["address"]), 74 | "filters": filters}) 75 | return srv 76 | 77 | 78 | -------------------------------------------------------------------------------- /wstunnel/filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import logging 17 | from logging import config 18 | 19 | import copy 20 | import sys 21 | import yaml 22 | from wstunnel import EnhancedRotatingFileHandler 23 | from wstunnel.toolbox import hex_dump 24 | 25 | logging.handlers.RotatingFileHandler = EnhancedRotatingFileHandler 26 | 27 | __author__ = 'fabio' 28 | 29 | WS_TO_SOCK = 0 30 | SOCK_TO_WS = 1 31 | BOTH = 2 32 | 33 | 34 | class FilterException(Exception): 35 | pass 36 | 37 | 38 | class BaseFilter(object): 39 | def __init__(self, *args, **kwargs): 40 | pass 41 | 42 | def ws_to_socket(self, data): 43 | """ 44 | Override this method to perform filtering on WebSocket to Socket dataflow 45 | """ 46 | return data 47 | 48 | def socket_to_ws(self, data): 49 | """ 50 | Override this method to perform filtering on Socket to WebSocket dataflow 51 | """ 52 | return data 53 | 54 | 55 | class DumpFilter(BaseFilter): 56 | """ 57 | Dump data on the given filepath or stdout 58 | """ 59 | default_conf = { 60 | "version": 1, 61 | "formatters": { 62 | "dump_formatter": { 63 | "format": "[%(asctime)s] %(name)-15s - %(message)s", 64 | } 65 | }, 66 | "handlers": { 67 | "dump_file_handler": { 68 | "class": "logging.handlers.RotatingFileHandler", 69 | "level": "INFO", 70 | "formatter": "dump_formatter", 71 | "filename": "logs/dump.log", 72 | "maxBytes": 536870912, 73 | "backupCount": 10, 74 | "encoding": "utf8", 75 | } 76 | }, 77 | "loggers": { 78 | __name__: { 79 | "level": "INFO", 80 | "handlers": ["dump_file_handler"], 81 | "propagate": "no", 82 | } 83 | } 84 | } 85 | 86 | def __init__(self, handler=None, fmt=None, conf_file=None, **kwargs): 87 | super(DumpFilter, self).__init__() 88 | 89 | if conf_file: 90 | with open(conf_file, "rt") as yml: 91 | conf = yaml.load(yml) 92 | else: 93 | conf = copy.copy(self.default_conf) 94 | if handler: 95 | conf["handlers"]["dump_file_handler"].update(handler) 96 | if fmt: 97 | conf["formatters"]["dump_formatter"].update(fmt) 98 | 99 | logging.config.dictConfig(conf) 100 | self.logger = logging.getLogger(__name__) 101 | 102 | def ws_to_socket(self, data, **kwargs): 103 | try: 104 | self.logger.info("[-->] From WebSocket endpoint\n{}".format(hex_dump(data))) 105 | except Exception as e: 106 | #Ignore errors... DumpFilter should not interpose the protocol flow 107 | sys.stderr.write("Unable to log filter dump: %s" % str(e)) 108 | return data 109 | 110 | def socket_to_ws(self, data, **kwargs): 111 | try: 112 | self.logger.info("[<--] To WebSocket endpoint\n{}".format(hex_dump(data))) 113 | except Exception as e: 114 | #Ignore errors... DumpFilter should not interpose the protocol flow 115 | sys.stderr.write("Unable to log filter dump: %s" % str(e)) 116 | return data 117 | -------------------------------------------------------------------------------- /wstunnel/server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import logging 17 | import socket 18 | from tornado.httpserver import HTTPServer 19 | from tornado.iostream import IOStream 20 | from tornado.web import Application 21 | from tornado.websocket import WebSocketHandler 22 | from wstunnel.filters import FilterException 23 | from wstunnel.toolbox import random_free_port, tuple_to_address 24 | 25 | __author__ = 'fabio' 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class WebSocketProxyHandler(WebSocketHandler): 31 | """ 32 | Proxy a websocket connection to a service listening on a given (host, port) pair 33 | """ 34 | 35 | def initialize(self, **kwargs): 36 | self.remote_address = kwargs.get("address") 37 | self.io_stream = IOStream(socket.socket(kwargs.get("family", socket.AF_INET), 38 | kwargs.get("type", socket.SOCK_STREAM), 39 | 0)) 40 | self.filters = kwargs.get("filters", []) 41 | self.io_stream.set_close_callback(self.on_close) 42 | 43 | def open(self): 44 | """ 45 | Open the connection to the service when the WebSocket connection has been established 46 | """ 47 | logger.info("Forwarding connection to server %s" % tuple_to_address(self.remote_address)) 48 | self.io_stream.connect(self.remote_address, self.on_connect) 49 | 50 | def on_message(self, message): 51 | """ 52 | On message received from WebSocket, forward data to the service 53 | """ 54 | try: 55 | data = None if message is None else bytes(message) 56 | for filtr in self.filters: 57 | data = filtr.ws_to_socket(data=data) 58 | if data: 59 | self.io_stream.write(data) 60 | except Exception as e: 61 | logger.exception(e) 62 | self.close() 63 | 64 | def on_close(self, *args, **kwargs): 65 | """ 66 | When web socket gets closed, close the connection to the service too 67 | """ 68 | logger.info("Closing connection with peer at %s" % tuple_to_address(self.remote_address)) 69 | logger.debug("Received args %s and %s", args, kwargs) 70 | #if not self.io_stream._closed: 71 | for message in args: 72 | self.on_peer_message(message) 73 | if not self.io_stream.closed(): 74 | self.io_stream.close() 75 | self.close() 76 | 77 | def on_connect(self): 78 | """ 79 | Callback invoked on connection with mapped service 80 | """ 81 | logger.info("Connection established with peer at %s" % tuple_to_address(self.remote_address)) 82 | self.io_stream.read_until_close(self.on_close, self.on_peer_message) 83 | 84 | def on_peer_message(self, message): 85 | """ 86 | On message received from peer service, send back to client through WebSocket 87 | """ 88 | try: 89 | data = None if message is None else bytes(message) 90 | for filtr in self.filters: 91 | data = filtr.socket_to_ws(data=data) 92 | if data: 93 | self.write_message(data, binary=True) 94 | except FilterException as e: 95 | logger.exception(e) 96 | self.on_close() 97 | 98 | 99 | class WSTunnelServer(object): 100 | """ 101 | WebSocket tunnel remote endpoint. 102 | Handles several proxy services on different paths 103 | """ 104 | 105 | def __init__(self, port=0, address='', proxies=None, io_loop=None, ssl_options=None, **kwargs): 106 | self.port = port 107 | self.address = address 108 | self.proxies = {} 109 | 110 | self.tunnel_options = { 111 | "io_loop": io_loop, 112 | "ssl_options": ssl_options 113 | } 114 | self.app_settings = kwargs 115 | 116 | if proxies: 117 | for resource, addr in proxies.items(): 118 | self.add_proxy(resource, {"address": addr}) 119 | 120 | @property 121 | def port(self): 122 | return self._port 123 | 124 | @port.setter 125 | def port(self, value): 126 | self._port = value if value else random_free_port() 127 | 128 | def add_proxy(self, key, ws_proxy): 129 | logger.info("Adding {0} as proxy for {1}".format(ws_proxy, key)) 130 | self.proxies[key] = ws_proxy 131 | 132 | def remove_proxy(self, key): 133 | logger.info("Removing proxy on {0}".format(key)) 134 | del self.proxies[key] 135 | 136 | def get_proxy(self, key): 137 | return self.proxies.get(key) 138 | 139 | def install_filter(self, filtr): 140 | """ 141 | Install a filter into each WebSocket proxy 142 | """ 143 | for ws_proxy in self.proxies.values(): 144 | if ws_proxy.get("filters") is not None: 145 | ws_proxy.get("filters").append(filtr) 146 | else: 147 | ws_proxy["filters"] = [filtr] 148 | 149 | def uninstall_filter(self, filtr): 150 | """ 151 | Uninstall a filter from each WebSocket proxy 152 | """ 153 | for ws_proxy in self.proxies.values(): 154 | if filtr in ws_proxy.get("filters"): 155 | ws_proxy.get("filters").remove(filtr) 156 | 157 | @property 158 | def handlers(self): 159 | return [(key, WebSocketProxyHandler, ws_proxy) for key, ws_proxy in self.proxies.items()] 160 | 161 | def start(self, num_processes=1): 162 | logger.info("Starting {0} {1} processes".format(num_processes, self.__class__.__name__)) 163 | self.app = Application(self.handlers, self.app_settings) 164 | self.server = HTTPServer(self.app, **self.tunnel_options) 165 | logger.info("Binding on port {}".format(self.port)) 166 | self.server.bind(self.port) 167 | self.server.start(num_processes) 168 | 169 | def stop(self): 170 | pass 171 | -------------------------------------------------------------------------------- /wstunnel/svc/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | __author__ = 'fabio' 17 | -------------------------------------------------------------------------------- /wstunnel/svc/registry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | from wstunnel import winreg 17 | import logging 18 | import errno 19 | 20 | __author__ = 'fabio' 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def get_reg_values(key, root_key=winreg.HKEY_LOCAL_MACHINE): # pragma: no cover 25 | """ 26 | Given a key name, return a dictionary of its values. 27 | """ 28 | key_handle = None 29 | count = 0 30 | result = {} 31 | try: 32 | logger.debug("Reading key {}".format(key)) 33 | key_handle = winreg.OpenKey(root_key, key) 34 | while True: 35 | values = winreg.EnumValue(key_handle, count) 36 | logger.debug("Found {}".format(values)) 37 | count += 1 38 | result.update({values[0]: values[1]}) 39 | except WindowsError as error: 40 | if error.errno == errno.EINVAL: 41 | if logger.isEnabledFor(logging.DEBUG): 42 | logger.debug("Returning {} values".format( 43 | len(result))) 44 | return result 45 | else: 46 | logger.error(error) 47 | raise error 48 | finally: 49 | if key_handle: 50 | logger.debug("Closing key handle for key {}".format(key)) 51 | key_handle.Close() 52 | -------------------------------------------------------------------------------- /wstunnel/svc/wstuncltd.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright (C) 2013 Fabio Falcinelli 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | import os 18 | 19 | from tornado.ioloop import IOLoop 20 | import yaml 21 | 22 | import servicemanager 23 | import win32event 24 | import win32service 25 | import win32serviceutil 26 | from wstunnel import winreg 27 | from wstunnel.factory import create_ws_client_endpoint 28 | from svc.registry import get_reg_values 29 | 30 | 31 | __author__ = 'fabio' 32 | WSTUNNELD_KEY = r"SOFTWARE\wstunneld" 33 | 34 | 35 | class wstuncltd(win32serviceutil.ServiceFramework): 36 | """ 37 | The client service class 38 | """ 39 | _svc_name_ = "WSTunnelClientSvc" 40 | _svc_display_name_ = "WebSocket tunnel client service" 41 | _svc_description_ = "This is the client endpoint of the WebSocket tunnel" 42 | 43 | def __init__(self, args): 44 | win32serviceutil.ServiceFramework.__init__(self, args) 45 | self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) 46 | #Read configuration from registry 47 | os.chdir(get_reg_values(key=WSTUNNELD_KEY, root_key=winreg.HKEY_LOCAL_MACHINE)["install_dir"]) 48 | self.reg_conf = get_reg_values(key=os.path.join(WSTUNNELD_KEY, "client")) 49 | self.srv = None 50 | 51 | def SvcStop(self): 52 | """ 53 | Stops the Windows service 54 | """ 55 | self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) 56 | win32event.SetEvent(self.hWaitStop) 57 | if self.srv: 58 | self.srv.stop() 59 | IOLoop.instance().stop() 60 | 61 | def SvcDoRun(self): 62 | """ 63 | Starts the Windows service 64 | """ 65 | servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, 66 | servicemanager.PYS_SERVICE_STARTED, 67 | (self._svc_name_, '')) 68 | with open(self.reg_conf["config"]) as yaml_conf: 69 | self.srv = create_ws_client_endpoint(yaml.load(yaml_conf.read())) 70 | self.srv.start() 71 | IOLoop.instance().start() 72 | 73 | 74 | def main(): 75 | """ 76 | Entry point for the WebSocket client tunnel service endpoint 77 | """ 78 | win32serviceutil.HandleCommandLine(wstuncltd) 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /wstunnel/svc/wstunsrvd.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright (C) 2013 Fabio Falcinelli 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | import os 18 | 19 | from tornado.ioloop import IOLoop 20 | import yaml 21 | 22 | import servicemanager 23 | import win32event 24 | import win32service 25 | import win32serviceutil 26 | from wstunnel import winreg 27 | from wstunnel.factory import create_ws_server_endpoint 28 | from svc.registry import get_reg_values 29 | 30 | 31 | __author__ = 'fabio' 32 | WSTUNNELD_KEY = r"SOFTWARE\wstunneld" 33 | 34 | 35 | class wstunsrvd(win32serviceutil.ServiceFramework): 36 | """ 37 | The server service class 38 | """ 39 | _svc_name_ = "WSTunnelServerSvc" 40 | _svc_display_name_ = "WebSocket tunnel server service" 41 | _svc_description_ = "This is the server endpoint of the WebSocket tunnel" 42 | 43 | def __init__(self, args): 44 | win32serviceutil.ServiceFramework.__init__(self, args) 45 | self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) 46 | #Read configuration from registry 47 | os.chdir(get_reg_values(key=WSTUNNELD_KEY, root_key=winreg.HKEY_LOCAL_MACHINE)["install_dir"]) 48 | self.reg_conf = get_reg_values(key=os.path.join(WSTUNNELD_KEY, "server")) 49 | self.srv = None 50 | 51 | def SvcStop(self): 52 | """ 53 | Stops the Windows service 54 | """ 55 | self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) 56 | win32event.SetEvent(self.hWaitStop) 57 | if self.srv: 58 | self.srv.stop() 59 | IOLoop.instance().stop() 60 | 61 | def SvcDoRun(self): 62 | """ 63 | Starts the Windows service 64 | """ 65 | servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE, 66 | servicemanager.PYS_SERVICE_STARTED, 67 | (self._svc_name_, '')) 68 | with open(self.reg_conf["config"]) as yaml_conf: 69 | self.srv = create_ws_server_endpoint(yaml.load(yaml_conf.read())) 70 | self.srv.start() 71 | IOLoop.instance().start() 72 | 73 | 74 | def main(): 75 | """ 76 | Entry point for the WebSocket server tunnel service endpoint 77 | """ 78 | win32serviceutil.HandleCommandLine(wstunsrvd) 79 | 80 | 81 | if __name__ == "__main__": 82 | main() -------------------------------------------------------------------------------- /wstunnel/test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import os 17 | import shutil 18 | import socket 19 | import sys 20 | from tornado.iostream import IOStream 21 | from tornado.tcpserver import TCPServer 22 | from wstunnel.filters import BaseFilter, FilterException 23 | 24 | 25 | class EchoHandler(object): 26 | """ 27 | Echo handler. Data is echoed back to uppercase 28 | """ 29 | 30 | def __init__(self, io_stream): 31 | self.io_stream = io_stream 32 | 33 | def closing_cb(data): 34 | pass 35 | 36 | def sending_cb(data): 37 | self.io_stream.write(data.upper()) 38 | 39 | self.io_stream.read_until_close(closing_cb, streaming_callback=sending_cb) 40 | 41 | 42 | class EchoServer(TCPServer): 43 | """ 44 | Asynchronous TCP Server echoing data back to uppercase 45 | """ 46 | 47 | def __init__(self, port, address='127.0.0.1', family=socket.AF_UNSPEC, backlog=128, io_loop=None, ssl_options=None): 48 | super(EchoServer, self).__init__(io_loop, ssl_options) 49 | self.bind(port, address, family, backlog) 50 | 51 | @property 52 | def address_list(self): 53 | return [(s.getsockname()[0], s.getsockname()[1]) for s in self._sockets.values()] 54 | 55 | def handle_stream(self, stream, address): 56 | handler = EchoHandler(stream) 57 | 58 | 59 | class EchoClient(object): 60 | """ 61 | An asynchronous client for EchoServer 62 | """ 63 | 64 | def __init__(self, address, family=socket.AF_INET, socktype=socket.SOCK_STREAM): 65 | self.io_stream = IOStream(socket.socket(family, socktype, 0)) 66 | self.address = address 67 | self.is_closed = False 68 | 69 | def handle_close(self, data): 70 | self.is_closed = True 71 | 72 | def send_message(self, message, handle_response): 73 | def handle_connect(): 74 | self.io_stream.read_until_close(self.handle_close, handle_response) 75 | self.write(message) 76 | 77 | self.io_stream.connect(self.address, handle_connect) 78 | 79 | def write(self, message): 80 | if not isinstance(message, bytes): 81 | message = message.encode("UTF-8") 82 | self.io_stream.write(message) 83 | 84 | 85 | class RaiseFromWSFilter(BaseFilter): 86 | """ 87 | A fake filter raising an exception when receiving data from websocket 88 | """ 89 | 90 | def ws_to_socket(self, data): 91 | raise FilterException(data) 92 | 93 | def socket_to_ws(self, data): 94 | return data 95 | 96 | 97 | class RaiseToWSFilter(BaseFilter): 98 | """ 99 | A fake filter raising an exception when sending data to websocket 100 | """ 101 | 102 | def ws_to_socket(self, data): 103 | return data 104 | 105 | def socket_to_ws(self, data): 106 | raise FilterException(data) 107 | 108 | #TODO: on windows, temporary files are not working so well... 109 | DELETE_TMPFILE = not sys.platform.startswith("win") 110 | fixture = os.path.join(os.path.dirname(__file__), "fixture") 111 | 112 | 113 | def setup_logging(): 114 | """ 115 | Sets up the logging and PID files 116 | """ 117 | log_file = os.path.join(fixture, "logs", "wstun_test_{0}.log".format(os.getpid())) 118 | pid_file = os.path.join(fixture, "temp", "wstun_test_{0}.pid".format(os.getpid())) 119 | 120 | for f in log_file, pid_file: 121 | if os.path.exists(f): 122 | os.remove(f) 123 | return log_file, pid_file 124 | 125 | 126 | def clean_logging(files): 127 | """ 128 | Cleans up logging and PID files 129 | """ 130 | for f in files: 131 | if os.path.exists(f): 132 | os.remove(f) 133 | for d in map(os.path.dirname, files): 134 | if os.path.exists(d): 135 | shutil.rmtree(d) 136 | -------------------------------------------------------------------------------- /wstunnel/test/fixture/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDseSR8ok95m4v3 3 | VEHVD384ocHJLXaVKgJXQ3NMsNnzFRcujR45bbpB2ueu9L3eroQSiHYvz6bkjHQL 4 | wyZm87b1V2kXuHuzb4bW6L7+5mWS0SkIAMYvHfv8fWBM3P5exaJ2NkbVOoipIecN 5 | yJMh6PDjGeeu0RUUWO1P08zVlWIB40E/w58gVJO9EKWpyGgToOsJCKsbpnaJcBYs 6 | rX+pU/Qk7RhyZ1mpmdqC6xXBRxuq+on2aVaZeMZnvKS8CDEK5xolKdhXrJhC9KKU 7 | stG/T9V8Ts+U+Gl+dDPTYUetnjjDZWYxym5HhDJ9ZslOSUHSW7jJcudLFeU3ft25 8 | x+ntL3z9AgMBAAECggEACQNtEpsVMGtvYFQD1l0q2jvAKSzkcjcRs8XMZUXwaMWL 9 | Bqk2V7YI/W2cmxyVCCHawuIUrynZEKkR20jq882iUaOtS8wqWuKLXzGr5gdeI8R5 10 | Lebppu4bproYq5VY1L/vu1XCSWpbvyadqfbVNNuuItnf7NfnV8kz8nD+Q73X6H5w 11 | QU3HcPB7J8HelAXpEC3P5AcB6XCDUyJ2mpN1I+UXGsXLidg3VGNhOCLuQXXPOZyb 12 | 9z+lW8usUtdCEM0IYPIaR8Wpmzzvjlc5OG8hgfM3j8w69AQUJM+/Lj7oO3eh8C5w 13 | kzSuyFTtNwq80sdiHIVm4/Ff0a67MCSyO7Iu7ZK3OQKBgQD7wLcDJM2lFexCebba 14 | tHR+NxhJXXXQJo5q9kZLjg2EzUgX5Ua3ft+/thHXY3Tp1bP9Wm6soS0PgxO2J1rJ 15 | R0Cj4C50/pf6CHo8rz0cFR1Uq0XaCwwwFVWSjyfBOGuI4ABk/s9elcC9zT21JvF9 16 | tvSaktjnUphjT6swSZZLHFBGBwKBgQDwdm/ww5RzPXsNhhp1ljgZ03yVWRlpI11J 17 | 8/0PsHcdKnXUHGwlE7LEJmcJ2wwhFdsxAUVp35gnXaGqUlyHVLcktOXuOKQC8Jx3 18 | ju3TEaE72DrktNwEB1zwlpx5TT5uUaBd4RI5Mrc0VSlMKI0rn6eJegHw5mWkdHGq 19 | h0/kHmKD2wKBgB0w0EemCc49h4KBuGkNiYBlBQTkuFdlURgn7CiwlPK0Fsrmg9ec 20 | 93a0NsdhudmvNMqIpNKRcbcdvLhfQdCa2Wzm/pwENT0BpKLKsuxBqn/5yASrSUN+ 21 | BckTnklyME3To1gSj5rpBEs9tA0AMfogr6YIpuvTkOXbA/96WNnms4wbAoGBAKCP 22 | dM9eyJDqTHALSz+Yvn0AKf/PLph1dKUctaz0N5TR9TtcfxmCvasbuVFrYf31ihZ+ 23 | ssqu8fnXG0uPExmKB4ALCjy2tU0BPHjYhxSYgQBksW5lFUPbZsN+zZxxZ25iMqJ8 24 | 1p46rvnSo3Cm4xxtzoCNZx5juRrGZd9n2oCHiWBhAoGBAJmHpKaXupeqoS0eF0Ic 25 | mJmYKq+c6TB0xEloSXBFrneY8RD+IjPWKio3B+6NiDnf7W57UwH8Inmj561GIv3O 26 | tx0mVA9gG6LtzbJTGn7SP5fWy8pIWxwniur7X97VEsUbPlnMXTEmC2lAfNjeYYFq 27 | 9so6/MyGGLdtRWw1EzaCErap 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /wstunnel/test/fixture/localhost.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID/TCCAuWgAwIBAgIJAIgVRt3EI5t4MA0GCSqGSIb3DQEBBQUAMIGUMQswCQYD 3 | VQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0 4 | IENvbXBhbnkgTHRkMREwDwYDVQQLDAh3c3R1bm5lbDESMBAGA1UEAwwJbG9jYWxo 5 | b3N0MSkwJwYJKoZIhvcNAQkBFhpmYWJpby5mYWxjaW5lbGxpQGdtYWlsLmNvbTAe 6 | Fw0xMzA1MzAxOTM2MjNaFw0yMzA1MjgxOTM2MjNaMIGUMQswCQYDVQQGEwJYWDEV 7 | MBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkg 8 | THRkMREwDwYDVQQLDAh3c3R1bm5lbDESMBAGA1UEAwwJbG9jYWxob3N0MSkwJwYJ 9 | KoZIhvcNAQkBFhpmYWJpby5mYWxjaW5lbGxpQGdtYWlsLmNvbTCCASIwDQYJKoZI 10 | hvcNAQEBBQADggEPADCCAQoCggEBAOx5JHyiT3mbi/dUQdUPfzihwcktdpUqAldD 11 | c0yw2fMVFy6NHjltukHa5670vd6uhBKIdi/PpuSMdAvDJmbztvVXaRe4e7Nvhtbo 12 | vv7mZZLRKQgAxi8d+/x9YEzc/l7FonY2RtU6iKkh5w3IkyHo8OMZ567RFRRY7U/T 13 | zNWVYgHjQT/DnyBUk70QpanIaBOg6wkIqxumdolwFiytf6lT9CTtGHJnWamZ2oLr 14 | FcFHG6r6ifZpVpl4xme8pLwIMQrnGiUp2FesmEL0opSy0b9P1XxOz5T4aX50M9Nh 15 | R62eOMNlZjHKbkeEMn1myU5JQdJbuMly50sV5Td+3bnH6e0vfP0CAwEAAaNQME4w 16 | HQYDVR0OBBYEFKiwSOWRv59B0yRv09vWdCGIB3buMB8GA1UdIwQYMBaAFKiwSOWR 17 | v59B0yRv09vWdCGIB3buMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEB 18 | AITR0g7Vb1fnBRnq6+AZ1Af7ZgimYVWb0nSAtpFAPBBo/JzkPbExUVzYqvSYZVzF 19 | xPXLgd5QKzod7EYUdkN6kp/vb6Fj1C9oRpUWoVRr6jVxDwbrr1fsXakFjYMiDJC8 20 | TqofMMI7fBOIJNT8a9rKj9Y8ghx7Q8GXPxZoMQ2lVVoC5aWzgm2XNDJ1UrHqhQkM 21 | Gorvcmgq7kxxm6KH9fS/R9zZ1r8St9VUSgVtVgVID+G9LUXpgdzPsXT0ixjMwEvN 22 | 5O+i7mt7KGw+ZzvlSs0Y7zSDlrjoVw8LTpupLVOA+IFxuSDB1LLhhrbmkYKTJaY9 23 | qxTvqviCrQ2fhv5NKQVkSMM= 24 | -----END CERTIFICATE----- 25 | -------------------------------------------------------------------------------- /wstunnel/test/fixture/wstuncltd.yml: -------------------------------------------------------------------------------- 1 | # The WebSocket server endpoint url 2 | # use wss:// schema to connect in SSL mode 3 | endpoint: client 4 | ws_url: ws://localhost:9000/ 5 | 6 | # The pid file used to recognize if there's already a running instance 7 | pid_file: temp/wstuncltd.pid 8 | 9 | # If a user is provided the daemon will be demoted to run as this user 10 | user: null 11 | 12 | # This is the set of proxy services. 13 | # For each service you can specify 14 | # the port where to listen for connections 15 | # and the remote resource mapped to the service 16 | # Additionally you can provide a list of filters 17 | # to intercept data before being send to WebSocket 18 | # or before being sent back to client 19 | proxies: 20 | /test: 21 | port: 50023 22 | filters: [] 23 | 24 | # Logging configuration 25 | logging: 26 | version: 1 27 | disable_existing_loggers: False 28 | formatters: 29 | simple: 30 | format: "[%(asctime)s] %(name)s - %(levelname)-7s - %(message)s" 31 | 32 | handlers: 33 | console: 34 | class: logging.StreamHandler 35 | level: DEBUG 36 | formatter: simple 37 | stream: ext://sys.stdout 38 | 39 | file_handler: 40 | class: logging.handlers.RotatingFileHandler 41 | level: INFO 42 | formatter: simple 43 | filename: logs/wstuncltd.log 44 | maxBytes: 10485760 # 10MB 45 | backupCount: 20 46 | encoding: utf8 47 | 48 | loggers: 49 | wstunnel.client: 50 | level: DEBUG 51 | handlers: [console] 52 | propagate: yes 53 | 54 | root: 55 | level: INFO 56 | handlers: [file_handler] 57 | -------------------------------------------------------------------------------- /wstunnel/test/fixture/wstunsrvd.yml: -------------------------------------------------------------------------------- 1 | # The WebSocket server endpoint will listen on 2 | # this host:port pair. If host is omitted then connections 3 | # will be accepted from any interface (both ipv4 and ipv6) 4 | endpoint: server 5 | listen: 9000 6 | # The SSL parameter enables/disables the criptography on channel. 7 | # When enabled, the ssl_options section is required 8 | ssl: false 9 | 10 | # These options should include at least the server certificate file. 11 | # If only this one is provided, the file must contain both the public 12 | # and the private keys 13 | ssl_options: 14 | certfile: null 15 | keyfile: null 16 | 17 | # The pid file used to recognize if there's already a running instance 18 | pid_file: temp/wstunsrvd.pid 19 | 20 | # If a user is provided the daemon will be demoted to run as this user 21 | user: null 22 | 23 | # This the resource/service mapping. 24 | # For each resource you can map a destination host:port 25 | # and a list of filters to be applied before sending data to the 26 | # service and before sending it back to the client. 27 | proxies: 28 | /telnet: 29 | address: 192.168.1.2:13131 30 | filters: [] 31 | 32 | # WSTunnel logging configuration 33 | logging: 34 | version: 1 35 | disable_existing_loggers: True 36 | formatters: 37 | simple: 38 | format: "[%(asctime)s] %(name)s - %(levelname)-7s - %(message)s" 39 | 40 | handlers: 41 | console: 42 | class: logging.StreamHandler 43 | level: DEBUG 44 | formatter: simple 45 | stream: ext://sys.stdout 46 | 47 | file_handler: 48 | class: logging.handlers.RotatingFileHandler 49 | level: INFO 50 | formatter: simple 51 | filename: logs/wstunsrvd.log 52 | maxBytes: 10485760 # 10MB 53 | backupCount: 20 54 | encoding: utf8 55 | 56 | loggers: 57 | 58 | wstunnel.server: 59 | level: DEBUG 60 | handlers: [console] 61 | propagate: no 62 | 63 | tornado.general: 64 | level: WARNING 65 | handlers: [file_handler] 66 | propagate: no 67 | 68 | root: 69 | level: INFO 70 | handlers: [file_handler] 71 | -------------------------------------------------------------------------------- /wstunnel/test/test_daemon.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import shutil 17 | import unittest 18 | import sys 19 | import os 20 | from wstunnel.test import setup_logging, fixture, clean_logging 21 | 22 | __author__ = 'fabio' 23 | 24 | if not sys.platform.startswith("win"): 25 | import mock 26 | import yaml 27 | from tornado.ioloop import IOLoop 28 | from wstunnel.toolbox import random_free_port 29 | from wstunnel.daemon import WSTunnelClientDaemon, WSTunnelServerDaemon, Daemon, wstuncltd, wstunsrvd 30 | 31 | class DaemonTestCase(unittest.TestCase): 32 | """ 33 | TestCase for the generic Daemon super class 34 | """ 35 | 36 | def setUp(self): 37 | self.log_file, self.pid_file = setup_logging() 38 | #open(self.log_file, 'w').close() 39 | 40 | self.daemon = Daemon(self.pid_file, workdir=fixture) 41 | self.daemon.hush = lambda **kwargs: 0 42 | 43 | def test_daemonize(self): 44 | """ 45 | Tests the daemonize method by checking the pid file being written 46 | """ 47 | with mock.patch("os.fork", return_value=0): 48 | with mock.patch("os.setsid"): 49 | self.daemon.daemonize() 50 | 51 | self.assertTrue(os.path.exists(self.pid_file)) 52 | 53 | def test_kill_parent(self): 54 | """ 55 | Tests the daemonize method killing the parent 56 | """ 57 | with mock.patch("os.fork", return_value=1): 58 | with self.assertRaises(SystemExit) as sysexit: 59 | self.daemon.daemonize() 60 | self.assertEqual(sysexit.exception.code, 0) 61 | self.assertFalse(os.path.exists(self.pid_file)) 62 | 63 | def test_fork_failed(self): 64 | """ 65 | Tests the daemonize method failure when fork fails 66 | """ 67 | 68 | def mock_fork(*args): 69 | raise OSError() 70 | 71 | with mock.patch("os.fork", return_value=1): 72 | self.assertRaises(SystemExit, self.daemon.daemonize) 73 | 74 | self.assertFalse(os.path.exists(self.pid_file)) 75 | 76 | 77 | def test_is_not_running(self): 78 | """ 79 | Test daemon is not running method by reading the pid file 80 | """ 81 | self.assertFalse(self.daemon.is_running()) 82 | 83 | def test_is_running(self): 84 | """ 85 | Test is running method by passing a pid of a running process 86 | """ 87 | self.assertTrue(self.daemon.is_running(pid=os.getpid())) 88 | 89 | def test_start_already_running(self): 90 | """ 91 | Test daemon started when another instance is running 92 | """ 93 | if not os.path.exists(os.path.dirname(self.pid_file)): 94 | os.makedirs(os.path.dirname(self.pid_file)) 95 | with open(self.pid_file, "w") as pid: 96 | pid.write(str(os.getpid()) + "\n") 97 | 98 | with mock.patch("os.fork", return_value=0): 99 | with self.assertRaises(SystemExit) as sysexit: 100 | self.daemon.start() 101 | self.assertEqual(sysexit.exception.code, -1) 102 | 103 | def test_start_and_stop(self): 104 | """ 105 | Test daemon start and stop 106 | """ 107 | 108 | def mock_shutdown(*args): 109 | self.daemon.is_running = lambda *args: False 110 | if os.path.exists(self.pid_file): 111 | os.remove(self.pid_file) 112 | return False 113 | 114 | with mock.patch("os.fork", return_value=0): 115 | with mock.patch("os.setsid", return_value=0): 116 | self.daemon.start() 117 | 118 | with mock.patch("os.kill", new=mock_shutdown): 119 | self.daemon.stop() 120 | 121 | self.assertFalse(os.path.exists(self.pid_file)) 122 | 123 | def test_restart(self): 124 | """ 125 | Test daemon restart 126 | """ 127 | 128 | def mock_shutdown(*args): 129 | self.daemon.is_running = lambda *args: False 130 | if os.path.exists(self.pid_file): 131 | os.remove(self.pid_file) 132 | self.daemon._gracefully_terminate() 133 | return False 134 | 135 | with mock.patch("os.fork", return_value=0): 136 | with mock.patch("os.setsid", return_value=0): 137 | with mock.patch("os.kill", new=mock_shutdown): 138 | self.daemon.restart() 139 | 140 | self.assertTrue(os.path.exists(self.pid_file)) 141 | 142 | def tearDown(self): 143 | self.daemon.shutdown() 144 | clean_logging([self.log_file, self.pid_file]) 145 | 146 | class WSTunnelClientDaemonTestCase(DaemonTestCase): 147 | """ 148 | TestCase for the client tunnel endpoint in daemon mode 149 | """ 150 | 151 | def setUp(self): 152 | #super(WSTunnelClientDaemonTestCase, self).setUp() 153 | self.log_file, self.pid_file = setup_logging() 154 | 155 | with open(os.path.join(fixture, "wstuncltd.yml")) as f: 156 | self.tun_conf = yaml.load(f.read()) 157 | self.tun_conf["logging"]["handlers"]["file_handler"]["filename"] = self.log_file 158 | self.tun_conf["pid_file"] = self.pid_file 159 | self.tun_conf["proxies"]["/test"]["port"] = random_free_port() 160 | 161 | IOLoop.instance().start = lambda: 0 162 | IOLoop.instance().stop = lambda: 0 163 | 164 | self.daemon = WSTunnelClientDaemon(self.tun_conf) 165 | self.daemon.hush = lambda **kwargs: 0 166 | 167 | def test_create_logging_directory(self): 168 | """ 169 | Tests automatic creation of logging directory through monkey patch on RotatingFileHandler 170 | """ 171 | self.assertTrue(os.path.exists(os.path.dirname(self.log_file))) 172 | 173 | class WSTunnelServerDaemonTestCase(DaemonTestCase): 174 | """ 175 | TestCase for the server tunnel endpoint in daemon mode 176 | """ 177 | 178 | def setUp(self): 179 | # super(WSTunnelServerDaemonTestCase, self).setUp() 180 | self.log_file, self.pid_file = setup_logging() 181 | 182 | with open(os.path.join(fixture, "wstunsrvd.yml")) as f: 183 | self.tun_conf = yaml.load(f.read()) 184 | self.tun_conf["logging"]["handlers"]["file_handler"]["filename"] = self.log_file 185 | self.tun_conf["pid_file"] = self.pid_file 186 | self.tun_conf["listen"] = random_free_port() 187 | 188 | IOLoop.instance().start = lambda: 0 189 | IOLoop.instance().stop = lambda: 0 190 | 191 | self.daemon = WSTunnelServerDaemon(self.tun_conf) 192 | self.daemon.hush = lambda **kwargs: 0 193 | 194 | def test_create_logging_directory(self): 195 | """ 196 | Tests automatic creation of logging directory through monkey patch on RotatingFileHandler 197 | """ 198 | self.assertTrue(os.path.exists(os.path.dirname(self.log_file))) 199 | 200 | class WSTunnelSSLClientDaemonTestCase(DaemonTestCase): 201 | """ 202 | TestCase for the ssl client tunnel endpoint in daemon mode 203 | """ 204 | 205 | def setUp(self): 206 | super(WSTunnelSSLClientDaemonTestCase, self).setUp() 207 | 208 | with open(os.path.join(fixture, "wstuncltd.yml")) as f: 209 | self.tun_conf = yaml.load(f.read()) 210 | self.tun_conf["logging"]["handlers"]["file_handler"]["filename"] = self.log_file 211 | self.tun_conf["pid_file"] = self.pid_file 212 | self.tun_conf["proxies"]["/test"]["port"] = random_free_port() 213 | self.tun_conf["ws_url"] = "wss://localhost:9000/" 214 | 215 | IOLoop.instance().start = lambda: 0 216 | IOLoop.instance().stop = lambda: 0 217 | 218 | self.daemon = WSTunnelClientDaemon(self.tun_conf) 219 | self.daemon.hush = lambda **kwargs: 0 220 | 221 | class WSTunnelSSLServerDaemonTestCase(DaemonTestCase): 222 | """ 223 | TestCase for the ssl server tunnel endpoint in daemon mode 224 | """ 225 | 226 | def setUp(self): 227 | super(WSTunnelSSLServerDaemonTestCase, self).setUp() 228 | 229 | with open(os.path.join(fixture, "wstunsrvd.yml")) as f: 230 | self.tun_conf = yaml.load(f.read()) 231 | self.tun_conf["logging"]["handlers"]["file_handler"]["filename"] = self.log_file 232 | self.tun_conf["pid_file"] = self.pid_file 233 | self.tun_conf["listen"] = random_free_port() 234 | self.tun_conf["ssl"] = True 235 | self.tun_conf["ssl_options"].update({"certfile": os.path.join(fixture, "localhost.pem"), 236 | "keyfile": os.path.join(fixture, "localhost.key")}) 237 | 238 | IOLoop.instance().start = lambda: 0 239 | IOLoop.instance().stop = lambda: 0 240 | 241 | self.daemon = WSTunnelServerDaemon(self.tun_conf) 242 | self.daemon.hush = lambda **kwargs: 0 243 | 244 | class MainTestCase(unittest.TestCase): 245 | """ 246 | TestCase for the main method parsing command line arguments 247 | """ 248 | 249 | def setUp(self): 250 | if not os.path.exists(os.path.join(fixture, "logs")): 251 | os.makedirs(os.path.join(fixture, "logs")) 252 | if not os.path.exists(os.path.join(fixture, "temp")): 253 | os.makedirs(os.path.join(fixture, "temp")) 254 | 255 | # TODO: these tests do not work when you have wstunnel actually installed 256 | # def test_wstuncltd_no_config(self): 257 | # """ 258 | # Tests invocation without explicit config file. Since no file is in common directories 259 | # it will fail 260 | # """ 261 | # sys.argv = ["./wstuncltd.py", "stop"] 262 | # with self.assertRaises(SystemExit) as sysexit: 263 | # wstuncltd.main() 264 | # self.assertEqual(sysexit.exception.code, 2) 265 | # 266 | # def test_wstunsrvd_no_config(self): 267 | # """ 268 | # Tests invocation without explicit config file. Since no file is in common directories 269 | # it will fail 270 | # """ 271 | # sys.argv = ["./wstunsrvd.py", "stop"] 272 | # with self.assertRaises(SystemExit) as sysexit: 273 | # wstunsrvd.main() 274 | # self.assertEqual(sysexit.exception.code, 2) 275 | 276 | def test_wstuncltd(self): 277 | """ 278 | Tests invocation of daemon client method via command line 279 | """ 280 | sys.argv = ["./wstuncltd.py", "-c", os.path.join(fixture, "wstuncltd.yml"), "stop"] 281 | with self.assertRaises(SystemExit) as sysexit: 282 | wstuncltd.main() 283 | self.assertEqual(sysexit.exception.code, 0) 284 | 285 | def test_wstunsrvd(self): 286 | """ 287 | Tests invocation of daemon server method via command line 288 | """ 289 | sys.argv = ["./wstunsrvd.py", "-c", os.path.join(fixture, "wstunsrvd.yml"), "stop"] 290 | with self.assertRaises(SystemExit) as sysexit: 291 | wstunsrvd.main() 292 | self.assertEqual(sysexit.exception.code, 0) 293 | -------------------------------------------------------------------------------- /wstunnel/test/test_toolbox.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import socket 17 | import sys 18 | import unittest 19 | 20 | from tempfile import NamedTemporaryFile 21 | from wstunnel.factory import load_filter 22 | from wstunnel.filters import DumpFilter 23 | from wstunnel.toolbox import address_to_tuple, tuple_to_address, hex_dump, random_free_port, get_config, printable 24 | 25 | __author__ = 'fabio' 26 | DELETE_TMP = not sys.platform.startswith("win") 27 | 28 | 29 | class ToolBoxTestCase(unittest.TestCase): 30 | """ 31 | Test cases for utility methods 32 | """ 33 | 34 | def test_address_to_tuple(self): 35 | """ 36 | Tests the address to tuple conversions 37 | """ 38 | addr = "127.0.0.1:443" 39 | t = address_to_tuple(addr) 40 | self.assertEqual(t, ("127.0.0.1", 443)) 41 | self.assertEqual(addr, tuple_to_address(t)) 42 | 43 | def test_address_to_tuple_missing_port(self): 44 | """ 45 | Tests the address to tuple conversions when port is missing 46 | """ 47 | addr = "127.0.0.1" 48 | t = address_to_tuple(addr) 49 | self.assertEqual(t, ("127.0.0.1", None)) 50 | self.assertEqual(addr, tuple_to_address(t)) 51 | 52 | def test_address_to_tuple_missing_host(self): 53 | """ 54 | Tests the address to tuple conversions when host is missing 55 | """ 56 | addr = 443 57 | t = address_to_tuple(addr) 58 | self.assertEqual(t, (None, 443)) 59 | self.assertEqual(addr, tuple_to_address(t)) 60 | 61 | def test_tuple_to_address_invalid_arg(self): 62 | """ 63 | Tests the tuple to address conversion when a wrong argument is passed 64 | """ 65 | with self.assertRaises(ValueError): 66 | try: 67 | tuple_to_address("WRONG") 68 | except ValueError as e: 69 | self.assertTrue(str(e).startswith("too many values to unpack")) 70 | raise e 71 | 72 | def test_tuple_to_address_empty_tuple(self): 73 | """ 74 | Tests the tuple to address conversion when a tuple with empty values is passed 75 | """ 76 | with self.assertRaises(ValueError): 77 | try: 78 | tuple_to_address((None, None)) 79 | except ValueError as e: 80 | self.assertEqual("invalid argument passed: (None, None)", str(e)) 81 | raise e 82 | 83 | def test_configuration_not_found(self): 84 | """ 85 | Tests the behavoiur when a configuration file is not found 86 | """ 87 | self.assertIsNone(get_config(filename="wstunneld.yml")) 88 | 89 | def test_printable_bytes(self): 90 | """ 91 | Tests the visual representation of non printable character 92 | """ 93 | b = bytes("\x00".encode("UTF-8")) 94 | self.assertEqual(printable(b), b".") 95 | 96 | def test_hex_dump_unicode(self): 97 | """ 98 | Tests the hex dump function with unicode strings 99 | """ 100 | size = 16 101 | data = u"Hello World" 102 | result = "0000 48 65 6c 6c 6f 20 57 6f 72 6c 64 Hello.Wo rld" 103 | self.assertEqual(hex_dump(data, size), result) 104 | 105 | def test_hex_dump_binary_data(self): 106 | """ 107 | Tests the hex dump function passing binary data 108 | """ 109 | size = 16 110 | data = b"\xff\xfd\x18\xff\xfd\x1f\xff\xfd#\xff\xfd'\xff\xfd$" 111 | result = "0000 ff fd 18 ff fd 1f ff fd 23 ff fd 27 ff fd 24 ........ #..'..$" 112 | self.assertEqual(hex_dump(data, size), result) 113 | 114 | def _test_random_free_port(self, address, family, type): 115 | port = random_free_port(family=family, type=type) 116 | sock = socket.socket(family=family, type=type) 117 | self.assertRaises(socket.error, sock.connect, (address, port)) 118 | 119 | def test_random_free_port_ipv4(self): 120 | """ 121 | Tests getting a random free port on ipv4 interface 122 | """ 123 | self._test_random_free_port("127.0.0.1", socket.AF_INET, socket.SOCK_STREAM) 124 | 125 | def test_random_free_port_ipv6(self): 126 | """ 127 | Tests getting a random free port on ipv6 interface 128 | """ 129 | self._test_random_free_port("::1", socket.AF_INET6, socket.SOCK_STREAM) 130 | 131 | def test_load_filter(self): 132 | """ 133 | Test loading a filter given the fully qualified class name 134 | """ 135 | with NamedTemporaryFile(delete=DELETE_TMP) as dumpf: 136 | filter_name = "wstunnel.filters.DumpFilter" 137 | DumpFilter.default_conf["handlers"]["dump_file_handler"]["filename"] = dumpf.name 138 | f = load_filter(filter_name) 139 | self.assertIsInstance(f, DumpFilter) -------------------------------------------------------------------------------- /wstunnel/test/test_wstunnel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import socket 17 | from tempfile import NamedTemporaryFile 18 | import os 19 | from tornado.testing import AsyncTestCase, LogTrapTestCase 20 | from wstunnel.filters import DumpFilter, FilterException 21 | from wstunnel.test import EchoServer, EchoClient, RaiseFromWSFilter, RaiseToWSFilter, setup_logging, clean_logging, \ 22 | fixture, DELETE_TMPFILE 23 | from wstunnel.client import WSTunnelClient, WebSocketProxy 24 | from wstunnel.server import WSTunnelServer 25 | from wstunnel.toolbox import hex_dump, random_free_port 26 | 27 | 28 | __author__ = 'fabio' 29 | ASYNC_TIMEOUT = 2 30 | 31 | 32 | class WSEndpointsTestCase(AsyncTestCase, LogTrapTestCase): 33 | """ 34 | TestCase for endpoints behaviour on various conditions 35 | """ 36 | 37 | def setUp(self): 38 | super(WSEndpointsTestCase, self).setUp() 39 | self.log_file, self.pid_file = setup_logging() 40 | 41 | def no_response(self, response): 42 | self.stop() 43 | self.fail("Connection should be dropped without a response if an error happens") 44 | 45 | 46 | def test_no_ws_endpoint(self): 47 | """ 48 | Tests the client tunnel endpoint behaviour when there's no server counterpart 49 | """ 50 | clt_tun = WSTunnelClient(family=socket.AF_INET, 51 | io_loop=self.io_loop) 52 | clt_tun.add_proxy("test", WebSocketProxy(port=0, ws_url="ws://localhost:{0}/test".format(random_free_port()))) 53 | clt_tun.start() 54 | message = "Hello World!" 55 | client = EchoClient(clt_tun.address_list[0]) 56 | client.send_message(message, self.no_response) 57 | #self.wait(timeout=ASYNC_TIMEOUT) 58 | with self.assertRaises(Exception): 59 | try: 60 | self.wait(timeout=ASYNC_TIMEOUT) 61 | except Exception as e: 62 | self.assertEqual(str(e), 63 | "The server endpoint is not available, caused by HTTPError('HTTP 599: [Errno 61] Connection refused',))") 64 | raise e 65 | 66 | def test_no_server_service(self): 67 | """ 68 | Tests the server tunnel endpoint behaviour when there's no service to connect 69 | """ 70 | srv_tun = WSTunnelServer(0, io_loop=self.io_loop, proxies={"/test": ("127.0.0.1", random_free_port())}) 71 | srv_tun.start() 72 | clt_tun = WSTunnelClient(io_loop=self.io_loop) 73 | clt_tun.add_proxy("test", WebSocketProxy(port=0, 74 | ws_url="ws://localhost:{0}/test".format(srv_tun.port))) 75 | 76 | clt_tun.start() 77 | 78 | message = "Hello World!" 79 | client = EchoClient(clt_tun.address_list[0]) 80 | client.send_message(message, self.no_response) 81 | with self.assertRaises(Exception): 82 | try: 83 | self.wait(timeout=ASYNC_TIMEOUT) 84 | except Exception as e: 85 | #print(e) 86 | #self.assertEqual(str(e),) 87 | raise e 88 | 89 | def test_no_client_ws_options(self): 90 | """ 91 | Tests the client tunnel endpoint behaviour when there's no server counterpart 92 | """ 93 | clt_tun = WSTunnelClient(family=socket.AF_INET, 94 | io_loop=self.io_loop) 95 | clt_tun.add_proxy("test", WebSocketProxy(port=0, ws_url="ws://localhost:{0}/test".format(random_free_port()))) 96 | clt_tun.start() 97 | self.assertEqual(clt_tun.ws_options, {}) 98 | 99 | def tearDown(self): 100 | super(WSEndpointsTestCase, self).tearDown() 101 | clean_logging([self.log_file, self.pid_file]) 102 | 103 | 104 | class WSTunnelTestCase(AsyncTestCase, LogTrapTestCase): 105 | """ 106 | Tunneling thorugh WebSocket tests 107 | """ 108 | 109 | def setUp(self): 110 | super(WSTunnelTestCase, self).setUp() 111 | self.log_file, self.pid_file = setup_logging() 112 | self.srv = EchoServer(port=0, 113 | address="127.0.0.1") 114 | self.srv.start(1) 115 | self.srv_tun = WSTunnelServer(port=0, 116 | address=self.srv.address_list[0][0], 117 | proxies={"/test": self.srv.address_list[0]}, io_loop=self.io_loop) 118 | self.srv_tun.start() 119 | self.clt_tun = WSTunnelClient(proxies={0: "ws://localhost:{0}/test".format(self.srv_tun.port)}, 120 | address=self.srv_tun.address, 121 | family=socket.AF_INET, 122 | io_loop=self.io_loop, 123 | ws_options={"validate_cert": False}) 124 | self.clt_tun.start() 125 | 126 | self.message = "Hello World!".encode("utf-8") 127 | self.client = EchoClient(self.clt_tun.address_list[0]) 128 | 129 | def tearDown(self): 130 | 131 | for srv in self.srv, self.srv_tun, self.clt_tun: 132 | srv.stop() 133 | 134 | super(WSTunnelTestCase, self).tearDown() 135 | clean_logging([self.log_file, self.pid_file]) 136 | 137 | def on_response_received(self, response): 138 | """ 139 | Callback invoked when response is received 140 | """ 141 | self.assertEqual(self.message.upper(), response) 142 | self.stop() 143 | 144 | def on_response_resend(self, response): 145 | """ 146 | Callback invoked when response is received. It resends the message, so that there will be an infinite loop. 147 | """ 148 | self.assertEqual(self.message.upper(), response) 149 | self.client.write(self.message) 150 | 151 | def test_request_response(self): 152 | """ 153 | Test a simple request/response chat through the websocket tunnel 154 | """ 155 | self.client.send_message(self.message, self.on_response_received) 156 | self.wait(timeout=ASYNC_TIMEOUT) 157 | 158 | def test_request_response_binary(self): 159 | """ 160 | Test a simple request/response chat through the websocket tunnel. Message contains 161 | non utf-8 characters 162 | """ 163 | self.message = bytes(b"\xff\xfd\x18\xff\xfd\x1f\xff\xfd#\xff\xfd'\xff\xfd$") 164 | self.client.send_message(self.message, self.on_response_received) 165 | self.wait(timeout=ASYNC_TIMEOUT) 166 | 167 | def test_client_dump_filter(self): 168 | """ 169 | Test the installing of a dump filter into client endpoint 170 | """ 171 | setup_logging() 172 | with NamedTemporaryFile(delete=DELETE_TMPFILE) as logf: 173 | client_filter = DumpFilter(handler={"filename": logf.name}) 174 | self.clt_tun.install_filter(client_filter) 175 | 176 | self.client.send_message(self.message, self.on_response_received) 177 | self.wait(timeout=ASYNC_TIMEOUT) 178 | 179 | content = logf.read() 180 | for line in hex_dump(self.message).splitlines(): 181 | self.assertIn(line, content.decode("utf-8")) 182 | 183 | self.clt_tun.uninstall_filter(client_filter) 184 | 185 | def test_server_dump_filter(self): 186 | """ 187 | Test the installing of a dump filter into server endpoint 188 | """ 189 | with NamedTemporaryFile(delete=DELETE_TMPFILE) as logf: 190 | server_filter = DumpFilter(handler={"filename": logf.name}) 191 | self.srv_tun.install_filter(server_filter) 192 | 193 | self.client.send_message(self.message, self.on_response_received) 194 | self.wait(timeout=ASYNC_TIMEOUT) 195 | 196 | content = logf.read() 197 | for line in hex_dump(self.message).splitlines(): 198 | self.assertIn(line, content.decode("utf-8")) 199 | 200 | self.srv_tun.uninstall_filter(server_filter) 201 | 202 | def test_raise_filter_exception_from_ws(self): 203 | """ 204 | Tests the behaviour when a filter raises exception reading from websocket 205 | """ 206 | server_filter = RaiseFromWSFilter() 207 | self.srv_tun.install_filter(server_filter) 208 | 209 | self.client.send_message(self.message, self.on_response_received) 210 | #TODO: this generically catch assertion errors... We should get the inner FilterException in some way 211 | self.assertRaises(AssertionError, self.wait, timeout=1) 212 | 213 | def test_raise_filter_exception_to_ws(self): 214 | """ 215 | Tests the behaviour when a filter raises exception writing to websocket 216 | """ 217 | server_filter = RaiseToWSFilter() 218 | self.srv_tun.install_filter(server_filter) 219 | 220 | self.client.send_message(self.message, self.on_response_received) 221 | #TODO: this generically catch assertion errors... We should get the inner FilterException in some way 222 | self.assertRaises(AssertionError, self.wait, timeout=1) 223 | 224 | def test_add_get_remove_client_proxy(self): 225 | """ 226 | Tests adding/remove/get operations on client proxies 227 | """ 228 | ws_proxy = WebSocketProxy(port=0, ws_url="ws://localhost:9000/test_add_remove") 229 | self.assertFalse(ws_proxy.serving) 230 | self.clt_tun.add_proxy("test_add_remove", ws_proxy) 231 | self.assertEqual(ws_proxy, self.clt_tun.get_proxy("test_add_remove")) 232 | self.assertTrue(ws_proxy.serving) 233 | self.clt_tun.remove_proxy("test_add_remove") 234 | self.assertFalse(ws_proxy.serving) 235 | 236 | def test_add_get_remove_server_proxy(self): 237 | """ 238 | Tests adding/remove/get operations on server proxies 239 | """ 240 | ws_proxies = {"/test": ("127.0.0.1", random_free_port())} 241 | for key, address in ws_proxies.items(): 242 | self.srv_tun.add_proxy(key, address) 243 | self.assertEqual(address, self.srv_tun.get_proxy(key)) 244 | self.assertEqual(1, len(self.srv_tun.proxies)) 245 | self.srv_tun.remove_proxy(key) 246 | self.assertEqual(0, len(self.srv_tun.proxies)) 247 | 248 | def test_server_peer_connection_drop_issue_6(self): 249 | """ 250 | Tests dropping peer connection server side. This test addresses issue #6 251 | """ 252 | 253 | def on_close(*args): 254 | raise Exception("CLOSED") 255 | 256 | self.client.handle_close = on_close 257 | self.client.send_message(self.message, handle_response=self.on_response_resend) 258 | self.srv.stop() 259 | try: 260 | self.wait(timeout=ASYNC_TIMEOUT) 261 | except Exception as e: 262 | self.assertEqual("CLOSED", str(e)) 263 | 264 | def test_server_tunnel_connection_drop(self): 265 | """ 266 | Tests dropping connection server side by shutting down the WebSocket tunnel server 267 | """ 268 | def on_close(*args): 269 | raise Exception("CLOSED") 270 | 271 | self.client.handle_close = on_close 272 | self.client.send_message(self.message, handle_response=self.on_response_resend) 273 | self.srv_tun.stop() 274 | try: 275 | self.wait(timeout=ASYNC_TIMEOUT) 276 | except Exception as e: 277 | # Possibile cases (timing matters here): 278 | # 1. Connection is lost when the client already sent next message --> CLOSED since the 279 | # endpoint notified the client just in time. 280 | # 2. Connection is lost when the client did not send the next message --> ASYNC_TIMEOUT since no endpoint 281 | # responding 282 | self.assertTrue(str(e).lower() in ("closed", "async operation timed out after %d seconds" % ASYNC_TIMEOUT)) 283 | 284 | 285 | class WSTunnelSSLTestCase(WSTunnelTestCase): 286 | """ 287 | Tests for SSL WebSocket tunnel 288 | """ 289 | 290 | def setUp(self): 291 | super(WSTunnelSSLTestCase, self).setUp() 292 | self.log_file, self.pid_file = setup_logging() 293 | self.srv = EchoServer(port=0, address="127.0.0.1") 294 | self.srv.start() 295 | self.srv_tun = WSTunnelServer(port=0, 296 | address=self.srv.address_list[0][0], 297 | proxies={"/test": self.srv.address_list[0]}, 298 | io_loop=self.io_loop, 299 | ssl_options={ 300 | "certfile": os.path.join(fixture, "localhost.pem"), 301 | "keyfile": os.path.join(fixture, "localhost.key"), 302 | }) 303 | self.srv_tun.start() 304 | self.clt_tun = WSTunnelClient(proxies={0: "wss://localhost:{0}/test".format(self.srv_tun.port)}, 305 | address=self.srv_tun.address, 306 | family=socket.AF_INET, 307 | io_loop=self.io_loop, 308 | ws_options={"validate_cert": False}) 309 | self.clt_tun.start() 310 | 311 | self.message = "Hello World!".encode("utf-8") 312 | self.client = EchoClient(self.clt_tun.address_list[0]) -------------------------------------------------------------------------------- /wstunnel/toolbox.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Fabio Falcinelli 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | import socket 17 | import string 18 | import os 19 | from wstunnel import unichr 20 | 21 | __author__ = 'fabio' 22 | 23 | 24 | def printable(x, encoding="UTF-8"): 25 | if isinstance(x, bytes): 26 | return x if x in string.printable[:-6].encode(encoding) else b'.' 27 | elif isinstance(x, int): 28 | return printable(unichr(x)) 29 | else: 30 | return x if x in string.printable[:-6] else u'.' 31 | 32 | 33 | hex_value = lambda x: hex(x if isinstance(x, int) else ord(x))[2:] 34 | 35 | 36 | def address_to_tuple(addr): 37 | """ 38 | Convert an host:port string into (host, port) tuple. 39 | """ 40 | addr = str(addr) 41 | address, port = None, None 42 | if ":" in addr: 43 | address, port = addr.split(":")[0], int(addr.split(":")[1]) 44 | elif addr.isdigit(): 45 | port = int(addr) 46 | else: 47 | address = addr 48 | return address, port 49 | 50 | 51 | def tuple_to_address(addr): 52 | """ 53 | Convert an (host, port) tuple into a host:port string. 54 | If one of the member is missing, the other is returned. 55 | """ 56 | host, port = addr 57 | if host and port: 58 | return "{}:{}".format(*addr) 59 | elif host and not port: 60 | return host 61 | elif port and not host: 62 | return port 63 | else: 64 | raise ValueError("invalid argument passed: %s" % str(addr)) 65 | 66 | 67 | def hex_dump(buff, size=16): 68 | """ 69 | Dump the buffer in wireshark style 70 | """ 71 | out = [] 72 | half = int(size / 2) 73 | if buff: 74 | for i in range(0, len(buff), size): 75 | hexed, plain = zip(*[(hex_value(c), printable(c)) for c in buff[i:i + size]]) 76 | hexed = "{:04x} {} {}".format(i, 77 | " ".join(hexed[:half]), 78 | " ".join(hexed[half:size])) 79 | plain = "{} {}".format("".join(plain[: half]), 80 | "".join(plain[half:size])) 81 | out.append("{0} {1:>{2}}".format(hexed, 82 | plain, 83 | 55 - (len(hexed) - len(plain)))) 84 | return "\n".join(out) 85 | 86 | 87 | def random_free_port(family=socket.AF_INET, type=socket.SOCK_STREAM): 88 | """ 89 | Pick a free port choosen by the operating system 90 | """ 91 | s = socket.socket(family, type) 92 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 93 | try: 94 | s.bind(("", 0)) 95 | return s.getsockname()[1] 96 | finally: 97 | s.close() 98 | 99 | 100 | def get_config(appname="wstunneld", filename="wstunneld.yml"): 101 | """ 102 | Search for a configuration file in current, user home or /etc (not suitable for windows...) folders 103 | """ 104 | path_list = [os.getcwd(), 105 | os.path.join(os.path.expanduser("~"), "." + appname), 106 | os.path.join("/etc", appname)] 107 | 108 | for conf_dir in path_list: 109 | if conf_dir: 110 | conf_file = os.path.join(conf_dir, filename) 111 | if os.path.exists(conf_file): 112 | return conf_file 113 | return None 114 | --------------------------------------------------------------------------------