VulpesOS/tundra/runtime/wifi-control.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

275 lines
10 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
import argparse
import configparser
import json
import os
from pathlib import Path
import re
import socket
import struct
import subprocess
import sys
import uuid
SOCKET = '/run/tundra-wifi/control.sock'
MAX_REQUEST = 16384
DEVICE = 'wlan0'
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
class WifiError(Exception):
pass
def fields(line):
"""Decode nmcli's escaped colon-separated output, including SSIDs."""
result, value, escaped = [], '', False
for char in line:
if escaped:
value += char
escaped = False
elif char == '\\':
escaped = True
elif char == ':':
result.append(value)
value = ''
else:
value += char
if escaped:
value += '\\'
return result + [value]
def nm(*args, timeout=45):
try:
return subprocess.run(['/usr/bin/nmcli', '--wait', '30', *args],
capture_output=True, text=True, timeout=timeout,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
except (OSError, subprocess.TimeoutExpired):
raise WifiError('WIFI_UNAVAILABLE') from None
def networks(rescan=False):
result = nm('-t', '--escape', 'yes', '-f', 'DBUS-PATH,SSID,SIGNAL,SECURITY,IN-USE',
'device', 'wifi', 'list', 'ifname', DEVICE,
'--rescan', 'yes' if rescan else 'no')
if result.returncode:
raise WifiError('WIFI_UNAVAILABLE')
found = []
for line in result.stdout.splitlines():
parts = fields(line)
if len(parts) != 5:
continue
ident, ssid, signal, security, active = parts
if not re.fullmatch(r'/org/freedesktop/NetworkManager/AccessPoint/\d+', ident) or not ssid:
continue
if not signal.isdigit() or not 0 <= int(signal) <= 100:
continue
if not security or security == '--':
mode = 'open'
elif '802.1X' in security or 'EAP' in security:
mode = 'unsupported'
elif 'WPA3' in security and 'WPA2' not in security:
mode = 'sae'
elif 'WPA2' in security or 'WPA1' in security or security == 'WPA':
mode = 'wpa-psk'
else:
mode = 'unsupported'
found.append({'id': ident, 'ssid': ssid, 'signal': int(signal),
'security': mode, 'connected': active == '*'})
return sorted(found, key=lambda row: -row['signal'])[:128]
def saved_profiles(profile_dir):
result = []
for path in profile_dir.glob('vulpes-*.nmconnection'):
try:
if path.is_symlink():
continue
parser = configparser.ConfigParser(interpolation=None)
parser.read(path)
uid = str(uuid.UUID(parser['connection']['uuid']))
if path.name != 'vulpes-' + uid + '.nmconnection':
continue
ssid = bytes(int(b) for b in parser['wifi']['ssid'].split(';') if b).decode()
mode = parser.get('wifi-security', 'key-mgmt', fallback='open')
result.append({'id': uid, 'ssid': ssid, 'security': mode, 'known': True})
except (OSError, ValueError, KeyError, configparser.Error):
continue
return result
def private_write(target, text):
temporary = target.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'w') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(target)
def profile_text(network, password, uid):
ssid = network['ssid']
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
raise WifiError('WIFI_INVALID_REQUEST')
mode = network['security']
if mode not in ('open', 'wpa-psk', 'sae'):
raise WifiError('WIFI_UNSUPPORTED_SECURITY')
if not isinstance(password, str) or any(ord(c) < 32 for c in password):
raise WifiError('WIFI_INVALID_PASSWORD')
length = len(password.encode())
if mode == 'wpa-psk' and not (8 <= length <= 63 or re.fullmatch(r'[0-9a-fA-F]{64}', password)):
raise WifiError('WIFI_INVALID_PASSWORD')
if mode == 'sae' and not 1 <= length <= 63:
raise WifiError('WIFI_INVALID_PASSWORD')
text = (f'[connection]\nid=Vulpes Wi-Fi\nuuid={uid}\ntype=wifi\n'
'interface-name=wlan0\nautoconnect=true\n[wifi]\nmode=infrastructure\n'
'cloned-mac-address=permanent\nssid=' +
''.join(str(b) + ';' for b in ssid.encode()) +
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
if mode != 'open':
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
return text
def dispatch(request, profile_dir=PROFILE_DIR):
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
raise WifiError('WIFI_INVALID_REQUEST')
operation = request.get('operation')
saved = saved_profiles(profile_dir)
if operation == 'known':
return {'networks': saved}
if operation == 'forget':
network = next((row for row in saved if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
uid = network['id']
if nm('connection', 'delete', 'uuid', uid).returncode:
raise WifiError('WIFI_FORGET_FAILED')
(profile_dir / ('vulpes-' + uid + '.nmconnection')).unlink(missing_ok=True)
return {'forgotten': True}
if operation in ('status', 'scan', 'setEnabled'):
if operation == 'setEnabled':
enabled = request.get('enabled')
if type(enabled) is not bool:
raise WifiError('WIFI_INVALID_REQUEST')
if nm('radio', 'wifi', 'on' if enabled else 'off').returncode:
raise WifiError('WIFI_UNAVAILABLE')
radio = nm('radio', 'wifi')
if radio.returncode:
raise WifiError('WIFI_UNAVAILABLE')
enabled = radio.stdout.strip() == 'enabled'
found = networks(rescan=True) if enabled and operation == 'scan' else []
for network in found:
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
return {'enabled': enabled, 'networks': found}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
if operation != 'connect':
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
network = next((row for row in networks() if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
uid = stored['id'] if stored else str(uuid.uuid4())
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
previous = target.read_text() if stored else None
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
connected = False
try:
private_write(target, text)
if nm('connection', 'load', str(target)).returncode:
raise WifiError('WIFI_UNAVAILABLE')
if nm('connection', 'up', 'uuid', uid).returncode:
raise WifiError('WIFI_CONNECT_FAILED')
connected = True
return {'connected': True}
finally:
if not connected:
# Never include nmcli stderr: it may contain the SSID or credentials.
if previous is not None:
private_write(target, previous)
nm('connection', 'load', str(target))
else:
nm('connection', 'delete', 'uuid', uid)
target.unlink(missing_ok=True)
def receive(stream, limit):
data = bytearray()
while b'\n' not in data:
part = stream.recv(min(4096, limit + 1 - len(data)))
if not part:
raise WifiError('WIFI_INVALID_REQUEST')
data.extend(part)
if len(data) > limit:
raise WifiError('WIFI_INVALID_REQUEST')
if data.count(b'\n') != 1 or not data.endswith(b'\n'):
raise WifiError('WIFI_INVALID_REQUEST')
return json.loads(data)
def handle(stream):
stream.settimeout(5)
try:
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise WifiError('PERMISSION_DENIED')
result = {'result': dispatch(receive(stream, MAX_REQUEST))}
except WifiError as error:
result = {'error': str(error)}
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
try:
stream.sendall(json.dumps(result).encode() + b'\n')
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--client', action='store_true')
args = parser.parse_args()
if args.client:
try:
data = sys.stdin.buffer.read(MAX_REQUEST + 1)
if len(data) > MAX_REQUEST:
raise WifiError('WIFI_INVALID_REQUEST')
with socket.socket(socket.AF_UNIX) as stream:
stream.settimeout(90)
stream.connect(SOCKET)
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
result = receive(stream, 65536)
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
print(json.dumps(result))
return
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise SystemExit('Temporary native boot required')
os.umask(0o077)
path = Path(SOCKET)
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(SOCKET)
os.chown(SOCKET, 0, 32011)
path.chmod(0o660)
server.listen(4)
while True:
stream, _ = server.accept()
with stream:
handle(stream)
if __name__ == '__main__':
main()