source: etherws/trunk/etherws.py @ 162

Revision 162, 11.1 KB checked in by atzm, 12 years ago (diff)
  • improve performance
  • 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 time
47import base64
48import hashlib
49import getpass
50import argparse
51import threading
52
53import pytun
54import websocket
55import tornado.web
56import tornado.ioloop
57import tornado.websocket
58import tornado.httpserver
59
60
61class DebugMixIn(object):
62    def dprintf(self, msg, *args):
63        if self._debug:
64            prefix = '[%s] %s - ' % (time.asctime(), self.__class__.__name__)
65            sys.stderr.write(prefix + (msg % args))
66
67
68class TapHandler(DebugMixIn):
69    READ_SIZE = 65535
70
71    def __init__(self, dev, debug=False):
72        self._debug = debug
73        self._clients = []
74        self._tap = pytun.TunTapDevice(dev, pytun.IFF_TAP | pytun.IFF_NO_PI)
75        self._tap.up()
76        self._taplock = threading.Lock()
77
78    def fileno(self):
79        with self._taplock:
80            return self._tap.fileno()
81
82    def register_client(self, client):
83        self._clients.append(client)
84
85    def unregister_client(self, client):
86        self._clients.remove(client)
87
88    def write(self, caller, message):
89        clients = self._clients[:]
90
91        if caller is not self:
92            clients.remove(caller)
93
94            with self._taplock:
95                self._tap.write(message)
96
97        for c in clients:
98            c.write_message(message, True)
99
100    def __call__(self, fd, events):
101        buf = []
102
103        while True:
104            with self._taplock:
105                data = self._tap.read(self.READ_SIZE)
106
107            if data:
108                buf.append(data)
109
110            if len(data) < self.READ_SIZE:
111                break
112
113        self.write(self, ''.join(buf))
114
115
116class EtherWebSocketHandler(tornado.websocket.WebSocketHandler, DebugMixIn):
117    def __init__(self, app, req, tap, debug=False):
118        super(EtherWebSocketHandler, self).__init__(app, req)
119        self._tap = tap
120        self._debug = debug
121
122    def open(self):
123        self._tap.register_client(self)
124        self.dprintf('connected: %s\n', self.request.remote_ip)
125
126    def on_message(self, message):
127        self._tap.write(self, message)
128        self.dprintf('received: %s %s\n',
129                     self.request.remote_ip, message.encode('hex'))
130
131    def on_close(self):
132        self._tap.unregister_client(self)
133        self.dprintf('disconnected: %s\n', self.request.remote_ip)
134
135
136class EtherWebSocketClient(DebugMixIn):
137    def __init__(self, tap, url, user=None, passwd=None, debug=False):
138        self._sock = None
139        self._tap = tap
140        self._url = url
141        self._debug = debug
142        self._options = {}
143
144        if user and passwd:
145            token = base64.b64encode('%s:%s' % (user, passwd))
146            auth = ['Authorization: Basic %s' % token]
147            self._options['header'] = auth
148
149    @property
150    def closed(self):
151        return not self._sock
152
153    def open(self):
154        if not self.closed:
155            raise websocket.WebSocketException('already opened')
156        self._sock = websocket.WebSocket()
157        self._sock.connect(self._url, **self._options)
158        self.dprintf('connected: %s\n', self._url)
159
160    def close(self):
161        if self.closed:
162            raise websocket.WebSocketException('already closed')
163        self._sock.close()
164        self._sock = None
165        self.dprintf('disconnected: %s\n', self._url)
166
167    def write_message(self, message, binary=False):
168        if self.closed:
169            raise websocket.WebSocketException('closed socket')
170        if binary:
171            flag = websocket.ABNF.OPCODE_BINARY
172        else:
173            flag = websocket.ABNF.OPCODE_TEXT
174        self._sock.send(message, flag)
175        self.dprintf('sent: %s %s\n', self._url, message.encode('hex'))
176
177    def run_forever(self):
178        try:
179            if self.closed:
180                self.open()
181            while True:
182                data = self._sock.recv()
183                if data is None:
184                    break
185                self._tap.write(self, data)
186        finally:
187            self.close()
188
189
190def daemonize(nochdir=False, noclose=False):
191    if os.fork() > 0:
192        sys.exit(0)
193
194    os.setsid()
195
196    if os.fork() > 0:
197        sys.exit(0)
198
199    if not nochdir:
200        os.chdir('/')
201
202    if not noclose:
203        os.umask(0)
204        sys.stdin.close()
205        sys.stdout.close()
206        sys.stderr.close()
207        os.close(0)
208        os.close(1)
209        os.close(2)
210        sys.stdin = open(os.devnull)
211        sys.stdout = open(os.devnull, 'a')
212        sys.stderr = open(os.devnull, 'a')
213
214
215def realpath(ns, *keys):
216    for k in keys:
217        v = getattr(ns, k, None)
218        if v is not None:
219            v = os.path.realpath(v)
220            setattr(ns, k, v)
221            open(v).close()  # check readable
222    return ns
223
224
225def server_main(args):
226    def wrap_basic_auth(cls, users):
227        o_exec = cls._execute
228
229        if not users:
230            return cls
231
232        def execute(self, transforms, *args, **kwargs):
233            def auth_required():
234                self.stream.write(tornado.escape.utf8(
235                    'HTTP/1.1 401 Authorization Required\r\n'
236                    'WWW-Authenticate: Basic realm=etherws\r\n\r\n'
237                ))
238                self.stream.close()
239
240            creds = self.request.headers.get('Authorization')
241
242            if not creds or not creds.startswith('Basic '):
243                return auth_required()
244
245            try:
246                name, passwd = base64.b64decode(creds[6:]).split(':', 1)
247                passwd = base64.b64encode(hashlib.sha1(passwd).digest())
248
249                if name not in users or users[name] != passwd:
250                    return auth_required()
251
252                return o_exec(self, transforms, *args, **kwargs)
253
254            except:
255                return auth_required()
256
257        cls._execute = execute
258        return cls
259
260    def load_htpasswd(path):
261        users = {}
262        try:
263            with open(path) as fp:
264                for line in fp:
265                    line = line.strip()
266                    if 0 <= line.find(':'):
267                        name, passwd = line.split(':', 1)
268                        if passwd.startswith('{SHA}'):
269                            users[name] = passwd[5:]
270            if not users:
271                raise ValueError('no valid users found')
272        except TypeError:
273            pass
274        return users
275
276    realpath(args, 'keyfile', 'certfile', 'htpasswd')
277
278    if args.keyfile and args.certfile:
279        ssl_options = {'keyfile': args.keyfile, 'certfile': args.certfile}
280    elif args.keyfile or args.certfile:
281        raise ValueError('both keyfile and certfile are required')
282    else:
283        ssl_options = None
284
285    if args.port is None:
286        if ssl_options:
287            args.port = 443
288        else:
289            args.port = 80
290    elif not (0 <= args.port <= 65535):
291        raise ValueError('invalid port: %s' % args.port)
292
293    handler = wrap_basic_auth(EtherWebSocketHandler,
294                              load_htpasswd(args.htpasswd))
295
296    tap = TapHandler(args.device, debug=args.debug)
297    app = tornado.web.Application([
298        (args.path, handler, {'tap': tap, 'debug': args.debug}),
299    ])
300    server = tornado.httpserver.HTTPServer(app, ssl_options=ssl_options)
301    server.listen(args.port, address=args.address)
302
303    ioloop = tornado.ioloop.IOLoop.instance()
304    ioloop.add_handler(tap.fileno(), tap, ioloop.READ)
305
306    if not args.foreground:
307        daemonize()
308
309    ioloop.start()
310
311
312def client_main(args):
313    realpath(args, 'cacerts')
314
315    if args.debug:
316        websocket.enableTrace(True)
317
318    if not args.insecure:
319        websocket._SSLSocketWrapper = \
320            lambda s: ssl.wrap_socket(s, cert_reqs=ssl.CERT_REQUIRED,
321                                      ca_certs=args.cacerts)
322
323    if args.user and args.passwd is None:
324        args.passwd = getpass.getpass()
325
326    tap = TapHandler(args.device, debug=args.debug)
327    client = EtherWebSocketClient(tap, args.uri,
328                                  args.user, args.passwd, args.debug)
329
330    tap.register_client(client)
331    client.open()
332
333    ioloop = tornado.ioloop.IOLoop.instance()
334    ioloop.add_handler(tap.fileno(), tap, ioloop.READ)
335
336    t = threading.Thread(target=ioloop.start)
337    t.setDaemon(True)
338
339    if not args.foreground:
340        daemonize()
341
342    t.start()
343    client.run_forever()
344
345
346def main():
347    parser = argparse.ArgumentParser()
348    parser.add_argument('--device', action='store', default='ethws%d')
349    parser.add_argument('--foreground', action='store_true', default=False)
350    parser.add_argument('--debug', action='store_true', default=False)
351
352    subparsers = parser.add_subparsers(dest='subcommand')
353
354    parser_s = subparsers.add_parser('server')
355    parser_s.add_argument('--address', action='store', default='')
356    parser_s.add_argument('--port', action='store', type=int)
357    parser_s.add_argument('--path', action='store', default='/')
358    parser_s.add_argument('--htpasswd', action='store')
359    parser_s.add_argument('--keyfile', action='store')
360    parser_s.add_argument('--certfile', action='store')
361
362    parser_c = subparsers.add_parser('client')
363    parser_c.add_argument('--uri', action='store', required=True)
364    parser_c.add_argument('--insecure', action='store_true', default=False)
365    parser_c.add_argument('--cacerts', action='store')
366    parser_c.add_argument('--user', action='store')
367    parser_c.add_argument('--passwd', action='store')
368
369    args = parser.parse_args()
370
371    if args.subcommand == 'server':
372        server_main(args)
373    elif args.subcommand == 'client':
374        client_main(args)
375
376
377if __name__ == '__main__':
378    main()
Note: See TracBrowser for help on using the repository browser.