Initial commit: nfdhcp.py
[snf-nfdhcpd] / 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 :