Statistics
| Branch: | Tag: | Revision:

root / vncauthproxy.py @ 7183f55d

History | View | Annotate | Download (20 kB)

1
#!/usr/bin/env python
2
#
3
# Copyright (c) 2010-2011 Greek Research and Technology Network S.A.
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 2 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, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13
# 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, write to the Free Software
17
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
18
# 02110-1301, USA.
19

    
20
DEFAULT_CTRL_SOCKET = "/var/run/vncauthproxy/ctrl.sock"
21
DEFAULT_LOG_FILE = "/var/log/vncauthproxy/vncauthproxy.log"
22
DEFAULT_PID_FILE = "/var/run/vncauthproxy/vncauthproxy.pid"
23
DEFAULT_CONNECT_TIMEOUT = 30
24
# Default values per http://www.iana.org/assignments/port-numbers
25
DEFAULT_MIN_PORT = 49152 
26
DEFAULT_MAX_PORT = 65535
27

    
28
import os
29
import sys
30
import logging
31
import gevent
32
import daemon
33
import random
34
import daemon.pidlockfile
35

    
36
import rfb
37
 
38
try:
39
    import simplejson as json
40
except ImportError:
41
    import json
42

    
43
from gevent import socket
44
from signal import SIGINT, SIGTERM
45
from gevent import signal
46
from gevent.select import select
47

    
48
class VncAuthProxy(gevent.Greenlet):
49
    """
50
    Simple class implementing a VNC Forwarder with MITM authentication as a
51
    Greenlet
52

53
    VncAuthProxy forwards VNC traffic from a specified port of the local host
54
    to a specified remote host:port. Furthermore, it implements VNC
55
    Authentication, intercepting the client/server handshake and asking the
56
    client for authentication even if the backend requires none.
57

58
    It is primarily intended for use in virtualization environments, as a VNC
59
    ``switch''.
60

61
    """
62
    id = 1
63

    
64
    def __init__(self, logger, listeners, pool, daddr, dport, password, connect_timeout):
65
        """
66
        @type logger: logging.Logger
67
        @param logger: the logger to use
68
        @type listeners: list
69
        @param listeners: list of listening sockets to use for client connections
70
        @type pool: list
71
        @param pool: if not None, return the client port number into this port pool
72
        @type daddr: str
73
        @param daddr: destination address (IPv4, IPv6 or hostname)
74
        @type dport: int
75
        @param dport: destination port
76
        @type password: str
77
        @param password: password to request from the client
78
        @type connect_timeout: int
79
        @param connect_timeout: how long to wait for client connections
80
                                (seconds)
81

82
        """
83
        gevent.Greenlet.__init__(self)
84
        self.id = VncAuthProxy.id
85
        VncAuthProxy.id += 1
86
        self.log = logger
87
        self.listeners = listeners
88
        # All listening sockets are assumed to be on the same port
89
        self.sport = listeners[0].getsockname()[1]
90
        self.pool = pool
91
        self.daddr = daddr
92
        self.dport = dport
93
        self.password = password
94
        self.server = None
95
        self.client = None
96
        self.timeout = connect_timeout
97

    
98
    def _cleanup(self):
99
        """Close all active sockets and exit gracefully"""
100
        # Reintroduce the port number of the client socket in
101
        # the port pool, if applicable.
102
        if not self.pool is None:
103
            self.pool.append(self.sport)
104
            self.log.debug("Returned port %d to port pool, contains %d ports",
105
                self.sport, len(self.pool))
106

    
107
        while self.listeners:
108
            self.listeners.pop().close()
109
        if self.server:
110
            self.server.close()
111
        if self.client:
112
            self.client.close()
113

    
114
        raise gevent.GreenletExit
115

    
116
    def info(self, msg):
117
        self.log.info("[C%d] %s" % (self.id, msg))
118

    
119
    def debug(self, msg):
120
        self.log.debug("[C%d] %s" % (self.id, msg))
121

    
122
    def warn(self, msg):
123
        self.log.warn("[C%d] %s" % (self.id, msg))
124

    
125
    def error(self, msg):
126
        self.log.error("[C%d] %s" % (self.id, msg))
127

    
128
    def critical(self, msg):
129
        self.log.critical("[C%d] %s" % (self.id, msg))
130

    
131
    def __str__(self):
132
        return "VncAuthProxy: %d -> %s:%d" % (self.sport, self.daddr, self.dport)
133

    
134
    def _forward(self, source, dest):
135
        """
136
        Forward traffic from source to dest
137

138
        @type source: socket
139
        @param source: source socket
140
        @type dest: socket
141
        @param dest: destination socket
142

143
        """
144

    
145
        while True:
146
            d = source.recv(16384)
147
            if d == '':
148
                if source == self.client:
149
                    self.info("Client connection closed")
150
                else:
151
                    self.info("Server connection closed")
152
                break
153
            dest.sendall(d)
154
        # No need to close the source and dest sockets here.
155
        # They are owned by and will be closed by the original greenlet.
156

    
157
    def _handshake(self):
158
        """
159
        Perform handshake/authentication with a connecting client
160

161
        Outline:
162
        1. Client connects
163
        2. We fake RFB 3.8 protocol and require VNC authentication [also supports RFB 3.3]
164
        3. Client accepts authentication method
165
        4. We send an authentication challenge
166
        5. Client sends the authentication response
167
        6. We check the authentication
168
        7. We initiate a connection with the backend server and perform basic
169
           RFB 3.8 handshake with it.
170

171
        Upon return, self.client and self.server are sockets
172
        connected to the client and the backend server, respectively.
173

174
        """
175
        self.client.send(rfb.RFB_VERSION_3_8 + "\n")
176
        client_version_str = self.client.recv(1024)
177
        client_version = rfb.check_version(client_version_str)
178
        if not client_version:
179
            self.error("Invalid version: %s" % client_version_str)
180
            raise gevent.GreenletExit
181

    
182
        # Both for RFB 3.3 and 3.8
183
        self.debug("Requesting authentication")
184
        auth_request = rfb.make_auth_request(rfb.RFB_AUTHTYPE_VNC,
185
            version=client_version)
186
        self.client.send(auth_request)
187

    
188
        # The client gets to propose an authtype only for RFB 3.8
189
        if client_version == rfb.RFB_VERSION_3_8:
190
            res = self.client.recv(1024)
191
            type = rfb.parse_client_authtype(res)
192
            if type == rfb.RFB_AUTHTYPE_ERROR:
193
                self.warn("Client refused authentication: %s" % res[1:])
194
            else:
195
                self.debug("Client requested authtype %x" % type)
196

    
197
            if type != rfb.RFB_AUTHTYPE_VNC:
198
                self.error("Wrong auth type: %d" % type)
199
                self.client.send(rfb.to_u32(rfb.RFB_AUTH_ERROR))
200
                raise gevent.GreenletExit
201
        
202
        # Generate the challenge
203
        challenge = os.urandom(16)
204
        self.client.send(challenge)
205
        response = self.client.recv(1024)
206
        if len(response) != 16:
207
            self.error("Wrong response length %d, should be 16" % len(response))
208
            raise gevent.GreenletExit
209

    
210
        if rfb.check_password(challenge, response, password):
211
            self.debug("Authentication successful!")
212
        else:
213
            self.warn("Authentication failed")
214
            self.client.send(rfb.to_u32(rfb.RFB_AUTH_ERROR))
215
            raise gevent.GreenletExit
216

    
217
        # Accept the authentication
218
        self.client.send(rfb.to_u32(rfb.RFB_AUTH_SUCCESS))
219

    
220
        # Try to connect to the server
221
        tries = 50
222

    
223
        while tries:
224
            tries -= 1
225

    
226
            # Initiate server connection
227
            for res in socket.getaddrinfo(self.daddr, self.dport, socket.AF_UNSPEC,
228
                                          socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
229
                af, socktype, proto, canonname, sa = res
230
                try:
231
                    self.server = socket.socket(af, socktype, proto)
232
                except socket.error, msg:
233
                    self.server = None
234
                    continue
235

    
236
                try:
237
                    self.debug("Connecting to %s:%s" % sa[:2])
238
                    self.server.connect(sa)
239
                    self.debug("Connection to %s:%s successful" % sa[:2])
240
                except socket.error, msg:
241
                    self.server.close()
242
                    self.server = None
243
                    continue
244

    
245
                # We succesfully connected to the server
246
                tries = 0
247
                break
248

    
249
            # Wait and retry
250
            gevent.sleep(0.2)
251

    
252
        if self.server is None:
253
            self.error("Failed to connect to server")
254
            raise gevent.GreenletExit
255

    
256
        version = self.server.recv(1024)
257
        if not rfb.check_version(version):
258
            self.error("Unsupported RFB version: %s" % version.strip())
259
            raise gevent.GreenletExit
260

    
261
        self.server.send(rfb.RFB_VERSION_3_8 + "\n")
262

    
263
        res = self.server.recv(1024)
264
        types = rfb.parse_auth_request(res)
265
        if not types:
266
            self.error("Error handshaking with the server")
267
            raise gevent.GreenletExit
268

    
269
        else:
270
            self.debug("Supported authentication types: %s" %
271
                           " ".join([str(x) for x in types]))
272

    
273
        if rfb.RFB_AUTHTYPE_NONE not in types:
274
            self.error("Error, server demands authentication")
275
            raise gevent.GreenletExit
276

    
277
        self.server.send(rfb.to_u8(rfb.RFB_AUTHTYPE_NONE))
278

    
279
        # Check authentication response
280
        res = self.server.recv(4)
281
        res = rfb.from_u32(res)
282

    
283
        if res != 0:
284
            self.error("Authentication error")
285
            raise gevent.GreenletExit
286
       
287
    def _run(self):
288
        try:
289
            self.log.debug("Waiting for client to connect")
290
            rlist, _, _ = select(listeners, [], [], timeout=self.timeout)
291

    
292
            if not rlist:
293
                self.info("Timed out, no connection after %d sec" % self.timeout)
294
                raise gevent.GreenletExit
295

    
296
            for sock in rlist:
297
                self.client, addrinfo = sock.accept()
298
                self.info("Connection from %s:%d" % addrinfo[:2])
299

    
300
                # Close all listening sockets, we only want a one-shot connection
301
                # from a single client.
302
                while self.listeners:
303
                    self.listeners.pop().close()
304
                break
305
       
306
            # Perform RFB handshake with the client and the backend server.
307
            # If all goes as planned, we have two connected sockets,
308
            # self.client and self.server.
309
            self._handshake()
310

    
311
            # Bridge both connections through two "forwarder" greenlets.
312
            self.workers = [gevent.spawn(self._forward, self.client, self.server),
313
                gevent.spawn(self._forward, self.server, self.client)]
314
            
315
            # If one greenlet goes, the other has to go too.
316
            self.workers[0].link(self.workers[1])
317
            self.workers[1].link(self.workers[0])
318
            gevent.joinall(self.workers)
319
            del self.workers
320
            raise gevent.GreenletExit
321
        except Exception, e:
322
            # Any unhandled exception in the previous block
323
            # is an error and must be logged accordingly
324
            if not isinstance(e, gevent.GreenletExit):
325
                self.log.exception(e)
326
            raise e
327
        finally:
328
            self._cleanup()
329

    
330

    
331
def fatal_signal_handler(signame):
332
    logger.info("Caught %s, will raise SystemExit" % signame)
333
    raise SystemExit
334

    
335
def get_listening_sockets(sport):
336
    sockets = []
337

    
338
    # Use two sockets, one for IPv4, one for IPv6. IPv4-to-IPv6 mapped
339
    # addresses do not work reliably everywhere (under linux it may have
340
    # been disabled in /proc/sys/net/ipv6/bind_ipv6_only).
341
    for res in socket.getaddrinfo(None, sport, socket.AF_UNSPEC,
342
                                  socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
343
        af, socktype, proto, canonname, sa = res
344
        try:
345
            s = None
346
            s = socket.socket(af, socktype, proto)
347
            if af == socket.AF_INET6:
348
                # Bind v6 only when AF_INET6, otherwise either v4 or v6 bind
349
                # will fail.
350
                s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
351
            s.bind(sa)
352
            s.listen(1)
353
            sockets.append(s)
354
            logger.debug("Listening on %s:%d" % sa[:2])
355
        except socket.error, msg:
356
            logger.error("Error binding to %s:%d: %s" %
357
                           (sa[0], sa[1], msg[1]))
358
            if s:
359
                s.close()
360
            while sockets:
361
                sockets.pop().close()
362
            
363
            # Make sure we fail immediately if we cannot get a socket
364
            raise msg
365
    
366
    return sockets
367

    
368
def parse_arguments(args):
369
    from optparse import OptionParser
370

    
371
    parser = OptionParser()
372
    parser.add_option("-s", "--socket", dest="ctrl_socket",
373
                      default=DEFAULT_CTRL_SOCKET,
374
                      metavar="PATH",
375
                      help="UNIX socket path for control connections (default: %s" %
376
                          DEFAULT_CTRL_SOCKET)
377
    parser.add_option("-d", "--debug", action="store_true", dest="debug",
378
                      help="Enable debugging information")
379
    parser.add_option("-l", "--log", dest="log_file",
380
                      default=DEFAULT_LOG_FILE,
381
                      metavar="FILE",
382
                      help="Write log to FILE instead of %s" % DEFAULT_LOG_FILE),
383
    parser.add_option('--pid-file', dest="pid_file",
384
                      default=DEFAULT_PID_FILE,
385
                      metavar='PIDFILE',
386
                      help="Save PID to file (default: %s)" %
387
                          DEFAULT_PID_FILE)
388
    parser.add_option("-t", "--connect-timeout", dest="connect_timeout",
389
                      default=DEFAULT_CONNECT_TIMEOUT, type="int", metavar="SECONDS",
390
                      help="How long to listen for clients to forward")
391
    parser.add_option("-p", "--min-port", dest="min_port",
392
                      default=DEFAULT_MIN_PORT, type="int", metavar="MIN_PORT",
393
                      help="The minimum port to use for automatically-allocated ephemeral ports")
394
    parser.add_option("-P", "--max-port", dest="max_port",
395
                      default=DEFAULT_MAX_PORT, type="int", metavar="MAX_PORT",
396
                      help="The minimum port to use for automatically-allocated ephemeral ports")
397

    
398
    return parser.parse_args(args)
399

    
400

    
401
if __name__ == '__main__':
402
    (opts, args) = parse_arguments(sys.argv[1:])
403

    
404
    # Create pidfile
405
    pidf = daemon.pidlockfile.TimeoutPIDLockFile(
406
        opts.pid_file, 10)
407
    
408
    # Initialize logger
409
    lvl = logging.DEBUG if opts.debug else logging.INFO
410
    logger = logging.getLogger("vncauthproxy")
411
    logger.setLevel(lvl)
412
    formatter = logging.Formatter("%(asctime)s %(module)s[%(process)d] %(levelname)s: %(message)s",
413
        "%Y-%m-%d %H:%M:%S")
414
    handler = logging.FileHandler(opts.log_file)
415
    handler.setFormatter(formatter)
416
    logger.addHandler(handler)
417

    
418
    # Become a daemon:
419
    # Redirect stdout and stderr to handler.stream to catch
420
    # early errors in the daemonization process [e.g., pidfile creation]
421
    # which will otherwise go to /dev/null.
422
    daemon_context = daemon.DaemonContext(
423
        pidfile=pidf,
424
        umask=0o0022,
425
        stdout=handler.stream,
426
        stderr=handler.stream,
427
        files_preserve=[handler.stream])
428
    daemon_context.open()
429
    logger.info("Became a daemon")
430

    
431
    # A fork() has occured while daemonizing,
432
    # we *must* reinit gevent
433
    gevent.reinit()
434

    
435
    if os.path.exists(opts.ctrl_socket):
436
        logger.critical("Socket '%s' already exists" % opts.ctrl_socket)
437
        sys.exit(1)
438

    
439
    # TODO: make this tunable? chgrp as well?
440
    old_umask = os.umask(0077)
441

    
442
    ctrl = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
443
    ctrl.bind(opts.ctrl_socket)
444

    
445
    os.umask(old_umask)
446

    
447
    ctrl.listen(1)
448
    logger.info("Initialized, waiting for control connections at %s" %
449
                 opts.ctrl_socket)
450

    
451
    # Catch signals to ensure graceful shutdown,
452
    # e.g., to make sure the control socket gets unlink()ed.
453
    #
454
    # Uses gevent.signal so the handler fires even during
455
    # gevent.socket.accept()
456
    gevent.signal(SIGINT, fatal_signal_handler, "SIGINT")
457
    gevent.signal(SIGTERM, fatal_signal_handler, "SIGTERM")
458

    
459
    # Init ephemeral port pool
460
    ports = range(opts.min_port, opts.max_port + 1) 
461

    
462
    while True:
463
        try:
464
            client, addr = ctrl.accept()
465
            logger.info("New control connection")
466
           
467
            # Receive and parse a client request.
468
            response = {
469
                "source_port": 0,
470
                "status": "FAILED"
471
            }
472
            try:
473
                # TODO: support multiple forwardings in the same message?
474
                # 
475
                # Control request, in JSON:
476
                #
477
                # {
478
                #     "source_port": <source port or 0 for automatic allocation>,
479
                #     "destination_address": <destination address of backend server>,
480
                #     "destination_port": <destination port>
481
                #     "password": <the password to use for MITM authentication of clients>
482
                # }
483
                # 
484
                # The <password> is used for MITM authentication of clients
485
                # connecting to <source_port>, who will subsequently be forwarded
486
                # to a VNC server at <destination_address>:<destination_port>
487
                #
488
                # Control reply, in JSON:
489
                # {
490
                #     "source_port": <the allocated source port>
491
                #     "status": <one of "OK" or "FAILED">
492
                # }
493
                buf = client.recv(1024)
494
                req = json.loads(buf)
495
                
496
                sport_orig = int(req['source_port'])
497
                daddr = req['destination_address']
498
                dport = int(req['destination_port'])
499
                password = req['password']
500
            except Exception, e:
501
                logger.warn("Malformed request: %s" % buf)
502
                client.send(json.dumps(response))
503
                client.close()
504
                continue
505
            
506
            # Spawn a new Greenlet to service the request.
507
            try:
508
                # If the client has so indicated, pick an ephemeral source port
509
                # randomly, and remove it from the port pool.
510
                if sport_orig == 0:
511
                    sport = random.choice(ports)
512
                    ports.remove(sport)
513
                    logger.debug("Got port %d from port pool, contains %d ports",
514
                        sport, len(ports))
515
                    pool = ports
516
                else:
517
                    sport = sport_orig
518
                    pool = None
519
                listeners = get_listening_sockets(sport)
520
                VncAuthProxy.spawn(logger, listeners, pool, daddr, dport,
521
                    password, opts.connect_timeout)
522
                logger.info("New forwarding [%d (req'd by client: %d) -> %s:%d]" %
523
                    (sport, sport_orig, daddr, dport))
524
                response = {
525
                    "source_port": sport,
526
                    "status": "OK"
527
                }
528
            except IndexError:
529
                logger.error("FAILED forwarding, out of ports for [req'd by "
530
                    "client: %d -> %s:%d]" % (sport_orig, daddr, dport))
531
            except socket.error, msg:
532
                logger.error("FAILED forwarding [%d (req'd by client: %d) -> %s:%d]" %
533
                    (sport, sport_orig, daddr, dport))
534
                if not pool is None:
535
                    pool.append(sport)
536
                    logger.debug("Returned port %d to port pool, contains %d ports",
537
                        sport, len(pool))
538
            finally:
539
                client.send(json.dumps(response))
540
                client.close()
541
        except Exception, e:
542
            logger.exception(e)
543
            continue
544
        except SystemExit:
545
            break
546
 
547
    logger.info("Unlinking control socket at %s" %
548
                 opts.ctrl_socket)
549
    os.unlink(opts.ctrl_socket)
550
    daemon_context.close()
551
    sys.exit(0)