4 # nfdcpd: A promiscuous, NFQUEUE-based DHCP server for virtual machine hosting
5 # Copyright (c) 2010 GRNET SA
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.
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.
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.
27 import logging.handlers
37 from select import select
38 from socket import AF_INET, AF_INET6
40 from scapy.layers.l2 import Ether
41 from scapy.layers.inet import IP, UDP
42 from scapy.layers.inet6 import *
43 from scapy.layers.dhcp import BOOTP, DHCP
44 from scapy.sendrecv import sendp
46 DEFAULT_CONFIG = "/etc/nfdhcpd/nfdhcpd.conf"
47 DEFAULT_PATH = "/var/run/ganeti-dhcpd"
48 DEFAULT_USER = "nobody"
49 DEFAULT_LEASE_LIFETIME = 604800 # 1 week
50 DEFAULT_LEASE_RENEWAL = 600 # 10 min
51 DEFAULT_RA_PERIOD = 300 # seconds
52 DHCP_DUMMY_SERVER_IP = "1.2.3.4"
54 LOG_FILENAME = "nfdhcpd.log"
56 SYSFS_NET = "/sys/class/net"
58 LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
60 # Configuration file specification (see configobj documentation)
69 enable_dhcp = boolean(default=True)
70 lease_lifetime = integer(min=0, max=4294967295)
71 lease_renewal = integer(min=0, max=4294967295)
73 dhcp_queue = integer(min=0, max=65535)
74 nameservers = ip_addr_list(family=4)
77 enable_ipv6 = boolean(default=True)
78 ra_period = integer(min=1, max=4294967295)
79 rs_queue = integer(min=0, max=65535)
80 ns_queue = integer(min=0, max=65535)
81 nameservers = ip_addr_list(family=6)
95 DHCPDISCOVER: "DHCPDISCOVER",
96 DHCPOFFER: "DHCPOFFER",
97 DHCPREQUEST: "DHCPREQUEST",
98 DHCPDECLINE: "DHCPDECLINE",
101 DHCPRELEASE: "DHCPRELEASE",
102 DHCPINFORM: "DHCPINFORM",
106 DHCPDISCOVER: DHCPOFFER,
107 DHCPREQUEST: DHCPACK,
112 class ClientFileHandler(pyinotify.ProcessEvent):
113 def __init__(self, server):
114 pyinotify.ProcessEvent.__init__(self)
117 def process_IN_DELETE(self, event):
118 self.server.remove_iface(event.name)
120 def process_IN_CLOSE_WRITE(self, event):
121 self.server.add_iface(os.path.join(event.path, event.name))
124 class Client(object):
125 def __init__(self, mac=None, ips=None, link=None, hostname=None):
128 self.hostname = hostname
137 return self.mac is not None and self.ips is not None\
138 and self.hostname is not None
141 class Subnet(object):
142 def __init__(self, net=None, gw=None, dev=None):
143 if isinstance(net, str):
144 self.net = IPy.IP(net)
152 return str(self.net.netmask())
156 return str(self.net.broadcast())
160 return self.net.net()
164 return self.net.prefixlen()
167 def _make_eui64(net, mac):
168 """ Compute an EUI-64 address from an EUI-48 (MAC) address
171 comp = mac.split(":")
172 prefix = IPy.IP(net).net().strFullsize().split(":")[:4]
173 eui64 = comp[:3] + ["ff", "fe"] + comp[3:]
174 eui64[0] = "%02x" % (int(eui64[0], 16) ^ 0x02)
175 for l in range(0, len(eui64), 2):
176 prefix += ["".join(eui64[l:l+2])]
177 return IPy.IP(":".join(prefix))
179 def make_eui64(self, mac):
180 return self._make_eui64(self.net, mac)
182 def make_ll64(self, mac):
183 return self._make_eui64("fe80::", mac)
186 class VMNetProxy(object):
187 def __init__(self, data_path, dhcp_queue_num=None,
188 rs_queue_num=None, ns_queue_num=None,
189 dhcp_lease_lifetime=DEFAULT_LEASE_LIFETIME,
190 dhcp_lease_renewal=DEFAULT_LEASE_RENEWAL,
191 dhcp_server_ip=DHCP_DUMMY_SERVER_IP, dhcp_nameservers = [],
192 ra_period=DEFAULT_RA_PERIOD, ipv6_nameservers = []):
194 self.data_path = data_path
195 self.lease_lifetime = dhcp_lease_lifetime
196 self.lease_renewal = dhcp_lease_renewal
197 self.dhcp_server_ip = dhcp_server_ip
198 self.ra_period = ra_period
199 self.dhcp_nameservers = dhcp_nameservers
200 self.ipv6_nameservers = ipv6_nameservers
201 self.ipv6_enabled = False
210 self.wm = pyinotify.WatchManager()
211 mask = pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"]
212 mask |= pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"]
213 handler = ClientFileHandler(self)
214 self.notifier = pyinotify.Notifier(self.wm, handler)
215 self.wm.add_watch(self.data_path, mask, rec=True)
218 if dhcp_queue_num is not None:
219 self._setup_nfqueue(dhcp_queue_num, AF_INET, self.dhcp_response)
221 if rs_queue_num is not None:
222 self._setup_nfqueue(rs_queue_num, AF_INET6, self.rs_response)
223 self.ipv6_enabled = True
225 if ns_queue_num is not None:
226 self._setup_nfqueue(ns_queue_num, AF_INET6, self.ns_response)
227 self.ipv6_enabled = True
229 def _setup_nfqueue(self, queue_num, family, callback):
230 logging.debug("Setting up NFQUEUE for queue %d, AF %s" %
233 q.set_callback(callback)
234 q.fast_open(queue_num, family)
235 q.set_queue_maxlen(5000)
236 # This is mandatory for the queue to operate
237 q.set_mode(nfqueue.NFQNL_COPY_PACKET)
238 self.nfq[q.get_fd()] = q
240 def build_config(self):
244 for file in glob.glob(os.path.join(self.data_path, "*")):
247 def get_ifindex(self, iface):
248 """ Get the interface index from sysfs
251 file = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
252 if not file.startswith(SYSFS_NET):
259 except EnvironmentError:
260 logging.debug("%s is probably down, removing" % iface)
261 self.remove_iface(iface)
266 ifindex = f.readline().strip()
268 ifindex = int(ifindex)
269 except ValueError, e:
270 logging.warn("Failed to get ifindex for %s, cannot parse sysfs"
271 " output '%s'" % (iface, ifindex))
272 except EnvironmentError, e:
273 logging.warn("Error reading %s's ifindex from sysfs: %s" %
275 self.remove_iface(iface)
282 def get_iface_hw_addr(self, iface):
283 """ Get the interface hardware address from sysfs
286 file = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
287 if not file.startswith(SYSFS_NET):
293 except EnvironmentError:
294 logging.debug("%s is probably down, removing" % iface)
295 self.remove_iface(iface)
299 addr = f.readline().strip()
300 except EnvironmentError, e:
301 logging.warn("Failed to read hw address for %s from sysfs: %s" %
308 def parse_routing_table(self, table="main", family=4):
309 """ Parse the given routing table to get connected route, gateway and
313 ipro = subprocess.Popen(["ip", "-%d" % family, "ro", "ls",
314 "table", table], stdout=subprocess.PIPE)
315 routes = ipro.stdout.readlines()
322 match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
324 def_gw, def_dev = match.groups()
328 # Find the least-specific connected route
329 m = re.match("^([^\\s]+) dev %s" % def_dev, route)
332 def_net = m.groups(1)
335 def_net = IPy.IP(def_net)
336 except ValueError, e:
337 logging.warn("Unable to parse default route entry %s: %s" %
340 return Subnet(net=def_net, gw=def_gw, dev=def_dev)
342 def parse_binding_file(self, path):
343 """ Read a client configuration from a tap file
347 iffile = open(path, 'r')
348 except EnvironmentError, e:
349 logging.warn("Unable to open binding file %s: %s" % (path, str(e)))
350 return (None, None, None, None)
358 if line.startswith("IP="):
359 ip = line.strip().split("=")[1]
361 elif line.startswith("MAC="):
362 mac = line.strip().split("=")[1]
363 elif line.startswith("LINK="):
364 link = line.strip().split("=")[1]
365 elif line.startswith("HOSTNAME="):
366 hostname = line.strip().split("=")[1]
368 return Client(mac=mac, ips=ips, link=link, hostname=hostname)
370 def add_iface(self, path):
371 """ Add an interface to monitor
374 iface = os.path.basename(path)
376 logging.debug("Updating configuration for %s" % iface)
377 binding = self.parse_binding_file(path)
378 ifindex = self.get_ifindex(iface)
381 logging.warn("Stale configuration for %s found" % iface)
383 if binding.is_valid():
384 binding.iface = iface
385 self.clients[binding.mac] = binding
386 self.subnets[binding.link] = self.parse_routing_table(
388 logging.debug("Added client %s on %s" %
389 (binding.hostname, iface))
390 self.ifaces[ifindex] = iface
391 self.v6nets[iface] = self.parse_routing_table(binding.link, 6)
393 def remove_iface(self, iface):
394 """ Cleanup clients on a removed interface
397 if iface in self.v6nets:
398 del self.v6nets[iface]
400 for mac in self.clients.keys():
401 if self.clients[mac].iface == iface:
402 del self.clients[mac]
404 for ifindex in self.ifaces.keys():
405 if self.ifaces[ifindex] == iface:
406 del self.ifaces[ifindex]
408 logging.debug("Removed interface %s" % iface)
410 def dhcp_response(self, i, payload):
411 """ Generate a reply to a BOOTP/DHCP request
414 # Decode the response - NFQUEUE relays IP packets
415 pkt = IP(payload.get_data())
417 # Get the actual interface from the ifindex
418 iface = self.ifaces[payload.get_indev()]
420 # Signal the kernel that it shouldn't further process the packet
421 payload.set_verdict(nfqueue.NF_DROP)
423 # Get the client MAC address
424 resp = pkt.getlayer(BOOTP).copy()
426 mac = resp.chaddr[:hlen].encode("hex")
427 mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
429 # Server responses are always BOOTREPLYs
430 resp.op = "BOOTREPLY"
434 binding = self.clients[mac]
436 logging.warn("Invalid client %s on %s" % (mac, iface))
439 if iface != binding.iface:
440 logging.warn("Received spoofed DHCP request for %s from interface"
441 " %s instead of %s" %
442 (mac, iface, binding.iface))
445 resp = Ether(dst=mac, src=self.get_iface_hw_addr(iface))/\
446 IP(src=DHCP_DUMMY_SERVER_IP, dst=binding.ip)/\
447 UDP(sport=pkt.dport, dport=pkt.sport)/resp
448 subnet = self.subnets[binding.link]
451 logging.warn("Invalid request from %s on %s, no DHCP"
452 " payload found" % (binding.mac, iface))
456 requested_addr = binding.ip
457 for opt in pkt[DHCP].options:
458 if type(opt) is tuple and opt[0] == "message-type":
460 if type(opt) is tuple and opt[0] == "requested_addr":
461 requested_addr = opt[1]
463 logging.info("%s from %s on %s" %
464 (DHCP_TYPES.get(req_type, "UNKNOWN"), binding.mac, iface))
466 if req_type == DHCPREQUEST and requested_addr != binding.ip:
468 logging.info("Sending DHCPNAK to %s on %s: requested %s"
470 (binding.mac, iface, requested_addr, binding.ip))
472 elif req_type in (DHCPDISCOVER, DHCPREQUEST):
473 resp_type = DHCP_REQRESP[req_type]
474 resp.yiaddr = self.clients[mac].ip
476 ("hostname", binding.hostname),
477 ("domain", binding.hostname.split('.', 1)[-1]),
478 ("router", subnet.gw),
479 ("broadcast_address", str(subnet.broadcast)),
480 ("subnet_mask", str(subnet.netmask)),
481 ("renewal_time", self.lease_renewal),
482 ("lease_time", self.lease_lifetime),
484 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
486 elif req_type == DHCPINFORM:
487 resp_type = DHCP_REQRESP[req_type]
489 ("hostname", binding.hostname),
490 ("domain", binding.hostname.split('.', 1)[-1]),
492 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
494 elif req_type == DHCPRELEASE:
496 logging.info("DHCPRELEASE from %s on %s" %
497 (binding.mac, iface))
500 # Finally, always add the server identifier and end options
502 ("message-type", resp_type),
503 ("server_id", DHCP_DUMMY_SERVER_IP),
506 resp /= DHCP(options=dhcp_options)
508 logging.info("%s to %s (%s) on %s" %
509 (DHCP_TYPES[resp_type], mac, binding.ip, iface))
510 sendp(resp, iface=iface, verbose=False)
512 def rs_response(self, i, payload):
513 """ Generate a reply to a BOOTP/DHCP request
516 # Get the actual interface from the ifindex
517 iface = self.ifaces[payload.get_indev()]
518 ifmac = self.get_iface_hw_addr(iface)
519 subnet = self.v6nets[iface]
520 ifll = subnet.make_ll64(ifmac)
522 # Signal the kernel that it shouldn't further process the packet
523 payload.set_verdict(nfqueue.NF_DROP)
525 resp = Ether(src=self.get_iface_hw_addr(iface))/\
526 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
527 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
528 prefixlen=subnet.prefixlen)
530 if self.ipv6_nameservers:
531 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
532 lifetime=self.ra_period * 3)
534 logging.info("RA on %s for %s" % (iface, subnet.net))
535 sendp(resp, iface=iface, verbose=False)
537 def ns_response(self, i, payload):
538 """ Generate a reply to an ICMPv6 neighbor solicitation
541 # Get the actual interface from the ifindex
542 iface = self.ifaces[payload.get_indev()]
543 ifmac = self.get_iface_hw_addr(iface)
544 subnet = self.v6nets[iface]
545 ifll = subnet.make_ll64(ifmac)
547 ns = IPv6(payload.get_data())
549 if not (subnet.net.overlaps(ns.tgt) or str(ns.tgt) == str(ifll)):
550 logging.debug("Received NS for a non-routable IP (%s)" % ns.tgt)
551 payload.set_verdict(nfqueue.NF_ACCEPT)
554 payload.set_verdict(nfqueue.NF_DROP)
557 client_lladdr = ns.lladdr
558 except AttributeError:
561 resp = Ether(src=ifmac, dst=client_lladdr)/\
562 IPv6(src=str(ifll), dst=ns.src)/\
563 ICMPv6ND_NA(R=1, O=0, S=1, tgt=ns.tgt)/\
564 ICMPv6NDOptDstLLAddr(lladdr=ifmac)
566 logging.info("NA on %s for %s" % (iface, ns.tgt))
567 sendp(resp, iface=iface, verbose=False)
570 def send_periodic_ra(self):
571 # Use a separate thread as this may take a _long_ time with
572 # many interfaces and we want to be responsive in the mean time
573 threading.Thread(target=self._send_periodic_ra).start()
575 def _send_periodic_ra(self):
576 logging.debug("Sending out periodic RAs")
579 for client in self.clients.values():
581 ifmac = self.get_iface_hw_addr(iface)
585 subnet = self.v6nets[iface]
586 ifll = subnet.make_ll64(ifmac)
587 resp = Ether(src=ifmac)/\
588 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
589 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
590 prefixlen=subnet.prefixlen)
591 if self.ipv6_nameservers:
592 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
593 lifetime=self.ra_period * 3)
595 sendp(resp, iface=iface, verbose=False)
596 except socket.error, e:
597 logging.warn("Periodic RA on %s failed: %s" % (iface, str(e)))
599 logging.warn("Unkown error during periodic RA on %s: %s" %
602 logging.debug("Sent %d RAs in %.2f seconds" % (i, time.time() - start))
605 """ Loop forever, serving DHCP requests
610 iwfd = self.notifier._fd
613 if self.ipv6_enabled:
614 timeout = self.ra_period
615 self.send_periodic_ra()
620 rlist, _, xlist = select(self.nfq.keys() + [iwfd], [], [], timeout)
622 logging.warn("Warning: Exception on %s" %
623 ", ".join([ str(fd) for fd in xlist]))
627 # First check if there are any inotify (= configuration change)
629 self.notifier.read_events()
630 self.notifier.process_events()
635 self.nfq[fd].process_pending()
637 logging.warn("Error processing fd %d: %s" %
640 if self.ipv6_enabled:
641 # Calculate the new timeout
642 timeout = self.ra_period - (time.time() - start)
646 self.send_periodic_ra()
647 timeout = self.ra_period - (time.time() - start)
650 if __name__ == "__main__":
652 from cStringIO import StringIO
654 from pwd import getpwnam, getpwuid
655 from configobj import ConfigObj, ConfigObjError, flatten_errors
659 validator = validate.Validator()
661 def is_ip_list(value, family=4):
665 raise vaildate.VdtParamError(family)
666 if isinstance(value, (str, unicode)):
668 if not isinstance(value, list):
669 raise validate.VdtTypeError(value)
675 raise validate.VdtValueError(entry)
677 if ip.version() != family:
678 raise validate.VdtValueError(entry)
681 validator.functions["ip_addr_list"] = is_ip_list
682 config_spec = StringIO(CONFIG_SPEC)
685 parser = optparse.OptionParser()
686 parser.add_option("-c", "--config", dest="config_file",
687 help="The location of the data files", metavar="FILE",
688 default=DEFAULT_CONFIG)
689 parser.add_option("-d", "--debug", action="store_true", dest="debug",
690 help="Turn on debugging messages")
691 parser.add_option("-f", "--foreground", action="store_false",
692 dest="daemonize", default=True,
693 help="Do not daemonize, stay in the foreground")
696 opts, args = parser.parse_args()
699 d = daemon.DaemonContext()
704 config = ConfigObj(opts.config_file, configspec=config_spec)
705 except ConfigObjError, e:
706 sys.stderr.write("Failed to parse config file %s: %s" %
707 (opts.config_file, str(e)))
710 results = config.validate(validator)
712 logging.fatal("Configuration file validation failed! See errors below:")
713 for (section_list, key, _) in flatten_errors(config, results):
715 logging.fatal(" '%s' in section '%s' failed validation" %
716 (key, ", ".join(section_list)))
718 logging.fatal(" Section '%s' is missing" %
719 ", ".join(section_list))
722 pidfile = open(config["general"]["pidfile"], "w")
723 pidfile.write("%s" % os.getpid())
726 logger = logging.getLogger()
728 logger.setLevel(logging.DEBUG)
730 logger.setLevel(logging.INFO)
732 logging.info("Starting up")
735 if config["dhcp"].as_bool("enable_dhcp"):
737 "dhcp_queue_num": config["dhcp"].as_int("dhcp_queue"),
738 "dhcp_lease_lifetime": config["dhcp"].as_int("lease_lifetime"),
739 "dhcp_lease_renewal": config["dhcp"].as_int("lease_renewal"),
740 "dhcp_server_ip": config["dhcp"]["server_ip"],
741 "dhcp_nameservers": config["dhcp"]["nameservers"],
744 if config["ipv6"].as_bool("enable_ipv6"):
746 "rs_queue_num": config["ipv6"].as_int("rs_queue"),
747 "ns_queue_num": config["ipv6"].as_int("ns_queue"),
748 "ra_period": config["ipv6"].as_int("ra_period"),
749 "ipv6_nameservers": config["ipv6"]["nameservers"],
752 proxy = VMNetProxy(data_path=config["general"]["datapath"], **proxy_opts)
754 # Drop all capabilities except CAP_NET_RAW and change uid
756 uid = getpwuid(config["general"].as_int("user"))
758 uid = getpwnam(config["general"]["user"])
760 logging.debug("Setting capabilities and changing uid")
761 logging.debug("User: %s, uid: %d, gid: %d" %
762 (config["general"]["user"], uid.pw_uid, uid.pw_gid))
763 capng_clear(CAPNG_SELECT_BOTH)
764 capng_update(CAPNG_ADD, CAPNG_EFFECTIVE|CAPNG_PERMITTED, CAP_NET_RAW)
765 capng_change_id(uid.pw_uid, uid.pw_gid,
766 CAPNG_DROP_SUPP_GRP | CAPNG_CLEAR_BOUNDING)
769 logfile = os.path.join(config["general"]["logdir"], LOG_FILENAME)
770 handler = logging.handlers.RotatingFileHandler(logfile,
773 handler = logging.StreamHandler()
775 handler.setFormatter(logging.Formatter(LOG_FORMAT))
776 logger.addHandler(handler)
778 logging.info("Ready to serve requests")
782 # vim: set ts=4 sts=4 sw=4 et :