diff --git a/backend/routers/network.py b/backend/routers/network.py index 4daebea..1008d85 100644 --- a/backend/routers/network.py +++ b/backend/routers/network.py @@ -37,6 +37,9 @@ _IP_BIN = _find_bin("ip") or "ip" _INTERFACES_FILE = Path("/etc/network/interfaces") _INTERFACES_DIR = Path("/etc/network/interfaces.d") _RESOLV_CONF = Path("/etc/resolv.conf") +_WPA_SUPPLICANT_DIR = Path("/etc/wpa_supplicant") +_IW_BIN = _find_bin("iw") +_WPA_CLI_BIN = _find_bin("wpa_cli") # --------------------------------------------------------------------------- @@ -438,6 +441,9 @@ async def _parse_ip_addr() -> list[dict]: if not ai.get("local", "").startswith("fe80"): ipv6 = ai.get("local", "") + # Detect wireless interfaces + is_wireless = Path(f"/sys/class/net/{name}/wireless").exists() + results.append({ "name": name, "state": state, @@ -445,6 +451,7 @@ async def _parse_ip_addr() -> list[dict]: "mac": mac, "ipv4": ipv4, "ipv6": ipv6, + "wireless": is_wireless, }) # Get default gateway @@ -492,6 +499,7 @@ async def _parse_ip_addr_text() -> list[dict]: "ipv4": "", "ipv6": "", "gateway": "", + "wireless": Path(f"/sys/class/net/{name}/wireless").exists(), } continue @@ -597,18 +605,23 @@ def _parse_interfaces_file() -> dict[str, dict]: def _write_interface_config(iface: str, method: str, address: str = "", - gateway: str = "", dns: list[str] = None): + gateway: str = "", dns: list[str] = None, + ssid: str = "", wifi_password: str = ""): """Write or update an interface block in /etc/network/interfaces.d/{iface}. Uses interfaces.d to avoid clobbering the main file. + For wireless interfaces, also writes wpa_supplicant config. """ _INTERFACES_DIR.mkdir(parents=True, exist_ok=True) + is_wireless = Path(f"/sys/class/net/{iface}/wireless").exists() + if method == "dhcp": - content = f"""# Managed by Atlus -auto {iface} -iface {iface} inet dhcp -""" + lines = [ + f"# Managed by Atlus", + f"auto {iface}", + f"iface {iface} inet dhcp", + ] else: # Parse CIDR to address + netmask try: @@ -629,11 +642,18 @@ iface {iface} inet dhcp lines.append(f" gateway {gateway}") if dns: lines.append(f" dns-nameservers {' '.join(dns)}") - content = "\n".join(lines) + "\n" + # Add WiFi config if wireless and SSID provided + if is_wireless and ssid: + wpa_conf = _WPA_SUPPLICANT_DIR / f"{iface}.conf" + _write_wpa_supplicant(iface, ssid, wifi_password) + lines.append(f" wpa-ssid {ssid}") + lines.append(f" wpa-conf {wpa_conf}") + + content = "\n".join(lines) + "\n" config_file = _INTERFACES_DIR / iface config_file.write_text(content) - log.info("Wrote interface config for %s: method=%s", iface, method) + log.info("Wrote interface config for %s: method=%s ssid=%s", iface, method, ssid or "(none)") # Also update resolv.conf for static DNS if method == "static" and dns: @@ -667,6 +687,7 @@ async def list_interfaces(_user: str = Depends(get_current_user)): iface["config_netmask"] = cfg.get("netmask", "") iface["config_gateway"] = cfg.get("gateway", "") iface["config_dns"] = cfg.get("dns-nameservers", "") + iface["config_ssid"] = cfg.get("wpa-ssid", "") # Get DNS from resolv.conf dns_servers = [] @@ -691,6 +712,8 @@ class InterfaceConfig(BaseModel): address: Optional[str] = None # CIDR e.g. "192.168.1.100/24" gateway: Optional[str] = None dns: Optional[list[str]] = None + ssid: Optional[str] = None # WiFi SSID + wifi_password: Optional[str] = None # WiFi password (WPA/WPA2) @router.put("/interfaces/{name}/config") @@ -717,6 +740,8 @@ async def configure_interface( address=cfg.address or "", gateway=cfg.gateway or "", dns=cfg.dns, + ssid=cfg.ssid or "", + wifi_password=cfg.wifi_password or "", ) # Apply: bring interface down then up @@ -768,6 +793,161 @@ async def _apply_with_ip(name: str, cfg: InterfaceConfig): log.warning("No DHCP client found — interface %s brought up without DHCP", name) +# --------------------------------------------------------------------------- +# WiFi support (fallback — no NetworkManager) +# --------------------------------------------------------------------------- + +def _write_wpa_supplicant(iface: str, ssid: str, password: str = ""): + """Write a wpa_supplicant config file for a wireless interface.""" + _WPA_SUPPLICANT_DIR.mkdir(parents=True, exist_ok=True) + conf_file = _WPA_SUPPLICANT_DIR / f"{iface}.conf" + + if password: + # WPA/WPA2 PSK + content = f"""# Managed by Atlus +ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev +update_config=1 +country=US + +network={{ + ssid="{ssid}" + psk="{password}" + key_mgmt=WPA-PSK +}} +""" + else: + # Open network + content = f"""# Managed by Atlus +ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev +update_config=1 +country=US + +network={{ + ssid="{ssid}" + key_mgmt=NONE +}} +""" + conf_file.write_text(content) + conf_file.chmod(0o600) + log.info("Wrote wpa_supplicant config for %s: ssid=%s", iface, ssid) + + +@router.get("/wifi/scan/{name}") +async def scan_wifi(name: str, _user: str = Depends(get_current_user)): + """Scan for WiFi networks using iw or iwlist (no NetworkManager required).""" + _validate_iface_name(name) + + # Check if interface is wireless + if not Path(f"/sys/class/net/{name}/wireless").exists(): + raise HTTPException(400, f"{name} is not a wireless interface") + + networks = [] + + # Try iw first + if _IW_BIN: + rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15) + if rc == 0: + networks = _parse_iw_scan(stdout) + elif rc != 0: + # May need to bring interface up first + await _run_cmd(_IP_BIN, "link", "set", name, "up") + await asyncio.sleep(1) + rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15) + if rc == 0: + networks = _parse_iw_scan(stdout) + + # Fallback: iwlist + if not networks: + iwlist = _find_bin("iwlist") + if iwlist: + rc, stdout, _ = await _run_cmd(iwlist, name, "scan", timeout=15) + if rc == 0: + networks = _parse_iwlist_scan(stdout) + + return {"networks": networks} + + +def _parse_iw_scan(output: str) -> list[dict]: + """Parse `iw dev wlan0 scan` output into network list.""" + networks = [] + current = None + for line in output.splitlines(): + if line.startswith("BSS "): + if current and current.get("ssid"): + networks.append(current) + bssid = re.match(r"BSS\s+(\S+)", line) + current = {"bssid": bssid.group(1) if bssid else "", "ssid": "", "signal": 0, "security": "Open"} + elif current is None: + continue + elif "SSID:" in line: + m = re.search(r"SSID:\s*(.*)", line) + if m: + current["ssid"] = m.group(1).strip() + elif "signal:" in line: + m = re.search(r"signal:\s*(-?\d+)", line) + if m: + current["signal"] = int(m.group(1)) + elif "WPA" in line or "RSN" in line: + current["security"] = "WPA2" if "RSN" in line else "WPA" + elif "WEP" in line: + current["security"] = "WEP" + if current and current.get("ssid"): + networks.append(current) + + # Sort by signal strength (strongest first) + networks.sort(key=lambda n: n.get("signal", -100), reverse=True) + # Deduplicate by SSID + seen = set() + unique = [] + for n in networks: + if n["ssid"] not in seen: + seen.add(n["ssid"]) + unique.append(n) + return unique[:30] + + +def _parse_iwlist_scan(output: str) -> list[dict]: + """Parse `iwlist wlan0 scan` output into network list.""" + networks = [] + current = None + for line in output.splitlines(): + line = line.strip() + if "Cell " in line and "Address:" in line: + if current and current.get("ssid"): + networks.append(current) + m = re.search(r"Address:\s*(\S+)", line) + current = {"bssid": m.group(1) if m else "", "ssid": "", "signal": 0, "security": "Open"} + elif current is None: + continue + elif line.startswith("ESSID:"): + m = re.search(r'ESSID:"(.+)"', line) + if m: + current["ssid"] = m.group(1) + elif "Signal level=" in line: + m = re.search(r"Signal level[=:]?\s*(-?\d+)", line) + if m: + current["signal"] = int(m.group(1)) + elif "Encryption key:on" in line: + if current.get("security") == "Open": + current["security"] = "WEP" + elif "WPA2" in line or "IEEE 802.11i" in line: + current["security"] = "WPA2" + elif "WPA" in line: + if current.get("security") != "WPA2": + current["security"] = "WPA" + if current and current.get("ssid"): + networks.append(current) + + networks.sort(key=lambda n: n.get("signal", -100), reverse=True) + seen = set() + unique = [] + for n in networks: + if n["ssid"] not in seen: + seen.add(n["ssid"]) + unique.append(n) + return unique[:30] + + @router.post("/interfaces/{name}/up") async def interface_up(name: str, _user: str = Depends(get_current_user)): """Bring an interface up.""" diff --git a/frontend/css/apps/settings.css b/frontend/css/apps/settings.css index c59b130..ec87858 100644 --- a/frontend/css/apps/settings.css +++ b/frontend/css/apps/settings.css @@ -203,14 +203,89 @@ display: none; } -.settings-toggle .toggle-label { - position: absolute; - left: 60px; - top: 50%; - transform: translateY(-50%); +/* Network method radio buttons */ +.net-method-radios { + display: flex; + gap: 4px; + background: var(--bg-input); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + padding: 3px; + flex-shrink: 0; +} + +.net-radio-label { + display: flex; + align-items: center; + cursor: pointer; + padding: 6px 14px; + border-radius: calc(var(--radius-md) - 2px); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); + transition: all var(--transition-fast); + user-select: none; +} + +.net-radio-label input[type="radio"] { + display: none; +} + +.net-radio-label:has(input:checked) { + background: var(--accent); + color: #fff; +} + +.net-radio-label:hover:not(:has(input:checked)) { + background: var(--accent-hover); + color: var(--text-primary); +} + +/* WiFi fields for wireless interfaces */ +.net-wifi-fields { + border-top: 1px dashed var(--border-structural); + margin-top: 4px; + padding-top: 4px; +} + +.wifi-scan-results { + max-height: 200px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + margin: 4px 0; + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + background: var(--bg-input); +} + +.wifi-scan-results.hidden { + display: none; +} + +.wifi-scan-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border-structural); +} + +.wifi-scan-item:last-child { + border-bottom: none; +} + +.wifi-scan-item:hover { + background: var(--accent-hover); +} + +.wifi-scan-ssid { + flex: 1; + font-family: var(--font-ui); + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js index f07671e..656dfb6 100644 --- a/frontend/js/apps/settings.js +++ b/frontend/js/apps/settings.js @@ -333,12 +333,34 @@
IP Configuration
-
Toggle between DHCP and Static IP
- +
+ + +
+ ${iface.wireless ? ` +
+
+
WiFi SSID
+
+ + +
+
+ +
+
WiFi Password
+ +
+
+ ` : ''}
IP Address
CIDR notation (e.g. 192.168.1.100/24)
@@ -398,15 +420,59 @@ }); }); - // DHCP/Static toggle - contentEl.querySelectorAll('[data-iface-dhcp]').forEach(toggle => { - const name = toggle.dataset.ifaceDhcp; + // DHCP/Static radio buttons + contentEl.querySelectorAll('[data-iface-method]').forEach(group => { + const name = group.dataset.ifaceMethod; const staticFields = contentEl.querySelector(`#staticFields-${name}`); - toggle.addEventListener('click', () => { - toggle.classList.toggle('on'); - const isNowStatic = toggle.classList.contains('on'); - toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP'; - staticFields.classList.toggle('hidden', !isNowStatic); + group.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', () => { + staticFields.classList.toggle('hidden', radio.value !== 'static'); + }); + }); + }); + + // WiFi scan buttons + contentEl.querySelectorAll('[data-wifi-scan]').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.wifiScan; + const resultsEl = contentEl.querySelector(`#wifiScanResults-${name}`); + btn.disabled = true; + btn.textContent = 'Scanning…'; + resultsEl.classList.remove('hidden'); + resultsEl.innerHTML = '
Scanning…
'; + + try { + const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`); + if (!res.ok) { + resultsEl.innerHTML = '
Failed to scan WiFi networks
'; + } else { + const networks = await res.json(); + if (networks.length === 0) { + resultsEl.innerHTML = '
No networks found
'; + } else { + resultsEl.innerHTML = ''; + for (const net of networks) { + const sig = _signalLabel(net.signal); + const item = document.createElement('div'); + item.className = 'wifi-scan-item'; + item.innerHTML = ` + ${net.ssid} + ${sig.text} + ${net.security || 'Open'} + `; + item.addEventListener('click', () => { + contentEl.querySelector(`#ifSsid-${name}`).value = net.ssid; + resultsEl.classList.add('hidden'); + }); + resultsEl.appendChild(item); + } + } + } + } catch (e) { + resultsEl.innerHTML = '
Scan failed
'; + } + btn.disabled = false; + btn.textContent = 'Scan'; }); }); @@ -417,9 +483,10 @@ const errEl = contentEl.querySelector(`#ifError-${name}`); errEl.classList.add('hidden'); - const toggle = contentEl.querySelector(`[data-iface-dhcp="${name}"]`); - const isStatic = toggle.classList.contains('on'); - const method = isStatic ? 'static' : 'dhcp'; + const methodGroup = contentEl.querySelector(`[data-iface-method="${name}"]`); + const selectedRadio = methodGroup.querySelector('input[type="radio"]:checked'); + const method = selectedRadio ? selectedRadio.value : 'dhcp'; + const isStatic = method === 'static'; const body = { method }; if (isStatic) { @@ -436,6 +503,18 @@ } } + // Include WiFi credentials if this is a wireless interface + const ssidInput = contentEl.querySelector(`#ifSsid-${name}`); + const wifiPassInput = contentEl.querySelector(`#ifWifiPass-${name}`); + if (ssidInput) { + const ssid = ssidInput.value.trim(); + if (ssid) body.ssid = ssid; + if (wifiPassInput) { + const wifiPass = wifiPassInput.value; + if (wifiPass) body.wifi_password = wifiPass; + } + } + if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return; btn.disabled = true; @@ -496,11 +575,17 @@
IP Configuration
-
Toggle between DHCP and Static IP
- +
+ + +
@@ -526,14 +611,13 @@
`; - // Toggle handler - const toggle = groupEl.querySelector(`#dhcpToggle-${dev.device}`); + // Radio handler + const methodRadios = groupEl.querySelector(`#methodRadios-${dev.device}`); const staticFields = groupEl.querySelector(`#staticFields-${dev.device}`); - toggle.addEventListener('click', () => { - toggle.classList.toggle('on'); - const isNowStatic = toggle.classList.contains('on'); - toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP'; - staticFields.classList.toggle('hidden', !isNowStatic); + methodRadios.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', () => { + staticFields.classList.toggle('hidden', radio.value !== 'static'); + }); }); // Apply handler @@ -541,7 +625,8 @@ const errEl = groupEl.querySelector(`#ethError-${dev.device}`); errEl.classList.add('hidden'); - const isNowStatic = toggle.classList.contains('on'); + const selectedRadio = methodRadios.querySelector('input[type="radio"]:checked'); + const isNowStatic = selectedRadio && selectedRadio.value === 'static'; const body = { method: isNowStatic ? 'manual' : 'auto' }; if (isNowStatic) {