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