source: etherws/trunk/etherws.py @ 156

Revision 156, 9.7 KB checked in by atzm, 13 years ago (diff)
  • server cert verification support
  • Property svn:keywords set to Id
Line 
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3#
4#              Ethernet over WebSocket tunneling server/client
5#
6# depends on:
7#   - python-2.7.2
8#   - python-pytun-0.2
9#   - websocket-client-0.7.0
10#   - tornado-2.2.1
11#
12# todo:
13#   - servant mode support (like typical p2p software)
14#
15# ===========================================================================
16# Copyright (c) 2012, Atzm WATANABE <atzm@atzm.org>
17# All rights reserved.
18#
19# Redistribution and use in source and binary forms, with or without
20# modification, are permitted provided that the following conditions are met:
21#
22# 1. Redistributions of source code must retain the above copyright notice,
23#    this list of conditions and the following disclaimer.
24# 2. Redistributions in binary form must reproduce the above copyright
25#    notice, this list of conditions and the following disclaimer in the
26#    documentation and/or other materials provided with the distribution.
27#
28# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
29# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
30# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
31# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
32# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
33# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
34# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
35# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
36# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
37# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
38# POSSIBILITY OF SUCH DAMAGE.
39# ===========================================================================
40#
41# $Id$
42
43import os
44import sys
45import ssl
46import base64
47import hashlib
48import getpass
49import argparse
50import threading
51
52import pytun
53import websocket
54import tornado.httpserver
55import tornado.ioloop
56import tornado.web
57import tornado.websocket
58
59
60class TapHandler(object):
61    def __init__(self, dev, debug=False):
62        self._debug = debug
63        self._clients = []
64        self._tap = pytun.TunTapDevice(dev, pytun.IFF_TAP | pytun.IFF_NO_PI)
65        self._tap.up()
66        self._write_lock = threading.Lock()
67
68    def fileno(self):
69        return self._tap.fileno()
70
71    def register_client(self, client):
72        self._clients.append(client)
73
74    def unregister_client(self, client):
75        self._clients.remove(client)
76
77    def write(self, caller, message):
78        if self._debug:
79            sys.stderr.write('%s: %s\n' % (caller.__class__.__name__,
80                                           message.encode('hex')))
81        with self._write_lock:
82            clients = self._clients[:]
83
84            if caller is not self:
85                clients.remove(caller)
86                self._tap.write(message)
87
88            for c in clients:
89                c.write_message(message, True)
90
91    def __call__(self, fd, events):
92        self.write(self, self._tap.read(self._tap.mtu))
93
94
95class EtherWebSocket(tornado.websocket.WebSocketHandler):
96    def __init__(self, app, req, tap, debug=False):
97        super(EtherWebSocket, self).__init__(app, req)
98        self._tap = tap
99        self._debug = debug
100
101    def open(self):
102        self._tap.register_client(self)
103
104    def on_message(self, message):
105        self._tap.write(self, message)
106
107    def on_close(self):
108        self._tap.unregister_client(self)
109
110
111class  EtherWebSocketClient(object):
112    def __init__(self, tap, url, user=None, passwd=None):
113        self._sock = None
114        self._tap = tap
115        self._url = url
116        self._options = {}
117
118        if user and passwd:
119            token = base64.b64encode('%s:%s' % (user, passwd))
120            auth = ['Authorization: Basic %s' % token]
121            self._options['header'] = auth
122
123    def open(self):
124        self._sock = websocket.WebSocket()
125        self._sock.connect(self._url, **self._options)
126
127    def close(self):
128        self._sock.close()
129        self._sock = None
130
131    def write_message(self, message, binary=False):
132        flag = websocket.ABNF.OPCODE_TEXT
133        if binary:
134            flag = websocket.ABNF.OPCODE_BINARY
135        self._sock.send(message, flag)
136
137    def run_forever(self):
138        try:
139            if not self._sock:
140                self.open()
141            while True:
142                data = self._sock.recv()
143                if data is None:
144                    break
145                self._tap.write(self, data)
146        finally:
147            self.close()
148
149
150def daemonize(nochdir=False, noclose=False):
151    if os.fork() > 0:
152        sys.exit(0)
153
154    os.setsid()
155
156    if os.fork() > 0:
157        sys.exit(0)
158
159    if not nochdir:
160        os.chdir('/')
161
162    if not noclose:
163        os.umask(0)
164        sys.stdin.close()
165        sys.stdout.close()
166        sys.stderr.close()
167        os.close(0)
168        os.close(1)
169        os.close(2)
170        sys.stdin = open(os.devnull)
171        sys.stdout = open(os.devnull, 'a')
172        sys.stderr = open(os.devnull, 'a')
173
174
175def server_main(args):
176    def may_auth_required(cls, users):
177        if not users:
178            return cls
179
180        orig_execute = cls._execute
181
182        def _execute(self, transforms, *args, **kwargs):
183            def auth_required():
184                self.stream.write(tornado.escape.utf8(
185                    'HTTP/1.1 401 Authorization Required\r\n'
186                    'WWW-Authenticate: Basic realm=etherws\r\n\r\n'
187                ))
188                self.stream.close()
189
190            try:
191                creds = self.request.headers.get('Authorization')
192
193                if not creds or not creds.startswith('Basic '):
194                    return auth_required()
195
196                creds = base64.b64decode(creds[6:])
197
198                if creds.find(':') < 0:
199                    return auth_required()
200
201                name, passwd = creds.split(':', 2)
202                passwd = base64.b64encode(hashlib.sha1(passwd).digest())
203
204                if name not in users or users[name] != passwd:
205                    return auth_required()
206
207                return orig_execute(self, transforms, *args, **kwargs)
208
209            except:
210                return auth_required()
211
212        cls._execute = _execute
213        return cls
214
215    def load_htpasswd(path):
216        users = {}
217        try:
218            with open(path) as fp:
219                for line in fp:
220                    line = line.strip()
221                    if 0 <= line.find(':'):
222                        name, passwd = line.split(':', 2)
223                        if passwd.startswith('{SHA}'):
224                            users[name] = passwd[5:]
225            if not users:
226                raise RuntimeError('no valid users found')
227        except TypeError:
228            pass
229        return users
230
231    handler = may_auth_required(EtherWebSocket, load_htpasswd(args.htpasswd))
232    ssl_options = {}
233
234    for k in ['keyfile', 'certfile']:
235        v = getattr(args, k, None)
236        if v:
237            v = os.path.realpath(v)
238            ssl_options[k] = v
239            open(v).close()  # readable test
240
241    if len(ssl_options) == 1:
242        raise ValueError('both keyfile and certfile are required')
243    elif not ssl_options:
244        ssl_options = None
245
246    if not args.port:
247        if ssl_options:
248            args.port = 443
249        else:
250            args.port = 80
251
252    tap = TapHandler(args.device, debug=args.debug)
253    app = tornado.web.Application([
254        (args.path, handler, {'tap': tap, 'debug': args.debug}),
255    ])
256    server = tornado.httpserver.HTTPServer(app, ssl_options=ssl_options)
257    server.listen(args.port, address=args.address)
258
259    ioloop = tornado.ioloop.IOLoop.instance()
260    ioloop.add_handler(tap.fileno(), tap, ioloop.READ)
261
262    if not args.foreground:
263        daemonize()
264
265    ioloop.start()
266
267
268def client_main(args):
269    if args.debug:
270        websocket.enableTrace(True)
271
272    if not args.insecure:
273        websocket._SSLSocketWrapper = \
274            lambda s: ssl.wrap_socket(s, cert_reqs=ssl.CERT_REQUIRED,
275                                      ca_certs=args.cacerts)
276
277    passwd = None
278    if args.user:
279        passwd = getpass.getpass()
280
281    tap = TapHandler(args.device, debug=args.debug)
282    client = EtherWebSocketClient(tap, args.uri, args.user, passwd)
283
284    tap.register_client(client)
285    client.open()
286
287    t = threading.Thread(target=client.run_forever)
288    t.setDaemon(True)
289
290    ioloop = tornado.ioloop.IOLoop.instance()
291    ioloop.add_handler(tap.fileno(), tap, ioloop.READ)
292
293    if not args.foreground:
294        daemonize()
295
296    t.start()
297    ioloop.start()
298
299
300def main():
301    parser = argparse.ArgumentParser()
302    parser.add_argument('--device', action='store', default='ethws%d')
303    parser.add_argument('--foreground', action='store_true', default=False)
304    parser.add_argument('--debug', action='store_true', default=False)
305
306    subparsers = parser.add_subparsers(dest='subcommand')
307
308    parser_server = subparsers.add_parser('server')
309    parser_server.add_argument('--address', action='store', default='')
310    parser_server.add_argument('--port', action='store', type=int)
311    parser_server.add_argument('--path', action='store', default='/')
312    parser_server.add_argument('--htpasswd', action='store')
313    parser_server.add_argument('--keyfile', action='store')
314    parser_server.add_argument('--certfile', action='store')
315
316    parser_client = subparsers.add_parser('client')
317    parser_client.add_argument('--uri', action='store', required=True)
318    parser_client.add_argument('--insecure', action='store_true', default=False)
319    parser_client.add_argument('--cacerts', action='store')
320    parser_client.add_argument('--user', action='store')
321
322    args = parser.parse_args()
323
324    if args.subcommand == 'server':
325        server_main(args)
326    elif args.subcommand == 'client':
327        client_main(args)
328
329
330if __name__ == '__main__':
331    main()
Note: See TracBrowser for help on using the repository browser.