source: etherws/tags/release-0.5/etherws.py @ 161

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