blob: d2117cf05f6365949fa8abbf77e178585db6f5e9 [file] [log] [blame]
#!/usr/bin/python
# Copyright 2016 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Apply ip address and default route for a set of network interfaces."""
import errno
import json
import os
import re
import socket
import struct
import subprocess
import sys
import time
import options
optspec = """
ipapply <interface names>
--
"""
# TODO(poist): Add support for alias and mtu.
# Allow test override of configuration file location.
base_path = os.getenv('IPAPPLY_CONFIG_FILE_BASE', '')
STATIC_IP_CONFIGS_DIR = base_path + '/config/ip/static'
DYNAMIC_IP_CONFIGS_DIR = base_path + '/tmp/ip/dynamic'
TMP_CONMAN_DIR = base_path + '/tmp/conman'
DISABLED_FILE = base_path + '/tmp/ipapply_disabled'
def AtomicWrite(filename, data):
tmp_filename = filename + '.tmp'
with open(tmp_filename, 'w') as tmp:
tmp.write(data)
os.rename(tmp_filename, filename)
def Log(s, *args):
sys.stdout.flush()
ss = 'ipapply: %s\n' % (s,)
if args:
sys.stderr.write(ss % args)
else:
sys.stderr.write(ss)
sys.stderr.flush()
def LoadJsonFromFile(path):
"""Deserializes a JSON file to a Python object.
Args:
path: The path to the JSON file to be converted.
Returns:
Dict containing the JSON data.
Raises:
OSError/IOError: If the JSON data file could not be loaded.
ValueError: If the JSON data loaded from the file does not contain the
requested key, a ValueError is raised.
"""
if not os.path.exists(path):
# It is a valid state that there might not be a JSON data file yet,
# so just return without printing an error.
return
with open(path) as f:
return json.load(f)
def InterfaceExists(iface_name):
"""Return True if the named network interface exists."""
return os.path.isdir(os.path.join(base_path + '/sys/class/net', iface_name))
def IsInterfaceUp(iface_name):
"""Return True if the named network interface is up."""
if subprocess.call(['is-interface-up', iface_name]) == 0:
return True
return False
def IsProcessRunning(pid):
"""Check whether a process is running."""
pid = int(pid)
if pid <= 0:
raise ValueError('invalid pid')
try:
os.kill(pid, 0)
except OSError as err:
if err.errno == errno.ESRCH:
# ESRCH == No such process
return False
elif err.errno == errno.EPERM:
# EPERM clearly means there's a process to deny access to
return True
else:
# According to "man 2 kill" possible error values are
# (EINVAL, EPERM, ESRCH)
raise
def IpAddrUpdate(iface_name, new_ips):
"""Add new address (with /netmask) on an interface, and remove old ones."""
ip_show = subprocess.check_output(['ip', 'addr', 'show', 'dev', iface_name])
current_ips = set()
pattern = r'\s*inet\s+(?P<ip>(\d{1,3}\.?){4})(?P<netmask>\/\d{1,2})?'
regex = re.compile(pattern)
for line in ip_show.splitlines():
match = re.match(pattern, line)
if match:
current_ips.add(match.group('ip') + (match.group('netmask') or '/32'))
for new_ip in new_ips - current_ips:
IpAddrAdd(iface_name, new_ip)
for old_ip in current_ips - new_ips:
IpAddrDel(iface_name, old_ip)
def IpAddrAdd(iface, ip):
Log('Adding address %r on interface %r', ip, iface)
subprocess.check_call(['ip', 'addr', 'add', ip, 'dev', iface])
# We don't want 'proto kernel' routes.
ip_route_show = ['ip', 'route', 'show', 'dev', iface]
for line in subprocess.check_output(ip_route_show).splitlines():
if 'proto kernel' in line:
route = line.split()[0]
if subprocess.call(['ip', 'route', 'del', route, 'dev', iface]) != 0:
Log('Failed to delete route %s on interface %s', route, iface)
def IpAddrDel(iface_name, ip):
Log('Deleting address %r on interface %r', ip, iface_name)
subprocess.check_call(['ip', 'addr', 'del', ip, 'dev', iface_name])
def RouteAdd(iface_name, routers=None, subnet=None):
"""Set a default route via this interface through apman."""
# apman supports only a single route, and installs it as 'default'.
# arbitrarily select the first route if multiple provided.
subnet_path = os.path.join(TMP_CONMAN_DIR, 'subnet.%s' % iface_name)
gateway_path = os.path.join(TMP_CONMAN_DIR, 'gateway.%s' % iface_name)
if subnet:
Log('add subnet route: %r on dev %r', subnet, iface_name)
AtomicWrite(subnet_path, subnet)
if routers:
first_route = routers.split()[0]
Log('add default route on dev %r with routers: %r',
iface_name, first_route)
AtomicWrite(gateway_path, first_route)
def SelectAndApply(iface_name):
"""Select the config file and apply the contents."""
static_config_file = os.path.join(STATIC_IP_CONFIGS_DIR, iface_name)
dynamic_config_file = os.path.join(DYNAMIC_IP_CONFIGS_DIR, iface_name)
# Select the appropriate configuration file.
# Normally static configuration takes precedence over dynamic
# configuration. If this is a box that may be running in bridge
# mode (ie with both a wan [uplink] port, and a lan port), decide
# which configuration based on the current state of the wan port.
selected_config_file = None
if iface_name == 'br0' and InterfaceExists('wan0'):
# Device that supports bridge mode, select based on wan link status.
if IsInterfaceUp('wan0'):
selected_config_file = static_config_file
else:
selected_config_file = dynamic_config_file
elif IsInterfaceUp(iface_name):
if os.path.isfile(static_config_file):
selected_config_file = static_config_file
else:
selected_config_file = dynamic_config_file
else:
Log('interface %r down, clearing config', iface_name)
if not selected_config_file or not os.path.isfile(selected_config_file):
Log('no available configuration file for interface %r', iface_name)
config = {}
else:
config = LoadJsonFromFile(selected_config_file)
if config:
config = config[iface_name]
else:
Log('failed to load JSON configuration from %r for interface %r',
selected_config_file, iface_name)
new_ips = set()
for ip in config.get('ip', []):
if 'new_ip_address' in ip:
ip_string = ip['new_ip_address']
netmask = '32'
if 'new_subnet_mask' in ip:
netmask = str(NormalizeNetmask(ip['new_subnet_mask']))
ip_string = ip_string + '/' + netmask
new_ips.add(ip_string)
IpAddrUpdate(iface_name, new_ips)
for ip in config.get('ip', []):
subnet = None
if 'new_ip_address' in ip and 'new_subnet_mask' in ip:
mask = str(NormalizeNetmask(ip['new_subnet_mask']))
subnet = GetSubnet(ip['new_ip_address'], mask)
RouteAdd(iface_name, routers=ip.get('new_routers', None), subnet=subnet)
def IpToInt(ip):
return struct.unpack('!I', socket.inet_pton(socket.AF_INET, ip))[0]
def IntToIp(ip):
return socket.inet_ntop(socket.AF_INET, struct.pack('!I', ip))
def NormalizeNetmask(netmask):
"""Convert a netmask to 'bits', e.g. 24, if it isn't already."""
if not isinstance(netmask, basestring):
raise ValueError('NormalizeNetmask expects a string')
if '.' in netmask:
return NetmaskToBits(netmask)
# If we got an invalid string, i.e. one that isn't an IP string (handled
# above) or an integer, it will be detected here and a ValueError will be
# raised.
x = int(netmask)
if not 0 <= x <= 32:
raise ValueError('Netmask bits should be between 0 and 32')
return x
def NetmaskToBits(netmask):
"""255.255.255.0' -> 24"""
# '255.255.255.0' -> '11111111111111111111111100000000'
netmask_bitstring = bin(IpToInt(netmask))[2:]
# '11111111111111111111111100000000' -> 24
return len(netmask_bitstring.split('0')[0])
def ApplyNetmask(ip, netmask):
int_ip = IpToInt(ip)
netmask = int(NormalizeNetmask(netmask))
int_netmask = (2**netmask - 1) << (32 - netmask)
return IntToIp(int_ip & int_netmask)
def GetSubnet(ip, netmask):
return ''.join((ApplyNetmask(ip, netmask), '/', netmask))
def Disabled():
"""Returns whether /tmp/ipapply_disabled has been written in the last hour."""
if os.path.exists(DISABLED_FILE):
disabled_s = (os.stat(DISABLED_FILE).st_mtime + 60 * 60) - time.time()
if disabled_s > 0:
Log('Disabled for the next %d seconds', disabled_s)
return True
return False
def main():
if Disabled():
return
o = options.Options(optspec)
(_, _, extra) = o.parse(sys.argv[1:])
if not extra:
o.fatal('at least one interface name expected')
# if using NFS root, don't mess with interfaces other than
# wireless, or we could break the NFS connection.
is_nfs = os.path.exists('/tmp/NFS')
# uniquify the set of interface names
for iface_name in set(extra):
if iface_name.startswith('wcli') or not is_nfs:
SelectAndApply(iface_name)
if __name__ == '__main__':
main()