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.
28 import logging.handlers
39 from select import select
40 from socket import AF_INET, AF_INET6
42 from scapy.data import ETH_P_ALL
43 from scapy.packet import BasePacket
44 from scapy.layers.l2 import Ether
45 from scapy.layers.inet import IP, UDP
46 from scapy.layers.inet6 import IPv6, ICMPv6ND_RA, ICMPv6ND_NA, \
47 ICMPv6NDOptDstLLAddr, \
48 ICMPv6NDOptPrefixInfo, \
50 from scapy.layers.dhcp import BOOTP, DHCP
52 DEFAULT_CONFIG = "/etc/nfdhcpd/nfdhcpd.conf"
53 DEFAULT_PATH = "/var/run/ganeti-dhcpd"
54 DEFAULT_USER = "nobody"
55 DEFAULT_LEASE_LIFETIME = 604800 # 1 week
56 DEFAULT_LEASE_RENEWAL = 600 # 10 min
57 DEFAULT_RA_PERIOD = 300 # seconds
58 DHCP_DUMMY_SERVER_IP = "1.2.3.4"
60 LOG_FILENAME = "nfdhcpd.log"
62 SYSFS_NET = "/sys/class/net"
64 LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
66 # Configuration file specification (see configobj documentation)
75 enable_dhcp = boolean(default=True)
76 lease_lifetime = integer(min=0, max=4294967295)
77 lease_renewal = integer(min=0, max=4294967295)
79 dhcp_queue = integer(min=0, max=65535)
80 nameservers = ip_addr_list(family=4)
83 enable_ipv6 = boolean(default=True)
84 ra_period = integer(min=1, max=4294967295)
85 rs_queue = integer(min=0, max=65535)
86 ns_queue = integer(min=0, max=65535)
87 nameservers = ip_addr_list(family=6)
101 DHCPDISCOVER: "DHCPDISCOVER",
102 DHCPOFFER: "DHCPOFFER",
103 DHCPREQUEST: "DHCPREQUEST",
104 DHCPDECLINE: "DHCPDECLINE",
107 DHCPRELEASE: "DHCPRELEASE",
108 DHCPINFORM: "DHCPINFORM",
112 DHCPDISCOVER: DHCPOFFER,
113 DHCPREQUEST: DHCPACK,
118 def parse_routing_table(table="main", family=4):
119 """ Parse the given routing table to get connected route, gateway and
123 ipro = subprocess.Popen(["ip", "-%d" % family, "ro", "ls",
124 "table", table], stdout=subprocess.PIPE)
125 routes = ipro.stdout.readlines()
132 match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
134 def_gw, def_dev = match.groups()
138 # Find the least-specific connected route
139 m = re.match("^([^\\s]+) dev %s" % def_dev, route)
143 if family == 6 and m.group(1).startswith("fe80:"):
144 # Skip link-local declarations in "main" table
150 def_net = IPy.IP(def_net)
151 except ValueError, e:
152 logging.warn("Unable to parse default route entry %s: %s",
155 return Subnet(net=def_net, gw=def_gw, dev=def_dev)
158 def parse_binding_file(path):
159 """ Read a client configuration from a tap file
163 iffile = open(path, 'r')
164 except EnvironmentError, e:
165 logging.warn("Unable to open binding file %s: %s", path, str(e))
174 if line.startswith("IP="):
175 ip = line.strip().split("=")[1]
177 elif line.startswith("MAC="):
178 mac = line.strip().split("=")[1]
179 elif line.startswith("LINK="):
180 link = line.strip().split("=")[1]
181 elif line.startswith("HOSTNAME="):
182 hostname = line.strip().split("=")[1]
184 return Client(mac=mac, ips=ips, link=link, hostname=hostname)
187 class ClientFileHandler(pyinotify.ProcessEvent):
188 def __init__(self, server):
189 pyinotify.ProcessEvent.__init__(self)
192 def process_IN_DELETE(self, event): # pylint: disable=C0103
193 """ Delete file handler
195 Currently this removes an interface from the watch list
198 self.server.remove_iface(event.name)
200 def process_IN_CLOSE_WRITE(self, event): # pylint: disable=C0103
203 Currently this adds an interface to the watch list
206 self.server.add_iface(os.path.join(event.path, event.name))
209 class Client(object):
210 def __init__(self, mac=None, ips=None, link=None, hostname=None):
213 self.hostname = hostname
222 return self.mac is not None and self.ips is not None\
223 and self.hostname is not None
226 class Subnet(object):
227 def __init__(self, net=None, gw=None, dev=None):
228 if isinstance(net, str):
229 self.net = IPy.IP(net)
237 """ Return the netmask in textual representation
240 return str(self.net.netmask())
244 """ Return the broadcast address in textual representation
247 return str(self.net.broadcast())
251 """ Return the network as an IPy.IP
254 return self.net.net()
258 """ Return the prefix length as an integer
261 return self.net.prefixlen()
264 def _make_eui64(net, mac):
265 """ Compute an EUI-64 address from an EUI-48 (MAC) address
268 comp = mac.split(":")
269 prefix = IPy.IP(net).net().strFullsize().split(":")[:4]
270 eui64 = comp[:3] + ["ff", "fe"] + comp[3:]
271 eui64[0] = "%02x" % (int(eui64[0], 16) ^ 0x02)
272 for l in range(0, len(eui64), 2):
273 prefix += ["".join(eui64[l:l+2])]
274 return IPy.IP(":".join(prefix))
276 def make_eui64(self, mac):
277 """ Compute an EUI-64 address from an EUI-48 (MAC) address in this
281 return self._make_eui64(self.net, mac)
283 def make_ll64(self, mac):
284 """ Compute an IPv6 Link-local address from an EUI-48 (MAC) address
287 return self._make_eui64("fe80::", mac)
290 class VMNetProxy(object): # pylint: disable=R0902
291 def __init__(self, data_path, dhcp_queue_num=None, # pylint: disable=R0913
292 rs_queue_num=None, ns_queue_num=None,
293 dhcp_lease_lifetime=DEFAULT_LEASE_LIFETIME,
294 dhcp_lease_renewal=DEFAULT_LEASE_RENEWAL,
295 dhcp_server_ip=DHCP_DUMMY_SERVER_IP, dhcp_nameservers=None,
296 ra_period=DEFAULT_RA_PERIOD, ipv6_nameservers=None):
298 self.data_path = data_path
299 self.lease_lifetime = dhcp_lease_lifetime
300 self.lease_renewal = dhcp_lease_renewal
301 self.dhcp_server_ip = dhcp_server_ip
302 self.ra_period = ra_period
303 if dhcp_nameservers is None:
304 self.dhcp_nameserver = []
306 self.dhcp_nameservers = dhcp_nameservers
308 if ipv6_nameservers is None:
309 self.ipv6_nameservers = []
311 self.ipv6_nameservers = ipv6_nameservers
313 self.ipv6_enabled = False
320 self.l2socket = socket.socket(socket.AF_PACKET,
321 socket.SOCK_RAW, ETH_P_ALL)
322 self.l2socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 0)
325 self.wm = pyinotify.WatchManager()
326 mask = pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"]
327 mask |= pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"]
328 inotify_handler = ClientFileHandler(self)
329 self.notifier = pyinotify.Notifier(self.wm, inotify_handler)
330 self.wm.add_watch(self.data_path, mask, rec=True)
333 if dhcp_queue_num is not None:
334 self._setup_nfqueue(dhcp_queue_num, AF_INET, self.dhcp_response)
336 if rs_queue_num is not None:
337 self._setup_nfqueue(rs_queue_num, AF_INET6, self.rs_response)
338 self.ipv6_enabled = True
340 if ns_queue_num is not None:
341 self._setup_nfqueue(ns_queue_num, AF_INET6, self.ns_response)
342 self.ipv6_enabled = True
345 """ Free all resources for a graceful exit
348 logging.info("Cleaning up")
350 logging.debug("Closing netfilter queues")
351 for q in self.nfq.values():
354 logging.debug("Closing socket")
355 self.l2socket.close()
357 logging.debug("Stopping inotify watches")
360 logging.info("Cleanup finished")
362 def _setup_nfqueue(self, queue_num, family, callback):
363 logging.debug("Setting up NFQUEUE for queue %d, AF %s",
366 q.set_callback(callback)
367 q.fast_open(queue_num, family)
368 q.set_queue_maxlen(5000)
369 # This is mandatory for the queue to operate
370 q.set_mode(nfqueue.NFQNL_COPY_PACKET)
371 self.nfq[q.get_fd()] = q
373 def sendp(self, data, iface):
374 """ Send a raw packet using a layer-2 socket
377 if isinstance(data, BasePacket):
380 self.l2socket.bind((iface, ETH_P_ALL))
381 count = self.l2socket.send(data)
384 logging.warn("Truncated send on %s (%d/%d bytes sent)",
387 def build_config(self):
391 for path in glob.glob(os.path.join(self.data_path, "*")):
394 def get_ifindex(self, iface):
395 """ Get the interface index from sysfs
398 path = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
399 if not path.startswith(SYSFS_NET):
406 except EnvironmentError:
407 logging.debug("%s is probably down, removing", iface)
408 self.remove_iface(iface)
413 ifindex = f.readline().strip()
415 ifindex = int(ifindex)
416 except ValueError, e:
417 logging.warn("Failed to get ifindex for %s, cannot parse sysfs"
418 " output '%s'", iface, ifindex)
419 except EnvironmentError, e:
420 logging.warn("Error reading %s's ifindex from sysfs: %s",
422 self.remove_iface(iface)
429 def get_iface_hw_addr(self, iface):
430 """ Get the interface hardware address from sysfs
433 path = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
434 if not path.startswith(SYSFS_NET):
440 except EnvironmentError:
441 logging.debug("%s is probably down, removing", iface)
442 self.remove_iface(iface)
446 addr = f.readline().strip()
447 except EnvironmentError, e:
448 logging.warn("Failed to read hw address for %s from sysfs: %s",
455 def add_iface(self, path):
456 """ Add an interface to monitor
459 iface = os.path.basename(path)
461 logging.debug("Updating configuration for %s", iface)
462 binding = parse_binding_file(path)
465 ifindex = self.get_ifindex(iface)
468 logging.warn("Stale configuration for %s found", iface)
470 if binding.is_valid():
471 binding.iface = iface
472 self.clients[binding.mac] = binding
473 self.subnets[binding.link] = parse_routing_table(binding.link)
474 logging.debug("Added client %s on %s", binding.hostname, iface)
475 self.ifaces[ifindex] = iface
476 self.v6nets[iface] = parse_routing_table(binding.link, 6)
478 def remove_iface(self, iface):
479 """ Cleanup clients on a removed interface
482 if iface in self.v6nets:
483 del self.v6nets[iface]
485 for mac in self.clients.keys():
486 if self.clients[mac].iface == iface:
487 del self.clients[mac]
489 for ifindex in self.ifaces.keys():
490 if self.ifaces[ifindex] == iface:
491 del self.ifaces[ifindex]
493 logging.debug("Removed interface %s", iface)
495 def dhcp_response(self, i, payload): # pylint: disable=W0613,R0914
496 """ Generate a reply to a BOOTP/DHCP request
499 indev = payload.get_indev()
501 # Get the actual interface from the ifindex
502 iface = self.ifaces[indev]
504 # We don't know anything about this interface, so accept the packet
506 logging.debug("Ignoring DHCP request on unknown iface %d", indev)
507 # We don't know what to do with this packet, so let the kernel
509 payload.set_verdict(nfqueue.NF_ACCEPT)
512 # Decode the response - NFQUEUE relays IP packets
513 pkt = IP(payload.get_data())
515 # Signal the kernel that it shouldn't further process the packet
516 payload.set_verdict(nfqueue.NF_DROP)
518 # Get the client MAC address
519 resp = pkt.getlayer(BOOTP).copy()
521 mac = resp.chaddr[:hlen].encode("hex")
522 mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
524 # Server responses are always BOOTREPLYs
525 resp.op = "BOOTREPLY"
529 binding = self.clients[mac]
531 logging.warn("Invalid client %s on %s", mac, iface)
534 if iface != binding.iface:
535 logging.warn("Received spoofed DHCP request for %s from interface"
536 " %s instead of %s", mac, iface, binding.iface)
539 resp = Ether(dst=mac, src=self.get_iface_hw_addr(iface))/\
540 IP(src=DHCP_DUMMY_SERVER_IP, dst=binding.ip)/\
541 UDP(sport=pkt.dport, dport=pkt.sport)/resp
542 subnet = self.subnets[binding.link]
545 logging.warn("Invalid request from %s on %s, no DHCP"
546 " payload found", binding.mac, iface)
550 requested_addr = binding.ip
551 for opt in pkt[DHCP].options:
552 if type(opt) is tuple and opt[0] == "message-type":
554 if type(opt) is tuple and opt[0] == "requested_addr":
555 requested_addr = opt[1]
557 logging.info("%s from %s on %s", DHCP_TYPES.get(req_type, "UNKNOWN"),
560 if req_type == DHCPREQUEST and requested_addr != binding.ip:
562 logging.info("Sending DHCPNAK to %s on %s: requested %s"
563 " instead of %s", binding.mac, iface, requested_addr,
566 elif req_type in (DHCPDISCOVER, DHCPREQUEST):
567 resp_type = DHCP_REQRESP[req_type]
568 resp.yiaddr = self.clients[mac].ip
570 ("hostname", binding.hostname),
571 ("domain", binding.hostname.split('.', 1)[-1]),
572 ("router", subnet.gw),
573 ("broadcast_address", str(subnet.broadcast)),
574 ("subnet_mask", str(subnet.netmask)),
575 ("renewal_time", self.lease_renewal),
576 ("lease_time", self.lease_lifetime),
578 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
580 elif req_type == DHCPINFORM:
581 resp_type = DHCP_REQRESP[req_type]
583 ("hostname", binding.hostname),
584 ("domain", binding.hostname.split('.', 1)[-1]),
586 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
588 elif req_type == DHCPRELEASE:
590 logging.info("DHCPRELEASE from %s on %s", binding.mac, iface)
593 # Finally, always add the server identifier and end options
595 ("message-type", resp_type),
596 ("server_id", DHCP_DUMMY_SERVER_IP),
599 resp /= DHCP(options=dhcp_options)
601 logging.info("%s to %s (%s) on %s", DHCP_TYPES[resp_type], mac,
603 self.sendp(resp, iface)
605 def rs_response(self, i, payload): # pylint: disable=W0613
606 """ Generate a reply to a BOOTP/DHCP request
609 indev = payload.get_indev()
611 # Get the actual interface from the ifindex
612 iface = self.ifaces[indev]
614 logging.debug("Ignoring router solicitation on"
615 " unknown interface %d", indev)
616 # We don't know what to do with this packet, so let the kernel
618 payload.set_verdict(nfqueue.NF_ACCEPT)
621 ifmac = self.get_iface_hw_addr(iface)
622 subnet = self.v6nets[iface]
623 ifll = subnet.make_ll64(ifmac)
625 # Signal the kernel that it shouldn't further process the packet
626 payload.set_verdict(nfqueue.NF_DROP)
628 resp = Ether(src=self.get_iface_hw_addr(iface))/\
629 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
630 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
631 prefixlen=subnet.prefixlen)
633 if self.ipv6_nameservers:
634 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
635 lifetime=self.ra_period * 3)
637 logging.info("RA on %s for %s", iface, subnet.net)
638 self.sendp(resp, iface)
640 def ns_response(self, i, payload): # pylint: disable=W0613
641 """ Generate a reply to an ICMPv6 neighbor solicitation
644 indev = payload.get_indev()
646 # Get the actual interface from the ifindex
647 iface = self.ifaces[indev]
649 logging.debug("Ignoring neighbour solicitation on"
650 " unknown interface %d", indev)
651 # We don't know what to do with this packet, so let the kernel
653 payload.set_verdict(nfqueue.NF_ACCEPT)
656 ifmac = self.get_iface_hw_addr(iface)
657 subnet = self.v6nets[iface]
658 ifll = subnet.make_ll64(ifmac)
660 ns = IPv6(payload.get_data())
662 if not (subnet.net.overlaps(ns.tgt) or str(ns.tgt) == str(ifll)):
663 logging.debug("Received NS for a non-routable IP (%s)", ns.tgt)
664 payload.set_verdict(nfqueue.NF_ACCEPT)
667 payload.set_verdict(nfqueue.NF_DROP)
670 client_lladdr = ns.lladdr
671 except AttributeError:
674 resp = Ether(src=ifmac, dst=client_lladdr)/\
675 IPv6(src=str(ifll), dst=ns.src)/\
676 ICMPv6ND_NA(R=1, O=0, S=1, tgt=ns.tgt)/\
677 ICMPv6NDOptDstLLAddr(lladdr=ifmac)
679 logging.info("NA on %s for %s", iface, ns.tgt)
680 self.sendp(resp, iface)
683 def send_periodic_ra(self):
684 # Use a separate thread as this may take a _long_ time with
685 # many interfaces and we want to be responsive in the mean time
686 threading.Thread(target=self._send_periodic_ra).start()
688 def _send_periodic_ra(self):
689 logging.debug("Sending out periodic RAs")
692 for client in self.clients.values():
694 ifmac = self.get_iface_hw_addr(iface)
698 subnet = self.v6nets[iface]
699 if subnet.net is None:
700 logging.debug("Skipping periodic RA on interface %s,"
701 " as it is not IPv6-connected", iface)
704 ifll = subnet.make_ll64(ifmac)
705 resp = Ether(src=ifmac)/\
706 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
707 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
708 prefixlen=subnet.prefixlen)
709 if self.ipv6_nameservers:
710 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
711 lifetime=self.ra_period * 3)
713 self.sendp(resp, iface)
714 except socket.error, e:
715 logging.warn("Periodic RA on %s failed: %s", iface, str(e))
717 logging.warn("Unkown error during periodic RA on %s: %s",
720 logging.debug("Sent %d RAs in %.2f seconds", i, time.time() - start)
723 """ Safely perform the main loop, freeing all resources upon exit
732 """ Loop forever, serving DHCP requests
737 # Yes, we are accessing _fd directly, but it's the only way to have a
738 # single select() loop ;-)
739 iwfd = self.notifier._fd # pylint: disable=W0212
742 if self.ipv6_enabled:
743 timeout = self.ra_period
744 self.send_periodic_ra()
749 rlist, _, xlist = select(self.nfq.keys() + [iwfd], [], [], timeout)
751 logging.warn("Warning: Exception on %s",
752 ", ".join([ str(fd) for fd in xlist]))
756 # First check if there are any inotify (= configuration change)
758 self.notifier.read_events()
759 self.notifier.process_events()
764 self.nfq[fd].process_pending()
765 except RuntimeError, e:
766 logging.warn("Error processing fd %d: %s", fd, str(e))
768 logging.warn("Unknown error processing fd %d: %s",
771 if self.ipv6_enabled:
772 # Calculate the new timeout
773 timeout = self.ra_period - (time.time() - start)
777 self.send_periodic_ra()
778 timeout = self.ra_period - (time.time() - start)
781 if __name__ == "__main__":
784 from cStringIO import StringIO
785 from pwd import getpwnam, getpwuid
786 from configobj import ConfigObj, ConfigObjError, flatten_errors
790 validator = validate.Validator()
792 def is_ip_list(value, family=4):
796 raise validate.VdtParamError(family)
797 if isinstance(value, (str, unicode)):
799 if not isinstance(value, list):
800 raise validate.VdtTypeError(value)
806 raise validate.VdtValueError(entry)
808 if ip.version() != family:
809 raise validate.VdtValueError(entry)
812 validator.functions["ip_addr_list"] = is_ip_list
813 config_spec = StringIO(CONFIG_SPEC)
816 parser = optparse.OptionParser()
817 parser.add_option("-c", "--config", dest="config_file",
818 help="The location of the data files", metavar="FILE",
819 default=DEFAULT_CONFIG)
820 parser.add_option("-d", "--debug", action="store_true", dest="debug",
821 help="Turn on debugging messages")
822 parser.add_option("-f", "--foreground", action="store_false",
823 dest="daemonize", default=True,
824 help="Do not daemonize, stay in the foreground")
827 opts, args = parser.parse_args()
830 d = daemon.DaemonContext()
835 config = ConfigObj(opts.config_file, configspec=config_spec)
836 except ConfigObjError, err:
837 sys.stderr.write("Failed to parse config file %s: %s" %
838 (opts.config_file, str(err)))
841 results = config.validate(validator)
843 logging.fatal("Configuration file validation failed! See errors below:")
844 for (section_list, key, unused) in flatten_errors(config, results):
846 logging.fatal(" '%s' in section '%s' failed validation",
847 key, ", ".join(section_list))
849 logging.fatal(" Section '%s' is missing",
850 ", ".join(section_list))
853 pidfile = open(config["general"]["pidfile"], "w")
854 pidfile.write("%s" % os.getpid())
857 logger = logging.getLogger()
859 logger.setLevel(logging.DEBUG)
861 logger.setLevel(logging.INFO)
863 logging.info("Starting up")
866 if config["dhcp"].as_bool("enable_dhcp"):
868 "dhcp_queue_num": config["dhcp"].as_int("dhcp_queue"),
869 "dhcp_lease_lifetime": config["dhcp"].as_int("lease_lifetime"),
870 "dhcp_lease_renewal": config["dhcp"].as_int("lease_renewal"),
871 "dhcp_server_ip": config["dhcp"]["server_ip"],
872 "dhcp_nameservers": config["dhcp"]["nameservers"],
875 if config["ipv6"].as_bool("enable_ipv6"):
877 "rs_queue_num": config["ipv6"].as_int("rs_queue"),
878 "ns_queue_num": config["ipv6"].as_int("ns_queue"),
879 "ra_period": config["ipv6"].as_int("ra_period"),
880 "ipv6_nameservers": config["ipv6"]["nameservers"],
883 # pylint: disable=W0142
884 proxy = VMNetProxy(data_path=config["general"]["datapath"], **proxy_opts)
886 # Drop all capabilities except CAP_NET_RAW and change uid
888 uid = getpwuid(config["general"].as_int("user"))
890 uid = getpwnam(config["general"]["user"])
892 logging.debug("Setting capabilities and changing uid")
893 logging.debug("User: %s, uid: %d, gid: %d",
894 config["general"]["user"], uid.pw_uid, uid.pw_gid)
896 # Keep only the capabilities we need
897 # CAP_NET_ADMIN: we need to send nfqueue packet verdicts to a netlinkgroup
898 capng.capng_clear(capng.CAPNG_SELECT_BOTH)
899 capng.capng_update(capng.CAPNG_ADD,
900 capng.CAPNG_EFFECTIVE|capng.CAPNG_PERMITTED,
902 capng.capng_change_id(uid.pw_uid, uid.pw_gid,
903 capng.CAPNG_DROP_SUPP_GRP|capng.CAPNG_CLEAR_BOUNDING)
906 logfile = os.path.join(config["general"]["logdir"], LOG_FILENAME)
907 handler = logging.handlers.RotatingFileHandler(logfile,
910 handler = logging.StreamHandler()
912 handler.setFormatter(logging.Formatter(LOG_FORMAT))
913 logger.addHandler(handler)
915 logging.info("Ready to serve requests")
920 exc = "".join(traceback.format_exception(*sys.exc_info()))
921 logging.critical(exc)
925 # vim: set ts=4 sts=4 sw=4 et :