Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions migrations/1784523021.sh
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +7

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
116 changes: 116 additions & 0 deletions test/shell.d/iwd-wifi-import-test.sh
Original file line number Diff line number Diff line change
@@ -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 =<hex> 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"