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
38 from select import select
39 from socket import AF_INET, AF_INET6
41 from scapy.data import ETH_P_ALL
42 from scapy.packet import BasePacket
43 from scapy.layers.l2 import Ether
44 from scapy.layers.inet import IP, UDP
45 from scapy.layers.inet6 import IPv6, ICMPv6ND_RA, ICMPv6ND_NA, \
46 ICMPv6NDOptDstLLAddr, \
47 ICMPv6NDOptPrefixInfo, \
49 from scapy.layers.dhcp import BOOTP, DHCP
51 DEFAULT_CONFIG = "/etc/nfdhcpd/nfdhcpd.conf"
52 DEFAULT_PATH = "/var/run/ganeti-dhcpd"
53 DEFAULT_USER = "nobody"
54 DEFAULT_LEASE_LIFETIME = 604800 # 1 week
55 DEFAULT_LEASE_RENEWAL = 600 # 10 min
56 DEFAULT_RA_PERIOD = 300 # seconds
57 DHCP_DUMMY_SERVER_IP = "1.2.3.4"
59 LOG_FILENAME = "nfdhcpd.log"
61 SYSFS_NET = "/sys/class/net"
63 LOG_FORMAT = "%(asctime)-15s %(levelname)-6s %(message)s"
65 # Configuration file specification (see configobj documentation)
74 enable_dhcp = boolean(default=True)
75 lease_lifetime = integer(min=0, max=4294967295)
76 lease_renewal = integer(min=0, max=4294967295)
78 dhcp_queue = integer(min=0, max=65535)
79 nameservers = ip_addr_list(family=4)
82 enable_ipv6 = boolean(default=True)
83 ra_period = integer(min=1, max=4294967295)
84 rs_queue = integer(min=0, max=65535)
85 ns_queue = integer(min=0, max=65535)
86 nameservers = ip_addr_list(family=6)
100 DHCPDISCOVER: "DHCPDISCOVER",
101 DHCPOFFER: "DHCPOFFER",
102 DHCPREQUEST: "DHCPREQUEST",
103 DHCPDECLINE: "DHCPDECLINE",
106 DHCPRELEASE: "DHCPRELEASE",
107 DHCPINFORM: "DHCPINFORM",
111 DHCPDISCOVER: DHCPOFFER,
112 DHCPREQUEST: DHCPACK,
117 def parse_routing_table(table="main", family=4):
118 """ Parse the given routing table to get connected route, gateway and
122 ipro = subprocess.Popen(["ip", "-%d" % family, "ro", "ls",
123 "table", table], stdout=subprocess.PIPE)
124 routes = ipro.stdout.readlines()
131 match = re.match(r'^default.*via ([^\s]+).*dev ([^\s]+)', route)
133 def_gw, def_dev = match.groups()
137 # Find the least-specific connected route
138 m = re.match("^([^\\s]+) dev %s" % def_dev, route)
142 if family == 6 and m.group(1).startswith("fe80:"):
143 # Skip link-local declarations in "main" table
149 def_net = IPy.IP(def_net)
150 except ValueError, e:
151 logging.warn("Unable to parse default route entry %s: %s",
154 return Subnet(net=def_net, gw=def_gw, dev=def_dev)
157 def parse_binding_file(path):
158 """ Read a client configuration from a tap file
162 iffile = open(path, 'r')
163 except EnvironmentError, e:
164 logging.warn("Unable to open binding file %s: %s", path, str(e))
173 if line.startswith("IP="):
174 ip = line.strip().split("=")[1]
176 elif line.startswith("MAC="):
177 mac = line.strip().split("=")[1]
178 elif line.startswith("LINK="):
179 link = line.strip().split("=")[1]
180 elif line.startswith("HOSTNAME="):
181 hostname = line.strip().split("=")[1]
183 return Client(mac=mac, ips=ips, link=link, hostname=hostname)
186 class ClientFileHandler(pyinotify.ProcessEvent):
187 def __init__(self, server):
188 pyinotify.ProcessEvent.__init__(self)
191 def process_IN_DELETE(self, event): # pylint: disable=C0103
192 """ Delete file handler
194 Currently this removes an interface from the watch list
197 self.server.remove_iface(event.name)
199 def process_IN_CLOSE_WRITE(self, event): # pylint: disable=C0103
202 Currently this adds an interface to the watch list
205 self.server.add_iface(os.path.join(event.path, event.name))
208 class Client(object):
209 def __init__(self, mac=None, ips=None, link=None, hostname=None):
212 self.hostname = hostname
221 return self.mac is not None and self.ips is not None\
222 and self.hostname is not None
225 class Subnet(object):
226 def __init__(self, net=None, gw=None, dev=None):
227 if isinstance(net, str):
228 self.net = IPy.IP(net)
236 """ Return the netmask in textual representation
239 return str(self.net.netmask())
243 """ Return the broadcast address in textual representation
246 return str(self.net.broadcast())
250 """ Return the network as an IPy.IP
253 return self.net.net()
257 """ Return the prefix length as an integer
260 return self.net.prefixlen()
263 def _make_eui64(net, mac):
264 """ Compute an EUI-64 address from an EUI-48 (MAC) address
267 comp = mac.split(":")
268 prefix = IPy.IP(net).net().strFullsize().split(":")[:4]
269 eui64 = comp[:3] + ["ff", "fe"] + comp[3:]
270 eui64[0] = "%02x" % (int(eui64[0], 16) ^ 0x02)
271 for l in range(0, len(eui64), 2):
272 prefix += ["".join(eui64[l:l+2])]
273 return IPy.IP(":".join(prefix))
275 def make_eui64(self, mac):
276 """ Compute an EUI-64 address from an EUI-48 (MAC) address in this
280 return self._make_eui64(self.net, mac)
282 def make_ll64(self, mac):
283 """ Compute an IPv6 Link-local address from an EUI-48 (MAC) address
286 return self._make_eui64("fe80::", mac)
289 class VMNetProxy(object): # pylint: disable=R0902
290 def __init__(self, data_path, dhcp_queue_num=None, # pylint: disable=R0913
291 rs_queue_num=None, ns_queue_num=None,
292 dhcp_lease_lifetime=DEFAULT_LEASE_LIFETIME,
293 dhcp_lease_renewal=DEFAULT_LEASE_RENEWAL,
294 dhcp_server_ip=DHCP_DUMMY_SERVER_IP, dhcp_nameservers=None,
295 ra_period=DEFAULT_RA_PERIOD, ipv6_nameservers=None):
297 self.data_path = data_path
298 self.lease_lifetime = dhcp_lease_lifetime
299 self.lease_renewal = dhcp_lease_renewal
300 self.dhcp_server_ip = dhcp_server_ip
301 self.ra_period = ra_period
302 if dhcp_nameservers is None:
303 self.dhcp_nameserver = []
305 self.dhcp_nameservers = dhcp_nameservers
307 if ipv6_nameservers is None:
308 self.ipv6_nameservers = []
310 self.ipv6_nameservers = ipv6_nameservers
312 self.ipv6_enabled = False
319 self.l2socket = socket.socket(socket.AF_PACKET,
320 socket.SOCK_RAW, ETH_P_ALL)
321 self.l2socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 0)
324 self.wm = pyinotify.WatchManager()
325 mask = pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"]
326 mask |= pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"]
327 inotify_handler = ClientFileHandler(self)
328 self.notifier = pyinotify.Notifier(self.wm, inotify_handler)
329 self.wm.add_watch(self.data_path, mask, rec=True)
332 if dhcp_queue_num is not None:
333 self._setup_nfqueue(dhcp_queue_num, AF_INET, self.dhcp_response)
335 if rs_queue_num is not None:
336 self._setup_nfqueue(rs_queue_num, AF_INET6, self.rs_response)
337 self.ipv6_enabled = True
339 if ns_queue_num is not None:
340 self._setup_nfqueue(ns_queue_num, AF_INET6, self.ns_response)
341 self.ipv6_enabled = True
344 """ Free all resources for a graceful exit
347 logging.info("Cleaning up")
349 logging.debug("Closing netfilter queues")
350 for q in self.nfq.values():
353 logging.debug("Closing socket")
354 self.l2socket.close()
356 logging.debug("Stopping inotify watches")
359 logging.info("Cleanup finished")
361 def _setup_nfqueue(self, queue_num, family, callback):
362 logging.debug("Setting up NFQUEUE for queue %d, AF %s",
365 q.set_callback(callback)
366 q.fast_open(queue_num, family)
367 q.set_queue_maxlen(5000)
368 # This is mandatory for the queue to operate
369 q.set_mode(nfqueue.NFQNL_COPY_PACKET)
370 self.nfq[q.get_fd()] = q
372 def sendp(self, data, iface):
373 """ Send a raw packet using a layer-2 socket
376 if isinstance(data, BasePacket):
379 self.l2socket.bind((iface, ETH_P_ALL))
380 count = self.l2socket.send(data)
383 logging.warn("Truncated send on %s (%d/%d bytes sent)",
386 def build_config(self):
390 for path in glob.glob(os.path.join(self.data_path, "*")):
393 def get_ifindex(self, iface):
394 """ Get the interface index from sysfs
397 path = os.path.abspath(os.path.join(SYSFS_NET, iface, "ifindex"))
398 if not path.startswith(SYSFS_NET):
405 except EnvironmentError:
406 logging.debug("%s is probably down, removing", iface)
407 self.remove_iface(iface)
412 ifindex = f.readline().strip()
414 ifindex = int(ifindex)
415 except ValueError, e:
416 logging.warn("Failed to get ifindex for %s, cannot parse sysfs"
417 " output '%s'", iface, ifindex)
418 except EnvironmentError, e:
419 logging.warn("Error reading %s's ifindex from sysfs: %s",
421 self.remove_iface(iface)
428 def get_iface_hw_addr(self, iface):
429 """ Get the interface hardware address from sysfs
432 path = os.path.abspath(os.path.join(SYSFS_NET, iface, "address"))
433 if not path.startswith(SYSFS_NET):
439 except EnvironmentError:
440 logging.debug("%s is probably down, removing", iface)
441 self.remove_iface(iface)
445 addr = f.readline().strip()
446 except EnvironmentError, e:
447 logging.warn("Failed to read hw address for %s from sysfs: %s",
454 def add_iface(self, path):
455 """ Add an interface to monitor
458 iface = os.path.basename(path)
460 logging.debug("Updating configuration for %s", iface)
461 binding = parse_binding_file(path)
464 ifindex = self.get_ifindex(iface)
467 logging.warn("Stale configuration for %s found", iface)
469 if binding.is_valid():
470 binding.iface = iface
471 self.clients[binding.mac] = binding
472 self.subnets[binding.link] = parse_routing_table(binding.link)
473 logging.debug("Added client %s on %s", binding.hostname, iface)
474 self.ifaces[ifindex] = iface
475 self.v6nets[iface] = parse_routing_table(binding.link, 6)
477 def remove_iface(self, iface):
478 """ Cleanup clients on a removed interface
481 if iface in self.v6nets:
482 del self.v6nets[iface]
484 for mac in self.clients.keys():
485 if self.clients[mac].iface == iface:
486 del self.clients[mac]
488 for ifindex in self.ifaces.keys():
489 if self.ifaces[ifindex] == iface:
490 del self.ifaces[ifindex]
492 logging.debug("Removed interface %s", iface)
494 def dhcp_response(self, i, payload): # pylint: disable=W0613,R0914
495 """ Generate a reply to a BOOTP/DHCP request
498 indev = payload.get_indev()
500 # Get the actual interface from the ifindex
501 iface = self.ifaces[indev]
503 # We don't know anything about this interface, so accept the packet
505 logging.debug("Ignoring DHCP request on unknown iface %d", indev)
506 # We don't know what to do with this packet, so let the kernel
508 payload.set_verdict(nfqueue.NF_ACCEPT)
511 # Decode the response - NFQUEUE relays IP packets
512 pkt = IP(payload.get_data())
514 # Signal the kernel that it shouldn't further process the packet
515 payload.set_verdict(nfqueue.NF_DROP)
517 # Get the client MAC address
518 resp = pkt.getlayer(BOOTP).copy()
520 mac = resp.chaddr[:hlen].encode("hex")
521 mac, _ = re.subn(r'([0-9a-fA-F]{2})', r'\1:', mac, hlen-1)
523 # Server responses are always BOOTREPLYs
524 resp.op = "BOOTREPLY"
528 binding = self.clients[mac]
530 logging.warn("Invalid client %s on %s", mac, iface)
533 if iface != binding.iface:
534 logging.warn("Received spoofed DHCP request for %s from interface"
535 " %s instead of %s", mac, iface, binding.iface)
538 resp = Ether(dst=mac, src=self.get_iface_hw_addr(iface))/\
539 IP(src=DHCP_DUMMY_SERVER_IP, dst=binding.ip)/\
540 UDP(sport=pkt.dport, dport=pkt.sport)/resp
541 subnet = self.subnets[binding.link]
544 logging.warn("Invalid request from %s on %s, no DHCP"
545 " payload found", binding.mac, iface)
549 requested_addr = binding.ip
550 for opt in pkt[DHCP].options:
551 if type(opt) is tuple and opt[0] == "message-type":
553 if type(opt) is tuple and opt[0] == "requested_addr":
554 requested_addr = opt[1]
556 logging.info("%s from %s on %s", DHCP_TYPES.get(req_type, "UNKNOWN"),
559 if req_type == DHCPREQUEST and requested_addr != binding.ip:
561 logging.info("Sending DHCPNAK to %s on %s: requested %s"
562 " instead of %s", binding.mac, iface, requested_addr,
565 elif req_type in (DHCPDISCOVER, DHCPREQUEST):
566 resp_type = DHCP_REQRESP[req_type]
567 resp.yiaddr = self.clients[mac].ip
569 ("hostname", binding.hostname),
570 ("domain", binding.hostname.split('.', 1)[-1]),
571 ("router", subnet.gw),
572 ("broadcast_address", str(subnet.broadcast)),
573 ("subnet_mask", str(subnet.netmask)),
574 ("renewal_time", self.lease_renewal),
575 ("lease_time", self.lease_lifetime),
577 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
579 elif req_type == DHCPINFORM:
580 resp_type = DHCP_REQRESP[req_type]
582 ("hostname", binding.hostname),
583 ("domain", binding.hostname.split('.', 1)[-1]),
585 dhcp_options += [("name_server", x) for x in self.dhcp_nameservers]
587 elif req_type == DHCPRELEASE:
589 logging.info("DHCPRELEASE from %s on %s", binding.mac, iface)
592 # Finally, always add the server identifier and end options
594 ("message-type", resp_type),
595 ("server_id", DHCP_DUMMY_SERVER_IP),
598 resp /= DHCP(options=dhcp_options)
600 logging.info("%s to %s (%s) on %s", DHCP_TYPES[resp_type], mac,
602 self.sendp(resp, iface)
604 def rs_response(self, i, payload): # pylint: disable=W0613
605 """ Generate a reply to a BOOTP/DHCP request
608 indev = payload.get_indev()
610 # Get the actual interface from the ifindex
611 iface = self.ifaces[indev]
613 logging.debug("Ignoring router solicitation on"
614 " unknown interface %d", indev)
615 # We don't know what to do with this packet, so let the kernel
617 payload.set_verdict(nfqueue.NF_ACCEPT)
620 ifmac = self.get_iface_hw_addr(iface)
621 subnet = self.v6nets[iface]
622 ifll = subnet.make_ll64(ifmac)
624 # Signal the kernel that it shouldn't further process the packet
625 payload.set_verdict(nfqueue.NF_DROP)
627 resp = Ether(src=self.get_iface_hw_addr(iface))/\
628 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
629 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
630 prefixlen=subnet.prefixlen)
632 if self.ipv6_nameservers:
633 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
634 lifetime=self.ra_period * 3)
636 logging.info("RA on %s for %s", iface, subnet.net)
637 self.sendp(resp, iface)
639 def ns_response(self, i, payload): # pylint: disable=W0613
640 """ Generate a reply to an ICMPv6 neighbor solicitation
643 indev = payload.get_indev()
645 # Get the actual interface from the ifindex
646 iface = self.ifaces[indev]
648 logging.debug("Ignoring neighbour solicitation on"
649 " unknown interface %d", indev)
650 # We don't know what to do with this packet, so let the kernel
652 payload.set_verdict(nfqueue.NF_ACCEPT)
655 ifmac = self.get_iface_hw_addr(iface)
656 subnet = self.v6nets[iface]
657 ifll = subnet.make_ll64(ifmac)
659 ns = IPv6(payload.get_data())
661 if not (subnet.net.overlaps(ns.tgt) or str(ns.tgt) == str(ifll)):
662 logging.debug("Received NS for a non-routable IP (%s)", ns.tgt)
663 payload.set_verdict(nfqueue.NF_ACCEPT)
666 payload.set_verdict(nfqueue.NF_DROP)
669 client_lladdr = ns.lladdr
670 except AttributeError:
673 resp = Ether(src=ifmac, dst=client_lladdr)/\
674 IPv6(src=str(ifll), dst=ns.src)/\
675 ICMPv6ND_NA(R=1, O=0, S=1, tgt=ns.tgt)/\
676 ICMPv6NDOptDstLLAddr(lladdr=ifmac)
678 logging.info("NA on %s for %s", iface, ns.tgt)
679 self.sendp(resp, iface)
682 def send_periodic_ra(self):
683 # Use a separate thread as this may take a _long_ time with
684 # many interfaces and we want to be responsive in the mean time
685 threading.Thread(target=self._send_periodic_ra).start()
687 def _send_periodic_ra(self):
688 logging.debug("Sending out periodic RAs")
691 for client in self.clients.values():
693 ifmac = self.get_iface_hw_addr(iface)
697 subnet = self.v6nets[iface]
698 ifll = subnet.make_ll64(ifmac)
699 resp = Ether(src=ifmac)/\
700 IPv6(src=str(ifll))/ICMPv6ND_RA(routerlifetime=14400)/\
701 ICMPv6NDOptPrefixInfo(prefix=str(subnet.prefix),
702 prefixlen=subnet.prefixlen)
703 if self.ipv6_nameservers:
704 resp /= ICMPv6NDOptRDNSS(dns=self.ipv6_nameservers,
705 lifetime=self.ra_period * 3)
707 self.sendp(resp, iface)
708 except socket.error, e:
709 logging.warn("Periodic RA on %s failed: %s", iface, str(e))
711 logging.warn("Unkown error during periodic RA on %s: %s",
714 logging.debug("Sent %d RAs in %.2f seconds", i, time.time() - start)
717 """ Safely perform the main loop, freeing all resources upon exit
726 """ Loop forever, serving DHCP requests
731 # Yes, we are accessing _fd directly, but it's the only way to have a
732 # single select() loop ;-)
733 iwfd = self.notifier._fd # pylint: disable=W0212
736 if self.ipv6_enabled:
737 timeout = self.ra_period
738 self.send_periodic_ra()
743 rlist, _, xlist = select(self.nfq.keys() + [iwfd], [], [], timeout)
745 logging.warn("Warning: Exception on %s",
746 ", ".join([ str(fd) for fd in xlist]))
750 # First check if there are any inotify (= configuration change)
752 self.notifier.read_events()
753 self.notifier.process_events()
758 self.nfq[fd].process_pending()
759 except RuntimeError, e:
760 logging.warn("Error processing fd %d: %s", fd, str(e))
762 logging.warn("Unknown error processing fd %d: %s",
765 if self.ipv6_enabled:
766 # Calculate the new timeout
767 timeout = self.ra_period - (time.time() - start)
771 self.send_periodic_ra()
772 timeout = self.ra_period - (time.time() - start)
775 if __name__ == "__main__":
778 from cStringIO import StringIO
779 from pwd import getpwnam, getpwuid
780 from configobj import ConfigObj, ConfigObjError, flatten_errors
784 validator = validate.Validator()
786 def is_ip_list(value, family=4):
790 raise validate.VdtParamError(family)
791 if isinstance(value, (str, unicode)):
793 if not isinstance(value, list):
794 raise validate.VdtTypeError(value)
800 raise validate.VdtValueError(entry)
802 if ip.version() != family:
803 raise validate.VdtValueError(entry)
806 validator.functions["ip_addr_list"] = is_ip_list
807 config_spec = StringIO(CONFIG_SPEC)
810 parser = optparse.OptionParser()
811 parser.add_option("-c", "--config", dest="config_file",
812 help="The location of the data files", metavar="FILE",
813 default=DEFAULT_CONFIG)
814 parser.add_option("-d", "--debug", action="store_true", dest="debug",
815 help="Turn on debugging messages")
816 parser.add_option("-f", "--foreground", action="store_false",
817 dest="daemonize", default=True,
818 help="Do not daemonize, stay in the foreground")
821 opts, args = parser.parse_args()
824 d = daemon.DaemonContext()
829 config = ConfigObj(opts.config_file, configspec=config_spec)
830 except ConfigObjError, err:
831 sys.stderr.write("Failed to parse config file %s: %s" %
832 (opts.config_file, str(err)))
835 results = config.validate(validator)
837 logging.fatal("Configuration file validation failed! See errors below:")
838 for (section_list, key, unused) in flatten_errors(config, results):
840 logging.fatal(" '%s' in section '%s' failed validation",
841 key, ", ".join(section_list))
843 logging.fatal(" Section '%s' is missing",
844 ", ".join(section_list))
847 pidfile = open(config["general"]["pidfile"], "w")
848 pidfile.write("%s" % os.getpid())
851 logger = logging.getLogger()
853 logger.setLevel(logging.DEBUG)
855 logger.setLevel(logging.INFO)
857 logging.info("Starting up")
860 if config["dhcp"].as_bool("enable_dhcp"):
862 "dhcp_queue_num": config["dhcp"].as_int("dhcp_queue"),
863 "dhcp_lease_lifetime": config["dhcp"].as_int("lease_lifetime"),
864 "dhcp_lease_renewal": config["dhcp"].as_int("lease_renewal"),
865 "dhcp_server_ip": config["dhcp"]["server_ip"],
866 "dhcp_nameservers": config["dhcp"]["nameservers"],
869 if config["ipv6"].as_bool("enable_ipv6"):
871 "rs_queue_num": config["ipv6"].as_int("rs_queue"),
872 "ns_queue_num": config["ipv6"].as_int("ns_queue"),
873 "ra_period": config["ipv6"].as_int("ra_period"),
874 "ipv6_nameservers": config["ipv6"]["nameservers"],
877 # pylint: disable=W0142
878 proxy = VMNetProxy(data_path=config["general"]["datapath"], **proxy_opts)
880 # Drop all capabilities except CAP_NET_RAW and change uid
882 uid = getpwuid(config["general"].as_int("user"))
884 uid = getpwnam(config["general"]["user"])
886 logging.debug("Setting capabilities and changing uid")
887 logging.debug("User: %s, uid: %d, gid: %d",
888 config["general"]["user"], uid.pw_uid, uid.pw_gid)
890 # Keep only the capabilities we need
891 # CAP_NET_ADMIN: we need to send nfqueue packet verdicts to a netlinkgroup
892 capng.capng_clear(capng.CAPNG_SELECT_BOTH)
893 capng.capng_update(capng.CAPNG_ADD,
894 capng.CAPNG_EFFECTIVE|capng.CAPNG_PERMITTED,
896 capng.capng_change_id(uid.pw_uid, uid.pw_gid,
897 capng.CAPNG_DROP_SUPP_GRP|capng.CAPNG_CLEAR_BOUNDING)
900 logfile = os.path.join(config["general"]["logdir"], LOG_FILENAME)
901 handler = logging.handlers.RotatingFileHandler(logfile,
904 handler = logging.StreamHandler()
906 handler.setFormatter(logging.Formatter(LOG_FORMAT))
907 logger.addHandler(handler)
909 logging.info("Ready to serve requests")
913 # vim: set ts=4 sts=4 sw=4 et :