2 # Copyright 2010 Google Inc. All Rights Reserved.
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at
8 # http://www.apache.org/licenses/LICENSE-2.0
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
16 """Provides cross-platform utility functions.
19 import platformsettings
20 ip = platformsettings.get_server_ip_address()
22 Functions with "_temporary_" in their name automatically clean-up upon
23 termination (via the atexit module).
25 For the full list of functions, see the bottom of the file.
29 import distutils.spawn
43 class PlatformSettingsError(Exception):
44 """Module catch-all error."""
48 class DnsReadError(PlatformSettingsError):
49 """Raised when unable to read DNS settings."""
53 class DnsUpdateError(PlatformSettingsError):
54 """Raised when unable to update DNS settings."""
58 class NotAdministratorError(PlatformSettingsError):
59 """Raised when not running as administrator."""
63 class CalledProcessError(PlatformSettingsError):
64 """Raised when a _check_output() process returns a non-zero exit status."""
65 def __init__(self, returncode, cmd):
66 self.returncode = returncode
70 return 'Command "%s" returned non-zero exit status %d' % (
71 ' '.join(self.cmd), self.returncode)
74 def FindExecutable(executable):
75 """Finds the given executable in PATH.
77 Since WPR may be invoked as sudo, meaning PATH is empty, we also hardcode a
81 The fully qualified path with .exe appended if appropriate or None if it
84 return distutils.spawn.find_executable(executable,
85 os.pathsep.join([os.environ['PATH'],
93 class _BasePlatformSettings(object):
95 def get_system_logging_handler(self):
96 """Return a handler for the logging module (optional)."""
99 def rerun_as_administrator(self):
100 """If needed, rerun the program with administrative privileges.
102 Raises NotAdministratorError if unable to rerun.
107 """Return the current time in seconds as a floating point number."""
110 def get_server_ip_address(self, is_server_mode=False):
111 """Returns the IP address to use for dnsproxy and ipfw."""
113 return socket.gethostbyname(socket.gethostname())
116 def get_httpproxy_ip_address(self, is_server_mode=False):
117 """Returns the IP address to use for httpproxy."""
123 raise NotImplementedError
125 def ipfw(self, *args):
126 ipfw_cmd = (self._ipfw_cmd(), ) + args
127 return self._check_output(*ipfw_cmd, elevate_privilege=True)
129 def ping_rtt(self, hostname):
130 """Pings the hostname by calling the OS system ping command.
131 Also stores the result internally.
134 hostname: hostname of the server to be pinged
136 round trip time to the server in seconds, or 0 if unable to calculate RTT
138 raise NotImplementedError
143 def _set_cwnd(self, args):
146 def _elevate_privilege_for_cmd(self, args):
149 def _check_output(self, *args, **kwargs):
150 """Run Popen(*args) and return its output as a byte string.
152 Python 2.7 has subprocess.check_output. This is essentially the same
153 except that, as a convenience, all the positional args are used as
154 command arguments and the |elevate_privilege| kwarg is supported.
157 *args: sequence of program arguments
158 elevate_privilege: Run the command with elevated privileges.
160 CalledProcessError if the program returns non-zero exit status.
162 output as a byte string.
164 command_args = [str(a) for a in args]
166 if os.path.sep not in command_args[0]:
167 qualified_command = FindExecutable(command_args[0])
168 assert qualified_command, 'Failed to find %s in path' % command_args[0]
169 command_args[0] = qualified_command
171 if kwargs.get('elevate_privilege'):
172 command_args = self._elevate_privilege_for_cmd(command_args)
174 logging.debug(' '.join(command_args))
175 process = subprocess.Popen(
176 command_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
177 output = process.communicate()[0]
178 retcode = process.poll()
180 raise CalledProcessError(retcode, command_args)
183 def set_temporary_tcp_init_cwnd(self, cwnd):
185 original_cwnd = self._get_cwnd()
186 if original_cwnd is None:
187 raise PlatformSettingsError('Unable to get current tcp init_cwnd.')
188 if cwnd == original_cwnd:
189 logging.info('TCP init_cwnd already set to target value: %s', cwnd)
192 if self._get_cwnd() == cwnd:
193 logging.info('Changed cwnd to %s', cwnd)
194 atexit.register(self._set_cwnd, original_cwnd)
196 logging.error('Unable to update cwnd to %s', cwnd)
198 def setup_temporary_loopback_config(self):
199 """Setup the loopback interface similar to real interface.
201 We use loopback for much of our testing, and on some systems, loopback
202 behaves differently from real interfaces.
204 logging.error('Platform does not support loopback configuration.')
206 def _save_primary_interface_properties(self):
207 self._orig_nameserver = self.get_original_primary_nameserver()
209 def _restore_primary_interface_properties(self):
210 self._set_primary_nameserver(self._orig_nameserver)
212 def _get_primary_nameserver(self):
213 raise NotImplementedError
215 def _set_primary_nameserver(self):
216 raise NotImplementedError
218 def get_original_primary_nameserver(self):
219 if not hasattr(self, '_original_nameserver'):
220 self._original_nameserver = self._get_primary_nameserver()
221 logging.info('Saved original primary DNS nameserver: %s',
222 self._original_nameserver)
223 return self._original_nameserver
225 def set_temporary_primary_nameserver(self, nameserver):
226 self._save_primary_interface_properties()
227 self._set_primary_nameserver(nameserver)
228 if self._get_primary_nameserver() == nameserver:
229 logging.info('Changed temporary primary nameserver to %s', nameserver)
230 atexit.register(self._restore_primary_interface_properties)
232 raise self._get_dns_update_error()
235 class _PosixPlatformSettings(_BasePlatformSettings):
236 PING_PATTERN = r'rtt min/avg/max/mdev = \d+\.\d+/(\d+\.\d+)/\d+\.\d+/\d+\.\d+'
237 PING_CMD = ('ping', '-c', '3', '-i', '0.2', '-W', '1')
238 # For OsX Lion non-root:
239 PING_RESTRICTED_CMD = ('ping', '-c', '1', '-i', '1', '-W', '1')
241 def rerun_as_administrator(self):
242 """If needed, rerun the program with administrative privileges.
244 Raises NotAdministratorError if unable to rerun.
246 if os.geteuid() != 0:
247 logging.warn('Rerunning with sudo: %s', sys.argv)
248 os.execv('/usr/bin/sudo', ['--'] + sys.argv)
250 def _elevate_privilege_for_cmd(self, args):
252 return (os.stat(path).st_mode & stat.S_ISUID) == stat.S_ISUID
255 p = subprocess.Popen(
256 ['sudo', '-nv'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
257 stderr=subprocess.STDOUT)
258 stdout = p.communicate()[0]
259 # Some versions of sudo set the returncode based on whether sudo requires
260 # a password currently. Other versions return output when password is
261 # required and no output when the user is already authenticated.
262 return not p.returncode and not stdout
264 if not IsSetUID(args[0]):
265 args = ['sudo'] + args
268 print 'WPR needs to run %s under sudo. Please authenticate.' % args[1]
269 subprocess.check_call(['sudo', '-v']) # Synchronously authenticate.
271 prompt = ('Would you like to always allow %s to run without sudo '
272 '(via `sudo chmod +s %s`)? (y/N)' % (args[1], args[1]))
273 if raw_input(prompt).lower() == 'y':
274 subprocess.check_call(['sudo', 'chmod', '+s', args[1]])
280 def _ping(self, hostname):
281 """Return ping output or None if ping fails.
283 Initially pings 'localhost' to test for ping command that works.
284 If the tests fails, subsequent calls will return None without calling ping.
287 hostname: host to ping
289 ping stdout string, or None if ping unavailable
291 CalledProcessError if ping returns non-zero exit
293 if not hasattr(self, 'ping_cmd'):
294 test_host = 'localhost'
295 for self.ping_cmd in (self.PING_CMD, self.PING_RESTRICTED_CMD):
297 if self._ping(test_host):
299 except (CalledProcessError, OSError) as e:
302 logging.critical('Ping configuration failed: %s', last_ping_error)
305 cmd = list(self.ping_cmd) + [hostname]
306 return self._check_output(*cmd)
309 def ping_rtt(self, hostname):
310 """Pings the hostname by calling the OS system ping command.
313 hostname: hostname of the server to be pinged
315 round trip time to the server in milliseconds, or 0 if unavailable
320 output = self._ping(hostname)
321 except CalledProcessError as e:
322 logging.critical('Ping failed: %s', e)
324 match = re.search(self.PING_PATTERN, output)
326 rtt = float(match.groups()[0])
328 logging.warning('Unable to ping %s: %s', hostname, output)
332 def _get_dns_update_error(self):
333 return DnsUpdateError('Did you run under sudo?')
336 def _sysctl(cls, *args, **kwargs):
337 sysctl_args = [FindExecutable('sysctl')]
338 if kwargs.get('use_sudo'):
339 sysctl_args = _elevate_privilege_for_cmd(sysctl_args)
340 sysctl_args.extend(str(a) for a in args)
341 sysctl = subprocess.Popen(
342 sysctl_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
343 stdout = sysctl.communicate()[0]
344 return sysctl.returncode, stdout
346 def has_sysctl(self, name):
347 if not hasattr(self, 'has_sysctl_cache'):
348 self.has_sysctl_cache = {}
349 if name not in self.has_sysctl_cache:
350 self.has_sysctl_cache[name] = self._sysctl(name)[0] == 0
351 return self.has_sysctl_cache[name]
353 def set_sysctl(self, name, value):
354 rv = self._sysctl('%s=%s' % (name, value), use_sudo=True)[0]
356 logging.error('Unable to set sysctl %s: %s', name, rv)
358 def get_sysctl(self, name):
359 rv, value = self._sysctl('-n', name)
363 logging.error('Unable to get sysctl %s: %s', name, rv)
367 class _OsxPlatformSettings(_PosixPlatformSettings):
368 LOCAL_SLOWSTART_MIB_NAME = 'net.inet.tcp.local_slowstart_flightsize'
370 def _scutil(self, cmd):
371 scutil = subprocess.Popen([FindExecutable('scutil')],
372 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
373 return scutil.communicate(cmd)[0]
375 def _ifconfig(self, *args):
376 return self._check_output('ifconfig', *args, elevate_privilege=True)
378 def set_sysctl(self, name, value):
379 rv = self._sysctl('-w', '%s=%s' % (name, value), use_sudo=True)[0]
381 logging.error('Unable to set sysctl %s: %s', name, rv)
384 return int(self.get_sysctl(self.LOCAL_SLOWSTART_MIB_NAME))
386 def _set_cwnd(self, size):
387 self.set_sysctl(self.LOCAL_SLOWSTART_MIB_NAME, size)
389 def _get_loopback_mtu(self):
390 config = self._ifconfig('lo0')
391 match = re.search(r'\smtu\s+(\d+)', config)
392 return int(match.group(1)) if match else None
394 def setup_temporary_loopback_config(self):
395 """Configure loopback to temporarily use reasonably sized frames.
397 OS X uses jumbo frames by default (16KB).
399 TARGET_LOOPBACK_MTU = 1500
400 original_mtu = self._get_loopback_mtu()
401 if original_mtu is None:
402 logging.error('Unable to read loopback mtu. Setting left unchanged.')
404 if original_mtu == TARGET_LOOPBACK_MTU:
405 logging.debug('Loopback MTU already has target value: %d', original_mtu)
407 self._ifconfig('lo0', 'mtu', TARGET_LOOPBACK_MTU)
408 if self._get_loopback_mtu() == TARGET_LOOPBACK_MTU:
409 logging.debug('Set loopback MTU to %d (was %d)',
410 TARGET_LOOPBACK_MTU, original_mtu)
411 atexit.register(self._ifconfig, 'lo0', 'mtu', original_mtu)
413 logging.error('Unable to change loopback MTU from %d to %d',
414 original_mtu, TARGET_LOOPBACK_MTU)
416 def _get_dns_service_key(self):
417 output = self._scutil('show State:/Network/Global/IPv4')
418 lines = output.split('\n')
420 key_value = line.split(' : ')
421 if key_value[0] == ' PrimaryService':
422 return 'State:/Network/Service/%s/DNS' % key_value[1]
423 raise DnsReadError('Unable to find DNS service key: %s', output)
425 def _get_primary_nameserver(self):
426 output = self._scutil('show %s' % self._get_dns_service_key())
428 br'ServerAddresses\s+:\s+<array>\s+{\s+0\s+:\s+((\d{1,3}\.){3}\d{1,3})',
431 return match.group(1)
433 raise DnsReadError('Unable to find primary DNS server: %s', output)
435 def _set_primary_nameserver(self, dns):
436 command = '\n'.join([
438 'd.add ServerAddresses * %s' % dns,
439 'set %s' % self._get_dns_service_key()
441 self._scutil(command)
444 class _LinuxPlatformSettings(_PosixPlatformSettings):
445 """The following thread recommends a way to update DNS on Linux:
447 http://ubuntuforums.org/showthread.php?t=337553
449 sudo cp /etc/dhcp3/dhclient.conf /etc/dhcp3/dhclient.conf.bak
450 sudo gedit /etc/dhcp3/dhclient.conf
451 #prepend domain-name-servers 127.0.0.1;
452 prepend domain-name-servers 208.67.222.222, 208.67.220.220;
454 prepend domain-name-servers 208.67.222.222, 208.67.220.220;
455 request subnet-mask, broadcast-address, time-offset, routers,
456 domain-name, domain-name-servers, host-name,
457 netbios-name-servers, netbios-scope;
458 #require subnet-mask, domain-name-servers;
460 sudo /etc/init.d/networking restart
462 The code below does not try to change dchp and does not restart networking.
463 Update this as needed to make it more robust on more systems.
465 RESOLV_CONF = '/etc/resolv.conf'
466 ROUTE_RE = re.compile('initcwnd (\d+)')
467 TCP_BASE_MSS = 'net.ipv4.tcp_base_mss'
468 TCP_MTU_PROBING = 'net.ipv4.tcp_mtu_probing'
470 def _get_default_route_line(self):
471 stdout = self._check_output('ip', 'route')
472 for line in stdout.split('\n'):
473 if line.startswith('default'):
477 def _set_cwnd(self, cwnd):
478 default_line = self._get_default_route_line()
480 'ip', 'route', 'change', default_line, 'initcwnd', str(cwnd))
483 default_line = self._get_default_route_line()
484 m = self.ROUTE_RE.search(default_line)
486 return int(m.group(1))
487 # If 'initcwnd' wasn't found, then 0 means it's the system default.
490 def setup_temporary_loopback_config(self):
491 """Setup Linux to temporarily use reasonably sized frames.
493 Linux uses jumbo frames by default (16KB), using the combination
494 of MTU probing and a base MSS makes it use normal sized packets.
496 The reason this works is because tcp_base_mss is only used when MTU
497 probing is enabled. And since we're using the max value, it will
498 always use the reasonable size. This is relevant for server-side realism.
499 The client-side will vary depending on the client TCP stack config.
501 ENABLE_MTU_PROBING = 2
502 original_probing = self.get_sysctl(self.TCP_MTU_PROBING)
503 self.set_sysctl(self.TCP_MTU_PROBING, ENABLE_MTU_PROBING)
504 atexit.register(self.set_sysctl, self.TCP_MTU_PROBING, original_probing)
507 original_mss = self.get_sysctl(self.TCP_BASE_MSS)
508 self.set_sysctl(self.TCP_BASE_MSS, TCP_FULL_MSS)
509 atexit.register(self.set_sysctl, self.TCP_BASE_MSS, original_mss)
511 def _write_resolve_conf(self, dns):
512 is_first_nameserver_replaced = False
513 # The fileinput module uses sys.stdout as the edited file output.
514 for line in fileinput.input(self.RESOLV_CONF, inplace=1, backup='.bak'):
515 if line.startswith('nameserver ') and not is_first_nameserver_replaced:
516 print 'nameserver %s' % dns
517 is_first_nameserver_replaced = True
520 if not is_first_nameserver_replaced:
521 raise DnsUpdateError('Could not find a suitable nameserver entry in %s' %
524 def _get_primary_nameserver(self):
526 resolv_file = open(self.RESOLV_CONF)
529 for line in resolv_file:
530 if line.startswith('nameserver '):
531 return line.split()[1]
534 def _set_primary_nameserver(self, dns):
535 """Replace the first nameserver entry with the one given."""
537 self._write_resolve_conf(dns)
539 if 'Permission denied' in e:
540 raise self._get_dns_update_error()
544 class _WindowsPlatformSettings(_BasePlatformSettings):
546 def get_system_logging_handler(self):
547 """Return a handler for the logging module (optional).
549 For Windows, output can be viewed with DebugView.
550 http://technet.microsoft.com/en-us/sysinternals/bb896647.aspx
553 output_debug_string = ctypes.windll.kernel32.OutputDebugStringA
554 output_debug_string.argtypes = [ctypes.c_char_p]
555 class DebugViewHandler(logging.Handler):
556 def emit(self, record):
557 output_debug_string('[wpr] ' + self.format(record))
558 return DebugViewHandler()
560 def rerun_as_administrator(self):
561 """If needed, rerun the program with administrative privileges.
563 Raises NotAdministratorError if unable to rerun.
566 if not ctypes.windll.shell32.IsUserAnAdmin():
567 raise NotAdministratorError('Rerun with administrator privileges.')
568 #os.execv('runas', sys.argv) # TODO: replace needed Windows magic
571 """Return the current time in seconds as a floating point number.
573 From time module documentation:
574 On Windows, this function [time.clock()] returns wall-clock
575 seconds elapsed since the first call to this function, as a
576 floating point number, based on the Win32 function
577 QueryPerformanceCounter(). The resolution is typically better
578 than one microsecond.
582 def _arp(self, *args):
583 return self._check_output('arp', *args)
585 def _route(self, *args):
586 return self._check_output('route', *args)
588 def _ipconfig(self, *args):
589 return self._check_output('ipconfig', *args)
591 def _get_mac_address(self, ip):
592 """Return the MAC address for the given ip."""
593 ip_re = re.compile(r'^\s*IP(?:v4)? Address[ .]+:\s+([0-9.]+)')
594 for line in self._ipconfig('/all').splitlines():
595 if line[:1].isalnum():
600 ip_match = ip_re.match(line)
602 current_ip = ip_match.group(1)
603 elif line.startswith('Physical Address'):
604 current_mac = line.split(':', 1)[1].lstrip()
605 if current_ip == ip and current_mac:
609 def setup_temporary_loopback_config(self):
610 """On Windows, temporarily route the server ip to itself."""
611 ip = self.get_server_ip_address()
612 mac_address = self._get_mac_address(ip)
614 self._arp('-s', ip, self.mac_address)
615 self._route('add', ip, ip, 'mask', '255.255.255.255')
616 atexit.register(self._arp, '-d', ip)
617 atexit.register(self._route, 'delete', ip, ip, 'mask', '255.255.255.255')
619 logging.warn('Unable to configure loopback: MAC address not found.')
620 # TODO(slamm): Configure cwnd, MTU size
622 def _get_dns_update_error(self):
623 return DnsUpdateError('Did you run as administrator?')
625 def _netsh_show_dns(self):
626 """Return DNS information:
629 Configuration for interface "Local Area Connection 3"
630 DNS servers configured through DHCP: None
631 Register with which suffix: Primary only
633 Configuration for interface "Wireless Network Connection 2"
634 DNS servers configured through DHCP: 192.168.1.1
635 Register with which suffix: Primary only
637 return self._check_output('netsh', 'interface', 'ip', 'show', 'dns')
639 def _netsh_set_dns(self, iface_name, addr):
640 """Modify DNS information on the primary interface."""
641 output = self._check_output('netsh', 'interface', 'ip', 'set', 'dns',
642 iface_name, 'static', addr)
644 def _netsh_set_dns_dhcp(self, iface_name):
645 """Modify DNS information on the primary interface."""
646 output = self._check_output('netsh', 'interface', 'ip', 'set', 'dns',
649 def _get_interfaces_with_dns(self):
650 output = self._netsh_show_dns()
651 lines = output.split('\n')
652 iface_re = re.compile(r'^Configuration for interface \"(?P<name>.*)\"')
653 dns_re = re.compile(r'(?P<kind>.*):\s+(?P<dns>\d+\.\d+\.\d+\.\d+)')
659 iface_match = iface_re.match(line)
661 iface_name = iface_match.group('name')
662 dns_match = dns_re.match(line)
664 iface_dns = dns_match.group('dns')
665 iface_dns_config = dns_match.group('kind').strip()
666 if iface_dns_config == "Statically Configured DNS Servers":
667 iface_kind = "static"
668 elif iface_dns_config == "DNS servers configured through DHCP":
670 if iface_name and iface_dns and iface_kind:
671 ifaces.append( (iface_dns, iface_name, iface_kind) )
676 def _save_primary_interface_properties(self):
677 # TODO(etienneb): On windows, an interface can have multiple DNS server
678 # configured. We should save/restore all of them.
679 ifaces = self._get_interfaces_with_dns()
680 self._primary_interfaces = ifaces
682 def _restore_primary_interface_properties(self):
683 for iface in self._primary_interfaces:
684 (iface_dns, iface_name, iface_kind) = iface
685 self._netsh_set_dns(iface_name, iface_dns)
686 if iface_kind == "dhcp":
687 self._netsh_set_dns_dhcp(iface_name)
689 def _get_primary_nameserver(self):
690 ifaces = self._get_interfaces_with_dns()
692 raise DnsUpdateError("Interface with valid DNS configured not found.")
693 (iface_dns, iface_name, iface_kind) = ifaces[0]
696 def _set_primary_nameserver(self, dns):
697 for iface in self._primary_interfaces:
698 (iface_dns, iface_name, iface_kind) = iface
699 self._netsh_set_dns(iface_name, dns)
702 class _WindowsXpPlatformSettings(_WindowsPlatformSettings):
704 return (r'third_party\ipfw_win32\ipfw.exe',)
707 def _new_platform_settings(system, release):
708 """Make a new instance of PlatformSettings for the current system."""
709 if system == 'Darwin':
710 return _OsxPlatformSettings()
711 if system == 'Linux':
712 return _LinuxPlatformSettings()
713 if system == 'Windows' and release == 'XP':
714 return _WindowsXpPlatformSettings()
715 if system == 'Windows':
716 return _WindowsPlatformSettings()
717 raise NotImplementedError('Sorry %s %s is not supported.' % (system, release))
720 # Create one instance of the platform-specific settings and
721 # make the functions available at the module-level.
722 _inst = _new_platform_settings(platform.system(), platform.release())
724 get_system_logging_handler = _inst.get_system_logging_handler
725 rerun_as_administrator = _inst.rerun_as_administrator
728 get_server_ip_address = _inst.get_server_ip_address
729 get_httpproxy_ip_address = _inst.get_httpproxy_ip_address
731 ping_rtt = _inst.ping_rtt
732 set_temporary_tcp_init_cwnd = _inst.set_temporary_tcp_init_cwnd
733 setup_temporary_loopback_config = _inst.setup_temporary_loopback_config
735 get_original_primary_nameserver = _inst.get_original_primary_nameserver
736 set_temporary_primary_nameserver = _inst.set_temporary_primary_nameserver