%PDF- %PDF-
Direktori : /usr/share/netplan/netplan_cli/cli/ |
Current File : //usr/share/netplan/netplan_cli/cli/state.py |
#!/usr/bin/python3 # # Copyright (C) 2023 Canonical, Ltd. # Authors: Lukas Märdian <slyon@ubuntu.com> # Danilo Egea Gondolfo <danilo.egea.gondolfo@canonical.com> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; version 3. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from collections import defaultdict, namedtuple import ipaddress import json import logging import re import socket import subprocess import sys from io import StringIO from typing import Dict, List, Type, Union import yaml import dbus import netplan from . import utils JSON = Union[Dict[str, 'JSON'], List['JSON'], int, str, float, bool, Type[None]] DEVICE_TYPES = { 'bond': 'bond', 'bridge': 'bridge', 'dummy': 'dummy-device', 'erspan': 'tunnel', 'ether': 'ethernet', 'gretap': 'tunnel', 'ipgre': 'tunnel', 'ip6gre': 'tunnel', 'loopback': 'ethernet', 'sit': 'tunnel', 'tunnel': 'tunnel', 'tun': 'tunnel', 'tunnel6': 'tunnel', 'wireguard': 'tunnel', 'wlan': 'wifi', 'wwan': 'modem', 'veth': 'virtual-ethernet', 'vlan': 'vlan', 'vrf': 'vrf', 'vxlan': 'tunnel', # Netplan netdef types 'wifis': 'wifi', 'ethernets': 'ethernet', 'bridges': 'bridge', 'bonds': 'bond', 'nm-devices': 'nm-device', 'dummy-devices': 'dummy-device', 'modems': 'modem', 'vlans': 'vlan', 'vrfs': 'vrf', } class Interface(): def __extract_mac(self, ip: dict) -> str: ''' Extract the MAC address if it's set inside the JSON data and seems to have the correct format. Return 'None' otherwise. ''' if len(address := ip.get('address', '')) == 17: # 6 byte MAC (+5 colons) return address.lower() return None def __init__(self, ip: dict, nd_data: JSON = [], nm_data: JSON = [], resolved_data: tuple = (None, None), route_data: tuple = (None, None)): self.idx: int = ip.get('ifindex', -1) self.name: str = ip.get('ifname', 'unknown') self.adminstate: str = 'UP' if 'UP' in ip.get('flags', []) else 'DOWN' self.operstate: str = ip.get('operstate', 'unknown').upper() self.macaddress: str = self.__extract_mac(ip) self.bridge: str = None self.bond: str = None self.vrf: str = None self.members: List[str] = [] # Filter networkd/NetworkManager data nm_data = nm_data or [] # avoid 'None' value on systems without NM self.nd: JSON = next((x for x in nd_data if x['Index'] == self.idx), None) self.nm: JSON = next((x for x in nm_data if x['device'] == self.name), None) # Filter resolved's DNS data self.dns_addresses: list = None if resolved_data[0]: self.dns_addresses = [] for itr in resolved_data[0]: if int(itr[0]) == int(self.idx): ipfamily = itr[1] dns = itr[2] self.dns_addresses.append(socket.inet_ntop(ipfamily, b''.join([v.to_bytes(1, 'big') for v in dns]))) self.dns_search: list = None if resolved_data[1]: self.dns_search = [] for v in resolved_data[1]: if int(v[0]) == int(self.idx): self.dns_search.append(str(v[1])) # Filter route data _routes: list = [] self.routes: list = None if route_data[0]: _routes += route_data[0] if route_data[1]: _routes += route_data[1] if _routes: self.routes = [] for obj in _routes: if obj.get('dev') == self.name: elem = {'to': obj.get('dst')} if val := obj.get('family'): elem['family'] = val if val := obj.get('gateway'): elem['via'] = val if val := obj.get('prefsrc'): elem['from'] = val if val := obj.get('metric'): elem['metric'] = val if val := obj.get('type'): elem['type'] = val if val := obj.get('scope'): elem['scope'] = val if val := obj.get('protocol'): elem['protocol'] = val if val := obj.get('table'): elem['table'] = val self.routes.append(elem) self.addresses: list = None if addr_info := ip.get('addr_info'): self.addresses = [] for addr in addr_info: flags: list = [] if ipaddress.ip_address(addr['local']).is_link_local: flags.append('link') if self.routes: for route in self.routes: if ('from' in route and ipaddress.ip_address(route['from']) == ipaddress.ip_address(addr['local'])): if route['protocol'] == 'dhcp': flags.append('dhcp') break ip_addr = addr['local'].lower() elem = {ip_addr: {'prefix': addr['prefixlen']}} if flags: elem[ip_addr]['flags'] = flags self.addresses.append(elem) self.iproute_type: str = None if info_kind := ip.get('linkinfo', {}).get('info_kind'): self.iproute_type = info_kind.strip() # workaround: query some data which is not available via networkctl's JSON output self._networkctl: str = self.query_networkctl(self.name) or '' def query_nm_ssid(self, con_name: str) -> str: ssid: str = None try: ssid = utils.nmcli_out(['--get-values', '802-11-wireless.ssid', 'con', 'show', 'id', con_name]) return ssid.strip() except Exception as e: logging.warning('Cannot query NetworkManager SSID for {}: {}'.format( con_name, str(e))) return ssid def query_networkctl(self, ifname: str) -> str: output: str = None try: output = subprocess.check_output(['networkctl', 'status', '--', ifname], text=True) except Exception as e: logging.warning('Cannot query networkctl for {}: {}'.format( ifname, str(e))) return output def json(self) -> JSON: json = { 'index': self.idx, 'adminstate': self.adminstate, 'operstate': self.operstate, } if self.type: json['type'] = self.type if self.ssid: json['ssid'] = self.ssid if self.tunnel_mode: json['tunnel_mode'] = self.tunnel_mode if self.backend: json['backend'] = self.backend if self.netdef_id: json['id'] = self.netdef_id if self.macaddress: json['macaddress'] = self.macaddress if self.vendor: json['vendor'] = self.vendor if self.addresses: json['addresses'] = self.addresses if self.dns_addresses: json['dns_addresses'] = self.dns_addresses if self.dns_search: json['dns_search'] = self.dns_search if self.routes: json['routes'] = self.routes if self.activation_mode: json['activation_mode'] = self.activation_mode if self.bridge: json['bridge'] = self.bridge if self.bond: json['bond'] = self.bond if self.vrf: json['vrf'] = self.vrf if self.members: json['interfaces'] = self.members return (self.name, json) @property def up(self) -> bool: return self.adminstate == 'UP' and self.operstate == 'UP' @property def down(self) -> bool: return self.adminstate == 'DOWN' and self.operstate == 'DOWN' @property def type(self) -> str: nd_type = self.nd.get('Type') if self.nd else None if nd_type == 'none': # If the Type is reported as 'none' by networkd, the interface still might have a Kind. nd_type = self.nd.get('Kind') if nd_type == 'ether': # There are different kinds of 'ether' devices, such as VRFs, veth and dummies if kind := self.nd.get('Kind'): nd_type = kind if device_type := DEVICE_TYPES.get(nd_type): return device_type logging.warning('Unknown device type: {}'.format(nd_type)) return None @property def tunnel_mode(self) -> str: if self.type == 'tunnel' and self.iproute_type: return self.iproute_type return None @property def backend(self) -> str: if (self.nd and 'unmanaged' not in self.nd.get('SetupState', '') and 'run/systemd/network/10-netplan-' in self.nd.get('NetworkFile', '')): return 'networkd' elif self.nm and 'run/NetworkManager/system-connections/netplan-' in self.nm.get('filename', ''): return 'NetworkManager' return None @property def netdef_id(self) -> str: if self.backend == 'networkd': return self.nd.get('NetworkFile', '').split( 'run/systemd/network/10-netplan-')[1].split('.network')[0] elif self.backend == 'NetworkManager': netdef = self.nm.get('filename', '').split( 'run/NetworkManager/system-connections/netplan-')[1].split('.nmconnection')[0] if self.nm.get('type', '') == '802-11-wireless': ssid = self.query_nm_ssid(self.nm.get('name')) if ssid: # XXX: escaping needed? netdef = netdef.split('-' + ssid)[0] return netdef return None @property def vendor(self) -> str: if self.nd and 'Vendor' in self.nd and self.nd['Vendor']: return self.nd['Vendor'].strip() return None @property def ssid(self) -> str: if self.type == 'wifi': # XXX: available from networkctl's JSON output as of v250: # https://github.com/systemd/systemd/commit/da7c995 for line in self._networkctl.splitlines(): line = line.strip() key = 'WiFi access point: ' if line.startswith(key): ssid = line[len(key):-len(' (xB:SS:ID:xx:xx:xx)')].strip() return ssid if ssid else None return None @property def activation_mode(self) -> str: if self.backend == 'networkd': # XXX: available from networkctl's JSON output as of v250: # https://github.com/systemd/systemd/commit/3b60ede for line in self._networkctl.splitlines(): line = line.strip() key = 'Activation Policy: ' if line.startswith(key): mode = line[len(key):].strip() return mode if mode != 'up' else None # XXX: this is not fully supported on NetworkManager, only 'manual'/'up' elif self.backend == 'NetworkManager': return 'manual' if self.nm['autoconnect'] == 'no' else None return None class SystemConfigState(): ''' Collects the system's network configuration ''' def __init__(self, ifname=None, all=False): # Make sure sd-networkd is running, as we need the data it provides. if not utils.systemctl_is_active('systemd-networkd.service'): if utils.systemctl_is_masked('systemd-networkd.service'): logging.error('\'netplan status\' depends on networkd, ' 'but systemd-networkd.service is masked. ' 'Please start it.') sys.exit(1) logging.debug('systemd-networkd.service is not active. Starting...') utils.systemctl('start', ['systemd-networkd.service'], True) # required data: iproute2 and sd-networkd can be expected to exist, # due to hard package dependencies iproute2 = self.query_iproute2() networkd = self.query_networkd() if not iproute2 or not networkd: logging.error('Could not query iproute2 or systemd-networkd') sys.exit(1) # optional data nmcli = self.query_nm() route4, route6 = self.query_routes() dns_addresses, dns_search = self.query_resolved() self.interface_list = [Interface(itf, networkd, nmcli, (dns_addresses, dns_search), (route4, route6)) for itf in iproute2] # get bridge/bond/vrf data self.correlate_members_and_uplink(self.interface_list) # show only active interfaces by default filtered = [itf for itf in self.interface_list if itf.operstate != 'DOWN'] # down interfaces do not contribute anything to the online state online_state = self.query_online_state(filtered) # show only a single interface, if requested # XXX: bash completion (for interfaces names) if ifname: filtered = [next((itf for itf in self.interface_list if itf.name == ifname), None)] filtered = [elem for elem in filtered if elem is not None] if ifname and filtered == []: logging.error('Could not find interface {}'.format(ifname)) sys.exit(1) # Global state self.state = { 'netplan-global-state': { 'online': online_state, 'nameservers': self.resolvconf_json() } } # Per interface itf_iter = self.interface_list if all else filtered for itf in itf_iter: ifname, obj = itf.json() self.state[ifname] = obj @classmethod def resolvconf_json(cls) -> dict: res = { 'addresses': [], 'search': [], 'mode': None, } try: with open('/etc/resolv.conf') as f: # check first line for systemd-resolved stub or compat modes firstline = f.readline() if '# This is /run/systemd/resolve/stub-resolv.conf' in firstline: res['mode'] = 'stub' elif '# This is /run/systemd/resolve/resolv.conf' in firstline: res['mode'] = 'compat' for line in [firstline] + f.readlines(): if line.startswith('nameserver'): res['addresses'] += line.split()[1:] # append if line.startswith('search'): res['search'] = line.split()[1:] # override except Exception as e: logging.warning('Cannot parse /etc/resolv.conf: {}'.format(str(e))) return res @classmethod def query_online_state(cls, interfaces: list) -> bool: # TODO: fully implement network-online.target specification (FO020): # https://discourse.ubuntu.com/t/spec-definition-of-an-online-system/27838 for itf in interfaces: if itf.up and itf.addresses and itf.routes and itf.dns_addresses: non_local_ips = [] for addr in itf.addresses: ip, extra = list(addr.items())[0] if 'flags' not in extra or 'link' not in extra['flags']: non_local_ips.append(ip) default_routes = [x for x in itf.routes if x.get('to', None) == 'default'] if non_local_ips and default_routes and itf.dns_addresses: return True return False @classmethod def process_generic(cls, cmd_output: str) -> JSON: return json.loads(cmd_output) @classmethod def query_iproute2(cls) -> JSON: data: JSON = None try: output: str = subprocess.check_output(['ip', '-d', '-j', 'addr'], text=True) data = cls.process_generic(output) except Exception as e: logging.critical('Cannot query iproute2 interface data: {}'.format(str(e))) return data @classmethod def process_networkd(cls, cmd_output) -> JSON: return json.loads(cmd_output)['Interfaces'] @classmethod def query_networkd(cls) -> JSON: data: JSON = None try: output: str = subprocess.check_output(['networkctl', '--json=short'], text=True) data = cls.process_networkd(output) except Exception as e: logging.critical('Cannot query networkd interface data: {}'.format(str(e))) return data @classmethod def process_nm(cls, cmd_output) -> JSON: data: JSON = [] for line in cmd_output.splitlines(): split = line.split(':') dev = split[0] if split[0] else None if dev: # ignore inactive connection profiles data.append({ 'device': dev, 'name': split[1], 'uuid': split[2], 'filename': split[3], 'type': split[4], 'autoconnect': split[5], }) return data @classmethod def query_nm(cls) -> JSON: data: JSON = None try: output: str = utils.nmcli_out(['-t', '-f', 'DEVICE,NAME,UUID,FILENAME,TYPE,AUTOCONNECT', 'con', 'show']) data = cls.process_nm(output) except Exception as e: logging.debug('Cannot query NetworkManager interface data: {}'.format(str(e))) return data @classmethod def query_routes(cls) -> tuple: data4 = None data6 = None try: output4: str = subprocess.check_output(['ip', '-d', '-j', '-4', 'route', 'show', 'table', 'all'], text=True) data4: JSON = cls.process_generic(output4) output6: str = subprocess.check_output(['ip', '-d', '-j', '-6', 'route', 'show', 'table', 'all'], text=True) data6: JSON = cls.process_generic(output6) except Exception as e: logging.debug('Cannot query iproute2 route data: {}'.format(str(e))) # Add the address family to the data # IPv4: 2, IPv6: 10 if data4: for route in data4: route.update({'family': socket.AF_INET.value}) if data6: for route in data6: route.update({'family': socket.AF_INET6.value}) return (data4, data6) @classmethod def query_resolved(cls) -> tuple: addresses = None search = None try: ipc = dbus.SystemBus() resolve1 = ipc.get_object('org.freedesktop.resolve1', '/org/freedesktop/resolve1') resolve1_if = dbus.Interface(resolve1, 'org.freedesktop.DBus.Properties') res = resolve1_if.GetAll('org.freedesktop.resolve1.Manager') addresses = res['DNS'] search = res['Domains'] except Exception as e: logging.debug('Cannot query resolved DNS data: {}'.format(str(e))) return (addresses, search) @classmethod def query_members(cls, ifname: str) -> List[str]: ''' Return a list containing the interfaces that are members of a bond/bridge/vrf ''' members = [] output: str = None try: output = subprocess.check_output( ['ip', '-d', '-j', 'link', 'show', 'master', ifname], text=True) # wokeignore:rule=master except Exception as e: logging.warning('Cannot query bridge: {}'.format(str(e))) return [] output_json = json.loads(output) for member in output_json: members.append(member.get('ifname')) return members @classmethod def correlate_members_and_uplink(cls, interfaces: List[Interface]) -> None: ''' Associate interfaces with their members and parent interfaces. If an interface is a member of a bond/bridge/vrf, identify which interface if a member of. If an interface has members, identify what are the members. ''' uplink_types = ['bond', 'bridge', 'vrf'] members_to_uplink = {} uplink_to_members = defaultdict(list) for interface in filter(lambda i: i.type in uplink_types, interfaces): members = cls.query_members(interface.name) for member in members: member_tuple = namedtuple('Member', ['name', 'type']) members_to_uplink[member] = member_tuple(interface.name, interface.type) uplink_to_members[interface.name] = members for interface in interfaces: if uplink := members_to_uplink.get(interface.name): if uplink.type == 'bridge': interface.bridge = uplink.name if uplink.type == 'bond': interface.bond = uplink.name if uplink.type == 'vrf': interface.vrf = uplink.name if interface.type in uplink_types: if members := uplink_to_members.get(interface.name): interface.members = members @property def number_of_interfaces(self) -> int: return len(self.interface_list) def get_data(self) -> dict: return self.state class NetplanConfigState(): ''' Collects the Netplan's network configuration ''' def __init__(self, subtree='all', rootdir='/'): parser = netplan.Parser() parser.load_yaml_hierarchy(rootdir) np_state = netplan.State() np_state.import_parser_results(parser) self.netdefs = np_state.netdefs self.state = StringIO() if subtree == 'all': np_state._dump_yaml(output_file=self.state) else: if not subtree.startswith('network'): subtree = '.'.join(('network', subtree)) # Split at '.' but not at '\.' via negative lookbehind expression subtree = re.split(r'(?<!\\)\.', subtree) # Replace remaining '\.' by plain '.' subtree = [elem.replace(r'\.', '.') for elem in subtree] tmp_in = StringIO() np_state._dump_yaml(output_file=tmp_in) netplan._dump_yaml_subtree(subtree, tmp_in, self.state) def __str__(self) -> str: return self.state.getvalue() def get_data(self) -> dict: return yaml.safe_load(self.state.getvalue())