Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f132f9d84 | |||
| 4e6938e9c2 | |||
| 68b1587ada | |||
| 0b906a1c3e | |||
| 6f0e06772a | |||
| 8a13c48428 | |||
| b2a93bf709 | |||
| 9bc002dcf9 | |||
| dc979f042c | |||
| 72b629838a | |||
| d947fb6e30 | |||
| 8dacbc974c | |||
| c415521214 | |||
| 5bd6554651 | |||
| 50cc65f1e4 | |||
| 3c04cf8c45 | |||
| 62c3a59192 | |||
| e401701311 | |||
| 714aa34e4e | |||
| 05cac26257 | |||
| 487db5f7c9 | |||
| f693871c58 | |||
| b445296816 | |||
| 1e2b5c2256 | |||
| 6c1543c05b | |||
| b7684e0914 | |||
| 12432c9b06 | |||
| 2051c2419f | |||
| cb6b309dad | |||
| addc2e6b20 |
+1
-1
@@ -4,7 +4,7 @@
|
||||
other/.lein-deps-sum
|
||||
other/classes
|
||||
other/lib
|
||||
other/node_modules
|
||||
other/js/node_modules
|
||||
.project
|
||||
.pydevproject
|
||||
target.cfg
|
||||
|
||||
+11
@@ -1,6 +1,17 @@
|
||||
Changes
|
||||
=======
|
||||
|
||||
0.8.0
|
||||
-----
|
||||
|
||||
* Make websockify properly terminate children on SIGTERM (#226)
|
||||
* Remove logging in signal handlers (this can cause Python to hang under certain conditions) (#219)
|
||||
* Make it easier to log to a file (#205)
|
||||
* Add support for IPv6 addresses in tokens in the TokenFile token plugins (#197)
|
||||
* Improve auth plugin framework to enable better support for HTTP auth (#194, #201)
|
||||
* Fix bug in JSONTokenAPI token plugin (#192)
|
||||
* Fix a missing variable in the exception handler (#178)
|
||||
|
||||
0.7.0
|
||||
-----
|
||||
|
||||
|
||||
@@ -47,17 +47,29 @@ which is why the negotiation is necessary.
|
||||
|
||||
### Encrypted WebSocket connections (wss://)
|
||||
|
||||
To encrypt the traffic using the WebSocket 'wss://' URI scheme you
|
||||
need to generate a certificate for websockify to load. By default websockify
|
||||
loads a certificate file name `self.pem` but the `--cert=CERT` option can
|
||||
override the file name. You can generate a self-signed certificate using
|
||||
openssl. When asked for the common name, use the hostname of the server where
|
||||
the proxy will be running:
|
||||
To encrypt the traffic using the WebSocket 'wss://' URI scheme you need to
|
||||
generate a certificate and key for Websockify to load. By default, Websockify
|
||||
loads a certificate file name `self.pem` but the `--cert=CERT` and `--key=KEY`
|
||||
options can override the file name. You can generate a self-signed certificate
|
||||
using openssl. When asked for the common name, use the hostname of the server
|
||||
where the proxy will be running:
|
||||
|
||||
```
|
||||
openssl req -new -x509 -days 365 -nodes -out self.pem -keyout self.pem
|
||||
```
|
||||
|
||||
For a self-signed certificate to work, you need to make your client/browser
|
||||
understand it. You can do this by installing it as accepted certificate, or by
|
||||
using that same certificate for a HTTPS connection to which you navigate first
|
||||
and approve. Browsers generally don't give you the "trust certificate?" prompt
|
||||
by opening a WSS socket with invalid certificate, hence you need to have it
|
||||
acccept it by either of those two methods.
|
||||
|
||||
If you have a commercial/valid SSL certificate with one ore more intermediate
|
||||
certificates, concat them into one file, server certificate first, then the
|
||||
intermediate(s) from the CA, etc. Point to this file with the `--cert` option
|
||||
and then also to the key with `--key`. Finally, use `--ssl-only` as needed.
|
||||
|
||||
|
||||
### Websock Javascript library
|
||||
|
||||
@@ -103,11 +115,14 @@ These are not necessary for the basic operation.
|
||||
|
||||
* Mini-webserver: websockify can detect and respond to normal web
|
||||
requests on the same port as the WebSockets proxy and Flash security
|
||||
policy. This functionality is activate with the `--web DIR` option
|
||||
policy. This functionality is activated with the `--web DIR` option
|
||||
where DIR is the root of the web directory to serve.
|
||||
|
||||
* Wrap a program: see the "Wrap a Program" section below.
|
||||
|
||||
* Log files: websockify can save all logging information in a file.
|
||||
This functionality is activated with the `--log-file FILE` option
|
||||
where FILE is the file where the logs should be saved.
|
||||
|
||||
### Implementations of websockify
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"author": "Joel Martin <github@martintribe.org> (http://github.com/kanaka)",
|
||||
"name": "websockify",
|
||||
"description": "websockify is a WebSocket-to-TCP proxy/bridge",
|
||||
"license": "LGPL-3",
|
||||
"version": "0.7.0",
|
||||
"license": "LGPL-3.0",
|
||||
"version": "0.8.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/kanaka/websockify.git"
|
||||
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": ">=0.4.27",
|
||||
"base64": "latest",
|
||||
"optimist": "latest",
|
||||
"policyfile": "latest"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
version = '0.7.0'
|
||||
version = '0.8.0'
|
||||
name = 'websockify'
|
||||
long_description = open("README.md").read() + "\n" + \
|
||||
open("CHANGES.txt").read() + "\n"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
""" Unit tests for Authentication plugins"""
|
||||
|
||||
from websockify.auth_plugins import BasicHTTPAuth, AuthenticationError
|
||||
import unittest
|
||||
|
||||
|
||||
class BasicHTTPAuthTestCase(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.plugin = BasicHTTPAuth('Aladdin:open sesame')
|
||||
|
||||
def test_no_auth(self):
|
||||
headers = {}
|
||||
self.assertRaises(AuthenticationError, self.plugin.authenticate, headers, 'localhost', '1234')
|
||||
|
||||
def test_invalid_password(self):
|
||||
headers = {'Authorization': 'Basic QWxhZGRpbjpzZXNhbWUgc3RyZWV0'}
|
||||
self.assertRaises(AuthenticationError, self.plugin.authenticate, headers, 'localhost', '1234')
|
||||
|
||||
def test_valid_password(self):
|
||||
headers = {'Authorization': 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='}
|
||||
self.plugin.authenticate(headers, 'localhost', '1234')
|
||||
|
||||
def test_garbage_auth(self):
|
||||
headers = {'Authorization': 'Basic xxxxxxxxxxxxxxxxxxxxxxxxxxxx'}
|
||||
self.assertRaises(AuthenticationError, self.plugin.authenticate, headers, 'localhost', '1234')
|
||||
@@ -106,11 +106,11 @@ class ProxyRequestHandlerTestCase(unittest.TestCase):
|
||||
def lookup(self, token):
|
||||
return (self.source + token).split(',')
|
||||
|
||||
self.stubs.Set(websocketproxy.ProxyRequestHandler, 'do_proxy',
|
||||
lambda *args, **kwargs: None)
|
||||
self.stubs.Set(websocketproxy.ProxyRequestHandler, 'send_auth_error',
|
||||
staticmethod(lambda *args, **kwargs: None))
|
||||
|
||||
self.handler.server.token_plugin = TestPlugin("somehost,")
|
||||
self.handler.new_websocket_client()
|
||||
self.handler.validate_connection()
|
||||
|
||||
self.assertEqual(self.handler.server.target_host, "somehost")
|
||||
self.assertEqual(self.handler.server.target_port, "blah")
|
||||
@@ -119,9 +119,9 @@ class ProxyRequestHandlerTestCase(unittest.TestCase):
|
||||
class TestPlugin(auth_plugins.BasePlugin):
|
||||
def authenticate(self, headers, target_host, target_port):
|
||||
if target_host == self.source:
|
||||
raise auth_plugins.AuthenticationError("some error")
|
||||
raise auth_plugins.AuthenticationError(response_msg="some_error")
|
||||
|
||||
self.stubs.Set(websocketproxy.ProxyRequestHandler, 'do_proxy',
|
||||
self.stubs.Set(websocketproxy.ProxyRequestHandler, 'send_auth_error',
|
||||
staticmethod(lambda *args, **kwargs: None))
|
||||
|
||||
self.handler.server.auth_plugin = TestPlugin("somehost")
|
||||
@@ -129,8 +129,8 @@ class ProxyRequestHandlerTestCase(unittest.TestCase):
|
||||
self.handler.server.target_port = "someport"
|
||||
|
||||
self.assertRaises(auth_plugins.AuthenticationError,
|
||||
self.handler.new_websocket_client)
|
||||
self.handler.validate_connection)
|
||||
|
||||
self.handler.server.target_host = "someotherhost"
|
||||
self.handler.new_websocket_client()
|
||||
self.handler.validate_connection()
|
||||
|
||||
|
||||
@@ -7,7 +7,15 @@ class BasePlugin(object):
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
def __init__(self, log_msg=None, response_code=403, response_headers={}, response_msg=None):
|
||||
self.code = response_code
|
||||
self.headers = response_headers
|
||||
self.msg = response_msg
|
||||
|
||||
if log_msg is None:
|
||||
log_msg = response_msg
|
||||
|
||||
super(AuthenticationError, self).__init__('%s %s' % (self.code, log_msg))
|
||||
|
||||
|
||||
class InvalidOriginError(AuthenticationError):
|
||||
@@ -16,10 +24,52 @@ class InvalidOriginError(AuthenticationError):
|
||||
self.actual_origin = actual
|
||||
|
||||
super(InvalidOriginError, self).__init__(
|
||||
"Invalid Origin Header: Expected one of "
|
||||
"%s, got '%s'" % (expected, actual))
|
||||
response_msg='Invalid Origin',
|
||||
log_msg="Invalid Origin Header: Expected one of "
|
||||
"%s, got '%s'" % (expected, actual))
|
||||
|
||||
|
||||
class BasicHTTPAuth(object):
|
||||
"""Verifies Basic Auth headers. Specify src as username:password"""
|
||||
|
||||
def __init__(self, src=None):
|
||||
self.src = src
|
||||
|
||||
def authenticate(self, headers, target_host, target_port):
|
||||
import base64
|
||||
auth_header = headers.get('Authorization')
|
||||
if auth_header:
|
||||
if not auth_header.startswith('Basic '):
|
||||
raise AuthenticationError(response_code=403)
|
||||
|
||||
try:
|
||||
user_pass_raw = base64.b64decode(auth_header[6:])
|
||||
except TypeError:
|
||||
raise AuthenticationError(response_code=403)
|
||||
|
||||
try:
|
||||
# http://stackoverflow.com/questions/7242316/what-encoding-should-i-use-for-http-basic-authentication
|
||||
user_pass_as_text = user_pass_raw.decode('ISO-8859-1')
|
||||
except UnicodeDecodeError:
|
||||
raise AuthenticationError(response_code=403)
|
||||
|
||||
user_pass = user_pass_as_text.split(':', 1)
|
||||
if len(user_pass) != 2:
|
||||
raise AuthenticationError(response_code=403)
|
||||
|
||||
if not self.validate_creds(*user_pass):
|
||||
raise AuthenticationError(response_code=403)
|
||||
|
||||
else:
|
||||
raise AuthenticationError(response_code=401,
|
||||
response_headers={'WWW-Authenticate': 'Basic realm="Websockify"'})
|
||||
|
||||
def validate_creds(self, username, password):
|
||||
if '%s:%s' % (username, password) == self.src:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
class ExpectOrigin(object):
|
||||
def __init__(self, src=None):
|
||||
if src is None:
|
||||
@@ -28,6 +78,6 @@ class ExpectOrigin(object):
|
||||
self.source = src.split()
|
||||
|
||||
def authenticate(self, headers, target_host, target_port):
|
||||
origin = headers.getheader('Origin', None)
|
||||
origin = headers.get('Origin', None)
|
||||
if origin is None or origin not in self.source:
|
||||
raise InvalidOriginError(expected=self.source, actual=origin)
|
||||
|
||||
@@ -28,7 +28,7 @@ class ReadOnlyTokenFile(BasePlugin):
|
||||
for line in [l.strip() for l in open(f).readlines()]:
|
||||
if line and not line.startswith('#'):
|
||||
tok, target = line.split(': ')
|
||||
self._targets[tok] = target.strip().split(':')
|
||||
self._targets[tok] = target.strip().rsplit(':', 1)
|
||||
|
||||
def lookup(self, token):
|
||||
if self._targets is None:
|
||||
@@ -79,4 +79,5 @@ class JSONTokenApi(BaseTokenAPI):
|
||||
# should go
|
||||
|
||||
def process_result(self, resp):
|
||||
return (resp.json['host'], resp.json['port'])
|
||||
resp_json = resp.json()
|
||||
return (resp_json['host'], resp_json['port'])
|
||||
|
||||
+51
-8
@@ -113,6 +113,9 @@ class WebSocketRequestHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
SimpleHTTPRequestHandler.__init__(self, req, addr, server)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
self.logger.info("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args))
|
||||
|
||||
@staticmethod
|
||||
def unmask(buf, hlen, plen):
|
||||
pstart = hlen + 4
|
||||
@@ -474,9 +477,13 @@ class WebSocketRequestHandler(SimpleHTTPRequestHandler):
|
||||
"""Upgrade a connection to Websocket, if requested. If this succeeds,
|
||||
new_websocket_client() will be called. Otherwise, False is returned.
|
||||
"""
|
||||
|
||||
if (self.headers.get('upgrade') and
|
||||
self.headers.get('upgrade').lower() == 'websocket'):
|
||||
|
||||
# ensure connection is authorized, and determine the target
|
||||
self.validate_connection()
|
||||
|
||||
if not self.do_websocket_handshake():
|
||||
return False
|
||||
|
||||
@@ -549,6 +556,10 @@ class WebSocketRequestHandler(SimpleHTTPRequestHandler):
|
||||
""" Do something with a WebSockets client connection. """
|
||||
raise Exception("WebSocketRequestHandler.new_websocket_client() must be overloaded")
|
||||
|
||||
def validate_connection(self):
|
||||
""" Ensure that the connection is a valid connection, and set the target. """
|
||||
pass
|
||||
|
||||
def do_HEAD(self):
|
||||
if self.only_upgrade:
|
||||
self.send_error(405, "Method Not Allowed")
|
||||
@@ -737,6 +748,10 @@ class WebSocketServer(object):
|
||||
|
||||
@staticmethod
|
||||
def daemonize(keepfd=None, chdir='/'):
|
||||
|
||||
if keepfd is None:
|
||||
keepfd = []
|
||||
|
||||
os.umask(0)
|
||||
if chdir:
|
||||
os.chdir(chdir)
|
||||
@@ -759,7 +774,7 @@ class WebSocketServer(object):
|
||||
if maxfd == resource.RLIM_INFINITY: maxfd = 256
|
||||
for fd in reversed(range(maxfd)):
|
||||
try:
|
||||
if fd != keepfd:
|
||||
if fd not in keepfd:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
_, exc, _ = sys.exc_info()
|
||||
@@ -789,7 +804,7 @@ class WebSocketServer(object):
|
||||
"""
|
||||
ready = select.select([sock], [], [], 3)[0]
|
||||
|
||||
|
||||
|
||||
if not ready:
|
||||
raise self.EClose("ignoring socket not ready")
|
||||
# Peek, but do not read the data so that we have a opportunity
|
||||
@@ -880,11 +895,14 @@ class WebSocketServer(object):
|
||||
raise self.Terminate()
|
||||
|
||||
def multiprocessing_SIGCHLD(self, sig, stack):
|
||||
self.vmsg('Reaping zombies, active child count is %s', len(multiprocessing.active_children()))
|
||||
# TODO: figure out a way to actually log this information without
|
||||
# calling `log` in the signal handlers
|
||||
multiprocessing.active_children()
|
||||
|
||||
def fallback_SIGCHLD(self, sig, stack):
|
||||
# Reap zombies when using os.fork() (python 2.4)
|
||||
self.vmsg("Got SIGCHLD, reaping zombies")
|
||||
# TODO: figure out a way to actually log this information without
|
||||
# calling `log` in the signal handlers
|
||||
try:
|
||||
result = os.waitpid(-1, os.WNOHANG)
|
||||
while result[0]:
|
||||
@@ -894,16 +912,18 @@ class WebSocketServer(object):
|
||||
pass
|
||||
|
||||
def do_SIGINT(self, sig, stack):
|
||||
self.msg("Got SIGINT, exiting")
|
||||
# TODO: figure out a way to actually log this information without
|
||||
# calling `log` in the signal handlers
|
||||
self.terminate()
|
||||
|
||||
def do_SIGTERM(self, sig, stack):
|
||||
self.msg("Got SIGTERM, exiting")
|
||||
# TODO: figure out a way to actually log this information without
|
||||
# calling `log` in the signal handlers
|
||||
self.terminate()
|
||||
|
||||
def top_new_client(self, startsock, address):
|
||||
""" Do something with a WebSockets client connection. """
|
||||
# handler process
|
||||
# handler process
|
||||
client = None
|
||||
try:
|
||||
try:
|
||||
@@ -926,6 +946,18 @@ class WebSocketServer(object):
|
||||
# Original socket closed by caller
|
||||
client.close()
|
||||
|
||||
def get_log_fd(self):
|
||||
"""
|
||||
Get file descriptors for the loggers.
|
||||
They should not be closed when the process is forked.
|
||||
"""
|
||||
descriptors = []
|
||||
for handler in self.logger.parent.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
descriptors.append(handler.stream.fileno())
|
||||
|
||||
return descriptors
|
||||
|
||||
def start_server(self):
|
||||
"""
|
||||
Daemonize if requested. Listen for for connections. Run
|
||||
@@ -941,7 +973,9 @@ class WebSocketServer(object):
|
||||
tcp_keepintvl=self.tcp_keepintvl)
|
||||
|
||||
if self.daemon:
|
||||
self.daemonize(keepfd=lsock.fileno(), chdir=self.web)
|
||||
keepfd = self.get_log_fd()
|
||||
keepfd.append(lsock.fileno())
|
||||
self.daemonize(keepfd=keepfd, chdir=self.web)
|
||||
|
||||
self.started() # Some things need to happen after daemonizing
|
||||
|
||||
@@ -1045,8 +1079,17 @@ class WebSocketServer(object):
|
||||
|
||||
except (self.Terminate, SystemExit, KeyboardInterrupt):
|
||||
self.msg("In exit")
|
||||
# terminate all child processes
|
||||
if multiprocessing and not self.run_once:
|
||||
children = multiprocessing.active_children()
|
||||
|
||||
for child in children:
|
||||
self.msg("Terminating child %s" % child.pid)
|
||||
child.terminate()
|
||||
|
||||
break
|
||||
except Exception:
|
||||
exc = sys.exc_info()[1]
|
||||
self.msg("handler exception: %s", str(exc))
|
||||
self.vmsg("exception", exc_info=True)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ try: from http.server import HTTPServer
|
||||
except: from BaseHTTPServer import HTTPServer
|
||||
import select
|
||||
from websockify import websocket
|
||||
from websockify import auth_plugins as auth
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
except:
|
||||
@@ -37,20 +38,34 @@ Traffic Legend:
|
||||
< - Client send
|
||||
<. - Client send partial
|
||||
"""
|
||||
|
||||
def send_auth_error(self, ex):
|
||||
self.send_response(ex.code, ex.msg)
|
||||
self.send_header('Content-Type', 'text/html')
|
||||
for name, val in ex.headers.items():
|
||||
self.send_header(name, val)
|
||||
|
||||
self.end_headers()
|
||||
|
||||
def validate_connection(self):
|
||||
if self.server.token_plugin:
|
||||
(self.server.target_host, self.server.target_port) = self.get_target(self.server.token_plugin, self.path)
|
||||
|
||||
if self.server.auth_plugin:
|
||||
try:
|
||||
self.server.auth_plugin.authenticate(
|
||||
headers=self.headers, target_host=self.server.target_host,
|
||||
target_port=self.server.target_port)
|
||||
except auth.AuthenticationError:
|
||||
ex = sys.exc_info()[1]
|
||||
self.send_auth_error(ex)
|
||||
raise
|
||||
|
||||
def new_websocket_client(self):
|
||||
"""
|
||||
Called after a new WebSocket connection has been established.
|
||||
"""
|
||||
# Checks if we receive a token, and look
|
||||
# for a valid target for it then
|
||||
if self.server.token_plugin:
|
||||
(self.server.target_host, self.server.target_port) = self.get_target(self.server.token_plugin, self.path)
|
||||
|
||||
if self.server.auth_plugin:
|
||||
self.server.auth_plugin.authenticate(
|
||||
headers=self.headers, target_host=self.server.target_host,
|
||||
target_port=self.server.target_port)
|
||||
# Checking for a token is done in validate_connection()
|
||||
|
||||
# Connect to the target
|
||||
if self.server.wrap_cmd:
|
||||
@@ -396,9 +411,22 @@ def websockify_init():
|
||||
help="Automatically respond to ping frames with a pong")
|
||||
parser.add_option("--heartbeat", type=int, default=0,
|
||||
help="send a ping to the client every HEARTBEAT seconds")
|
||||
parser.add_option("--log-file", metavar="FILE",
|
||||
dest="log_file",
|
||||
help="File where logs will be saved")
|
||||
|
||||
|
||||
(opts, args) = parser.parse_args()
|
||||
|
||||
if opts.log_file:
|
||||
opts.log_file = os.path.abspath(opts.log_file)
|
||||
handler = logging.FileHandler(opts.log_file)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logging.getLogger(WebSocketProxy.log_prefix).addHandler(handler)
|
||||
|
||||
del opts.log_file
|
||||
|
||||
if opts.verbose:
|
||||
logging.getLogger(WebSocketProxy.log_prefix).setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user