Statistics
| Branch: | Tag: | Revision:

root / vncauthproxy / proxy.py @ 5a196d84

History | View | Annotate | Download (20.1 kB)

1
#!/usr/bin/env python
2
"""
3
vncauthproxy - a VNC authentication proxy
4
"""
5
#
6
# Copyright (c) 2010-2011 Greek Research and Technology Network S.A.
7
#
8
# This program is free software; you can redistribute it and/or modify
9
# it under the terms of the GNU General Public License as published by
10
# the Free Software Foundation; either version 2 of the License, or
11
# (at your option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful, but
14
# WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16
# General Public License for more details.
17
#
18
# You should have received a copy of the GNU General Public License
19
# along with this program; if not, write to the Free Software
20
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
21
# 02110-1301, USA.
22

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

    
31
import os
32
import sys
33
import logging
34
import gevent
35
import daemon
36
import random
37
import daemon.pidlockfile
38

    
39
import rfb
40
 
41
try:
42
    import simplejson as json
43
except ImportError:
44
    import json
45

    
46
from gevent import socket
47
from signal import SIGINT, SIGTERM
48
from gevent import signal
49
from gevent.select import select
50

    
51
logger = None
52

    
53
class VncAuthProxy(gevent.Greenlet):
54
    """
55
    Simple class implementing a VNC Forwarder with MITM authentication as a
56
    Greenlet
57

58
    VncAuthProxy forwards VNC traffic from a specified port of the local host
59
    to a specified remote host:port. Furthermore, it implements VNC
60
    Authentication, intercepting the client/server handshake and asking the
61
    client for authentication even if the backend requires none.
62

63
    It is primarily intended for use in virtualization environments, as a VNC
64
    ``switch''.
65

66
    """
67
    id = 1
68

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

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

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

    
112
        while self.listeners:
113
            self.listeners.pop().close()
114
        if self.server:
115
            self.server.close()
116
        if self.client:
117
            self.client.close()
118

    
119
        raise gevent.GreenletExit
120

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

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

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

    
130
    def error(self, msg):
131
        self.log.error("[C%d] %s" % (self.id, msg))
132

    
133
    def critical(self, msg):
134
        self.log.critical("[C%d] %s" % (self.id, msg))
135

    
136
    def __str__(self):
137
        return "VncAuthProxy: %d -> %s:%d" % (self.sport, self.daddr, self.dport)
138

    
139
    def _forward(self, source, dest):
140
        """
141
        Forward traffic from source to dest
142

143
        @type source: socket
144
        @param source: source socket
145
        @type dest: socket
146
        @param dest: destination socket
147

148
        """
149

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

    
162
    def _handshake(self):
163
        """
164
        Perform handshake/authentication with a connecting client
165

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

176
        Upon return, self.client and self.server are sockets
177
        connected to the client and the backend server, respectively.
178

179
        """
180
        self.client.send(rfb.RFB_VERSION_3_8 + "\n")
181
        client_version_str = self.client.recv(1024)
182
        client_version = rfb.check_version(client_version_str)
183
        if not client_version:
184
            self.error("Invalid version: %s" % client_version_str)
185
            raise gevent.GreenletExit
186

    
187
        # Both for RFB 3.3 and 3.8
188
        self.debug("Requesting authentication")
189
        auth_request = rfb.make_auth_request(rfb.RFB_AUTHTYPE_VNC,
190
            version=client_version)
191
        self.client.send(auth_request)
192

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

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

    
215
        if rfb.check_password(challenge, response, self.password):
216
            self.debug("Authentication successful!")
217
        else:
218
            self.warn("Authentication failed")
219
            self.client.send(rfb.to_u32(rfb.RFB_AUTH_ERROR))
220
            raise gevent.GreenletExit
221

    
222
        # Accept the authentication
223
        self.client.send(rfb.to_u32(rfb.RFB_AUTH_SUCCESS))
224

    
225
        # Try to connect to the server
226
        tries = 50
227

    
228
        while tries:
229
            tries -= 1
230

    
231
            # Initiate server connection
232
            for res in socket.getaddrinfo(self.daddr, self.dport, socket.AF_UNSPEC,
233
                                          socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
234
                af, socktype, proto, canonname, sa = res
235
                try:
236
                    self.server = socket.socket(af, socktype, proto)
237
                except socket.error, msg:
238
                    self.server = None
239
                    continue
240

    
241
                try:
242
                    self.debug("Connecting to %s:%s" % sa[:2])
243
                    self.server.connect(sa)
244
                    self.debug("Connection to %s:%s successful" % sa[:2])
245
                except socket.error, msg:
246
                    self.server.close()
247
                    self.server = None
248
                    continue
249

    
250
                # We succesfully connected to the server
251
                tries = 0
252
                break
253

    
254
            # Wait and retry
255
            gevent.sleep(0.2)
256

    
257
        if self.server is None:
258
            self.error("Failed to connect to server")
259
            raise gevent.GreenletExit
260

    
261
        version = self.server.recv(1024)
262
        if not rfb.check_version(version):
263
            self.error("Unsupported RFB version: %s" % version.strip())
264
            raise gevent.GreenletExit
265

    
266
        self.server.send(rfb.RFB_VERSION_3_8 + "\n")
267

    
268
        res = self.server.recv(1024)
269
        types = rfb.parse_auth_request(res)
270
        if not types:
271
            self.error("Error handshaking with the server")
272
            raise gevent.GreenletExit
273

    
274
        else:
275
            self.debug("Supported authentication types: %s" %
276
                           " ".join([str(x) for x in types]))
277

    
278
        if rfb.RFB_AUTHTYPE_NONE not in types:
279
            self.error("Error, server demands authentication")
280
            raise gevent.GreenletExit
281

    
282
        self.server.send(rfb.to_u8(rfb.RFB_AUTHTYPE_NONE))
283

    
284
        # Check authentication response
285
        res = self.server.recv(4)
286
        res = rfb.from_u32(res)
287

    
288
        if res != 0:
289
            self.error("Authentication error")
290
            raise gevent.GreenletExit
291
       
292
    def _run(self):
293
        try:
294
            self.log.debug("Waiting for client to connect")
295
            rlist, _, _ = select(self.listeners, [], [], timeout=self.timeout)
296

    
297
            if not rlist:
298
                self.info("Timed out, no connection after %d sec" % self.timeout)
299
                raise gevent.GreenletExit
300

    
301
            for sock in rlist:
302
                self.client, addrinfo = sock.accept()
303
                self.info("Connection from %s:%d" % addrinfo[:2])
304

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

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

    
335

    
336
def fatal_signal_handler(signame):
337
    logger.info("Caught %s, will raise SystemExit" % signame)
338
    raise SystemExit
339

    
340
def get_listening_sockets(sport):
341
    sockets = []
342

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

    
373
def parse_arguments(args):
374
    from optparse import OptionParser
375

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

    
403
    return parser.parse_args(args)
404

    
405

    
406
def main():
407
    """Run the daemon from the command line."""
408

    
409
    (opts, args) = parse_arguments(sys.argv[1:])
410

    
411
    # Create pidfile
412
    pidf = daemon.pidlockfile.TimeoutPIDLockFile(
413
        opts.pid_file, 10)
414
    
415
    # Initialize logger
416
    lvl = logging.DEBUG if opts.debug else logging.INFO
417

    
418
    global logger
419
    logger = logging.getLogger("vncauthproxy")
420
    logger.setLevel(lvl)
421
    formatter = logging.Formatter("%(asctime)s %(module)s[%(process)d] %(levelname)s: %(message)s",
422
        "%Y-%m-%d %H:%M:%S")
423
    handler = logging.FileHandler(opts.log_file)
424
    handler.setFormatter(formatter)
425
    logger.addHandler(handler)
426

    
427
    # Become a daemon:
428
    # Redirect stdout and stderr to handler.stream to catch
429
    # early errors in the daemonization process [e.g., pidfile creation]
430
    # which will otherwise go to /dev/null.
431
    daemon_context = daemon.DaemonContext(
432
        pidfile=pidf,
433
        umask=0022,
434
        stdout=handler.stream,
435
        stderr=handler.stream,
436
        files_preserve=[handler.stream])
437
    daemon_context.open()
438
    logger.info("Became a daemon")
439

    
440
    # A fork() has occured while daemonizing,
441
    # we *must* reinit gevent
442
    gevent.reinit()
443

    
444
    if os.path.exists(opts.ctrl_socket):
445
        logger.critical("Socket '%s' already exists" % opts.ctrl_socket)
446
        sys.exit(1)
447

    
448
    # TODO: make this tunable? chgrp as well?
449
    old_umask = os.umask(0007)
450

    
451
    ctrl = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
452
    ctrl.bind(opts.ctrl_socket)
453

    
454
    os.umask(old_umask)
455

    
456
    ctrl.listen(1)
457
    logger.info("Initialized, waiting for control connections at %s" %
458
                 opts.ctrl_socket)
459

    
460
    # Catch signals to ensure graceful shutdown,
461
    # e.g., to make sure the control socket gets unlink()ed.
462
    #
463
    # Uses gevent.signal so the handler fires even during
464
    # gevent.socket.accept()
465
    gevent.signal(SIGINT, fatal_signal_handler, "SIGINT")
466
    gevent.signal(SIGTERM, fatal_signal_handler, "SIGTERM")
467

    
468
    # Init ephemeral port pool
469
    ports = range(opts.min_port, opts.max_port + 1) 
470

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