Statistics
| Branch: | Tag: | Revision:

root / nfdhcp.py @ 1f3139f3

History | View | Annotate | Download (14.6 kB)

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

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

    
22
import os
23
import re
24
import glob
25
import logging
26
import logging.handlers
27
import subprocess
28

    
29
import daemon
30
import nfqueue
31
import pyinotify
32

    
33
import IPy
34
from select import select
35
from socket import AF_INET, AF_PACKET, AF_UNSPEC
36

    
37
from scapy.layers.l2 import Ether
38
from scapy.layers.inet import IP, UDP
39
from scapy.layers.dhcp import BOOTP, DHCP
40
from scapy.sendrecv import sendp
41

    
42
DEFAULT_PATH = "/var/run/ganeti-dhcpd"
43
DEFAULT_NFQUEUE_NUM = 42
44
DEFAULT_USER = "nobody"
45
DEFAULT_LEASE_TIME = 604800 # 1 week
46
DEFAULT_RENEWAL_TIME = 600  # 10 min
47

    
48
LOG_FILENAME = "/var/log/nfdhcpd/nfdhcpd.log"
49

    
50
SYSFS_NET = "/sys/class/net"
51
MY_IP = "1.2.3.4"
52

    
53
LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
54

    
55
DHCPDISCOVER = 1
56
DHCPOFFER = 2
57
DHCPREQUEST = 3
58
DHCPDECLINE = 4
59
DHCPACK = 5
60
DHCPNAK = 6
61
DHCPRELEASE = 7
62
DHCPINFORM = 8
63

    
64
DHCP_TYPES = {
65
    DHCPDISCOVER: "DHCPDISCOVER",
66
    DHCPOFFER: "DHCPOFFER",
67
    DHCPREQUEST: "DHCPREQUEST",
68
    DHCPDECLINE: "DHCPDECLINE",
69
    DHCPACK: "DHCPACK",
70
    DHCPNAK: "DHCPNAK",
71
    DHCPRELEASE: "DHCPRELEASE",
72
    DHCPINFORM: "DHCPINFORM",
73
}
74

    
75
DHCP_REQRESP = {
76
    DHCPDISCOVER: DHCPOFFER,
77
    DHCPREQUEST: DHCPACK,
78
    DHCPINFORM: DHCPACK,
79
    }
80

    
81
class DhcpBindingHandler(pyinotify.ProcessEvent):
82
    def __init__(self, dhcp):
83
        pyinotify.ProcessEvent.__init__(self)
84
        self.dhcp = dhcp
85

    
86
    def process_IN_DELETE(self, event):
87
        self.dhcp.remove_iface(event.name)
88

    
89
    def process_IN_CLOSE_WRITE(self, event):
90
        self.dhcp.add_iface(os.path.join(event.path, event.name))
91

    
92
class DhcpBinding(object):
93
    def __init__(self, mac=None, ips=None, link=None, hostname=None):
94
        self.mac = mac
95
        self.ips = ips
96
        self.hostname = hostname
97
        self.link = link
98
        self.iface = None
99
        
100
    @property
101
    def ip(self):
102
        return self.ips[0]
103

    
104
    def is_valid(self):
105
        return self.mac is not None and self.ips is not None\
106
               and self.hostname is not None
107

    
108

    
109
class Subnet(object):
110
    def __init__(self, net=None, gw=None, dev=None):
111
        if isinstance(net, str):
112
            self.net = IPy.IP(net)
113
        else:
114
            self.net = net
115
        self.gw = gw
116
        self.dev = dev
117

    
118
    @property
119
    def netmask(self):
120
        return str(self.net.netmask())
121

    
122
    @property
123
    def broadcast(self):
124
        return str(self.net.broadcast())
125

    
126

    
127
class DhcpServer(object):
128
    def __init__(self, data_path, queue_num):
129
        self.data_path = data_path
130
        self.clients = {}
131
        self.subnets = {}
132
        self.ifaces = {}
133
        
134
        # Inotify setup
135
        self.wm = pyinotify.WatchManager()
136
        mask = pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"]
137
        mask |= pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"]
138
        handler = DhcpBindingHandler(self)
139
        self.notifier = pyinotify.Notifier(self.wm, handler)
140
        self.wm.add_watch(self.data_path, mask, rec=True)
141

    
142
        # NFQueue setup
143
        self.q = nfqueue.queue()
144
        self.q.set_callback(self.make_reply)
145
        self.q.fast_open(queue_num, AF_INET)
146
        self.q.set_queue_maxlen(5000)
147
        # This is mandatory for the queue to operate
148
        self.q.set_mode(nfqueue.NFQNL_COPY_PACKET)
149

    
150
    def build_config(self):
151
        self.clients.clear()
152
        self.subnets.clear()
153

    
154
        for file in glob.glob(os.path.join(self.data_path, "*")):
155
            self.add_iface(file)
156

    
157
    def get_ifindex(self, iface):
158
        """ Get the interface index from sysfs
159

160
        """
161
        file = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
162
        if not file.startswith(SYSFS_NET):
163
            return None
164

    
165
        ifindex = None
166

    
167
        try:
168
            f = open(file, 'r')
169
            ifindex = int(f.readline().strip())
170
            f.close()
171
        except:
172
            pass
173

    
174
        return ifindex
175
            
176
        
177
    def get_iface_hw_addr(self, iface):
178
        """ Get the interface hardware address from sysfs
179

180
        """
181
        file = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
182
        if not file.startswith(SYSFS_NET):
183
            return None
184

    
185
        addr = None
186
        try:
187
            f = open(file, 'r')
188
            addr = f.readline().strip()
189
            f.close()
190
        except:
191
            pass
192
        return addr
193

    
194
    def parse_routing_table(self, table="main"):
195
        """ Parse the given routing table to get connected route, gateway and
196
        default device.
197

198
        """
199
        ipro = subprocess.Popen(["ip", "ro", "ls", "table", table],
200
                                stdout=subprocess.PIPE)
201
        routes = ipro.stdout.readlines()
202
        
203
        def_gw = None
204
        def_dev = None
205
        def_net = None
206

    
207
        for route in routes:
208
            match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
209
            if match:
210
                def_gw, def_dev = match.groups()
211
                break
212

    
213
        for route in routes:
214
            # Find the least-specific connected route
215
            try:
216
                def_net = re.match("^([^\\s]+) dev %s" %
217
                                   def_dev, route).groups()[0]
218
                def_net = IPy.IP(def_net)
219
            except:
220
                pass
221

    
222
        return Subnet(net=def_net, gw=def_gw, dev=def_dev)
223
        
224
    def parse_binding_file(self, path):
225
        """ Read a client configuration from a tap file
226

227
        """
228
        try:
229
            iffile = open(path, 'r')
230
        except:
231
            return (None, None, None, None)
232
        mac = None
233
        ips = None
234
        link = None
235
        hostname = None
236

    
237
        for line in iffile:
238
            if line.startswith("IP="):
239
                ip = line.strip().split("=")[1]
240
                ips = ip.split()
241
            elif line.startswith("MAC="):
242
                mac = line.strip().split("=")[1]
243
            elif line.startswith("LINK="):
244
                link = line.strip().split("=")[1]
245
            elif line.startswith("HOSTNAME="):
246
                hostname = line.strip().split("=")[1]
247

    
248
        return DhcpBinding(mac=mac, ips=ips, link=link, hostname=hostname)
249

    
250
    def add_iface(self, path):
251
        """ Add an interface to monitor
252

253
        """
254
        iface = os.path.basename(path)
255

    
256
        logging.debug("Updating configuration for %s" % iface)
257
        binding = self.parse_binding_file(path)
258
        ifindex = self.get_ifindex(iface)
259

    
260
        if ifindex is None:
261
            logging.warn("Stale configuration for %s found" % iface)
262
        else:
263
            if binding.is_valid():
264
                binding.iface = iface
265
                self.clients[binding.mac] = binding
266
                self.subnets[binding.link] = self.parse_routing_table(
267
                                                binding.link)
268
                logging.debug("Added client %s on %s" %
269
                              (binding.hostname, iface))
270
                self.ifaces[ifindex] = iface
271

    
272
    def remove_iface(self, iface):
273
        """ Cleanup clients on a removed interface
274

275
        """
276
        for mac in self.clients.keys():
277
            if self.clients[mac].iface == iface:
278
                del self.clients[mac]
279

    
280
        for ifindex in self.ifaces.keys():
281
            if self.ifaces[ifindex] == iface:
282
                del self.ifaces[ifindex]
283

    
284
        logging.debug("Removed interface %s" % iface)
285

    
286
    def make_reply(self, i, payload):
287
        """ Generate a reply to a BOOTP/DHCP request
288

289
        """
290
        # Decode the response - NFQUEUE relays IP packets
291
        pkt = IP(payload.get_data())
292

    
293
        # Get the actual interface from the ifindex
294
        iface = self.ifaces[payload.get_indev()]
295

    
296
        # Signal the kernel that it shouldn't further process the packet
297
        payload.set_verdict(nfqueue.NF_DROP)
298
        
299
        # Get the client MAC address
300
        resp = pkt.getlayer(BOOTP).copy()
301
        hlen = resp.hlen
302
        mac = resp.chaddr[:hlen].encode("hex")
303
        mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
304

    
305
        # Server responses are always BOOTREPLYs
306
        resp.op = "BOOTREPLY"
307
        del resp.payload
308

    
309
        try:
310
            binding = self.clients[mac]
311
        except KeyError:
312
            logging.warn("Invalid client %s on %s" % (mac, iface))
313
            return
314

    
315
        if iface != binding.iface:
316
            logging.warn("Received spoofed DHCP request for %s from interface"
317
                         " %s instead of %s" %
318
                         (mac, iface, binding.iface))
319
            return
320

    
321
        resp = Ether(dst=mac, src=self.get_iface_hw_addr(iface))/\
322
               IP(src=MY_IP, dst=binding.ip)/\
323
               UDP(sport=pkt.dport, dport=pkt.sport)/resp
324
        subnet = self.subnets[binding.link]
325

    
326
        if not DHCP in pkt:
327
            logging.warn("Invalid request from %s on %s, no DHCP"
328
                         " payload found" % (binding.mac, iface))
329
            return
330

    
331
        dhcp_options = []
332
        requested_addr = binding.ip
333
        for opt in pkt[DHCP].options:
334
            if type(opt) is tuple and opt[0] == "message-type":
335
                req_type = opt[1]
336
            if type(opt) is tuple and opt[0] == "requested_addr":
337
                requested_addr = opt[1]
338

    
339
        logging.info("%s from %s on %s" %
340
                    (DHCP_TYPES.get(req_type, "UNKNOWN"), binding.mac, iface))
341

    
342
        if req_type == DHCPREQUEST and requested_addr != binding.ip:
343
            resp_type = DHCPNAK
344
            logging.info("Sending DHCPNAK to %s on %s: requested %s"
345
                         " instead of %s" %
346
                         (binding.mac, iface, requested_addr, binding.ip))
347

    
348
        elif req_type in (DHCPDISCOVER, DHCPREQUEST):
349
            resp_type = DHCP_REQRESP[req_type]
350
            resp.yiaddr = self.clients[mac].ip
351
            dhcp_options += [
352
                 ("hostname", binding.hostname),
353
                 ("domain", binding.hostname.split('.', 1)[-1]),
354
                 ("router", subnet.gw),
355
                 ("name_server", "194.177.210.10"),
356
                 ("name_server", "194.177.210.211"),
357
                 ("broadcast_address", str(subnet.broadcast)),
358
                 ("subnet_mask", str(subnet.netmask)),
359
                 ("renewal_time", DEFAULT_RENEWAL_TIME),
360
                 ("lease_time", DEFAULT_LEASE_TIME),
361
            ]
362

    
363
        elif req_type == DHCPINFORM:
364
            resp_type = DHCP_REQRESP[req_type]
365
            dhcp_options += [
366
                 ("hostname", binding.hostname),
367
                 ("domain", binding.hostname.split('.', 1)[-1]),
368
                 ("name_server", "194.177.210.10"),
369
                 ("name_server", "194.177.210.211"),
370
            ]
371

    
372
        elif req_type == DHCPRELEASE:
373
            # Log and ignore
374
            logging.info("DHCPRELEASE from %s on %s" %
375
                         (binding.mac, iface))
376
            return
377

    
378
        # Finally, always add the server identifier and end options
379
        dhcp_options += [
380
            ("message-type", resp_type),
381
            ("server_id", MY_IP),
382
            "end"
383
        ]
384
        resp /= DHCP(options=dhcp_options)
385

    
386
        logging.info("%s to %s (%s) on %s" %
387
                      (DHCP_TYPES[resp_type], mac, binding.ip, iface))
388
        sendp(resp, iface=iface, verbose=False)
389

    
390

    
391
    def serve(self):
392
        """ Loop forever, serving DHCP requests
393

394
        """
395
        self.build_config()
396

    
397
        iwfd = self.notifier._fd
398
        qfd = self.q.get_fd()
399

    
400
        while True:
401
            rlist, _, xlist = select([iwfd, qfd], [], [], 1.0)
402
            # First check if there are any inotify (= configuration change)
403
            # events
404
            if iwfd in rlist:
405
                self.notifier.read_events()
406
                self.notifier.process_events()
407
                rlist.remove(iwfd)
408

    
409
            for fd in rlist:
410
                self.q.process_pending()
411

    
412

    
413
if __name__ == "__main__":
414
    import optparse
415
    from capng import *
416
    from pwd import getpwnam, getpwuid
417

    
418
    parser = optparse.OptionParser()
419
    parser.add_option("-p", "--path", dest="data_path",
420
                      help="The location of the data files", metavar="DIR",
421
                      default=DEFAULT_PATH)
422
    parser.add_option("-n", "--nfqueue-num", dest="queue_num",
423
                      help="The nfqueue to receive DHCP requests from",
424
                      metavar="NUM", default=DEFAULT_NFQUEUE_NUM)
425
    parser.add_option("-u", "--user", dest="user",
426
                      help="An unprivileged user to run as" ,
427
                      metavar="UID", default=DEFAULT_USER)
428
    parser.add_option("-d", "--debug", action="store_true", dest="debug",
429
                      help="Turn on debugging messages")
430
    parser.add_option("-f", "--foreground", action="store_false", dest="daemonize",
431
                      default=True, help="Do not daemonize, stay in the foreground")
432

    
433

    
434
    opts, args = parser.parse_args()
435

    
436
    if opts.daemonize:
437
        d = daemon.DaemonContext()
438
        d.open()
439

    
440
    pidfile = open("/var/run/nfdhcpd.pid", "w")
441
    pidfile.write("%s" % os.getpid())
442
    pidfile.close()
443

    
444
    logger = logging.getLogger()
445
    if opts.debug:
446
        logger.setLevel(logging.DEBUG)
447
    else:
448
        logger.setLevel(logging.INFO)
449

    
450
    if opts.daemonize:
451
        handler = logging.handlers.RotatingFileHandler(LOG_FILENAME,
452
                                                       maxBytes=2097152)
453
    else:
454
        handler = logging.StreamHandler()
455

    
456
    handler.setFormatter(logging.Formatter(LOG_FORMAT))
457
    logger.addHandler(handler)
458

    
459
    logging.info("Starting up")
460
    dhcp = DhcpServer(opts.data_path, opts.queue_num)
461

    
462
    # Drop all capabilities except CAP_NET_RAW and change uid
463
    try:
464
        uid = getpwuid(int(opts.user))
465
    except ValueError:
466
        uid = getpwnam(opts.user)
467

    
468
    logging.info("Setting capabilities and changing uid")
469
    logging.debug("User: %s, uid: %d, gid: %d" %
470
                  (opts.user, uid.pw_uid, uid.pw_gid))
471
    capng_clear(CAPNG_SELECT_BOTH)
472
    capng_update(CAPNG_ADD, CAPNG_EFFECTIVE|CAPNG_PERMITTED, CAP_NET_RAW)
473
    capng_change_id(uid.pw_uid, uid.pw_gid,
474
                    CAPNG_DROP_SUPP_GRP | CAPNG_CLEAR_BOUNDING)
475
    logging.info("Ready to serve requests")
476
    dhcp.serve()
477

    
478

    
479
# vim: set ts=4 sts=4 sw=4 et :