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
44 class PlatformSettingsError(Exception):
45 """Module catch-all error."""
49 class DnsReadError(PlatformSettingsError):
50 """Raised when unable to read DNS settings."""
54 class DnsUpdateError(PlatformSettingsError):
55 """Raised when unable to update DNS settings."""
59 class NotAdministratorError(PlatformSettingsError):
60 """Raised when not running as administrator."""
64 class CalledProcessError(PlatformSettingsError):
65 """Raised when a _check_output() process returns a non-zero exit status."""
66 def __init__(self, returncode, cmd):
67 self.returncode = returncode
71 return 'Command "%s" returned non-zero exit status %d' % (
72 ' '.join(self.cmd), self.returncode)
75 def FindExecutable(executable):
76 """Finds the given executable in PATH.
78 Since WPR may be invoked as sudo, meaning PATH is empty, we also hardcode a
82 The fully qualified path with .exe appended if appropriate or None if it
85 return distutils.spawn.find_executable(executable,
86 os.pathsep.join([os.environ['PATH'],
94 class SystemProxy(object):
95 """A host/port pair for a HTTP or HTTPS proxy configuration."""
97 def __init__(self, host, port):
98 """Initialize a SystemProxy instance.
101 host: a host name or IP address string (e.g. "example.com" or "1.1.1.1").
102 port: a port string or integer (e.g. "8888" or 8888).
105 self.port = int(port) if port else None
107 def __nonzero__(self):
108 """True if the host is set."""
109 return bool(self.host)
112 def from_url(cls, proxy_url):
113 """Create a SystemProxy instance.
115 If proxy_url is None, an empty string, or an invalid URL, the
116 SystemProxy instance with have None and None for the host and port
117 (no exception is raised).
120 proxy_url: a proxy url string such as "http://proxy.com:8888/".
122 a System proxy instance.
125 parse_result = urlparse.urlparse(proxy_url)
126 return cls(parse_result.hostname, parse_result.port)
127 return cls(None, None)
130 class _BasePlatformSettings(object):
132 def get_system_logging_handler(self):
133 """Return a handler for the logging module (optional)."""
136 def rerun_as_administrator(self):
137 """If needed, rerun the program with administrative privileges.
139 Raises NotAdministratorError if unable to rerun.
144 """Return the current time in seconds as a floating point number."""
147 def get_server_ip_address(self, is_server_mode=False):
148 """Returns the IP address to use for dnsproxy and ipfw."""
150 return socket.gethostbyname(socket.gethostname())
153 def get_httpproxy_ip_address(self, is_server_mode=False):
154 """Returns the IP address to use for httpproxy."""
159 def get_system_proxy(self, use_ssl):
160 """Returns the system HTTP(S) proxy host, port."""
161 return SystemProxy(None, None)
164 raise NotImplementedError
166 def ipfw(self, *args):
167 ipfw_cmd = (self._ipfw_cmd(), ) + args
168 return self._check_output(*ipfw_cmd, elevate_privilege=True)
173 def _set_cwnd(self, args):
176 def _elevate_privilege_for_cmd(self, args):
179 def _check_output(self, *args, **kwargs):
180 """Run Popen(*args) and return its output as a byte string.
182 Python 2.7 has subprocess.check_output. This is essentially the same
183 except that, as a convenience, all the positional args are used as
184 command arguments and the |elevate_privilege| kwarg is supported.
187 *args: sequence of program arguments
188 elevate_privilege: Run the command with elevated privileges.
190 CalledProcessError if the program returns non-zero exit status.
192 output as a byte string.
194 command_args = [str(a) for a in args]
196 if os.path.sep not in command_args[0]:
197 qualified_command = FindExecutable(command_args[0])
198 assert qualified_command, 'Failed to find %s in path' % command_args[0]
199 command_args[0] = qualified_command
201 if kwargs.get('elevate_privilege'):
202 command_args = self._elevate_privilege_for_cmd(command_args)
204 logging.debug(' '.join(command_args))
205 process = subprocess.Popen(
206 command_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
207 output = process.communicate()[0]
208 retcode = process.poll()
210 raise CalledProcessError(retcode, command_args)
213 def set_temporary_tcp_init_cwnd(self, cwnd):
215 original_cwnd = self._get_cwnd()
216 if original_cwnd is None:
217 raise PlatformSettingsError('Unable to get current tcp init_cwnd.')
218 if cwnd == original_cwnd:
219 logging.info('TCP init_cwnd already set to target value: %s', cwnd)
222 if self._get_cwnd() == cwnd:
223 logging.info('Changed cwnd to %s', cwnd)
224 atexit.register(self._set_cwnd, original_cwnd)
226 logging.error('Unable to update cwnd to %s', cwnd)
228 def setup_temporary_loopback_config(self):
229 """Setup the loopback interface similar to real interface.
231 We use loopback for much of our testing, and on some systems, loopback
232 behaves differently from real interfaces.
234 logging.error('Platform does not support loopback configuration.')
236 def _save_primary_interface_properties(self):
237 self._orig_nameserver = self.get_original_primary_nameserver()
239 def _restore_primary_interface_properties(self):
240 self._set_primary_nameserver(self._orig_nameserver)
242 def _get_primary_nameserver(self):
243 raise NotImplementedError
245 def _set_primary_nameserver(self):
246 raise NotImplementedError
248 def get_original_primary_nameserver(self):
249 if not hasattr(self, '_original_nameserver'):
250 self._original_nameserver = self._get_primary_nameserver()
251 logging.info('Saved original primary DNS nameserver: %s',
252 self._original_nameserver)
253 return self._original_nameserver
255 def set_temporary_primary_nameserver(self, nameserver):
256 self._save_primary_interface_properties()
257 self._set_primary_nameserver(nameserver)
258 if self._get_primary_nameserver() == nameserver:
259 logging.info('Changed temporary primary nameserver to %s', nameserver)
260 atexit.register(self._restore_primary_interface_properties)
262 raise self._get_dns_update_error()
265 class _PosixPlatformSettings(_BasePlatformSettings):
267 def rerun_as_administrator(self):
268 """If needed, rerun the program with administrative privileges.
270 Raises NotAdministratorError if unable to rerun.
272 if os.geteuid() != 0:
273 logging.warn('Rerunning with sudo: %s', sys.argv)
274 os.execv('/usr/bin/sudo', ['--'] + sys.argv)
276 def _elevate_privilege_for_cmd(self, args):
278 return (os.stat(path).st_mode & stat.S_ISUID) == stat.S_ISUID
281 p = subprocess.Popen(
282 ['sudo', '-nv'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
283 stderr=subprocess.STDOUT)
284 stdout = p.communicate()[0]
285 # Some versions of sudo set the returncode based on whether sudo requires
286 # a password currently. Other versions return output when password is
287 # required and no output when the user is already authenticated.
288 return not p.returncode and not stdout
290 if not IsSetUID(args[0]):
291 args = ['sudo'] + args
294 print 'WPR needs to run %s under sudo. Please authenticate.' % args[1]
295 subprocess.check_call(['sudo', '-v']) # Synchronously authenticate.
297 prompt = ('Would you like to always allow %s to run without sudo '
298 '(via `sudo chmod +s %s`)? (y/N)' % (args[1], args[1]))
299 if raw_input(prompt).lower() == 'y':
300 subprocess.check_call(['sudo', 'chmod', '+s', args[1]])
303 def get_system_proxy(self, use_ssl):
304 """Returns the system HTTP(S) proxy host, port."""
305 proxy_url = os.environ.get('https_proxy' if use_ssl else 'http_proxy')
306 return SystemProxy.from_url(proxy_url)
311 def _get_dns_update_error(self):
312 return DnsUpdateError('Did you run under sudo?')
314 def _sysctl(self, *args, **kwargs):
315 sysctl_args = [FindExecutable('sysctl')]
316 if kwargs.get('use_sudo'):
317 sysctl_args = self._elevate_privilege_for_cmd(sysctl_args)
318 sysctl_args.extend(str(a) for a in args)
319 sysctl = subprocess.Popen(
320 sysctl_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
321 stdout = sysctl.communicate()[0]
322 return sysctl.returncode, stdout
324 def has_sysctl(self, name):
325 if not hasattr(self, 'has_sysctl_cache'):
326 self.has_sysctl_cache = {}
327 if name not in self.has_sysctl_cache:
328 self.has_sysctl_cache[name] = self._sysctl(name)[0] == 0
329 return self.has_sysctl_cache[name]
331 def set_sysctl(self, name, value):
332 rv = self._sysctl('%s=%s' % (name, value), use_sudo=True)[0]
334 logging.error('Unable to set sysctl %s: %s', name, rv)
336 def get_sysctl(self, name):
337 rv, value = self._sysctl('-n', name)
341 logging.error('Unable to get sysctl %s: %s', name, rv)
345 class _OsxPlatformSettings(_PosixPlatformSettings):
346 LOCAL_SLOWSTART_MIB_NAME = 'net.inet.tcp.local_slowstart_flightsize'
348 def _scutil(self, cmd):
349 scutil = subprocess.Popen([FindExecutable('scutil')],
350 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
351 return scutil.communicate(cmd)[0]
353 def _ifconfig(self, *args):
354 return self._check_output('ifconfig', *args, elevate_privilege=True)
356 def set_sysctl(self, name, value):
357 rv = self._sysctl('-w', '%s=%s' % (name, value), use_sudo=True)[0]
359 logging.error('Unable to set sysctl %s: %s', name, rv)
362 return int(self.get_sysctl(self.LOCAL_SLOWSTART_MIB_NAME))
364 def _set_cwnd(self, size):
365 self.set_sysctl(self.LOCAL_SLOWSTART_MIB_NAME, size)
367 def _get_loopback_mtu(self):
368 config = self._ifconfig('lo0')
369 match = re.search(r'\smtu\s+(\d+)', config)
370 return int(match.group(1)) if match else None
372 def setup_temporary_loopback_config(self):
373 """Configure loopback to temporarily use reasonably sized frames.
375 OS X uses jumbo frames by default (16KB).
377 TARGET_LOOPBACK_MTU = 1500
378 original_mtu = self._get_loopback_mtu()
379 if original_mtu is None:
380 logging.error('Unable to read loopback mtu. Setting left unchanged.')
382 if original_mtu == TARGET_LOOPBACK_MTU:
383 logging.debug('Loopback MTU already has target value: %d', original_mtu)
385 self._ifconfig('lo0', 'mtu', TARGET_LOOPBACK_MTU)
386 if self._get_loopback_mtu() == TARGET_LOOPBACK_MTU:
387 logging.debug('Set loopback MTU to %d (was %d)',
388 TARGET_LOOPBACK_MTU, original_mtu)
389 atexit.register(self._ifconfig, 'lo0', 'mtu', original_mtu)
391 logging.error('Unable to change loopback MTU from %d to %d',
392 original_mtu, TARGET_LOOPBACK_MTU)
394 def _get_dns_service_key(self):
395 output = self._scutil('show State:/Network/Global/IPv4')
396 lines = output.split('\n')
398 key_value = line.split(' : ')
399 if key_value[0] == ' PrimaryService':
400 return 'State:/Network/Service/%s/DNS' % key_value[1]
401 raise DnsReadError('Unable to find DNS service key: %s', output)
403 def _get_primary_nameserver(self):
404 output = self._scutil('show %s' % self._get_dns_service_key())
406 br'ServerAddresses\s+:\s+<array>\s+{\s+0\s+:\s+((\d{1,3}\.){3}\d{1,3})',
409 return match.group(1)
411 raise DnsReadError('Unable to find primary DNS server: %s', output)
413 def _set_primary_nameserver(self, dns):
414 command = '\n'.join([
416 'd.add ServerAddresses * %s' % dns,
417 'set %s' % self._get_dns_service_key()
419 self._scutil(command)
422 class _FreeBSDPlatformSettings(_PosixPlatformSettings):
423 """Partial implementation for FreeBSD. Does not allow a DNS server to be
424 launched nor ipfw to be used.
426 RESOLV_CONF = '/etc/resolv.conf'
428 def _get_default_route_line(self):
429 raise NotImplementedError
431 def _set_cwnd(self, cwnd):
432 raise NotImplementedError
435 raise NotImplementedError
437 def setup_temporary_loopback_config(self):
438 raise NotImplementedError
440 def _write_resolve_conf(self, dns):
441 raise NotImplementedError
443 def _get_primary_nameserver(self):
445 resolv_file = open(self.RESOLV_CONF)
448 for line in resolv_file:
449 if line.startswith('nameserver '):
450 return line.split()[1]
453 def _set_primary_nameserver(self, dns):
454 raise NotImplementedError
457 class _LinuxPlatformSettings(_PosixPlatformSettings):
458 """The following thread recommends a way to update DNS on Linux:
460 http://ubuntuforums.org/showthread.php?t=337553
462 sudo cp /etc/dhcp3/dhclient.conf /etc/dhcp3/dhclient.conf.bak
463 sudo gedit /etc/dhcp3/dhclient.conf
464 #prepend domain-name-servers 127.0.0.1;
465 prepend domain-name-servers 208.67.222.222, 208.67.220.220;
467 prepend domain-name-servers 208.67.222.222, 208.67.220.220;
468 request subnet-mask, broadcast-address, time-offset, routers,
469 domain-name, domain-name-servers, host-name,
470 netbios-name-servers, netbios-scope;
471 #require subnet-mask, domain-name-servers;
473 sudo /etc/init.d/networking restart
475 The code below does not try to change dchp and does not restart networking.
476 Update this as needed to make it more robust on more systems.
478 RESOLV_CONF = '/etc/resolv.conf'
479 ROUTE_RE = re.compile('initcwnd (\d+)')
480 TCP_BASE_MSS = 'net.ipv4.tcp_base_mss'
481 TCP_MTU_PROBING = 'net.ipv4.tcp_mtu_probing'
483 def _get_default_route_line(self):
484 stdout = self._check_output('ip', 'route')
485 for line in stdout.split('\n'):
486 if line.startswith('default'):
490 def _set_cwnd(self, cwnd):
491 default_line = self._get_default_route_line()
493 'ip', 'route', 'change', default_line, 'initcwnd', str(cwnd))
496 default_line = self._get_default_route_line()
497 m = self.ROUTE_RE.search(default_line)
499 return int(m.group(1))
500 # If 'initcwnd' wasn't found, then 0 means it's the system default.
503 def setup_temporary_loopback_config(self):
504 """Setup Linux to temporarily use reasonably sized frames.
506 Linux uses jumbo frames by default (16KB), using the combination
507 of MTU probing and a base MSS makes it use normal sized packets.
509 The reason this works is because tcp_base_mss is only used when MTU
510 probing is enabled. And since we're using the max value, it will
511 always use the reasonable size. This is relevant for server-side realism.
512 The client-side will vary depending on the client TCP stack config.
514 ENABLE_MTU_PROBING = 2
515 original_probing = self.get_sysctl(self.TCP_MTU_PROBING)
516 self.set_sysctl(self.TCP_MTU_PROBING, ENABLE_MTU_PROBING)
517 atexit.register(self.set_sysctl, self.TCP_MTU_PROBING, original_probing)
520 original_mss = self.get_sysctl(self.TCP_BASE_MSS)
521 self.set_sysctl(self.TCP_BASE_MSS, TCP_FULL_MSS)
522 atexit.register(self.set_sysctl, self.TCP_BASE_MSS, original_mss)
524 def _write_resolve_conf(self, dns):
525 is_first_nameserver_replaced = False
526 # The fileinput module uses sys.stdout as the edited file output.
527 for line in fileinput.input(self.RESOLV_CONF, inplace=1, backup='.bak'):
528 if line.startswith('nameserver ') and not is_first_nameserver_replaced:
529 print 'nameserver %s' % dns
530 is_first_nameserver_replaced = True
533 if not is_first_nameserver_replaced:
534 raise DnsUpdateError('Could not find a suitable nameserver entry in %s' %
537 def _get_primary_nameserver(self):
539 resolv_file = open(self.RESOLV_CONF)
542 for line in resolv_file:
543 if line.startswith('nameserver '):
544 return line.split()[1]
547 def _set_primary_nameserver(self, dns):
548 """Replace the first nameserver entry with the one given."""
550 self._write_resolve_conf(dns)
552 if 'Permission denied' in e:
553 raise self._get_dns_update_error()
557 class _WindowsPlatformSettings(_BasePlatformSettings):
559 def get_system_logging_handler(self):
560 """Return a handler for the logging module (optional).
562 For Windows, output can be viewed with DebugView.
563 http://technet.microsoft.com/en-us/sysinternals/bb896647.aspx
566 output_debug_string = ctypes.windll.kernel32.OutputDebugStringA
567 output_debug_string.argtypes = [ctypes.c_char_p]
568 class DebugViewHandler(logging.Handler):
569 def emit(self, record):
570 output_debug_string('[wpr] ' + self.format(record))
571 return DebugViewHandler()
573 def rerun_as_administrator(self):
574 """If needed, rerun the program with administrative privileges.
576 Raises NotAdministratorError if unable to rerun.
579 if not ctypes.windll.shell32.IsUserAnAdmin():
580 raise NotAdministratorError('Rerun with administrator privileges.')
581 #os.execv('runas', sys.argv) # TODO: replace needed Windows magic
584 """Return the current time in seconds as a floating point number.
586 From time module documentation:
587 On Windows, this function [time.clock()] returns wall-clock
588 seconds elapsed since the first call to this function, as a
589 floating point number, based on the Win32 function
590 QueryPerformanceCounter(). The resolution is typically better
591 than one microsecond.
595 def _arp(self, *args):
596 return self._check_output('arp', *args)
598 def _route(self, *args):
599 return self._check_output('route', *args)
601 def _ipconfig(self, *args):
602 return self._check_output('ipconfig', *args)
604 def _get_mac_address(self, ip):
605 """Return the MAC address for the given ip."""
606 ip_re = re.compile(r'^\s*IP(?:v4)? Address[ .]+:\s+([0-9.]+)')
607 for line in self._ipconfig('/all').splitlines():
608 if line[:1].isalnum():
613 ip_match = ip_re.match(line)
615 current_ip = ip_match.group(1)
616 elif line.startswith('Physical Address'):
617 current_mac = line.split(':', 1)[1].lstrip()
618 if current_ip == ip and current_mac:
622 def setup_temporary_loopback_config(self):
623 """On Windows, temporarily route the server ip to itself."""
624 ip = self.get_server_ip_address()
625 mac_address = self._get_mac_address(ip)
627 self._arp('-s', ip, self.mac_address)
628 self._route('add', ip, ip, 'mask', '255.255.255.255')
629 atexit.register(self._arp, '-d', ip)
630 atexit.register(self._route, 'delete', ip, ip, 'mask', '255.255.255.255')
632 logging.warn('Unable to configure loopback: MAC address not found.')
633 # TODO(slamm): Configure cwnd, MTU size
635 def _get_dns_update_error(self):
636 return DnsUpdateError('Did you run as administrator?')
638 def _netsh_show_dns(self):
639 """Return DNS information:
642 Configuration for interface "Local Area Connection 3"
643 DNS servers configured through DHCP: None
644 Register with which suffix: Primary only
646 Configuration for interface "Wireless Network Connection 2"
647 DNS servers configured through DHCP: 192.168.1.1
648 Register with which suffix: Primary only
650 return self._check_output('netsh', 'interface', 'ip', 'show', 'dns')
652 def _netsh_set_dns(self, iface_name, addr):
653 """Modify DNS information on the primary interface."""
654 output = self._check_output('netsh', 'interface', 'ip', 'set', 'dns',
655 iface_name, 'static', addr)
657 def _netsh_set_dns_dhcp(self, iface_name):
658 """Modify DNS information on the primary interface."""
659 output = self._check_output('netsh', 'interface', 'ip', 'set', 'dns',
662 def _get_interfaces_with_dns(self):
663 output = self._netsh_show_dns()
664 lines = output.split('\n')
665 iface_re = re.compile(r'^Configuration for interface \"(?P<name>.*)\"')
666 dns_re = re.compile(r'(?P<kind>.*):\s+(?P<dns>\d+\.\d+\.\d+\.\d+)')
672 iface_match = iface_re.match(line)
674 iface_name = iface_match.group('name')
675 dns_match = dns_re.match(line)
677 iface_dns = dns_match.group('dns')
678 iface_dns_config = dns_match.group('kind').strip()
679 if iface_dns_config == "Statically Configured DNS Servers":
680 iface_kind = "static"
681 elif iface_dns_config == "DNS servers configured through DHCP":
683 if iface_name and iface_dns and iface_kind:
684 ifaces.append( (iface_dns, iface_name, iface_kind) )
689 def _save_primary_interface_properties(self):
690 # TODO(etienneb): On windows, an interface can have multiple DNS server
691 # configured. We should save/restore all of them.
692 ifaces = self._get_interfaces_with_dns()
693 self._primary_interfaces = ifaces
695 def _restore_primary_interface_properties(self):
696 for iface in self._primary_interfaces:
697 (iface_dns, iface_name, iface_kind) = iface
698 self._netsh_set_dns(iface_name, iface_dns)
699 if iface_kind == "dhcp":
700 self._netsh_set_dns_dhcp(iface_name)
702 def _get_primary_nameserver(self):
703 ifaces = self._get_interfaces_with_dns()
705 raise DnsUpdateError("Interface with valid DNS configured not found.")
706 (iface_dns, iface_name, iface_kind) = ifaces[0]
709 def _set_primary_nameserver(self, dns):
710 for iface in self._primary_interfaces:
711 (iface_dns, iface_name, iface_kind) = iface
712 self._netsh_set_dns(iface_name, dns)
715 class _WindowsXpPlatformSettings(_WindowsPlatformSettings):
717 return (r'third_party\ipfw_win32\ipfw.exe',)
720 def _new_platform_settings(system, release):
721 """Make a new instance of PlatformSettings for the current system."""
722 if system == 'Darwin':
723 return _OsxPlatformSettings()
724 if system == 'Linux':
725 return _LinuxPlatformSettings()
726 if system == 'Windows' and release == 'XP':
727 return _WindowsXpPlatformSettings()
728 if system == 'Windows':
729 return _WindowsPlatformSettings()
730 if system == 'FreeBSD':
731 return _FreeBSDPlatformSettings()
732 raise NotImplementedError('Sorry %s %s is not supported.' % (system, release))
735 # Create one instance of the platform-specific settings and
736 # make the functions available at the module-level.
737 _inst = _new_platform_settings(platform.system(), platform.release())
739 get_system_logging_handler = _inst.get_system_logging_handler
740 rerun_as_administrator = _inst.rerun_as_administrator
743 get_server_ip_address = _inst.get_server_ip_address
744 get_httpproxy_ip_address = _inst.get_httpproxy_ip_address
745 get_system_proxy = _inst.get_system_proxy
747 set_temporary_tcp_init_cwnd = _inst.set_temporary_tcp_init_cwnd
748 setup_temporary_loopback_config = _inst.setup_temporary_loopback_config
750 get_original_primary_nameserver = _inst.get_original_primary_nameserver
751 set_temporary_primary_nameserver = _inst.set_temporary_primary_nameserver