blob: 33f3e53d89de99974146ef588646ec8d5e05e5aa [file] [log] [blame]
#!/usr/bin/python -S
"""Small util functions for /bin/wifi scripts."""
from __future__ import print_function
import collections
import math
import os
import re
import signal
import subprocess
import sys
import time
import unicodedata
_CONFIG_DIR = '/fiber/config/wifi'
FILENAME_KIND = collections.namedtuple(
'FilenameKind', ('options', 'config', 'pid', 'alive'))(
options='opts', config='conf', pid='pid', alive='alive')
class Error(Exception):
"""Common base class for all exception types in /bin/wifi."""
class BinWifiException(Error):
def __init__(self, message, *args):
super(BinWifiException, self).__init__(message)
self.args = args
def __str__(self):
return '/bin/wifi failed: %s' % (self.message % self.args)
def log(msg, *args, **kwargs):
print(msg % args, file=sys.stderr, **kwargs)
def atomic_write(filename, data):
"""Performs an atomic file write of data to filename.
This is done by writing data to filename.new and then renaming filename.new to
filename.
Args:
filename: The filename to to write to.
data: The data to write.
Raises:
BinWifiException: If the write fails.
"""
tmp_filename = filename + '.new'
try:
with open(tmp_filename, 'w') as tmp:
tmp.write(data)
os.rename(tmp_filename, filename)
except (OSError, IOError) as e:
raise BinWifiException('Writing %s failed: %s', filename, e)
def subprocess_quiet(args, no_stderr=True, no_stdout=False):
"""Run a subprocess command with no stderr, and optionally no stdout."""
with open(os.devnull, 'w') as devnull:
kwargs = {}
if no_stderr:
kwargs['stderr'] = devnull
if no_stdout:
kwargs['stdout'] = devnull
return subprocess.call(args, **kwargs)
def subprocess_output_or_none(args):
try:
return subprocess.check_output(args)
except subprocess.CalledProcessError:
return None
def subprocess_lines(args, no_stderr=False):
"""Yields each line in the stdout of a subprocess call."""
with open(os.devnull, 'w') as devnull:
kwargs = {}
if no_stderr:
kwargs['stderr'] = devnull
for line in subprocess.check_output(args, **kwargs).split('\n'):
yield line
def subprocess_line_tokens(args, no_stderr=False):
return (line.split() for line in subprocess_lines(args, no_stderr))
def babysit(command, name, retry_timeout, pid_filename):
"""Run a command wrapped with babysit and startpid, and piped to logos.
Args:
command: The command to run, e.g. ('ls', '-l').
name: The name to pass to logos.
retry_timeout: The babysit retry_timeout, in seconds.
pid_filename: The filename to use for the startpid pid file.
Returns:
The name of the interface if found, otherwise None.
"""
args = ['babysit', str(retry_timeout), 'startpid', pid_filename] + command
process = subprocess.Popen(args,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
# Sleep for two seconds to give startpid time to create the pid filename.
time.sleep(2)
subprocess.Popen(['logos', name], stdin=process.stdout)
def get_mac_address_for_interface(interface):
with open('/sys/class/net/%s/address' % interface) as mac_address_file:
return mac_address_file.read().strip()
def increment_mac_address(mac_address):
numeric_mac_address = int(''.join(mac_address.split(':')), 16) + 1
numeric_mac_address %= 2 ** 48
octets = ('%012x' % numeric_mac_address).decode('hex')
return ':'.join(octet.encode('hex') for octet in octets)
def get_filename(program, kind, disambiguator, tmp=False):
"""Gets the filename for storing a specific kind of state.
Args:
program: E.g. 'hostapd' or 'wpa_supplicant'.
kind: A FILENAME_KIND value.
disambiguator: E.g. an interface or a band.
tmp: True if you want the /tmp filename rather than the _CONFIG_DIR one.
Returns:
The requested filename.
"""
return os.path.join('/tmp' if tmp else _CONFIG_DIR,
'%s.%s.%s' % (program, kind, disambiguator))
def check_pid(pid_filename):
"""Checks whether a program with a given pid is running.
Args:
pid_filename: The location of a file containing the pid to check.
Returns:
Whether the program is running.
Raises:
BinWifiException: If the pidfile cannot be opened.
"""
try:
with open(pid_filename) as pid_file:
pid = pid_file.read().strip()
return subprocess_quiet(('kill', '-0', pid)) == 0
except IOError as e:
raise BinWifiException("Couldn't open specified pidfile %s: %s",
pid_filename, e)
def kill_pid(program, pid_filename):
"""Kill a program which was run with startpid.
Args:
program: The program to stop.
pid_filename: The location of the startpid pid file.
Returns:
Whether stopping the program succeeded.
"""
try:
subprocess.check_call(('pkillwait', '-f', program))
subprocess.check_call(('killpid', pid_filename))
except subprocess.CalledProcessError as e:
log('Error stopping process: %s', e)
return False
finally:
try:
os.remove(pid_filename)
except OSError:
if os.path.exists(pid_filename):
raise
return True
def read_or_empty(filename):
try:
with open(filename) as f:
return f.read().strip()
except IOError:
return ''
def validate_set_wifi_options(opt):
"""Validates options to set_wifi.
Args:
opt: The options to validate.
Raises:
BinWifiException: if anything is not valid.
"""
band = opt.band
width = opt.width
autotype = opt.autotype
protocols = set(opt.protocols.split('/'))
if band not in ('2.4', '5'):
raise BinWifiException('You must specify band with -b2.4 or -b5')
if (band, width) == ('2.4', '80'):
raise BinWifiException(
'80 MHz not valid in 2.4 GHz: type=%s band=%s width=%s',
autotype, band, width)
if (band, autotype) == ('2.4', 'DFS'):
raise BinWifiException('DFS not valid in 2.4 GHz: type=%s band=%s width=%s',
autotype, band, width)
if (band, autotype) == ('5', 'OVERLAP'):
raise BinWifiException(
'OVERLAP not allowed in 5 GHz: type=%s band=%s width=%s',
autotype, band, width)
if not protocols:
raise BinWifiException('Must specify some 802.11 protocols')
for protocol in protocols:
if protocol not in ('a', 'b', 'ab', 'g', 'n', 'ac'):
raise BinWifiException('Unknown 802.11 protocol: %s', protocol)
if width not in ('20', '40', '80'):
raise BinWifiException('Invalid channel width %s', width)
elif width == '40' and 'n' not in protocols:
raise BinWifiException('-p n is needed for 40 MHz channels')
elif width == '80' and 'ac' not in protocols:
raise BinWifiException('-p ac is needed for 40 MHz channels')
if opt.encryption == 'WEP' or '_PSK_' in opt.encryption:
if 'WIFI_PSK' not in os.environ:
raise BinWifiException(
'Encryption enabled; use WIFI_PSK=whatever wifi set ...')
if opt.wds and not opt.bridge:
raise BinWifiException('WDS mode enabled; must specify a bridge.')
def sanitize_ssid(ssid):
"""Remove control and non-UTF8 characters from an SSID.
We use hostapd's utf8_ssid option to specify a UTF8 SSID.
Args:
ssid: The SSID to sanitize, as a string.
Returns:
The sanitized SSID, as a UTF8-encoded string.
"""
return ''.join(c for c in ssid.decode('utf-8', 'ignore')
if unicodedata.category(c)[0] != 'C').encode('utf-8')
def validate_and_sanitize_bssid(bssid):
maybe_octets = bssid.lower().split(':')
if (len(maybe_octets) == 6 and
all(re.match('[0-9a-f]{2}', maybe_octet)
for maybe_octet in maybe_octets)):
return ':'.join(maybe_octets)
else:
raise BinWifiException('%s is not a valid BSSID', bssid)
def validate_and_sanitize_psk(psk):
"""Validates a PSK and removes control characters.
Checks that requirement that PSKs must be either 8-63 ASCII characters or 64
hex characters.
Args:
psk: The PSK to validate, as a string.
Returns:
The sanitized PSK, as a string.
Raises:
BinWifiException: If the PSK is invalid.
"""
if len(psk) == 64:
try:
psk.decode('hex')
except TypeError:
raise BinWifiException('64-character PSK is not hex: %s', psk)
elif 8 <= len(psk) <= 63:
try:
psk.decode('ascii')
except UnicodeDecodeError:
raise BinWifiException('8-63 character PSK is not ASCII: %s', psk)
psk = ''.join(c for c in psk if ord(c) >= 32)
if len(psk) < 8:
raise BinWifiException('PSK is not of a valid length: %d', len(psk))
else:
raise BinWifiException('PSK is not of a valid length: %d', len(psk))
return psk
def _lockfile_create_retries(timeout_sec):
"""Invert the lockfile-create --retry option.
The --retry option specifies how many times to retry. Each retry takes an
additional five seconds, starting at 0, so --retry 1 takes 5 seconds,
--retry 2 takes 15 (5 + 10), and so on. So:
timeout_sec = 5 * (retries * (retries + 1)) / 2 =>
2.5retries^2 + 2.5retries + -timeout_sec = 0 =>
retries = (-2.5 +/- sqrt(2.5^2 - 4*2.5*-timeout_sec)) / (2*2.5)
retries = (-2.5 +/- sqrt(6.25 + 10*timeout_sec)) / 5
We want to ceil this to make sure we have more than enough time, and we can
even also add 1 to timeout_sec in case we'd otherwise get a whole number and
don't want it to be close. We can also reduce the +/- to a + because we
don't care about negative solutions.
(Alternatively, we could remove the signal.alarm and
expose /bin/wifi callers to this logic by letting them specify the retry
count directly, but that would be even worse than this.)
Args:
timeout_sec: The number of seconds the timeout must exceed.
Returns:
A value for lockfile-create --retry.
"""
return math.ceil((-2.5 + math.sqrt(6.25 + 10.0 * (timeout_sec + 1))) / 5.0)
def lock(lockfile, timeout_sec):
"""Attempt to lock lockfile for up to timeout_sec.
Args:
lockfile: The file to lock.
timeout_sec: How long to try before giving up.
Raises:
BinWifiException: If the timeout is exceeded.
"""
def time_out(*_):
raise BinWifiException('Failed to obtain lock %s after %d seconds',
lockfile, timeout_sec)
retries = _lockfile_create_retries(timeout_sec)
signal.signal(signal.SIGALRM, time_out)
signal.alarm(timeout_sec)
subprocess.call(['lockfile-create', '--use-pid', '--retry', str(retries),
lockfile])
signal.alarm(0)
def unlock(lockfile):
subprocess.call(['lockfile-remove', lockfile])