Revision 1f3139f3

b/.gitignore
1
*.py[co]
2
*.swp
3
*~
4
.dir
b/nfdhcp.py
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 :

Also available in: Unified diff