Add WiFi SSID/password support and replace DHCP/Static toggle with radio buttons

Backend: wireless interface detection, wpa_supplicant config writing, WiFi scan
endpoint via iw/iwlist. Frontend: segmented radio selector for DHCP/Static (fixes
text overflow behind right panel), SSID/password fields for wireless interfaces
with network scan dropdown, Apply handler sends WiFi credentials.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-14 23:21:47 -05:00
parent a73b515258
commit 06e958f488
3 changed files with 379 additions and 39 deletions

View file

@ -37,6 +37,9 @@ _IP_BIN = _find_bin("ip") or "ip"
_INTERFACES_FILE = Path("/etc/network/interfaces") _INTERFACES_FILE = Path("/etc/network/interfaces")
_INTERFACES_DIR = Path("/etc/network/interfaces.d") _INTERFACES_DIR = Path("/etc/network/interfaces.d")
_RESOLV_CONF = Path("/etc/resolv.conf") _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"): if not ai.get("local", "").startswith("fe80"):
ipv6 = ai.get("local", "") ipv6 = ai.get("local", "")
# Detect wireless interfaces
is_wireless = Path(f"/sys/class/net/{name}/wireless").exists()
results.append({ results.append({
"name": name, "name": name,
"state": state, "state": state,
@ -445,6 +451,7 @@ async def _parse_ip_addr() -> list[dict]:
"mac": mac, "mac": mac,
"ipv4": ipv4, "ipv4": ipv4,
"ipv6": ipv6, "ipv6": ipv6,
"wireless": is_wireless,
}) })
# Get default gateway # Get default gateway
@ -492,6 +499,7 @@ async def _parse_ip_addr_text() -> list[dict]:
"ipv4": "", "ipv4": "",
"ipv6": "", "ipv6": "",
"gateway": "", "gateway": "",
"wireless": Path(f"/sys/class/net/{name}/wireless").exists(),
} }
continue continue
@ -597,18 +605,23 @@ def _parse_interfaces_file() -> dict[str, dict]:
def _write_interface_config(iface: str, method: str, address: str = "", 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}. """Write or update an interface block in /etc/network/interfaces.d/{iface}.
Uses interfaces.d to avoid clobbering the main file. 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) _INTERFACES_DIR.mkdir(parents=True, exist_ok=True)
is_wireless = Path(f"/sys/class/net/{iface}/wireless").exists()
if method == "dhcp": if method == "dhcp":
content = f"""# Managed by Atlus lines = [
auto {iface} f"# Managed by Atlus",
iface {iface} inet dhcp f"auto {iface}",
""" f"iface {iface} inet dhcp",
]
else: else:
# Parse CIDR to address + netmask # Parse CIDR to address + netmask
try: try:
@ -629,11 +642,18 @@ iface {iface} inet dhcp
lines.append(f" gateway {gateway}") lines.append(f" gateway {gateway}")
if dns: if dns:
lines.append(f" dns-nameservers {' '.join(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 = _INTERFACES_DIR / iface
config_file.write_text(content) 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 # Also update resolv.conf for static DNS
if method == "static" and 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_netmask"] = cfg.get("netmask", "")
iface["config_gateway"] = cfg.get("gateway", "") iface["config_gateway"] = cfg.get("gateway", "")
iface["config_dns"] = cfg.get("dns-nameservers", "") iface["config_dns"] = cfg.get("dns-nameservers", "")
iface["config_ssid"] = cfg.get("wpa-ssid", "")
# Get DNS from resolv.conf # Get DNS from resolv.conf
dns_servers = [] dns_servers = []
@ -691,6 +712,8 @@ class InterfaceConfig(BaseModel):
address: Optional[str] = None # CIDR e.g. "192.168.1.100/24" address: Optional[str] = None # CIDR e.g. "192.168.1.100/24"
gateway: Optional[str] = None gateway: Optional[str] = None
dns: Optional[list[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") @router.put("/interfaces/{name}/config")
@ -717,6 +740,8 @@ async def configure_interface(
address=cfg.address or "", address=cfg.address or "",
gateway=cfg.gateway or "", gateway=cfg.gateway or "",
dns=cfg.dns, dns=cfg.dns,
ssid=cfg.ssid or "",
wifi_password=cfg.wifi_password or "",
) )
# Apply: bring interface down then up # 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) 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") @router.post("/interfaces/{name}/up")
async def interface_up(name: str, _user: str = Depends(get_current_user)): async def interface_up(name: str, _user: str = Depends(get_current_user)):
"""Bring an interface up.""" """Bring an interface up."""

View file

@ -203,14 +203,89 @@
display: none; display: none;
} }
.settings-toggle .toggle-label { /* Network method radio buttons */
position: absolute; .net-method-radios {
left: 60px; display: flex;
top: 50%; gap: 4px;
transform: translateY(-50%); 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-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--text-secondary); 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; white-space: nowrap;
} }

View file

@ -333,12 +333,34 @@
<div class="settings-row"> <div class="settings-row">
<div> <div>
<div class="settings-row-label">IP Configuration</div> <div class="settings-row-label">IP Configuration</div>
<div class="settings-row-desc">Toggle between DHCP and Static IP</div>
</div> </div>
<button class="settings-toggle ${isStatic ? 'on' : ''}" data-iface-dhcp="${iface.name}"> <div class="net-method-radios" data-iface-method="${iface.name}">
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span> <label class="net-radio-label">
</button> <input type="radio" name="ifMethod-${iface.name}" value="dhcp" ${!isStatic ? 'checked' : ''}>
<span>DHCP</span>
</label>
<label class="net-radio-label">
<input type="radio" name="ifMethod-${iface.name}" value="static" ${isStatic ? 'checked' : ''}>
<span>Static</span>
</label>
</div>
</div> </div>
${iface.wireless ? `
<div class="net-wifi-fields">
<div class="settings-row">
<div><div class="settings-row-label">WiFi SSID</div></div>
<div style="display:flex;align-items:center;gap:8px;">
<input class="settings-input" id="ifSsid-${iface.name}" value="${iface.config_ssid || ''}" placeholder="Network name">
<button class="settings-btn secondary" style="height:40px;font-size:11px;padding:0 10px;white-space:nowrap;" data-wifi-scan="${iface.name}">Scan</button>
</div>
</div>
<div id="wifiScanResults-${iface.name}" class="wifi-scan-results hidden"></div>
<div class="settings-row">
<div><div class="settings-row-label">WiFi Password</div></div>
<input class="settings-input" id="ifWifiPass-${iface.name}" type="password" placeholder="Password">
</div>
</div>
` : ''}
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}"> <div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}">
<div class="settings-row"> <div class="settings-row">
<div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div> <div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div>
@ -398,15 +420,59 @@
}); });
}); });
// DHCP/Static toggle // DHCP/Static radio buttons
contentEl.querySelectorAll('[data-iface-dhcp]').forEach(toggle => { contentEl.querySelectorAll('[data-iface-method]').forEach(group => {
const name = toggle.dataset.ifaceDhcp; const name = group.dataset.ifaceMethod;
const staticFields = contentEl.querySelector(`#staticFields-${name}`); const staticFields = contentEl.querySelector(`#staticFields-${name}`);
toggle.addEventListener('click', () => { group.querySelectorAll('input[type="radio"]').forEach(radio => {
toggle.classList.toggle('on'); radio.addEventListener('change', () => {
const isNowStatic = toggle.classList.contains('on'); staticFields.classList.toggle('hidden', radio.value !== 'static');
toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP'; });
staticFields.classList.toggle('hidden', !isNowStatic); });
});
// 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 = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Scanning…</div>';
try {
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
if (!res.ok) {
resultsEl.innerHTML = '<div class="net-error">Failed to scan WiFi networks</div>';
} else {
const networks = await res.json();
if (networks.length === 0) {
resultsEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found</div>';
} 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 = `
<span class="wifi-scan-ssid">${net.ssid}</span>
<span style="font-family:var(--font-mono);font-size:11px;color:${sig.color};">${sig.text}</span>
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${net.security || 'Open'}</span>
`;
item.addEventListener('click', () => {
contentEl.querySelector(`#ifSsid-${name}`).value = net.ssid;
resultsEl.classList.add('hidden');
});
resultsEl.appendChild(item);
}
}
}
} catch (e) {
resultsEl.innerHTML = '<div class="net-error">Scan failed</div>';
}
btn.disabled = false;
btn.textContent = 'Scan';
}); });
}); });
@ -417,9 +483,10 @@
const errEl = contentEl.querySelector(`#ifError-${name}`); const errEl = contentEl.querySelector(`#ifError-${name}`);
errEl.classList.add('hidden'); errEl.classList.add('hidden');
const toggle = contentEl.querySelector(`[data-iface-dhcp="${name}"]`); const methodGroup = contentEl.querySelector(`[data-iface-method="${name}"]`);
const isStatic = toggle.classList.contains('on'); const selectedRadio = methodGroup.querySelector('input[type="radio"]:checked');
const method = isStatic ? 'static' : 'dhcp'; const method = selectedRadio ? selectedRadio.value : 'dhcp';
const isStatic = method === 'static';
const body = { method }; const body = { method };
if (isStatic) { 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; if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return;
btn.disabled = true; btn.disabled = true;
@ -496,11 +575,17 @@
<div class="settings-row"> <div class="settings-row">
<div> <div>
<div class="settings-row-label">IP Configuration</div> <div class="settings-row-label">IP Configuration</div>
<div class="settings-row-desc">Toggle between DHCP and Static IP</div>
</div> </div>
<button class="settings-toggle ${isStatic ? 'on' : ''}" id="dhcpToggle-${dev.device}"> <div class="net-method-radios" id="methodRadios-${dev.device}">
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span> <label class="net-radio-label">
</button> <input type="radio" name="ethMethod-${dev.device}" value="dhcp" ${!isStatic ? 'checked' : ''}>
<span>DHCP</span>
</label>
<label class="net-radio-label">
<input type="radio" name="ethMethod-${dev.device}" value="static" ${isStatic ? 'checked' : ''}>
<span>Static</span>
</label>
</div>
</div> </div>
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${dev.device}"> <div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${dev.device}">
<div class="settings-row"> <div class="settings-row">
@ -526,14 +611,13 @@
</div> </div>
`; `;
// Toggle handler // Radio handler
const toggle = groupEl.querySelector(`#dhcpToggle-${dev.device}`); const methodRadios = groupEl.querySelector(`#methodRadios-${dev.device}`);
const staticFields = groupEl.querySelector(`#staticFields-${dev.device}`); const staticFields = groupEl.querySelector(`#staticFields-${dev.device}`);
toggle.addEventListener('click', () => { methodRadios.querySelectorAll('input[type="radio"]').forEach(radio => {
toggle.classList.toggle('on'); radio.addEventListener('change', () => {
const isNowStatic = toggle.classList.contains('on'); staticFields.classList.toggle('hidden', radio.value !== 'static');
toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP'; });
staticFields.classList.toggle('hidden', !isNowStatic);
}); });
// Apply handler // Apply handler
@ -541,7 +625,8 @@
const errEl = groupEl.querySelector(`#ethError-${dev.device}`); const errEl = groupEl.querySelector(`#ethError-${dev.device}`);
errEl.classList.add('hidden'); 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' }; const body = { method: isNowStatic ? 'manual' : 'auto' };
if (isNowStatic) { if (isNowStatic) {