diff --git a/migrations/1784523021.sh b/migrations/1784523021.sh new file mode 100644 index 0000000000..35eaaa53bb --- /dev/null +++ b/migrations/1784523021.sh @@ -0,0 +1,108 @@ +echo "Import WiFi networks from iwd into NetworkManager" + +# The quattro upgrade retired iwd/systemd-networkd in favor of NetworkManager +# but did not carry over previously-saved WiFi networks, so they appear +# "forgotten". Their credentials still live in iwd's store; re-create them as +# NetworkManager profiles. Idempotent and best-effort: it never aborts the +# migration run and skips networks NetworkManager already knows. + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Overridable so the test suite can point at a temp store. +IWD_DIR="${OMARCHY_IWD_DIR:-/var/lib/iwd}" +NM_DIR="${OMARCHY_NM_DIR:-/etc/NetworkManager/system-connections}" + +# Nothing to do unless iwd left saved networks behind and NetworkManager is +# the active backend to import them into. +command -v nmcli >/dev/null 2>&1 || exit 0 +command -v uuidgen >/dev/null 2>&1 || exit 0 +systemctl is-active --quiet NetworkManager.service 2>/dev/null || exit 0 +as_root test -d "$IWD_DIR" || exit 0 + +# iwd stores the SSID verbatim in the filename, or as '=' followed by the hex +# of the SSID when it contains unusual bytes. Decode both forms. +decode_ssid() { + local name=$1 + if [[ $name == =* ]]; then + printf '%b' "$(sed 's/../\\x&/g' <<<"${name#=}")" + else + printf '%s' "$name" + fi +} + +# True when NetworkManager already has a wifi profile for this SSID. +nm_has_ssid() { + local want=$1 uuid ssid + while read -r uuid; do + [[ -n $uuid ]] || continue + ssid=$(nmcli -t -g 802-11-wireless.ssid connection show "$uuid" 2>/dev/null || true) + [[ $ssid == "$want" ]] && return 0 + done < <(nmcli -t -f UUID,TYPE connection show 2>/dev/null | awk -F: '$2=="802-11-wireless"{print $1}') + return 1 +} + +# Write a keyfile profile straight to NetworkManager's store rather than +# shelling out to `nmcli connection add`. Secrets passed to nmcli would sit in +# argv and be readable from /proc by any local user for the life of the call; +# here the profile travels over stdin into a root-owned 0600 file instead. +write_profile() { + local ssid=$1 hidden=$2 secret=$3 file=$4 + { + printf '[connection]\nid=%s\nuuid=%s\ntype=wifi\n\n' "$ssid" "$(uuidgen)" + printf '[wifi]\nmode=infrastructure\nssid=%s\n' "$ssid" + [[ $hidden == yes ]] && printf 'hidden=true\n' + printf '\n' + if [[ -n $secret ]]; then + printf '[wifi-security]\nkey-mgmt=wpa-psk\npsk=%s\n\n' "$secret" + fi + printf '[ipv4]\nmethod=auto\n\n[ipv6]\nmethod=auto\n' + } | as_root install -m 600 /dev/stdin "$file" +} + +imported=0 + +while IFS= read -r file; do + [[ -n $file ]] || continue + base=${file##*/} + ext=${base##*.} + ssid=$(decode_ssid "${base%.*}") + + # A newline in the id/ssid would forge extra keyfile lines; '/' would escape + # the profile directory. Neither is a real SSID we can round-trip safely. + [[ $ssid == *$'\n'* || $ssid == */* || -z $ssid ]] && continue + nm_has_ssid "$ssid" && continue + + contents=$(as_root cat "$file" 2>/dev/null || true) + hidden=no + grep -qi '^Hidden=true' <<<"$contents" && hidden=yes + + secret="" + case "$ext" in + open) + : # no secret; open network + ;; + psk) + secret=$(sed -n 's/^Passphrase=//p' <<<"$contents" | head -n1) + [[ -n $secret ]] || secret=$(sed -n 's/^PreSharedKey=//p' <<<"$contents" | head -n1) + [[ -n $secret ]] || continue + ;; + *) + continue # 8021x/enterprise carry certs/identities; leave for manual re-add + ;; + esac + + write_profile "$ssid" "$hidden" "$secret" "$NM_DIR/$ssid.nmconnection" \ + && ((imported++)) || true +done < <(as_root bash -c 'ls -1 "$0"/*.psk "$0"/*.open 2>/dev/null' "$IWD_DIR") + +if (( imported > 0 )); then + as_root nmcli connection reload >/dev/null 2>&1 || true + echo "Imported $imported WiFi network(s) previously saved by iwd." +fi +exit 0 diff --git a/test/shell.d/iwd-wifi-import-test.sh b/test/shell.d/iwd-wifi-import-test.sh new file mode 100755 index 0000000000..fc2af8e66f --- /dev/null +++ b/test/shell.d/iwd-wifi-import-test.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1784523021.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +bin="$test_tmp/bin" +iwd="$test_tmp/iwd" +nm="$test_tmp/nm" +mkdir -p "$bin" "$iwd" "$nm" + +# sudo/systemctl/uuidgen stubs so the migration runs unprivileged. `sudo` just +# execs its arguments; the migration's own as_root() is exercised unchanged. +cat >"$bin/sudo" <<'SH' +#!/bin/bash +exec "$@" +SH +cat >"$bin/systemctl" <<'SH' +#!/bin/bash +exit 0 +SH +cat >"$bin/uuidgen" <<'SH' +#!/bin/bash +echo "00000000-0000-0000-0000-00000000000$RANDOM" +SH + +# nmcli stub: reports the SSIDs listed in $TEST_KNOWN_SSIDS as already-known +# wifi profiles, and records every invocation for later assertions. +cat >"$bin/nmcli" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_NMCLI_CALLS" +case "$*" in + *"-f UUID,TYPE connection show"*) + i=0 + while IFS= read -r s; do + [[ -n $s ]] || continue + echo "uuid-$i:802-11-wireless" + i=$((i + 1)) + done <"$TEST_KNOWN_SSIDS" + ;; + *"-g 802-11-wireless.ssid connection show "*) + uuid=${*: -1} + sed -n "$((${uuid#uuid-} + 1))p" "$TEST_KNOWN_SSIDS" + ;; +esac +exit 0 +SH +chmod +x "$bin"/* + +known="$test_tmp/known" +calls="$test_tmp/calls" +: >"$known" +: >"$calls" + +printf 'Passphrase=hunter2hunter2\n' >"$iwd/HomeNet.psk" +printf '[Settings]\nHidden=true\n' >"$iwd/OpenCafe.open" +printf 'Passphrase=another-secret\n' >"$iwd/=4869C3A9.psk" # "Hié" +printf 'Identity=someone\n' >"$iwd/Campus.8021x" + +run_migration() { + PATH="$bin:$PATH" \ + OMARCHY_IWD_DIR="$iwd" \ + OMARCHY_NM_DIR="$nm" \ + TEST_KNOWN_SSIDS="$known" \ + TEST_NMCLI_CALLS="$calls" \ + bash -euo pipefail "$migration" +} + +bash -n "$migration" || fail "iwd import migration parses" +pass "iwd import migration parses" + +out=$(run_migration) + +[[ -f $nm/HomeNet.nmconnection ]] || fail "migration imports a WPA-PSK network" +grep -Fx 'key-mgmt=wpa-psk' "$nm/HomeNet.nmconnection" >/dev/null || fail "PSK profile sets wpa-psk" +grep -Fx 'psk=hunter2hunter2' "$nm/HomeNet.nmconnection" >/dev/null || fail "PSK profile carries the passphrase" +grep -Fx 'ssid=HomeNet' "$nm/HomeNet.nmconnection" >/dev/null || fail "PSK profile sets the ssid" +pass "migration imports WPA-PSK networks" + +[[ -f $nm/OpenCafe.nmconnection ]] || fail "migration imports an open network" +! grep -F 'wifi-security' "$nm/OpenCafe.nmconnection" >/dev/null || fail "open profile omits wifi-security" +grep -Fx 'hidden=true' "$nm/OpenCafe.nmconnection" >/dev/null || fail "open profile preserves hidden" +pass "migration imports open and hidden networks" + +[[ -f $nm/Hié.nmconnection ]] || fail "migration decodes hex-encoded SSID filenames" +grep -Fx 'ssid=Hié' "$nm/Hié.nmconnection" >/dev/null || fail "hex-decoded profile sets the decoded ssid" +pass "migration decodes iwd's = SSID filenames" + +[[ ! -f $nm/Campus.nmconnection ]] || fail "migration skips 802.1x enterprise networks" +pass "migration leaves 802.1x networks for manual re-add" + +for f in "$nm"/*.nmconnection; do + [[ $(stat -c '%a' "$f") == 600 ]] || fail "profile $f is written 0600" +done +pass "imported profiles are written 0600" + +! grep -F 'hunter2hunter2' "$calls" >/dev/null || fail "PSK must never be passed to nmcli on argv" +! grep -F 'another-secret' "$calls" >/dev/null || fail "PSK must never be passed to nmcli on argv" +pass "secrets never reach nmcli's argv" + +grep -F 'Imported 3 WiFi network(s)' <<<"$out" >/dev/null || fail "migration reports the import count" +pass "migration reports the import count" + +# Second run with every SSID already known to NetworkManager must be a no-op. +printf 'HomeNet\nOpenCafe\nHié\n' >"$known" +rm -f "$nm"/*.nmconnection +out=$(run_migration) + +[[ -z $(ls -A "$nm") ]] || fail "migration re-imports networks NetworkManager already knows" +! grep -F 'Imported' <<<"$out" >/dev/null || fail "migration reports an import when nothing was imported" +pass "migration is idempotent against existing NetworkManager profiles"