| 1 | #!/bin/sh |
| 2 | # Requires: rofi, libnotify, iw, wpa_supplicant, udhcpc, openrc, doas |
| 3 | # WiFi menu: scan, connect, disconnect. This is the rofi rewrite of the old |
| 4 | # dm-wifi-connect, with two changes worth knowing about — the passphrase goes |
| 5 | # through `rofi -password` instead of the black-on-black dmenu trick, and a |
| 6 | # new network is merged into wpa_supplicant.conf beside the ones already |
| 7 | # there rather than replacing them, so the machine can still roam back to a |
| 8 | # network it has seen before. |
| 9 | # |
| 10 | # Prompting runs as the calling user so rofi keeps the X session and |
| 11 | # notifications keep the user's bus; only scanning, the config write and the |
| 12 | # bring-up are handed to doas. |
| 13 | set -u |
| 14 | |
| 15 | CONF=/etc/wpa_supplicant/wpa_supplicant.conf |
| 16 | NOTIFY_ID=7301 |
| 17 | |
| 18 | menu() { rofi -dmenu -i "$@"; } |
| 19 | |
| 20 | notify() { |
| 21 | # dunstify can replace an earlier notification in place, so the progress |
| 22 | # line becomes the result line instead of stacking up. |
| 23 | if command -v dunstify >/dev/null 2>&1; then |
| 24 | dunstify -r "$NOTIFY_ID" ${2:+-u "$2"} "WiFi" "$1" |
| 25 | else |
| 26 | notify-send ${2:+-u "$2"} "WiFi" "$1" |
| 27 | fi |
| 28 | } |
| 29 | |
| 30 | fail() { notify "$1" critical; exit 1; } |
| 31 | |
| 32 | # doas asks for a passphrase on a tty, and launched from a keybinding there |
| 33 | # is none — fail with something readable instead of hanging invisibly. |
| 34 | doas -n true 2>/dev/null || fail "doas needs a password; add a nopass rule for this user." |
| 35 | |
| 36 | # Ask the kernel which interfaces are wireless rather than assuming wlan0. |
| 37 | IFACES=$(iw dev | awk '$1 == "Interface" { print $2 }') |
| 38 | case $(printf '%s' "$IFACES" | grep -c .) in |
| 39 | 0) fail "No wireless interfaces found." ;; |
| 40 | 1) IFACE=$IFACES ;; |
| 41 | *) IFACE=$(printf '%s\n' "$IFACES" | menu -l 5 -p "interface") ;; |
| 42 | esac |
| 43 | [ -n "$IFACE" ] || exit 1 |
| 44 | |
| 45 | link_ssid() { iw dev "$IFACE" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p'; } |
| 46 | ipv4() { ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{ print $2; exit }'; } |
| 47 | |
| 48 | saved_ssids() { doas -n grep -o 'ssid="[^"]*"' "$CONF" 2>/dev/null | sed 's/^ssid="//; s/"$//'; } |
| 49 | |
| 50 | disconnect() { |
| 51 | notify " Disabling $IFACE..." |
| 52 | doas sh -c ' |
| 53 | # wpa_supplicant runs under supervise-daemon, which respawns the |
| 54 | # daemon a couple of seconds after any kill. Stopping the service is |
| 55 | # what takes the supervisor down with it; pkill only kills the child. |
| 56 | rc-service -s -q wpa_supplicant stop |
| 57 | ifdown "$1" 2>/dev/null |
| 58 | |
| 59 | PIDFILE=/var/run/udhcpc.$1.pid |
| 60 | if [ -f "$PIDFILE" ]; then |
| 61 | kill "$(cat "$PIDFILE")" 2>/dev/null |
| 62 | rm -f "$PIDFILE" |
| 63 | fi |
| 64 | |
| 65 | ip addr flush dev "$1" 2>/dev/null |
| 66 | ip link set "$1" down |
| 67 | ' _ "$IFACE" |
| 68 | notify " $IFACE disabled" |
| 69 | exit 0 |
| 70 | } |
| 71 | |
| 72 | bring_up() { |
| 73 | ssid=$1 |
| 74 | notify " Connecting to $ssid..." |
| 75 | doas sh -c ' |
| 76 | # wpa_supplicant will not reread its config on its own, so restart it |
| 77 | # rather than signalling it. This also covers the case where the link |
| 78 | # was disabled outright. ifup is no help: wlan0 has no stanza in |
| 79 | # /etc/network/interfaces. |
| 80 | rc-service wpa_supplicant restart >/dev/null 2>&1 |
| 81 | |
| 82 | # Give the supplicant time to associate before asking for a lease — a |
| 83 | # DHCP request sent before association just burns its whole timeout. |
| 84 | i=0 |
| 85 | while [ "$i" -lt 20 ]; do |
| 86 | iw dev "$1" link 2>/dev/null | grep -q "^Connected to" && break |
| 87 | sleep 1 |
| 88 | i=$((i + 1)) |
| 89 | done |
| 90 | |
| 91 | # Replace any DHCP client left over from a previous connect; without |
| 92 | # this they stack up, one per run, all renewing the same lease. |
| 93 | PIDFILE=/var/run/udhcpc.$1.pid |
| 94 | if [ -f "$PIDFILE" ]; then |
| 95 | kill "$(cat "$PIDFILE")" 2>/dev/null |
| 96 | rm -f "$PIDFILE" |
| 97 | fi |
| 98 | ip addr flush dev "$1" 2>/dev/null |
| 99 | |
| 100 | # Same invocation ifupdown-ng uses for a dhcp stanza. udhcpc |
| 101 | # daemonises once it has the lease, so this returns with the address |
| 102 | # already set. |
| 103 | udhcpc -b -R -p "$PIDFILE" -i "$1" -x "hostname:$(hostname)" >/dev/null 2>&1 |
| 104 | ' _ "$IFACE" |
| 105 | |
| 106 | if [ -n "$(ipv4)" ]; then |
| 107 | notify " $ssid on $IFACE — $(ipv4)" |
| 108 | else |
| 109 | fail " Failed to connect to $ssid" |
| 110 | fi |
| 111 | exit 0 |
| 112 | } |
| 113 | |
| 114 | connect() { |
| 115 | ssid=$1 |
| 116 | |
| 117 | # A network already in the config has a working passphrase on file; only |
| 118 | # ask again if there is nothing saved for it. |
| 119 | if saved_ssids | grep -Fxq "$ssid"; then |
| 120 | bring_up "$ssid" |
| 121 | fi |
| 122 | |
| 123 | pass=$(rofi -dmenu -password -p "password for $ssid" -mesg "Leave empty for an open network" -l 0 </dev/null) || exit 0 |
| 124 | |
| 125 | # Build the new stanza before touching anything: a rejected passphrase |
| 126 | # must not disturb the config that is currently working. The PSK never |
| 127 | # lands on disk unprivileged — it goes to the root shell over a pipe. |
| 128 | if [ -z "$pass" ]; then |
| 129 | NEW=$(printf 'network={\n\tssid="%s"\n\tkey_mgmt=NONE\n}\n' "$ssid") |
| 130 | else |
| 131 | NEW=$(wpa_passphrase "$ssid" "$pass" 2>/dev/null | grep -v '^[[:space:]]*#psk=') || |
| 132 | fail "Rejected by wpa_passphrase — password must be 8-63 characters." |
| 133 | fi |
| 134 | [ -n "$NEW" ] || fail "Could not generate a config for $ssid." |
| 135 | |
| 136 | # Merge: keep the global settings and every other network block, drop |
| 137 | # any earlier block for this SSID, then append the new one. awk does the |
| 138 | # dropping because a network={...} block spans lines; its program travels |
| 139 | # as an argument so it needs no escaping inside the root shell. |
| 140 | DROP_BLOCK=' |
| 141 | /^[[:space:]]*network[[:space:]]*=[[:space:]]*\{/ { |
| 142 | block = $0 ORS; inblock = 1; matched = 0; next |
| 143 | } |
| 144 | inblock { |
| 145 | block = block $0 ORS |
| 146 | if (index($0, "ssid=\"" ssid "\"")) matched = 1 |
| 147 | if ($0 ~ /^[[:space:]]*\}/) { |
| 148 | if (!matched) printf "%s", block |
| 149 | inblock = 0 |
| 150 | } |
| 151 | next |
| 152 | } |
| 153 | { print } |
| 154 | ' |
| 155 | |
| 156 | printf '%s\n' "$NEW" | doas sh -c ' |
| 157 | CONF=$1; SSID=$2; PROG=$3 |
| 158 | NEW=$(cat) |
| 159 | [ -f "$CONF" ] && cp "$CONF" "$CONF.bak" |
| 160 | { |
| 161 | [ -f "$CONF.bak" ] && awk -v ssid="$SSID" "$PROG" "$CONF.bak" |
| 162 | printf "%s\n" "$NEW" |
| 163 | } > "$CONF" |
| 164 | chmod 600 "$CONF" |
| 165 | ' _ "$CONF" "$ssid" "$DROP_BLOCK" || fail "Could not write $CONF." |
| 166 | |
| 167 | bring_up "$ssid" |
| 168 | } |
| 169 | |
| 170 | # --- menu ------------------------------------------------------------------- |
| 171 | |
| 172 | CURRENT=$(link_ssid) |
| 173 | IP=$(ipv4) |
| 174 | if [ -n "$CURRENT" ]; then |
| 175 | STATUS=" connected to $CURRENT${IP:+ — $IP}" |
| 176 | else |
| 177 | STATUS=" not connected on $IFACE" |
| 178 | fi |
| 179 | |
| 180 | doas ip link set "$IFACE" up 2>/dev/null |
| 181 | notify " Scanning for networks on $IFACE..." |
| 182 | |
| 183 | # `scan dump` reuses cached results if the card is busy (already associated), |
| 184 | # which is when a live scan tends to fail. |
| 185 | SCAN=$(doas iw dev "$IFACE" scan 2>/dev/null || doas iw dev "$IFACE" scan dump 2>/dev/null) |
| 186 | |
| 187 | # Only real SSID lines: bare "SSID: " is a hidden network, and "SSID List" |
| 188 | # under extended capabilities is not a network at all. |
| 189 | SSIDS=$(printf '%s\n' "$SCAN" | sed -n 's/^[[:space:]]*SSID: \(..*\)$/\1/p' | sort -u) |
| 190 | |
| 191 | SAVED=$(saved_ssids) |
| 192 | LIST=$(printf '%s\n' "$SSIDS" | awk -v cur="$CURRENT" -v saved="$SAVED" ' |
| 193 | BEGIN { n = split(saved, s, "\n"); for (i = 1; i <= n; i++) known[s[i]] = 1 } |
| 194 | NF { |
| 195 | # marks a network this machine already has credentials for, the |
| 196 | # one it is on right now. |
| 197 | icon = ($0 == cur) ? "" : (known[$0] ? "" : "") |
| 198 | printf "%s %s\n", icon, $0 |
| 199 | }') |
| 200 | |
| 201 | ACTIONS=" rescan |
| 202 | hidden network" |
| 203 | [ -n "$CURRENT" ] && ACTIONS="$ACTIONS |
| 204 | disconnect" |
| 205 | |
| 206 | CHOICE=$(printf '%s\n%s\n' "$LIST" "$ACTIONS" | grep -v '^$' | menu -l 12 -p "wifi" -mesg "$STATUS") || exit 0 |
| 207 | [ -n "$CHOICE" ] || exit 0 |
| 208 | |
| 209 | case ${CHOICE##* } in |
| 210 | rescan) exec "$0" ;; |
| 211 | disconnect) disconnect ;; |
| 212 | "hidden network") ssid=$(menu -p "ssid" -l 0 </dev/null); [ -n "$ssid" ] && connect "$ssid" ;; |
| 213 | *) connect "${CHOICE##* }" ;; |
| 214 | esac |