VulpesOS/tundra/runtime/wifi-control.py
OverSu 1311bed5e9 Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
2026-10-13 00:19:45 +02:00

402 lines
18 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
from concurrent.futures import ThreadPoolExecutor
import threading
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')
MUTATION_LOCK = threading.Lock()
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 connection_info():
info = {}
address = nm('-g','IP4.ADDRESS','device','show',DEVICE,timeout=5)
if address.returncode == 0:
import ipaddress
for line in address.stdout.splitlines():
try:info['ipAddress'] = str(ipaddress.ip_interface(line).ip);break
except ValueError:continue
device = nm('-g','GENERAL.DBUS-PATH','device','show',DEVICE,timeout=5)
path=device.stdout.strip() if device.returncode==0 else ''
if re.fullmatch(r'/org/freedesktop/NetworkManager/Devices/[0-9]+',path):
speed=subprocess.run(['/usr/bin/busctl','--system','--timeout=2','--json=short','get-property',
'org.freedesktop.NetworkManager',path,'org.freedesktop.NetworkManager.Device.Wireless','Bitrate'],
capture_output=True,text=True,timeout=3)
if speed.returncode==0:
try:
kbps=json.loads(speed.stdout)['data']
if isinstance(kbps,list):kbps=kbps[0]
if type(kbps) is int and kbps>0:info['linkSpeed']=kbps/1000
except (ValueError,KeyError,TypeError,IndexError):pass
return info
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 network.get('hidden'): text = text.replace('[wifi]\n','[wifi]\nhidden=true\n')
if mode != 'open':
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
return text
HOTSPOT_UUID = '5e962c71-d3d0-4c39-b4de-460bb63fc045'
HOTSPOT_RETURN = Path('/run/tundra-wifi/hotspot-return.json')
def active_wifi_uuid():
result=nm('-g','GENERAL.CON-UUID','device','show',DEVICE,timeout=5)
value=result.stdout.strip() if result.returncode==0 else ''
try:return str(uuid.UUID(value))
except ValueError:return None
def hotspot_state(profile_dir):
path=profile_dir/'vulpes-hotspot.nmconnection'
result={'enabled':active_wifi_uuid()==HOTSPOT_UUID,'configured':False,'ssid':'Vulpes OS','security':'wpa2-psk','password':''}
if path.exists() and not path.is_symlink():
config=configparser.ConfigParser(interpolation=None);config.read(path)
result.update(configured=True,ssid=bytes(int(b) for b in config['wifi']['ssid'].split(';') if b).decode(),
security='wpa2-psk' if config.has_section('wifi-security') else 'open',
password=re.sub(r'\\([\\s])',lambda match:' ' if match[1]=='s' else '\\',config.get('wifi-security','psk',fallback='')))
return result
def hotspot(request, profile_dir):
action=request.get('operation')
allowed={'hotspotStatus':set(),'hotspotSave':{'ssid','security','password'},'hotspotPower':{'enabled'}}
if action not in allowed or set(request)-{'operation'}-allowed[action]:raise WifiError('WIFI_INVALID_REQUEST')
if action=='hotspotStatus':return hotspot_state(profile_dir)
target=profile_dir/'vulpes-hotspot.nmconnection'
if action=='hotspotSave':
ssid=request.get('ssid');security=request.get('security');password=request.get('password')
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or any(ord(c)<32 for c in ssid):raise WifiError('WIFI_INVALID_REQUEST')
if security not in ('open','wpa2-psk') or not isinstance(password,str):raise WifiError('WIFI_INVALID_REQUEST')
if any(ord(c)<32 for c in password) or (security=='wpa2-psk' and not 8<=len(password.encode())<=63):raise WifiError('WIFI_INVALID_PASSWORD')
if active_wifi_uuid()==HOTSPOT_UUID:raise WifiError('HOTSPOT_IN_USE')
previous=target.read_text() if target.exists() and not target.is_symlink() else None
value=profile_text({'ssid':ssid,'security':'wpa-psk' if security=='wpa2-psk' else 'open'},password,HOTSPOT_UUID)
value=value.replace('id=Vulpes Wi-Fi','id=Vulpes hotspot',1).replace('autoconnect=true','autoconnect=false',1).replace('mode=infrastructure','mode=ap\nband=bg',1).replace('[ipv4]\nmethod=auto','[ipv4]\nmethod=shared',1).replace('[ipv6]\nmethod=auto','[ipv6]\nmethod=disabled',1)
if security=='wpa2-psk':value=value.replace('[wifi-security]\n','[wifi-security]\nproto=rsn\n')
profile_dir.mkdir(mode=0o700,parents=True,exist_ok=True);private_write(target,value)
if nm('connection','load',str(target)).returncode:
if previous is not None:private_write(target,previous);nm('connection','load',str(target))
else:
nm('connection','delete','uuid',HOTSPOT_UUID)
target.unlink(missing_ok=True)
raise WifiError('HOTSPOT_SAVE_FAILED')
return hotspot_state(profile_dir)
enabled=request.get('enabled')
if type(enabled) is not bool:raise WifiError('WIFI_INVALID_REQUEST')
current=active_wifi_uuid()
if enabled:
if not target.is_file() or target.is_symlink():raise WifiError('HOTSPOT_NOT_CONFIGURED')
if current==HOTSPOT_UUID:return hotspot_state(profile_dir)
HOTSPOT_RETURN.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
private_write(HOTSPOT_RETURN,json.dumps({'uuid':current}))
if nm('connection','up','uuid',HOTSPOT_UUID).returncode or active_wifi_uuid()!=HOTSPOT_UUID:
nm('connection','down','uuid',HOTSPOT_UUID)
if current:nm('connection','up','uuid',current)
HOTSPOT_RETURN.unlink(missing_ok=True)
raise WifiError('HOTSPOT_START_FAILED')
elif current==HOTSPOT_UUID:
if nm('connection','down','uuid',HOTSPOT_UUID).returncode:raise WifiError('HOTSPOT_STOP_FAILED')
previous=None
try:previous=str(uuid.UUID(json.loads(HOTSPOT_RETURN.read_text()).get('uuid')))
except (OSError,ValueError,TypeError,AttributeError):pass
HOTSPOT_RETURN.unlink(missing_ok=True)
if previous and previous!=HOTSPOT_UUID:
if nm('connection','up','uuid',previous).returncode:raise WifiError('WIFI_RECONNECT_FAILED')
result=hotspot_state(profile_dir)
if result['enabled']!=enabled:raise WifiError('HOTSPOT_STATE_MISMATCH')
return result
def dispatch(request, profile_dir=PROFILE_DIR):
if isinstance(request,dict) and request.get('operation') in ('hotspotStatus','hotspotSave','hotspotPower'):
return hotspot(request,profile_dir)
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled', 'ssid', 'security'}:
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)
info={}
if any(n['connected'] for n in found):
try:info=connection_info()
except (WifiError,OSError,subprocess.TimeoutExpired):pass
return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
if operation not in ('connect','connectHidden'):
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
if operation == 'connectHidden':
ssid=request.get('ssid');security=request.get('security')
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or security not in ('open','wpa-psk','sae'):
raise WifiError('WIFI_INVALID_REQUEST')
network={'ssid':ssid,'security':security,'hidden':True}
else:
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)
if network.get('hidden') and 'hidden=true' not in text:
text = text.replace('[wifi]\n', '[wifi]\nhidden=true\n')
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')
request = receive(stream, MAX_REQUEST)
if isinstance(request,dict) and request.get('operation') in ('connect','connectHidden','forget','disconnect','setEnabled','hotspotSave','hotspotPower'):
with MUTATION_LOCK:
result = {'result': dispatch(request)}
else:
result = {'result': dispatch(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:
value=json.loads(data)
stream.settimeout(180 if isinstance(value,dict) and value.get('operation')=='hotspotPower' else 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)
# Reading saved profiles must not wait behind a radio scan or connection.
# Bound both workers and queued sockets; mutations remain serialized.
slots = threading.BoundedSemaphore(8)
def serve_client(stream):
try:
with stream: handle(stream)
finally: slots.release()
with ThreadPoolExecutor(max_workers=4) as workers:
while True:
stream, _ = server.accept()
if slots.acquire(blocking=False): workers.submit(serve_client,stream)
else: stream.close()
if __name__ == '__main__':
main()