"""Reading what ifupdown was told about this machine's network. Only the parsing, so it can be tested against the file a real machine actually has rather than against a running laptop. WHY THIS EXISTS. The Bible study laptop had working internet and a Plasma network applet that said there was no connection. Both were true. Debian's installer had been given the Wi-Fi during installation, so it wrote the network into /etc/network/interfaces: auto wlp2s0 iface wlp2s0 inet dhcp wpa-ssid Something wpa-psk a-secret ifupdown brings that up with its own wpa_supplicant and dhcpcd, which is why the internet works. NetworkManager ships with plugins=ifupdown and [ifupdown] managed=false, so an interface named in that file is marked UNMANAGED — which is why nmcli showed `wlp2s0 wifi unmanaged`, why the applet had nothing to report, and why scanning found nothing: an unmanaged device is not a radio NetworkManager will scan with. managed=false is deliberate and stays. It is what keeps a tower's static Ethernet under ifupdown's control where an administrator put it. What has to change is narrower: a WIRELESS interface should belong to NetworkManager, so the room can see it, scan with it, and so its password is stored the way this package stores passwords. So this finds the wireless stanza and everything needed to recreate it as a NetworkManager system connection. """ import re # ifupdown's own spelling. Values may be quoted, and a psk may be a # 64-character hex key rather than a passphrase. SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto", "wpa-scan-ssid") # Whether the access point broadcasts its name. # # wpa-scan-ssid 1 is ifupdown's spelling of wpa_supplicant's scan_ssid=1: # probe for this network by name, because it is not in a passive scan. # NetworkManager spells the same thing 802-11-wireless.hidden, and a # migration that drops it produces a connection that is correct in every # visible respect and can never associate — NetworkManager will not # connect to an SSID it has not seen in a scan, and a hidden one is never # in a scan. That is what happened to the prayer-room laptop on 0.2.5. HIDDEN = "wpa-scan-ssid" _STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$") TRUTHY = {"1", "yes", "true", "on"} def _unquote(value): value = (value or "").strip() for quote in ('"', "'"): if len(value) >= 2 and value.startswith(quote) and value.endswith(quote): return value[1:-1] return value def parse(text): """Every `iface` stanza in an interfaces file, with its options. Returns a list of dicts: name, family, method, options, and the line range the stanza occupies so a caller can comment exactly it out and nothing else. """ lines = (text or "").splitlines() stanzas = [] current = None for number, raw in enumerate(lines): # A comment is a WHOLE line, and only a whole line. # # interfaces(5): "Lines starting with '#' are ignored. Note that # end-of-line comments are NOT supported." This used to cut every # line at its first '#', which is a silent corruption of exactly # one value: a pre-shared key containing a '#' was truncated, the # migrated connection got a password that was a prefix of the # real one, and the only symptom is an association that fails the # way a wrong password fails. if raw.lstrip().startswith("#"): continue line = raw.rstrip() match = _STANZA.match(line) if match: keyword, rest = match.group(1), match.group(2).split() if keyword == "iface": current = { "name": rest[0] if rest else "", "family": rest[1] if len(rest) > 1 else "", "method": rest[2] if len(rest) > 2 else "", "options": {}, "first": number, "last": number, } stanzas.append(current) else: # auto/allow-hotplug end the previous stanza's body. current = None continue if current is None or not line.strip(): continue parts = line.strip().split(None, 1) if not parts: continue current["options"][parts[0].lower()] = _unquote( parts[1] if len(parts) > 1 else "") current["last"] = number return stanzas def wireless(stanzas, is_wireless): """The stanzas that describe a wireless interface. `is_wireless` answers for a device name, so the caller decides how to ask the machine — /sys/class/net//wireless in practice. A wpa-ssid is accepted as evidence on its own, for a machine whose device has since been renamed or removed. """ found = [] for stanza in stanzas: name = stanza.get("name") or "" if not name or name == "lo": continue if is_wireless(name) or "wpa-ssid" in stanza["options"]: found.append(stanza) return found def credentials(stanza): """SSID and pre-shared key from a stanza, or (None, None). A stanza that points at a wpa_supplicant.conf instead of naming the network inline returns no SSID: that one is not ours to move, and the caller must leave it alone rather than guess. """ found = profile(stanza) if found is None: return None, None return found["ssid"], found["psk"] def profile(stanza): """Everything needed to recreate this stanza in NetworkManager. A dict, or None when the stanza is not ours to move. Returning the whole network rather than a pair is the fix for the 0.2.5 failure: `wpa-scan-ssid` was sitting in the parsed options the whole time and the only accessor threw it away, so the hand-over could not have preserved it however carefully it was written. `key_mgmt` is "wpa-psk", "none", or None meaning "this file says something this command does not understand". The caller must refuse that last one rather than guess: a WPA-Enterprise network recreated as a PSK network is a migration that cannot work and has already taken the interface away from ifupdown to find out. """ options = stanza.get("options") or {} if "wpa-conf" in options and "wpa-ssid" not in options: return None ssid = options.get("wpa-ssid") or None if not ssid: return None psk = options.get("wpa-psk") or None declared = (options.get("wpa-key-mgmt") or "").strip().upper() if declared in ("", "WPA-PSK", "WPA-PSK-SHA256", "SAE", "WPA-PSK SAE"): key_mgmt = "wpa-psk" if psk else "none" elif declared == "NONE": key_mgmt = "none" else: # WPA-EAP, IEEE8021X and anything else this has never seen. key_mgmt = None return { "ssid": ssid, "psk": psk, # A network that has to be probed for by name. See HIDDEN. "hidden": (options.get(HIDDEN) or "").strip().lower() in TRUTHY, "key_mgmt": key_mgmt, } def comment_out(text, stanza, marker="# ekklesia-classroom: "): """Comment out exactly one stanza, leaving every other byte alone.""" lines = (text or "").splitlines(keepends=True) for number in range(stanza["first"], stanza["last"] + 1): if number < len(lines) and not lines[number].lstrip().startswith("#"): lines[number] = marker + lines[number] # The `auto`/`allow-hotplug` line above it would otherwise ask # ifupdown to bring up an interface it no longer has a stanza for. for number in range(stanza["first"] - 1, -1, -1): stripped = lines[number].strip() if number < len(lines) else "" if not stripped or stripped.startswith("#"): continue if re.match(r"^(auto|allow-hotplug)\s", stripped) and \ stanza["name"] in stripped.split(): lines[number] = marker + lines[number] break return "".join(lines) def uncomment(text, marker="# ekklesia-classroom: "): """Undo comment_out, in the file as it stands now. Returns (text, count). The rollback uses this rather than copying the backup over the top, because the backup is kept once and never refreshed: an administrator who corrected a static Ethernet address between the migration and the rollback would have had that correction silently reverted by a whole-file restore. This touches only the lines this package marked, so everything else in the file — wired stanzas, source lines, an administrator's own comments — is exactly as the machine left it. """ lines = (text or "").splitlines(keepends=True) restored = 0 for number, line in enumerate(lines): stripped = line.lstrip() if not stripped.startswith(marker): continue indent = line[:len(line) - len(stripped)] lines[number] = indent + stripped[len(marker):] restored += 1 return "".join(lines), restored