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.
26 import logging.handlers
34 from select import select
35 from socket import AF_INET, AF_PACKET, AF_UNSPEC
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
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
48 LOG_FILENAME = "/var/log/nfdhcpd/nfdhcpd.log"
50 SYSFS_NET = "/sys/class/net"
53 LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
65 DHCPDISCOVER: "DHCPDISCOVER",
66 DHCPOFFER: "DHCPOFFER",
67 DHCPREQUEST: "DHCPREQUEST",
68 DHCPDECLINE: "DHCPDECLINE",
71 DHCPRELEASE: "DHCPRELEASE",
72 DHCPINFORM: "DHCPINFORM",
76 DHCPDISCOVER: DHCPOFFER,
81 class DhcpBindingHandler(pyinotify.ProcessEvent):
82 def __init__(self, dhcp):
83 pyinotify.ProcessEvent.__init__(self)
86 def process_IN_DELETE(self, event):
87 self.dhcp.remove_iface(event.name)
89 def process_IN_CLOSE_WRITE(self, event):
90 self.dhcp.add_iface(os.path.join(event.path, event.name))
92 class DhcpBinding(object):
93 def __init__(self, mac=None, ips=None, link=None, hostname=None):
96 self.hostname = hostname
105 return self.mac is not None and self.ips is not None\
106 and self.hostname is not None
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)
120 return str(self.net.netmask())
124 return str(self.net.broadcast())
127 class DhcpServer(object):
128 def __init__(self, data_path, queue_num):
129 self.data_path = data_path
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)
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)
150 def build_config(self):
154 for file in glob.glob(os.path.join(self.data_path, "*")):
157 def get_ifindex(self, iface):
158 """ Get the interface index from sysfs
161 file = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
162 if not file.startswith(SYSFS_NET):
169 ifindex = int(f.readline().strip())
177 def get_iface_hw_addr(self, iface):
178 """ Get the interface hardware address from sysfs
181 file = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
182 if not file.startswith(SYSFS_NET):
188 addr = f.readline().strip()
194 def parse_routing_table(self, table="main"):
195 """ Parse the given routing table to get connected route, gateway and
199 ipro = subprocess.Popen(["ip", "ro", "ls", "table", table],
200 stdout=subprocess.PIPE)
201 routes = ipro.stdout.readlines()
208 match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
210 def_gw, def_dev = match.groups()
214 # Find the least-specific connected route
216 def_net = re.match("^([^\\s]+) dev %s" %
217 def_dev, route).groups()[0]
218 def_net = IPy.IP(def_net)
222 return Subnet(net=def_net, gw=def_gw, dev=def_dev)
224 def parse_binding_file(self, path):
225 """ Read a client configuration from a tap file
229 iffile = open(path, 'r')
231 return (None, None, None, None)
238 if line.startswith("IP="):
239 ip = line.strip().split("=")[1]
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]
248 return DhcpBinding(mac=mac, ips=ips, link=link, hostname=hostname)
250 def add_iface(self, path):
251 """ Add an interface to monitor
254 iface = os.path.basename(path)
256 logging.debug("Updating configuration for %s" % iface)
257 binding = self.parse_binding_file(path)
258 ifindex = self.get_ifindex(iface)
261 logging.warn("Stale configuration for %s found" % iface)
263 if binding.is_valid():
264 binding.iface = iface
265 self.clients[binding.mac] = binding
266 self.subnets[binding.link] = self.parse_routing_table(
268 logging.debug("Added client %s on %s" %
269 (binding.hostname, iface))
270 self.ifaces[ifindex] = iface
272 def remove_iface(self, iface):
273 """ Cleanup clients on a removed interface
276 for mac in self.clients.keys():
277 if self.clients[mac].iface == iface:
278 del self.clients[mac]
280 for ifindex in self.ifaces.keys():
281 if self.ifaces[ifindex] == iface:
282 del self.ifaces[ifindex]
284 logging.debug("Removed interface %s" % iface)
286 def make_reply(self, i, payload):
287 """ Generate a reply to a BOOTP/DHCP request
290 # Decode the response - NFQUEUE relays IP packets
291 pkt = IP(payload.get_data())
293 # Get the actual interface from the ifindex
294 iface = self.ifaces[payload.get_indev()]
296 # Signal the kernel that it shouldn't further process the packet
297 payload.set_verdict(nfqueue.NF_DROP)
299 # Get the client MAC address
300 resp = pkt.getlayer(BOOTP).copy()
302 mac = resp.chaddr[:hlen].encode("hex")
303 mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
305 # Server responses are always BOOTREPLYs
306 resp.op = "BOOTREPLY"
310 binding = self.clients[mac]
312 logging.warn("Invalid client %s on %s" % (mac, iface))
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))
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]
327 logging.warn("Invalid request from %s on %s, no DHCP"
328 " payload found" % (binding.mac, iface))
332 requested_addr = binding.ip
333 for opt in pkt[DHCP].options:
334 if type(opt) is tuple and opt[0] == "message-type":
336 if type(opt) is tuple and opt[0] == "requested_addr":
337 requested_addr = opt[1]
339 logging.info("%s from %s on %s" %
340 (DHCP_TYPES.get(req_type, "UNKNOWN"), binding.mac, iface))
342 if req_type == DHCPREQUEST and requested_addr != binding.ip:
344 logging.info("Sending DHCPNAK to %s on %s: requested %s"
346 (binding.mac, iface, requested_addr, binding.ip))
348 elif req_type in (DHCPDISCOVER, DHCPREQUEST):
349 resp_type = DHCP_REQRESP[req_type]
350 resp.yiaddr = self.clients[mac].ip
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),
363 elif req_type == DHCPINFORM:
364 resp_type = DHCP_REQRESP[req_type]
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"),
372 elif req_type == DHCPRELEASE:
374 logging.info("DHCPRELEASE from %s on %s" %
375 (binding.mac, iface))
378 # Finally, always add the server identifier and end options
380 ("message-type", resp_type),
381 ("server_id", MY_IP),
384 resp /= DHCP(options=dhcp_options)
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)
392 """ Loop forever, serving DHCP requests
397 iwfd = self.notifier._fd
398 qfd = self.q.get_fd()
401 rlist, _, xlist = select([iwfd, qfd], [], [], 1.0)
402 # First check if there are any inotify (= configuration change)
405 self.notifier.read_events()
406 self.notifier.process_events()
410 self.q.process_pending()
413 if __name__ == "__main__":
416 from pwd import getpwnam, getpwuid
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")
434 opts, args = parser.parse_args()
437 d = daemon.DaemonContext()
440 pidfile = open("/var/run/nfdhcpd.pid", "w")
441 pidfile.write("%s" % os.getpid())
444 logger = logging.getLogger()
446 logger.setLevel(logging.DEBUG)
448 logger.setLevel(logging.INFO)
451 handler = logging.handlers.RotatingFileHandler(LOG_FILENAME,
454 handler = logging.StreamHandler()
456 handler.setFormatter(logging.Formatter(LOG_FORMAT))
457 logger.addHandler(handler)
459 logging.info("Starting up")
460 dhcp = DhcpServer(opts.data_path, opts.queue_num)
462 # Drop all capabilities except CAP_NET_RAW and change uid
464 uid = getpwuid(int(opts.user))
466 uid = getpwnam(opts.user)
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")
479 # vim: set ts=4 sts=4 sw=4 et :