| #!/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() |