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.
402 lines
18 KiB
Python
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()
|