Rename nfdhcp.py to nfdhcpd
[snf-nfdhcpd] / nfdhcpd
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 time
26 import logging
27 import logging.handlers
28 import subprocess
29
30 import daemon
31 import nfqueue
32 import pyinotify
33
34 import IPy
35 from select import select
36 from socket import AF_INET, AF_INET6
37
38 from scapy.layers.l2 import Ether
39 from scapy.layers.inet import IP, UDP
40 from scapy.layers.inet6 import *
41 from scapy.layers.dhcp import BOOTP, DHCP
42 from scapy.sendrecv import sendp
43
44 DEFAULT_PATH = "/var/run/ganeti-dhcpd"
45 DEFAULT_NFQUEUE_NUM = 42
46 DEFAULT_USER = "nobody"
47 DEFAULT_LEASE_TIME = 604800 # 1 week
48 DEFAULT_RENEWAL_TIME = 600  # 10 min
49
50 LOG_FILENAME = "/var/log/nfdhcpd/nfdhcpd.log"
51
52 SYSFS_NET = "/sys/class/net"
53 DHCP_DUMMY_SERVER_IP = "1.2.3.4"
54
55 LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
56 PERIODIC_RA_TIMEOUT = 30 # seconds
57
58 DHCPDISCOVER = 1
59 DHCPOFFER = 2
60 DHCPREQUEST = 3
61 DHCPDECLINE = 4
62 DHCPACK = 5
63 DHCPNAK = 6
64 DHCPRELEASE = 7
65 DHCPINFORM = 8
66
67 DHCP_TYPES = {
68     DHCPDISCOVER: "DHCPDISCOVER",
69     DHCPOFFER: "DHCPOFFER",
70     DHCPREQUEST: "DHCPREQUEST",
71     DHCPDECLINE: "DHCPDECLINE",
72     DHCPACK: "DHCPACK",
73     DHCPNAK: "DHCPNAK",
74     DHCPRELEASE: "DHCPRELEASE",
75     DHCPINFORM: "DHCPINFORM",
76 }
77
78 DHCP_REQRESP = {
79     DHCPDISCOVER: DHCPOFFER,
80     DHCPREQUEST: DHCPACK,
81     DHCPINFORM: DHCPACK,
82     }
83
84 class ClientFileHandler(pyinotify.ProcessEvent):
85     def __init__(self, server):
86         pyinotify.ProcessEvent.__init__(self)
87         self.server = server
88
89     def process_IN_DELETE(self, event):
90         self.server.remove_iface(event.name)
91
92     def process_IN_CLOSE_WRITE(self, event):
93         self.server.add_iface(os.path.join(event.path, event.name))
94
95
96 class Client(object):
97     def __init__(self, mac=None, ips=None, link=None, hostname=None):
98         self.mac = mac
99         self.ips = ips
100         self.hostname = hostname
101         self.link = link
102         self.iface = None
103
104     @property
105     def ip(self):
106         return self.ips[0]
107
108     def is_valid(self):
109         return self.mac is not None and self.ips is not None\
110                and self.hostname is not None
111
112
113 class Subnet(object):
114     def __init__(self, net=None, gw=None, dev=None):
115         if isinstance(net, str):
116             self.net = IPy.IP(net)
117         else:
118             self.net = net
119         self.gw = gw
120         self.dev = dev
121
122     @property
123     def netmask(self):
124         return str(self.net.netmask())
125
126     @property
127     def broadcast(self):
128         return str(self.net.broadcast())
129
130     @property
131     def prefix(self):
132         return self.net.net()
133
134     @property
135     def prefixlen(self):
136         return self.net.prefixlen()
137
138     @staticmethod
139     def _make_eui64(net, mac):
140         """ Compute an EUI-64 address from an EUI-48 (MAC) address
141
142         """
143         comp = mac.split(":")
144         prefix = IPy.IP(net).net().strFullsize().split(":")[:4]
145         eui64 = comp[:3] + ["ff", "fe"] + comp[3:]
146         eui64[0] = "%02x" % (int(eui64[0], 16) ^ 0x02)
147         for l in range(0, len(eui64), 2):
148             prefix += ["".join(eui64[l:l+2])]
149         return IPy.IP(":".join(prefix))
150
151     def make_eui64(self, mac):
152         return self._make_eui64(self.net, mac)
153
154     def make_ll64(self, mac):
155         return self._make_eui64("fe80::", mac)
156
157
158 class VMNetProxy(object):
159     def __init__(self, data_path, dhcp_queue_num=None,
160                  rs_queue_num=None, ns_queue_num=None):
161         self.data_path = data_path
162         self.clients = {}
163         self.subnets = {}
164         self.ifaces = {}
165         self.v6nets = {}
166         self.nfq = {}
167
168         # Inotify setup
169         self.wm = pyinotify.WatchManager()
170         mask = pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"]
171         mask |= pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"]
172         handler = ClientFileHandler(self)
173         self.notifier = pyinotify.Notifier(self.wm, handler)
174         self.wm.add_watch(self.data_path, mask, rec=True)
175
176         # NFQUEUE setup
177         if dhcp_queue_num is not None:
178             self._setup_nfqueue(dhcp_queue_num, AF_INET, self.dhcp_response)
179
180         if rs_queue_num is not None:
181             self._setup_nfqueue(rs_queue_num, AF_INET6, self.rs_response)
182
183         if ns_queue_num is not None:
184             self._setup_nfqueue(ns_queue_num, AF_INET6, self.ns_response)
185
186     def _setup_nfqueue(self, queue_num, family, callback):
187         logging.debug("Setting up NFQUEUE for queue %d, AF %s" %
188                       (queue_num, family))
189         q = nfqueue.queue()
190         q.set_callback(callback)
191         q.fast_open(queue_num, family)
192         q.set_queue_maxlen(5000)
193         # This is mandatory for the queue to operate
194         q.set_mode(nfqueue.NFQNL_COPY_PACKET)
195         self.nfq[q.get_fd()] = q
196
197     def build_config(self):
198         self.clients.clear()
199         self.subnets.clear()
200
201         for file in glob.glob(os.path.join(self.data_path, "*")):
202             self.add_iface(file)
203
204     def get_ifindex(self, iface):
205         """ Get the interface index from sysfs
206
207         """
208         file = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
209         if not file.startswith(SYSFS_NET):
210             return None
211
212         ifindex = None
213
214         try:
215             f = open(file, 'r')
216             ifindex = int(f.readline().strip())
217             f.close()
218         except:
219             pass
220
221         return ifindex
222
223
224     def get_iface_hw_addr(self, iface):
225         """ Get the interface hardware address from sysfs
226
227         """
228         file = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
229         if not file.startswith(SYSFS_NET):
230             return None
231
232         addr = None
233         try:
234             f = open(file, 'r')
235             addr = f.readline().strip()
236             f.close()
237         except:
238             pass
239         return addr
240
241     def parse_routing_table(self, table="main", family=4):
242         """ Parse the given routing table to get connected route, gateway and
243         default device.
244
245         """
246         ipro = subprocess.Popen(["ip", "-%d" % family, "ro", "ls",
247                                  "table", table], stdout=subprocess.PIPE)
248         routes = ipro.stdout.readlines()
249
250         def_gw = None
251         def_dev = None
252         def_net = None
253
254         for route in routes:
255             match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
256             if match:
257                 def_gw, def_dev = match.groups()
258                 break
259
260         for route in routes:
261             # Find the least-specific connected route
262             try:
263                 def_net = re.match("^([^\\s]+) dev %s" %
264                                    def_dev, route).groups()[0]
265                 def_net = IPy.IP(def_net)
266             except:
267                 pass
268
269         return Subnet(net=def_net, gw=def_gw, dev=def_dev)
270
271     def parse_binding_file(self, path):
272         """ Read a client configuration from a tap file
273
274         """
275         try:
276             iffile = open(path, 'r')
277         except:
278             return (None, None, None, None)
279         mac = None
280         ips = None
281         link = None
282         hostname = None
283
284         for line in iffile:
285             if line.startswith("IP="):
286                 ip = line.strip().split("=")[1]
287                 ips = ip.split()
288             elif line.startswith("MAC="):
289                 mac = line.strip().split("=")[1]
290             elif line.startswith("LINK="):
291                 link = line.strip().split("=")[1]
292             elif line.startswith("HOSTNAME="):
293                 hostname = line.strip().split("=")[1]
294
295         return Client(mac=mac, ips=ips, link=link, hostname=hostname)
296
297     def add_iface(self, path):
298         """ Add an interface to monitor
299
300         """
301         iface = os.path.basename(path)
302
303         logging.debug("Updating configuration for %s" % iface)
304         binding = self.parse_binding_file(path)
305         ifindex = self.get_ifindex(iface)
306
307         if ifindex is None:
308             logging.warn("Stale configuration for %s found" % iface)
309         else:
310             if binding.is_valid():
311                 binding.iface = iface
312                 self.clients[binding.mac] = binding
313                 self.subnets[binding.link] = self.parse_routing_table(
314                                                 binding.link)
315                 logging.debug("Added client %s on %s" %
316                               (binding.hostname, iface))
317                 self.ifaces[ifindex] = iface
318                 self.v6nets[iface] = self.parse_routing_table(binding.link, 6)
319
320     def remove_iface(self, iface):
321         """ Cleanup clients on a removed interface
322
323         """
324         if iface in self.v6nets:
325             del self.v6nets.iface
326
327         for mac in self.clients.keys():
328             if self.clients[mac].iface == iface:
329                 del self.clients[mac]
330
331         for ifindex in self.ifaces.keys():
332             if self.ifaces[ifindex] == iface:
333                 del self.ifaces[ifindex]
334
335         logging.debug("Removed interface %s" % iface)
336
337     def dhcp_response(self, i, payload):
338         """ Generate a reply to a BOOTP/DHCP request
339
340         """
341         # Decode the response - NFQUEUE relays IP packets
342         pkt = IP(payload.get_data())
343
344         # Get the actual interface from the ifindex
345         iface = self.ifaces[payload.get_indev()]
346
347         # Signal the kernel that it shouldn't further process the packet
348         payload.set_verdict(nfqueue.NF_DROP)
349
350         # Get the client MAC address
351         resp = pkt.getlayer(BOOTP).copy()
352         hlen = resp.hlen
353         mac = resp.chaddr[:hlen].encode("hex")
354         mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
355
356         # Server responses are always BOOTREPLYs
357         resp.op = "BOOTREPLY"
358         del resp.payload
359
360         try:
361             binding = self.clients[mac]
362         except KeyError:
363             logging.warn("Invalid client %s on %s" % (mac, iface))
364             return
365
366         if iface != binding.iface:
367             logging.warn("Received spoofed DHCP request for %s from interface"
368                          " %s instead of %s" %
369                          (mac, iface, binding.iface))
370             return
371
372         resp = Ether(dst=mac, src=self.get_iface_hw_addr(iface))/\
373                IP(src=DHCP_DUMMY_SERVER_IP, dst=binding.ip)/\
374                UDP(sport=pkt.dport, dport=pkt.sport)/resp
375         subnet = self.subnets[binding.link]
376
377         if not DHCP in pkt:
378             logging.warn("Invalid request from %s on %s, no DHCP"
379                          " payload found" % (binding.mac, iface))
380             return
381
382         dhcp_options = []
383         requested_addr = binding.ip
384         for opt in pkt[DHCP].options:
385             if type(opt) is tuple and opt[0] == "message-type":
386                 req_type = opt[1]
387             if type(opt) is tuple and opt[0] == "requested_addr":
388                 requested_addr = opt[1]
389
390         logging.info("%s from %s on %s" %
391                     (DHCP_TYPES.get(req_type, "UNKNOWN"), binding.mac, iface))
392
393         if req_type == DHCPREQUEST and requested_addr != binding.ip:
394             resp_type = DHCPNAK
395             logging.info("Sending DHCPNAK to %s on %s: requested %s"
396                          " instead of %s" %
397                          (binding.mac, iface, requested_addr, binding.ip))
398
399         elif req_type in (DHCPDISCOVER, DHCPREQUEST):
400             resp_type = DHCP_REQRESP[req_type]
401             resp.yiaddr = self.clients[mac].ip
402             dhcp_options += [
403                  ("hostname", binding.hostname),
404                  ("domain", binding.hostname.split('.', 1)[-1]),
405                  ("router", subnet.gw),
406                  ("name_server", "194.177.210.10"),
407                  ("name_server", "194.177.210.211"),
408                  ("broadcast_address", str(subnet.broadcast)),
409                  ("subnet_mask", str(subnet.netmask)),
410                  ("renewal_time", DEFAULT_RENEWAL_TIME),
411                  ("lease_time", DEFAULT_LEASE_TIME),
412             ]
413
414         elif req_type == DHCPINFORM:
415             resp_type = DHCP_REQRESP[req_type]
416             dhcp_options += [
417                  ("hostname", binding.hostname),
418                  ("domain", binding.hostname.split('.', 1)[-1]),
419                  ("name_server", "194.177.210.10"),
420                  ("name_server", "194.177.210.211"),
421             ]
422
423         elif req_type == DHCPRELEASE:
424             # Log and ignore
425             logging.info("DHCPRELEASE from %s on %s" %
426                          (binding.mac, iface))
427             return
428
429         # Finally, always add the server identifier and end options
430         dhcp_options += [
431             ("message-type", resp_type),
432             ("server_id", DHCP_DUMMY_SERVER_IP),
433             "end"
434         ]
435         resp /= DHCP(options=dhcp_options)
436
437         logging.info("%s to %s (%s) on %s" %
438                       (DHCP_TYPES[resp_type], mac, binding.ip, iface))
439         sendp(resp, iface=iface, verbose=False)
440
441     def rs_response(self, i, payload):
442         """ Generate a reply to a BOOTP/DHCP request
443
444         """
445         # Get the actual interface from the ifindex
446         iface = self.ifaces[payload.get_indev()]
447         ifmac = self.get_iface_hw_addr(iface)
448         subnet = self.v6nets[iface]
449         ifll = subnet.make_ll64(ifmac)
450
451         # Signal the kernel that it shouldn't further process the packet
452         payload.set_verdict(nfqueue.NF_DROP)
453
454         resp = Ether(src=self.get_iface_hw_addr(iface))/\
455                IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
456                ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
457                                      prefixlen=subnet.prefixlen)
458
459         logging.info("RA on %s for %s" % (iface, subnet.net))
460         sendp(resp, iface=iface, verbose=False)
461
462     def ns_response(self, i, payload):
463         """ Generate a reply to an ICMPv6 neighbor solicitation
464
465         """
466         # Get the actual interface from the ifindex
467         iface = self.ifaces[payload.get_indev()]
468         ifmac = self.get_iface_hw_addr(iface)
469         subnet = self.v6nets[iface]
470         ifll = subnet.make_ll64(ifmac)
471
472         ns = IPv6(payload.get_data())
473
474         if not (subnet.net.overlaps(ns.tgt) or str(ns.tgt) == str(ifll)):
475             logging.debug("Received NS for a non-routable IP (%s)" % ns.tgt)
476             payload.set_verdict(nfqueue.NF_ACCEPT)
477             return 1
478
479         payload.set_verdict(nfqueue.NF_DROP)
480
481         resp = Ether(src=ifmac, dst=ns.lladdr)/\
482                IPv6(src=str(ifll), dst=ns.src)/\
483                ICMPv6ND_NA(R=1, O=0, S=1, tgt=ns.tgt)/\
484                ICMPv6NDOptDstLLAddr(lladdr=ifmac)
485
486         logging.info("NA on %s for %s" % (iface, ns.tgt))
487         sendp(resp, iface=iface, verbose=False)
488         return 1
489
490     def send_periodic_ra(self):
491         logging.debug("Sending out periodic RAs")
492         for client in self.clients.values():
493             iface = client.iface
494             subnet = self.v6nets[iface]
495             ifmac = self.get_iface_hw_addr(iface)
496             ifll = subnet.make_ll64(ifmac)
497             resp = Ether(src=ifmac)/\
498                    IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
499                    ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
500                                          prefixlen=subnet.prefixlen)
501             try:
502                 sendp(resp, iface=iface, verbose=False)
503             except:
504                 logging.debug("Periodic RA on %s failed" % iface)
505
506     def serve(self):
507         """ Loop forever, serving DHCP requests
508
509         """
510         self.build_config()
511
512         iwfd = self.notifier._fd
513
514         start = time.time()
515         timeout = PERIODIC_RA_TIMEOUT
516         self.send_periodic_ra()
517
518         while True:
519             rlist, _, xlist = select(self.nfq.keys() + [iwfd], [], [], timeout)
520             # First check if there are any inotify (= configuration change)
521             # events
522             if not (rlist or xlist):
523                 # We were woken up by a timeout
524                 start = time.time()
525                 self.send_periodic_ra()
526
527             else:
528                 if iwfd in rlist:
529                     self.notifier.read_events()
530                     self.notifier.process_events()
531                     rlist.remove(iwfd)
532
533                 for fd in rlist:
534                     self.nfq[fd].process_pending()
535
536             # Calculate the new timeout
537             timeout = PERIODIC_RA_TIMEOUT - (time.time() - start)
538
539             # Just to be safe we won't miss anything
540             if timeout <= 0:
541                 logging.debug("Send extra RAs")
542                 self.send_periodic_ra()
543                 timeout = PERIODIC_RA_TIMEOUT
544
545
546
547 if __name__ == "__main__":
548     import optparse
549     from capng import *
550     from pwd import getpwnam, getpwuid
551
552     parser = optparse.OptionParser()
553     parser.add_option("-p", "--path", dest="data_path",
554                       help="The location of the data files", metavar="DIR",
555                       default=DEFAULT_PATH)
556     parser.add_option("-c", "--dhcp-queue", dest="dhcp_queue",
557                       help="The nfqueue to receive DHCP requests from"
558                            " (default: %d" % DEFAULT_NFQUEUE_NUM, type="int",
559                       metavar="NUM", default=DEFAULT_NFQUEUE_NUM)
560     parser.add_option("-r", "--rs-queue", dest="rs_queue",
561                       help="The nfqueue to receive IPv6 router"
562                            " solicitations from (default: %d)" %
563                            DEFAULT_NFQUEUE_NUM, type="int",
564                       metavar="NUM", default=DEFAULT_NFQUEUE_NUM)
565     parser.add_option("-n", "--ns-queue", dest="ns_queue",
566                       help="The nfqueue to receive IPv6 neighbor"
567                            " solicitations from (default: %d)" %
568                            DEFAULT_NFQUEUE_NUM, type="int",
569                       metavar="NUM", default=44)
570     parser.add_option("-u", "--user", dest="user",
571                       help="An unprivileged user to run as",
572                       metavar="UID", default=DEFAULT_USER)
573     parser.add_option("-d", "--debug", action="store_true", dest="debug",
574                       help="Turn on debugging messages")
575     parser.add_option("-f", "--foreground", action="store_false", dest="daemonize",
576                       default=True, help="Do not daemonize, stay in the foreground")
577
578
579     opts, args = parser.parse_args()
580
581     if opts.daemonize:
582         d = daemon.DaemonContext()
583         d.open()
584
585     pidfile = open("/var/run/nfdhcpd.pid", "w")
586     pidfile.write("%s" % os.getpid())
587     pidfile.close()
588
589     logger = logging.getLogger()
590     if opts.debug:
591         logger.setLevel(logging.DEBUG)
592     else:
593         logger.setLevel(logging.INFO)
594
595     if opts.daemonize:
596         handler = logging.handlers.RotatingFileHandler(LOG_FILENAME,
597                                                        maxBytes=2097152)
598     else:
599         handler = logging.StreamHandler()
600
601     handler.setFormatter(logging.Formatter(LOG_FORMAT))
602     logger.addHandler(handler)
603
604     logging.info("Starting up")
605     proxy = VMNetProxy(opts.data_path, opts.dhcp_queue,
606                        opts.rs_queue, opts.ns_queue)
607
608     # Drop all capabilities except CAP_NET_RAW and change uid
609     try:
610         uid = getpwuid(int(opts.user))
611     except ValueError:
612         uid = getpwnam(opts.user)
613
614     logging.info("Setting capabilities and changing uid")
615     logging.debug("User: %s, uid: %d, gid: %d" %
616                   (opts.user, uid.pw_uid, uid.pw_gid))
617     capng_clear(CAPNG_SELECT_BOTH)
618     capng_update(CAPNG_ADD, CAPNG_EFFECTIVE|CAPNG_PERMITTED, CAP_NET_RAW)
619     capng_change_id(uid.pw_uid, uid.pw_gid,
620                     CAPNG_DROP_SUPP_GRP | CAPNG_CLEAR_BOUNDING)
621     logging.info("Ready to serve requests")
622     proxy.serve()
623
624
625 # vim: set ts=4 sts=4 sw=4 et :