#!/usr/bin/env bash # Adaptive Wi-Fi installer for Debian 12/13 (PCI and USB). # v0.2.0 - automatic, logged Debian APT component configuration. # Usage: sudo bash install-wifi.sh [--dry-run] [--force] set -Eeuo pipefail export DEBIAN_FRONTEND=noninteractive DRY=0; FORCE=0 for arg in "$@"; do case "$arg" in --dry-run) DRY=1;; --force) FORCE=1;; -h|--help) echo 'Usage: sudo bash install-wifi.sh [--dry-run] [--force]'; exit 0;; *) echo "Unknown argument: $arg" >&2; exit 2;; esac; done log(){ printf '[wifi] %s\n' "$*"; } warn(){ printf '[wifi] WARNING: %s\n' "$*" >&2; } die(){ printf '[wifi] ERROR: %s\n' "$*" >&2; exit 1; } run(){ if ((DRY)); then printf '[dry-run]'; printf ' %q' "$@"; printf '\n'; else "$@"; fi; } [[ $EUID -eq 0 ]] || die 'Run with sudo.' [[ -r /etc/os-release ]] || die 'Cannot identify distribution.' # shellcheck source=/dev/null . /etc/os-release [[ ${ID:-} == debian ]] || die 'This script supports Debian only.' case "${VERSION_ID:-}" in 12|13) ;; *) die 'Supported Debian versions: 12 and 13.';; esac for cmd in lspci lsusb modprobe apt-get awk grep sed python3; do command -v "$cmd" >/dev/null || { if [[ $cmd == lspci || $cmd == lsusb ]]; then warn "$cmd not installed; detection may be incomplete (install pciutils/usbutils)."; else die "Missing command: $cmd"; fi; } done log "Debian ${VERSION_ID}; kernel $(uname -r)" [[ -d /sys/class/net ]] || die 'Network sysfs is unavailable.' # Find Wi-Fi interfaces, including interfaces that are currently down. mapfile -t WIFI_IFACES < <(find /sys/class/net -mindepth 1 -maxdepth 1 -type l -printf '%f\n' | while read -r iface; do [[ -d /sys/class/net/$iface/wireless || -d /sys/class/net/$iface/phy80211 ]] && echo "$iface"; done) if ((${#WIFI_IFACES[@]})); then log "Wi-Fi interface(s): ${WIFI_IFACES[*]}" if (( ! FORCE )); then log 'Driver is exposing a Wi-Fi interface. No packages changed. Use --force to attempt firmware installation.'; exit 0; fi fi # Scan PCI wireless network controllers by PCI class 0280 (and 0200 with matching wireless descriptions). PCI_LINES=''; USB_LINES='' if command -v lspci >/dev/null; then PCI_LINES=$(lspci -nn 2>/dev/null | grep -Ei 'Network controller|Wireless|802\.11|Wi-Fi' || true); fi if command -v lsusb >/dev/null; then USB_LINES=$(lsusb 2>/dev/null || true); fi log 'PCI Wi-Fi candidates:'; [[ -z $PCI_LINES ]] && log ' (none found)' || printf '%s\n' "$PCI_LINES" log 'USB devices (review for USB Wi-Fi adapters):'; [[ -z $USB_LINES ]] && log ' (none found)' || printf '%s\n' "$USB_LINES" # Collect possible vendors from PCI network candidates and USB vendor IDs. PACKAGES=(); NEED_WL=0; UNKNOWN=0 add_pkg(){ local p; for p in "$@"; do local found=0 x; for x in "${PACKAGES[@]:-}"; do [[ $x == "$p" ]] && found=1; done; ((found)) || PACKAGES+=("$p"); done; } # PCI: match exact Broadcom BCM4360 first; generic vendor families after. if [[ $PCI_LINES =~ \[14e4:43a0\] ]]; then log 'Detected Broadcom BCM4360 (14e4:43a0): proprietary wl/DKMS driver required.' add_pkg broadcom-sta-dkms dkms "linux-headers-$(uname -r)"; NEED_WL=1 fi if [[ $PCI_LINES =~ \[8086: ]]; then log 'Detected Intel PCI wireless hardware.'; add_pkg firmware-iwlwifi; fi if [[ $PCI_LINES =~ \[14c3: ]]; then log 'Detected MediaTek PCI wireless hardware.'; add_pkg firmware-misc-nonfree; fi if [[ $PCI_LINES =~ \[10ec: ]]; then log 'Detected Realtek PCI wireless hardware.'; add_pkg firmware-realtek; fi if [[ $PCI_LINES =~ \[168c: || $PCI_LINES =~ \[17cb: ]]; then log 'Detected Qualcomm/Atheros PCI wireless hardware.'; add_pkg firmware-atheros; fi if [[ $PCI_LINES =~ \[14e4: && $NEED_WL -eq 0 ]]; then warn 'Broadcom PCI device detected but chipset-specific driver choice is uncertain.' UNKNOWN=1 fi # USB vendors alone are not enough for reliable chipset/driver choice; firmware candidates are safe, # but USB IDs can represent non-Wi-Fi devices. Require wireless descriptors in lsusb output. if [[ $USB_LINES =~ [Ii]ntel.*[Ww]ireless || $USB_LINES =~ [Ww]ireless.*8087: ]]; then add_pkg firmware-iwlwifi; fi if [[ $USB_LINES =~ [Rr]ealtek.*([Ww]ireless|802\.11|[Ww]LAN) ]]; then add_pkg firmware-realtek; fi if [[ $USB_LINES =~ [Mm]edia[Tt]ek.*([Ww]ireless|802\.11|[Ww]LAN) ]]; then add_pkg firmware-misc-nonfree; fi if [[ $USB_LINES =~ [Aa]theros.*([Ww]ireless|802\.11|[Ww]LAN) ]]; then add_pkg firmware-atheros; fi if ((${#PACKAGES[@]} == 0)); then warn 'No confidently supported chipset mapping found. No system changes made.' warn 'Run: lspci -nnk; lsusb; journalctl -k -b | grep -Ei "wifi|wlan|firmware"' exit 3 fi ((UNKNOWN)) && warn 'At least one adapter requires manual driver verification.' log "Candidate packages: ${PACKAGES[*]}" # Enable required components on Debian's own active repositories only. # Supports both legacy .list and Deb822 .sources formats, including the Debian 13 default. # Third-party repositories and disabled entries are never modified. log 'APT repository policy: ensure contrib, non-free, and non-free-firmware on official Debian entries.' log 'Any changed source file will be backed up alongside the original before modification.' if ((DRY)); then log '[dry-run] Would inspect Debian .list/.sources and show required component changes; no files modified.' else python3 - <<'PYAPT' from pathlib import Path from datetime import datetime import re import shutil import sys needed = ('main', 'contrib', 'non-free', 'non-free-firmware') paths = [Path('/etc/apt/sources.list')] d = Path('/etc/apt/sources.list.d') if d.is_dir(): paths += sorted(list(d.glob('*.list')) + list(d.glob('*.sources'))) def official(uri): # Only Debian-owned hosts; never alter PPAs or vendor repositories. from urllib.parse import urlparse u = urlparse(uri) host = (u.hostname or '').lower() return u.scheme in ('http', 'https') and (host in ('deb.debian.org', 'security.debian.org', 'ftp.debian.org', 'archive.debian.org') or host.endswith('.debian.org')) def update_legacy(text): out = [] for line in text.splitlines(keepends=True): # Keep comments, disabled lines, and cdrom entries unchanged. m = re.match(r'^(\s*deb(?:-src)?\s+)(?:\[[^]]+\]\s+)?', line) if not m: out.append(line); continue # Extract URI, allowing [options] blocks. body = line[m.end():] parts = body.split(None, 2) if len(parts) < 3 or not official(parts[0]): out.append(line); continue uri, suite, rest = parts if not (suite.startswith(('bookworm', 'trixie'))): out.append(line); continue comps, sep, comment = rest.partition('#') existing = comps.split() missing = [x for x in needed if x not in existing] if missing: newline = '\n' if line.endswith('\n') else '' suffix = (' #' + comment.rstrip('\n')) if sep else '' line = line[:m.end()] + uri + ' ' + suite + ' ' + ' '.join(existing + missing) + suffix + newline out.append(line) return ''.join(out) def update_deb822(text): # Process stanzas, preserving comments and unrelated fields. blocks = re.split(r'(\n\s*\n)', text) for i in range(0, len(blocks), 2): block = blocks[i] if re.search(r'^Enabled:\s*no\s*$', block, re.I | re.M): continue uris = re.search(r'^URIs:\s*(.+)$', block, re.M) suites = re.search(r'^Suites:\s*(.+)$', block, re.M) if not uris or not suites: continue if not all(official(uri) for uri in uris.group(1).split()): continue if not all(s.startswith(('bookworm', 'trixie')) for s in suites.group(1).split()): continue comp = re.search(r'^(Components:\s*)([^\n]*)', block, re.M) if comp: existing = comp.group(2).split() missing = [x for x in needed if x not in existing] if missing: block = block[:comp.start(2)] + ' '.join(existing + missing) + block[comp.end(2):] else: block = block.rstrip('\n') + '\nComponents: ' + ' '.join(needed) + '\n' blocks[i] = block return ''.join(blocks) changed = 0 for path in paths: if not path.is_file(): continue original = path.read_text() updated = update_deb822(original) if path.suffix == '.sources' else update_legacy(original) if updated == original: continue stamp = datetime.now().strftime('%Y%m%d-%H%M%S') backup = path.with_name(path.name + '.bak-' + stamp) try: shutil.copy2(path, backup) path.write_text(updated) changed += 1 print(f'[wifi] APT SOURCE UPDATED: {path}') print(f'[wifi] BACKUP CREATED: {backup}') print('[wifi] Enabled components: main contrib non-free non-free-firmware') except OSError as e: print(f'[wifi] ERROR updating {path}: {e}', file=sys.stderr) sys.exit(1) if not changed: print('[wifi] No Debian APT source changes required (already configured or no eligible official Debian source found).') PYAPT fi log 'Internet or an offline APT mirror is required to install packages.' run apt-get update # Check package availability before attempting installation. if (( ! DRY )); then for pkg in "${PACKAGES[@]}"; do if [[ $(apt-cache policy "$pkg" | awk '/Candidate:/{print $2}') == '(none)' ]]; then die "Package $pkg unavailable. Check APT sources or kernel headers; no driver changes attempted." fi done fi run apt-get install -y "${PACKAGES[@]}" if ((NEED_WL)); then log 'Broadcom wl installed. Loading may require unloading conflicting modules and/or reboot.' log 'Not unloading modules automatically to avoid disrupting networking.' fi log 'Installation finished. Reboot, then verify: lspci -nnk; ip link; nmcli device status'