/mandos/trunk

To get this branch, use:
bzr branch http://bzr.recompile.se/loggerhead/mandos/trunk

« back to all changes in this revision

Viewing changes to server.py

  • Committer: Björn Påhlsson
  • Date: 2007-10-20 21:38:25 UTC
  • Revision ID: belorn@tower-20071020213825-abf6f0d1c33ee961
First working version with: IPv6, GnuTLS, X.509 certificates, DN
retrieval.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/python
2
 
 
3
 
from __future__ import division
4
 
 
5
 
import SocketServer
6
 
import socket
7
 
import select
8
 
from optparse import OptionParser
9
 
import datetime
10
 
import errno
11
 
import gnutls.crypto
12
 
import gnutls.connection
13
 
import gnutls.errors
14
 
import ConfigParser
15
 
import sys
16
 
import re
17
 
import os
18
 
import signal
19
 
from sets import Set
20
 
import subprocess
21
 
 
22
 
import dbus
23
 
import gobject
24
 
import avahi
25
 
from dbus.mainloop.glib import DBusGMainLoop
26
 
 
27
 
# This variable is used to optionally bind to a specified
28
 
# interface.
29
 
serviceInterface = avahi.IF_UNSPEC
30
 
# It is a global variable to fit in with the rest of the
31
 
# variables from the Avahi server example code:
32
 
serviceName = "Mandos"
33
 
serviceType = "_mandos._tcp" # http://www.dns-sd.org/ServiceTypes.html
34
 
servicePort = None                      # Not known at startup
35
 
serviceTXT = []                         # TXT record for the service
36
 
domain = ""                  # Domain to publish on, default to .local
37
 
host = ""          # Host to publish records for, default to localhost
38
 
group = None #our entry group
39
 
rename_count = 12       # Counter so we only rename after collisions a
40
 
                        # sensible number of times
41
 
# End of Avahi example code
42
 
 
43
 
 
44
 
class Client(object):
45
 
    """A representation of a client host served by this server.
46
 
    Attributes:
47
 
    password:  string
48
 
    fqdn:      string, FQDN (used by the checker)
49
 
    created:   datetime.datetime()
50
 
    last_seen: datetime.datetime() or None if not yet seen
51
 
    timeout:   datetime.timedelta(); How long from last_seen until
52
 
                                     this client is invalid
53
 
    interval:  datetime.timedelta(); How often to start a new checker
54
 
    timeout_milliseconds: Used by gobject.timeout_add()
55
 
    interval_milliseconds: - '' -
56
 
    stop_hook: If set, called by stop() as stop_hook(self)
57
 
    checker:   subprocess.Popen(); a running checker process used
58
 
                                   to see if the client lives.
59
 
                                   Is None if no process is running.
60
 
    checker_initiator_tag: a gobject event source tag, or None
61
 
    stop_initiator_tag:    - '' -
62
 
    checker_callback_tag:  - '' -
63
 
    """
64
 
    def __init__(self, name=None, options=None, stop_hook=None,
65
 
                 dn=None, password=None, passfile=None, fqdn=None,
66
 
                 timeout=None, interval=-1):
67
 
        self.name = name
68
 
        self.dn = dn
69
 
        if password:
70
 
            self.password = password
71
 
        elif passfile:
72
 
            self.password = open(passfile).readall()
73
 
        else:
74
 
            raise RuntimeError(u"No Password or Passfile for client %s"
75
 
                               % self.name)
76
 
        self.fqdn = fqdn                # string
77
 
        self.created = datetime.datetime.now()
78
 
        self.last_seen = None
79
 
        if timeout is None:
80
 
            timeout = options.timeout
81
 
        self.timeout = timeout
82
 
        self.timeout_milliseconds = ((self.timeout.days
83
 
                                      * 24 * 60 * 60 * 1000)
84
 
                                     + (self.timeout.seconds * 1000)
85
 
                                     + (self.timeout.microseconds
86
 
                                        // 1000))
87
 
        if interval == -1:
88
 
            interval = options.interval
89
 
        else:
90
 
            interval = string_to_delta(interval)
91
 
        self.interval = interval
92
 
        self.interval_milliseconds = ((self.interval.days
93
 
                                       * 24 * 60 * 60 * 1000)
94
 
                                      + (self.interval.seconds * 1000)
95
 
                                      + (self.interval.microseconds
96
 
                                         // 1000))
97
 
        self.stop_hook = stop_hook
98
 
        self.checker = None
99
 
        self.checker_initiator_tag = None
100
 
        self.stop_initiator_tag = None
101
 
        self.checker_callback_tag = None
102
 
    def start(self):
103
 
        """Start this clients checker and timeout hooks"""
104
 
        # Schedule a new checker to be started an 'interval' from now,
105
 
        # and every interval from then on.
106
 
        self.checker_initiator_tag = gobject.\
107
 
                                     timeout_add(self.interval_milliseconds,
108
 
                                                 self.start_checker)
109
 
        # Also start a new checker *right now*.
110
 
        self.start_checker()
111
 
        # Schedule a stop() when 'timeout' has passed
112
 
        self.stop_initiator_tag = gobject.\
113
 
                                     timeout_add(self.timeout_milliseconds,
114
 
                                                 self.stop)
115
 
    def stop(self):
116
 
        """Stop this client.
117
 
        The possibility that this client might be restarted is left
118
 
        open, but not currently used."""
119
 
        # print "Stopping client", self.name
120
 
        self.password = None
121
 
        if self.stop_initiator_tag:
122
 
            gobject.source_remove(self.stop_initiator_tag)
123
 
            self.stop_initiator_tag = None
124
 
        if self.checker_initiator_tag:
125
 
            gobject.source_remove(self.checker_initiator_tag)
126
 
            self.checker_initiator_tag = None
127
 
        self.stop_checker()
128
 
        if self.stop_hook:
129
 
            self.stop_hook(self)
130
 
        # Do not run this again if called by a gobject.timeout_add
131
 
        return False
132
 
    def __del__(self):
133
 
        # Some code duplication here and in stop()
134
 
        if hasattr(self, "stop_initiator_tag") \
135
 
               and self.stop_initiator_tag:
136
 
            gobject.source_remove(self.stop_initiator_tag)
137
 
            self.stop_initiator_tag = None
138
 
        if hasattr(self, "checker_initiator_tag") \
139
 
               and self.checker_initiator_tag:
140
 
            gobject.source_remove(self.checker_initiator_tag)
141
 
            self.checker_initiator_tag = None
142
 
        self.stop_checker()
143
 
    def checker_callback(self, pid, condition):
144
 
        """The checker has completed, so take appropriate actions."""
145
 
        now = datetime.datetime.now()
146
 
        if os.WIFEXITED(condition) \
147
 
               and (os.WEXITSTATUS(condition) == 0):
148
 
            #print "Checker for %(name)s succeeded" % vars(self)
149
 
            self.last_seen = now
150
 
            gobject.source_remove(self.stop_initiator_tag)
151
 
            self.stop_initiator_tag = gobject.\
152
 
                                      timeout_add(self.timeout_milliseconds,
153
 
                                                  self.stop)
154
 
        #else:
155
 
        #    if not os.WIFEXITED(condition):
156
 
        #        print "Checker for %(name)s crashed?" % vars(self)
157
 
        #    else:
158
 
        #        print "Checker for %(name)s failed" % vars(self)
159
 
        self.checker = None
160
 
        self.checker_callback_tag = None
161
 
    def start_checker(self):
162
 
        """Start a new checker subprocess if one is not running.
163
 
        If a checker already exists, leave it running and do
164
 
        nothing."""
165
 
        if self.checker is None:
166
 
            #print "Starting checker for", self.name
167
 
            try:
168
 
                self.checker = subprocess.\
169
 
                               Popen("sleep 1; fping -q -- %s"
170
 
                                     % re.escape(self.fqdn),
171
 
                                     stdout=subprocess.PIPE,
172
 
                                     close_fds=True, shell=True,
173
 
                                     cwd="/")
174
 
                self.checker_callback_tag = gobject.\
175
 
                                            child_watch_add(self.checker.pid,
176
 
                                                            self.\
177
 
                                                            checker_callback)
178
 
            except subprocess.OSError, error:
179
 
                sys.stderr.write(u"Failed to start subprocess: %s\n"
180
 
                                 % error)
181
 
        # Re-run this periodically if run by gobject.timeout_add
182
 
        return True
183
 
    def stop_checker(self):
184
 
        """Force the checker process, if any, to stop."""
185
 
        if not hasattr(self, "checker") or self.checker is None:
186
 
            return
187
 
        gobject.source_remove(self.checker_callback_tag)
188
 
        self.checker_callback_tag = None
189
 
        os.kill(self.checker.pid, signal.SIGTERM)
190
 
        if self.checker.poll() is None:
191
 
            os.kill(self.checker.pid, signal.SIGKILL)
192
 
        self.checker = None
193
 
    def still_valid(self, now=None):
194
 
        """Has the timeout not yet passed for this client?"""
195
 
        if now is None:
196
 
            now = datetime.datetime.now()
197
 
        if self.last_seen is None:
198
 
            return now < (self.created + self.timeout)
199
 
        else:
200
 
            return now < (self.last_seen + self.timeout)
201
 
 
202
 
 
203
 
class tcp_handler(SocketServer.BaseRequestHandler, object):
204
 
    """A TCP request handler class.
205
 
    Instantiated by IPv6_TCPServer for each request to handle it.
206
 
    Note: This will run in its own forked process."""
207
 
    def handle(self):
208
 
        #print u"TCP request came"
209
 
        #print u"Request:", self.request
210
 
        #print u"Client Address:", self.client_address
211
 
        #print u"Server:", self.server
212
 
        session = gnutls.connection.ServerSession(self.request,
213
 
                                                  self.server\
214
 
                                                  .credentials)
215
 
        try:
216
 
            session.handshake()
217
 
        except gnutls.errors.GNUTLSError, error:
218
 
            #sys.stderr.write(u"Handshake failed: %s\n" % error)
219
 
            # Do not run session.bye() here: the session is not
220
 
            # established.  Just abandon the request.
221
 
            return
222
 
        #if session.peer_certificate:
223
 
        #    print "DN:", session.peer_certificate.subject
224
 
        try:
225
 
            session.verify_peer()
226
 
        except gnutls.errors.CertificateError, error:
227
 
            #sys.stderr.write(u"Verify failed: %s\n" % error)
228
 
            session.bye()
229
 
            return
230
 
        client = None
231
 
        for c in clients:
232
 
            if c.dn == session.peer_certificate.subject:
233
 
                client = c
234
 
                break
235
 
        # Have to check if client.still_valid(), since it is possible
236
 
        # that the client timed out while establishing the GnuTLS
237
 
        # session.
238
 
        if client and client.still_valid():
239
 
            session.send(client.password)
240
 
        else:
241
 
            #if client:
242
 
            #    sys.stderr.write(u"Client %(name)s is invalid\n"
243
 
            #                     % vars(client))
244
 
            #else:
245
 
            #    sys.stderr.write(u"Client not found for DN: %s\n"
246
 
            #                     % session.peer_certificate.subject)
247
 
            #session.send("gazonk")
248
 
            pass
249
 
        session.bye()
250
 
 
251
 
 
252
 
class IPv6_TCPServer(SocketServer.ForkingTCPServer, object):
253
 
    """IPv6 TCP server.  Accepts 'None' as address and/or port.
254
 
    Attributes:
255
 
        options:        Command line options
256
 
        clients:        Set() of Client objects
257
 
        credentials:    GnuTLS X.509 credentials
258
 
    """
259
 
    address_family = socket.AF_INET6
260
 
    def __init__(self, *args, **kwargs):
261
 
        if "options" in kwargs:
262
 
            self.options = kwargs["options"]
263
 
            del kwargs["options"]
264
 
        if "clients" in kwargs:
265
 
            self.clients = kwargs["clients"]
266
 
            del kwargs["clients"]
267
 
        if "credentials" in kwargs:
268
 
            self.credentials = kwargs["credentials"]
269
 
            del kwargs["credentials"]
270
 
        return super(type(self), self).__init__(*args, **kwargs)
271
 
    def server_bind(self):
272
 
        """This overrides the normal server_bind() function
273
 
        to bind to an interface if one was specified, and also NOT to
274
 
        bind to an address or port if they were not specified."""
275
 
        if self.options.interface:
276
 
            if not hasattr(socket, "SO_BINDTODEVICE"):
277
 
                # From /usr/include/asm-i486/socket.h
278
 
                socket.SO_BINDTODEVICE = 25
279
 
            try:
280
 
                self.socket.setsockopt(socket.SOL_SOCKET,
281
 
                                       socket.SO_BINDTODEVICE,
282
 
                                       self.options.interface)
283
 
            except socket.error, error:
284
 
                if error[0] == errno.EPERM:
285
 
                    sys.stderr.write(u"Warning: No permission to bind to interface %s\n"
286
 
                                     % self.options.interface)
287
 
                else:
288
 
                    raise error
289
 
        # Only bind(2) the socket if we really need to.
290
 
        if self.server_address[0] or self.server_address[1]:
291
 
            if not self.server_address[0]:
292
 
                in6addr_any = "::"
293
 
                self.server_address = (in6addr_any,
294
 
                                       self.server_address[1])
295
 
            elif self.server_address[1] is None:
296
 
                self.server_address = (self.server_address[0],
297
 
                                       0)
298
 
            return super(type(self), self).server_bind()
299
 
 
300
 
 
301
 
def string_to_delta(interval):
302
 
    """Parse a string and return a datetime.timedelta
303
 
 
304
 
    >>> string_to_delta('7d')
305
 
    datetime.timedelta(7)
306
 
    >>> string_to_delta('60s')
307
 
    datetime.timedelta(0, 60)
308
 
    >>> string_to_delta('60m')
309
 
    datetime.timedelta(0, 3600)
310
 
    >>> string_to_delta('24h')
311
 
    datetime.timedelta(1)
312
 
    >>> string_to_delta(u'1w')
313
 
    datetime.timedelta(7)
314
 
    """
315
 
    try:
316
 
        suffix=unicode(interval[-1])
317
 
        value=int(interval[:-1])
318
 
        if suffix == u"d":
319
 
            delta = datetime.timedelta(value)
320
 
        elif suffix == u"s":
321
 
            delta = datetime.timedelta(0, value)
322
 
        elif suffix == u"m":
323
 
            delta = datetime.timedelta(0, 0, 0, 0, value)
324
 
        elif suffix == u"h":
325
 
            delta = datetime.timedelta(0, 0, 0, 0, 0, value)
326
 
        elif suffix == u"w":
327
 
            delta = datetime.timedelta(0, 0, 0, 0, 0, 0, value)
328
 
        else:
329
 
            raise ValueError
330
 
    except (ValueError, IndexError):
331
 
        raise ValueError
332
 
    return delta
333
 
 
334
 
 
335
 
def add_service():
336
 
    """From the Avahi server example code"""
337
 
    global group, serviceName, serviceType, servicePort, serviceTXT, \
338
 
           domain, host
339
 
    if group is None:
340
 
        group = dbus.Interface(
341
 
                bus.get_object( avahi.DBUS_NAME,
342
 
                                server.EntryGroupNew()),
343
 
                avahi.DBUS_INTERFACE_ENTRY_GROUP)
344
 
        group.connect_to_signal('StateChanged',
345
 
                                entry_group_state_changed)
346
 
    
347
 
    # print "Adding service '%s' of type '%s' ..." % (serviceName,
348
 
    #                                                 serviceType)
349
 
    
350
 
    group.AddService(
351
 
            serviceInterface,           # interface
352
 
            avahi.PROTO_INET6,          # protocol
353
 
            dbus.UInt32(0),             # flags
354
 
            serviceName, serviceType,
355
 
            domain, host,
356
 
            dbus.UInt16(servicePort),
357
 
            avahi.string_array_to_txt_array(serviceTXT))
358
 
    group.Commit()
359
 
 
360
 
 
361
 
def remove_service():
362
 
    """From the Avahi server example code"""
363
 
    global group
364
 
    
365
 
    if not group is None:
366
 
        group.Reset()
367
 
 
368
 
 
369
 
def server_state_changed(state):
370
 
    """From the Avahi server example code"""
371
 
    if state == avahi.SERVER_COLLISION:
372
 
        print "WARNING: Server name collision"
373
 
        remove_service()
374
 
    elif state == avahi.SERVER_RUNNING:
375
 
        add_service()
376
 
 
377
 
 
378
 
def entry_group_state_changed(state, error):
379
 
    """From the Avahi server example code"""
380
 
    global serviceName, server, rename_count
381
 
    
382
 
    # print "state change: %i" % state
383
 
    
384
 
    if state == avahi.ENTRY_GROUP_ESTABLISHED:
385
 
        pass
386
 
        # print "Service established."
387
 
    elif state == avahi.ENTRY_GROUP_COLLISION:
388
 
        
389
 
        rename_count = rename_count - 1
390
 
        if rename_count > 0:
391
 
            name = server.GetAlternativeServiceName(name)
392
 
            print "WARNING: Service name collision, changing name to '%s' ..." % name
393
 
            remove_service()
394
 
            add_service()
395
 
            
396
 
        else:
397
 
            print "ERROR: No suitable service name found after %i retries, exiting." % n_rename
398
 
            main_loop.quit()
399
 
    elif state == avahi.ENTRY_GROUP_FAILURE:
400
 
        print "Error in group state changed", error
401
 
        main_loop.quit()
402
 
        return
403
 
 
404
 
 
405
 
def if_nametoindex(interface):
406
 
    """Call the C function if_nametoindex()"""
407
 
    try:
408
 
        if "ctypes" not in sys.modules:
409
 
            import ctypes
410
 
        libc = ctypes.cdll.LoadLibrary("libc.so.6")
411
 
        return libc.if_nametoindex(interface)
412
 
    except (ImportError, OSError, AttributeError):
413
 
        if "struct" not in sys.modules:
414
 
            import struct
415
 
        if "fcntl" not in sys.modules:
416
 
            import fcntl
417
 
        SIOCGIFINDEX = 0x8933      # From /usr/include/linux/sockios.h
418
 
        s = socket.socket()
419
 
        ifreq = fcntl.ioctl(s, SIOCGIFINDEX,
420
 
                            struct.pack("16s16x", interface))
421
 
        s.close()
422
 
        interface_index = struct.unpack("I", ifreq[16:20])[0]
423
 
        return interface_index
424
 
 
425
 
 
426
 
if __name__ == '__main__':
427
 
    parser = OptionParser()
428
 
    parser.add_option("-i", "--interface", type="string",
429
 
                      default=None, metavar="IF",
430
 
                      help="Bind to interface IF")
431
 
    parser.add_option("--cert", type="string", default="cert.pem",
432
 
                      metavar="FILE",
433
 
                      help="Public key certificate PEM file to use")
434
 
    parser.add_option("--key", type="string", default="key.pem",
435
 
                      metavar="FILE",
436
 
                      help="Private key PEM file to use")
437
 
    parser.add_option("--ca", type="string", default="ca.pem",
438
 
                      metavar="FILE",
439
 
                      help="Certificate Authority certificate PEM file to use")
440
 
    parser.add_option("--crl", type="string", default="crl.pem",
441
 
                      metavar="FILE",
442
 
                      help="Certificate Revokation List PEM file to use")
443
 
    parser.add_option("-p", "--port", type="int", default=None,
444
 
                      help="Port number to receive requests on")
445
 
    parser.add_option("--timeout", type="string", # Parsed later
446
 
                      default="1h",
447
 
                      help="Amount of downtime allowed for clients")
448
 
    parser.add_option("--interval", type="string", # Parsed later
449
 
                      default="5m",
450
 
                      help="How often to check that a client is up")
451
 
    parser.add_option("--check", action="store_true", default=False,
452
 
                      help="Run self-test")
453
 
    (options, args) = parser.parse_args()
454
 
    
455
 
    if options.check:
456
 
        import doctest
457
 
        doctest.testmod()
458
 
        sys.exit()
459
 
    
460
 
    # Parse the time arguments
461
 
    try:
462
 
        options.timeout = string_to_delta(options.timeout)
463
 
    except ValueError:
464
 
        parser.error("option --timeout: Unparseable time")
465
 
    try:
466
 
        options.interval = string_to_delta(options.interval)
467
 
    except ValueError:
468
 
        parser.error("option --interval: Unparseable time")
469
 
    
470
 
    cert = gnutls.crypto.X509Certificate(open(options.cert).read())
471
 
    key = gnutls.crypto.X509PrivateKey(open(options.key).read())
472
 
    ca = gnutls.crypto.X509Certificate(open(options.ca).read())
473
 
    crl = gnutls.crypto.X509CRL(open(options.crl).read())
474
 
    cred = gnutls.connection.X509Credentials(cert, key, [ca], [crl])
475
 
    
476
 
    # Parse config file
477
 
    defaults = {}
478
 
    client_config = ConfigParser.SafeConfigParser(defaults)
479
 
    #client_config.readfp(open("secrets.conf"), "secrets.conf")
480
 
    client_config.read("mandos-clients.conf")
481
 
    
482
 
    # From the Avahi server example code
483
 
    DBusGMainLoop(set_as_default=True )
484
 
    main_loop = gobject.MainLoop()
485
 
    bus = dbus.SystemBus()
486
 
    server = dbus.Interface(
487
 
            bus.get_object( avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER ),
488
 
            avahi.DBUS_INTERFACE_SERVER )
489
 
    # End of Avahi example code
490
 
    
491
 
    clients = Set()
492
 
    def remove_from_clients(client):
493
 
        clients.remove(client)
494
 
        if not clients:
495
 
            print "No clients left, exiting"
496
 
            main_loop.quit()
497
 
    
498
 
    clients.update(Set(Client(name=section, options=options,
499
 
                              stop_hook = remove_from_clients,
500
 
                              **(dict(client_config\
501
 
                                      .items(section))))
502
 
                       for section in client_config.sections()))
503
 
    for client in clients:
504
 
        client.start()
505
 
    
506
 
    tcp_server = IPv6_TCPServer((None, options.port),
507
 
                                tcp_handler,
508
 
                                options=options,
509
 
                                clients=clients,
510
 
                                credentials=cred)
511
 
    # Find out what random port we got
512
 
    servicePort = tcp_server.socket.getsockname()[1]
513
 
    #sys.stderr.write("Now listening on port %d\n" % servicePort)
514
 
    
515
 
    if options.interface is not None:
516
 
        serviceInterface = if_nametoindex(options.interface)
517
 
    
518
 
    # From the Avahi server example code
519
 
    server.connect_to_signal("StateChanged", server_state_changed)
520
 
    server_state_changed(server.GetState())
521
 
    # End of Avahi example code
522
 
    
523
 
    gobject.io_add_watch(tcp_server.fileno(), gobject.IO_IN,
524
 
                         lambda *args, **kwargs:
525
 
                         tcp_server.handle_request(*args[2:],
526
 
                                                   **kwargs) or True)
527
 
    try:
528
 
        main_loop.run()
529
 
    except KeyboardInterrupt:
530
 
        print
531
 
    
532
 
    # Cleanup here
533
 
 
534
 
    # From the Avahi server example code
535
 
    if not group is None:
536
 
        group.Free()
537
 
    # End of Avahi example code
538
 
    
539
 
    for client in clients:
540
 
        client.stop_hook = None
541
 
        client.stop()