Statistics
| Branch: | Tag: | Revision:

root / vncauthproxy / vncauthproxy.py @ 07b0130f

History | View | Annotate | Download (12.7 kB)

1
#!/usr/bin/env python
2
#
3

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

    
21

    
22
import os
23
import sys
24
import logging
25
import gevent
26

    
27
import rfb
28

    
29
from gevent import socket
30
from gevent.select import select
31

    
32
class VncAuthProxy(gevent.Greenlet):
33
    """
34
    Simple class implementing a VNC Forwarder with MITM authentication as a
35
    Greenlet
36

37
    VncAuthProxy forwards VNC traffic from a specified port of the local host
38
    to a specified remote host:port. Furthermore, it implements VNC
39
    Authentication, intercepting the client/server handshake and asking the
40
    client for authentication even if the backend requires none.
41

42
    It is primarily intended for use in virtualization environments, as a VNC
43
    ``switch''.
44

45
    """
46
    id = 1
47

    
48
    def __init__(self, sport, daddr, dport, password, connect_timeout=30):
49
        """
50
        @type sport: int
51
        @param sport: source port
52
        @type daddr: str
53
        @param daddr: destination address (IPv4, IPv6 or hostname)
54
        @type dport: int
55
        @param dport: destination port
56
        @type password: str
57
        @param password: password to request from the client
58
        @type connect_timeout: int
59
        @param connect_timeout: how long to wait for client connections
60
                                (seconds)
61

62
        """
63
        gevent.Greenlet.__init__(self)
64
        self.id = VncAuthProxy.id
65
        VncAuthProxy.id += 1
66
        self.sport = sport
67
        self.daddr = daddr
68
        self.dport = dport
69
        self.password = password
70
        self.log = logging
71
        self.server = None
72
        self.client = None
73
        self.timeout = connect_timeout
74

    
75
    def _cleanup(self):
76
        """Close all active sockets and exit gracefully"""
77
        if self.server:
78
            self.server.close()
79
        if self.client:
80
            self.client.close()
81
        raise gevent.GreenletExit
82

    
83
    def info(self, msg):
84
        logging.info("[C%d] %s" % (self.id, msg))
85

    
86
    def debug(self, msg):
87
        logging.debug("[C%d] %s" % (self.id, msg))
88

    
89
    def warn(self, msg):
90
        logging.warn("[C%d] %s" % (self.id, msg))
91

    
92
    def error(self, msg):
93
        logging.error("[C%d] %s" % (self.id, msg))
94

    
95
    def critical(self, msg):
96
        logging.critical("[C%d] %s" % (self.id, msg))
97

    
98
    def __str__(self):
99
        return "VncAuthProxy: %d -> %s:%d" % (self.sport, self.daddr, self.dport)
100

    
101
    def _forward(self, source, dest):
102
        """
103
        Forward traffic from source to dest
104

105
        @type source: socket
106
        @param source: source socket
107
        @type dest: socket
108
        @param dest: destination socket
109

110
        """
111

    
112
        while True:
113
            d = source.recv(8096)
114
            if d == '':
115
                if source == self.client:
116
                    self.info("Client connection closed")
117
                else:
118
                    self.info("Server connection closed")
119
                break
120
            dest.sendall(d)
121
        source.close()
122
        dest.close()
123

    
124

    
125
    def _handshake(self):
126
        """
127
        Perform handshake/authentication with a connecting client
128

129
        Outline:
130
        1. Client connects
131
        2. We fake RFB 3.8 protocol and require VNC authentication
132
        3. Client accepts authentication method
133
        4. We send an authentication challenge
134
        5. Client sends the authentication response
135
        6. We check the authentication
136
        7. We initiate a connection with the backend server and perform basic
137
           RFB 3.8 handshake with it.
138
        8. If the above is successful, "bridge" both connections through two
139
           "fowrarder" greenlets.
140

141
        """
142
        self.client.send(rfb.RFB_VERSION_3_8 + "\n")
143
        client_version = self.client.recv(1024)
144
        if not rfb.check_version(client_version):
145
            self.error("Invalid version: %s" % client_version)
146
            self._cleanup()
147
        self.debug("Requesting authentication")
148
        auth_request = rfb.make_auth_request(rfb.RFB_AUTHTYPE_VNC)
149
        self.client.send(auth_request)
150
        res = self.client.recv(1024)
151
        type = rfb.parse_client_authtype(res)
152
        if type == rfb.RFB_AUTHTYPE_ERROR:
153
            self.warn("Client refused authentication: %s" % res[1:])
154
        else:
155
            self.debug("Client requested authtype %x" % type)
156

    
157
        if type != rfb.RFB_AUTHTYPE_VNC:
158
            self.error("Wrong auth type: %d" % type)
159
            self.client.send(rfb.to_u32(rfb.RFB_AUTH_ERROR))
160
            self._cleanup()
161

    
162
        # Generate the challenge
163
        challenge = os.urandom(16)
164
        self.client.send(challenge)
165
        response = self.client.recv(1024)
166
        if len(response) != 16:
167
            self.error("Wrong response length %d, should be 16" % len(response))
168
            self._cleanup()
169

    
170
        if rfb.check_password(challenge, response, password):
171
            self.debug("Authentication successful!")
172
        else:
173
            self.warn("Authentication failed")
174
            self.client.send(rfb.to_u32(rfb.RFB_AUTH_ERROR))
175
            self._cleanup()
176

    
177
        # Accept the authentication
178
        self.client.send(rfb.to_u32(rfb.RFB_AUTH_SUCCESS))
179

    
180
        # Try to connect to the server
181
        tries = 50
182

    
183
        while tries:
184
            tries -= 1
185

    
186
            # Initiate server connection
187
            for res in socket.getaddrinfo(self.daddr, self.dport, socket.AF_UNSPEC,
188
                                          socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
189
                af, socktype, proto, canonname, sa = res
190
                try:
191
                    self.server = socket.socket(af, socktype, proto)
192
                except socket.error, msg:
193
                    self.server = None
194
                    continue
195

    
196
                try:
197
                    self.debug("Connecting to %s:%s" % sa[:2])
198
                    self.server.connect(sa)
199
                    self.debug("Connection to %s:%s successful" % sa[:2])
200
                except socket.error, msg:
201
                    self.server.close()
202
                    self.server = None
203
                    continue
204

    
205
                # We succesfully connected to the server
206
                tries = 0
207
                break
208

    
209
            # Wait and retry
210
            gevent.sleep(0.2)
211

    
212
        if self.server is None:
213
            self.error("Failed to connect to server")
214
            self._cleanup()
215

    
216
        version = self.server.recv(1024)
217
        if not rfb.check_version(version):
218
            self.error("Unsupported RFB version: %s" % version.strip())
219
            self._cleanup()
220

    
221
        self.server.send(rfb.RFB_VERSION_3_8 + "\n")
222

    
223
        res = self.server.recv(1024)
224
        types = rfb.parse_auth_request(res)
225
        if not types:
226
            self.error("Error handshaking with the server")
227
            self._cleanup()
228

    
229
        else:
230
            self.debug("Supported authentication types: %s" %
231
                           " ".join([str(x) for x in types]))
232

    
233
        if rfb.RFB_AUTHTYPE_NONE not in types:
234
            self.error("Error, server demands authentication")
235
            self._cleanup()
236

    
237
        self.server.send(rfb.to_u8(rfb.RFB_AUTHTYPE_NONE))
238

    
239
        # Check authentication response
240
        res = self.server.recv(4)
241
        res = rfb.from_u32(res)
242

    
243
        if res != 0:
244
            self.error("Authentication error")
245
            self._cleanup()
246

    
247
        # Bridge client/server connections
248
        self.workers = [gevent.spawn(self._forward, self.client, self.server),
249
                        gevent.spawn(self._forward, self.server, self.client)]
250
        gevent.joinall(self.workers)
251

    
252
        del self.workers
253
        self._cleanup()
254

    
255
    def _run(self):
256
        sockets = []
257

    
258
        # Use two sockets, one for IPv4, one for IPv6. IPv4-to-IPv6 mapped
259
        # addresses do not work reliably everywhere (under linux it may have
260
        # been disabled in /proc/sys/net/ipv6/bind_ipv6_only).
261
        for res in socket.getaddrinfo(None, self.sport, socket.AF_UNSPEC,
262
                                      socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
263
            af, socktype, proto, canonname, sa = res
264
            try:
265
                s = socket.socket(af, socktype, proto)
266
                if af == socket.AF_INET6:
267
                    # Bind v6 only when AF_INET6, otherwise either v4 or v6 bind
268
                    # will fail.
269
                    s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
270
            except socket.error, msg:
271
                s = None
272
                continue;
273

    
274
            try:
275
                s.bind(sa)
276
                s.listen(1)
277
                self.debug("Listening on %s:%d" % sa[:2])
278
            except socket.error, msg:
279
                self.error("Error binding to %s:%d: %s" %
280
                               (sa[0], sa[1], msg[1]))
281
                s.close()
282
                s = None
283
                continue
284

    
285
            if s:
286
                sockets.append(s)
287

    
288
        if not sockets:
289
            self.error("Failed to listen for connections")
290
            self._cleanup()
291

    
292
        self.log.debug("Waiting for client to connect")
293
        rlist, _, _ = select(sockets, [], [], timeout=self.timeout)
294

    
295
        if not rlist:
296
            self.info("Timed out, no connection after %d sec" % self.timeout)
297
            self._cleanup()
298

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

    
303
            # Close all listening sockets, we only want a one-shot connection
304
            # from a single client.
305
            for listener in sockets:
306
                listener.close()
307
            break
308

    
309
        self._handshake()
310

    
311

    
312
if __name__ == '__main__':
313
    from optparse import OptionParser
314

    
315
    parser = OptionParser()
316
    parser.add_option("-s", "--socket", dest="ctrl_socket",
317
                      help="UNIX socket path for control connections",
318
                      default="/tmp/vncproxy.sock",
319
                      metavar="PATH")
320
    parser.add_option("-d", "--debug", action="store_true", dest="debug",
321
                      help="Enable debugging information")
322
    parser.add_option("-l", "--log", dest="logfile", default=None,
323
                      help="Write log to FILE instead of stdout",
324
                      metavar="FILE")
325
    parser.add_option("-t", "--connect-timeout", dest="connect_timeout",
326
                      default=30, type="int", metavar="SECONDS",
327
                      help="How long to listen for clients to forward")
328

    
329
    (opts, args) = parser.parse_args(sys.argv[1:])
330

    
331
    lvl = logging.DEBUG if opts.debug else logging.INFO
332

    
333
    logging.basicConfig(level=lvl, filename=opts.logfile,
334
                        format="%(asctime)s %(levelname)s: %(message)s",
335
                        datefmt="%m/%d/%Y %H:%M:%S")
336

    
337
    if os.path.exists(opts.ctrl_socket):
338
        logging.critical("Socket '%s' already exists" % opts.ctrl_socket)
339
        sys.exit(1)
340

    
341
    # TODO: make this tunable? chgrp as well?
342
    old_umask = os.umask(0077)
343

    
344
    ctrl = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
345
    ctrl.bind(opts.ctrl_socket)
346

    
347
    os.umask(old_umask)
348

    
349
    ctrl.listen(1)
350
    logging.info("Initalized, waiting for control connections at %s" %
351
                 opts.ctrl_socket)
352

    
353
    while True:
354
        try:
355
            client, addr = ctrl.accept()
356
        except KeyboardInterrupt:
357
            break
358

    
359
        logging.info("New control connection")
360
        line = client.recv(1024).strip()
361
        try:
362
            # Control message format:
363
            # TODO: make this json-based?
364
            # TODO: support multiple forwardings in the same message?
365
            # <source_port>:<destination_address>:<destination_port>:<password>
366
            # <password> will be used for MITM authentication of clients
367
            # connecting to <source_port>, who will subsequently be forwarded
368
            # to a VNC server at <destination_address>:<destination_port>
369
            sport, daddr, dport, password = line.split(':', 3)
370
            logging.info("New forwarding [%d -> %s:%d]" %
371
                         (int(sport), daddr, int(dport)))
372
        except:
373
            logging.warn("Malformed request: %s" % line)
374
            client.send("FAILED\n")
375
            client.close()
376
            continue
377

    
378
        client.send("OK\n")
379
        VncAuthProxy.spawn(sport, daddr, dport, password, opts.connect_timeout)
380
        client.close()
381

    
382
    os.unlink(opts.ctrl_socket)
383
    sys.exit(0)