VulpesOS/tundra/devices/beatles/wifi_control.py
2026-10-22 20:42:26 +02:00

334 lines
16 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Nokia Wi-Fi adapter: vendor supplicant, private client socket, no credentials in argv."""
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import socket
import struct
import subprocess
import sys
import time
import uuid
SOCKET = '/work/wifi-control.sock'
STATE = Path('/run/tundra-wifi/state.json')
WPA = '/work/radio-sockets/wpa_wlan0'
ERRORS = {'WIFI_UNAVAILABLE', 'WIFI_DISABLED', 'WIFI_INVALID_REQUEST', 'WIFI_INVALID_PASSWORD',
'WIFI_UNSUPPORTED_SECURITY', 'WIFI_CONNECTION_FAILED', 'WIFI_DHCP_FAILED', 'WIFI_SAVE_FAILED', 'NOT_SUPPORTED'}
def decode_ssid(text):
raw = bytearray()
for part in re.findall(r'\\x[0-9a-fA-F]{2}|\\.|[^\\]+|\\$', text):
if re.fullmatch(r'\\x[0-9a-fA-F]{2}', part): raw.append(int(part[2:], 16))
elif part.startswith('\\'): raw.extend({'\\n':b'\n','\\r':b'\r','\\t':b'\t','\\\\':b'\\','\\"':b'"'}.get(part,part.encode()))
else: raw.extend(part.encode())
return raw.decode('utf-8', errors='replace')
def security(flags):
if 'EAP' in flags or 'WEP' in flags: return 'unsupported'
if 'PSK' in flags: return 'wpa-psk'
if 'SAE' in flags: return 'unsupported'
return 'open'
def parse_scan(text):
rows=[]
for line in text.splitlines()[1:]:
parts=line.split('\t',4)
if len(parts)!=5 or not re.fullmatch(r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',parts[0]): continue
try: signal_level=max(0,min(100,2*(int(parts[2])+100)))
except ValueError: continue
ssid=decode_ssid(parts[4])
if ssid: rows.append({'id':parts[0].lower(),'ssid':ssid,'signal':signal_level,'security':security(parts[3])})
return sorted(rows,key=lambda row:-row['signal'])[:128]
def android(*args, check=True):
result=subprocess.run(['/system/bin/'+args[0],*args[1:]],capture_output=True,text=True,timeout=5,
env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
if check and result.returncode: raise RuntimeError('WIFI_UNAVAILABLE')
return result.stdout.strip()
def command(value, required=True):
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as sock:
# An abstract return address is reachable by the host supplicant outside our chroot.
sock.bind('\0vulpes-wifi-'+uuid.uuid4().hex)
sock.settimeout(3);sock.connect(WPA)
sock.send(('IFNAME=wlan0 '+value).encode())
result=sock.recv(65536).decode('utf-8',errors='replace').strip()
if required and (result.startswith('FAIL') or result=='UNKNOWN COMMAND'):
raise RuntimeError('WIFI_UNAVAILABLE')
return result
class Wifi:
def __init__(self):
self.last_scan=[];self.original_enabled=None;self.route=None;self.last_lease=0
self.initial_resolver=Path('/etc/resolv.conf').read_bytes() if Path('/etc/resolv.conf').exists() else None
self.rule_owned=False;self.stopping=False
self.lease_network=None
def enabled(self):
try: return bool(int(Path('/sys/class/net/wlan0/flags').read_text(),16)&1)
except OSError: return False
def state(self):
enabled=self.enabled()
result={'available':True,'enabled':enabled,'state':'disconnected','network':None,'connectionInformation':{}}
if not enabled: result['state']='disabled';return result
status=dict(line.split('=',1) for line in command('STATUS').splitlines() if '=' in line)
result['macAddress']=status.get('address','')
if status.get('wpa_state')=='COMPLETED':
result['state']='connected'
result['network']={'ssid':decode_ssid(status.get('ssid','')),'savedId':status.get('id'),
'security':security(status.get('key_mgmt','')),'signal':50}
try: result['connectionInformation']['ipAddress']=str(ipaddress.IPv4Address(status.get('ip_address','')))
except ValueError: pass
poll=command('SIGNAL_POLL',False)
for line in poll.splitlines():
try:
name,value=line.split('=',1);value=int(value)
if name=='RSSI': result['network']['signal']=max(0,min(100,2*(value+100)))
if name=='LINKSPEED': result['connectionInformation']['linkSpeed']=value
except ValueError: pass
return result
def known(self):
rows=[]
for line in command('LIST_NETWORKS').splitlines()[1:]:
parts=line.split('\t',3)
if len(parts)!=4 or not parts[0].isdigit(): continue
flags=command('GET_NETWORK '+parts[0]+' key_mgmt',False)
rows.append({'id':parts[0],'ssid':decode_ssid(parts[1]),'security':security(flags)})
return rows
def scan(self):
state=self.state()
if not state['enabled']:return {**state,'networks':[]}
# Subscribe before requesting a scan so fast completions are not lost.
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as events:
events.bind('\0vulpes-wifi-scan-'+uuid.uuid4().hex);events.settimeout(3);events.connect(WPA)
events.send(b'IFNAME=wlan0 ATTACH');events.recv(4096)
command('SCAN',False)
deadline=time.monotonic()+4
while time.monotonic()<deadline:
try:
if b'CTRL-EVENT-SCAN-RESULTS' in events.recv(8192):break
except socket.timeout:break
rows=parse_scan(command('SCAN_RESULTS'));known=self.known();state=self.state()
for row in rows:
saved=next((n for n in known if n['ssid']==row['ssid'] and n['security']==row['security']),None)
row.update(known=bool(saved),savedId=saved['id'] if saved else None,
connected=bool(state['network'] and state['network']['ssid']==row['ssid']))
self.last_scan=rows
return {**state,'networks':rows}
def set_enabled(self, enabled):
if type(enabled) is not bool:raise ValueError('WIFI_INVALID_REQUEST')
if self.original_enabled is None:self.original_enabled=self.enabled()
self.route=None;self.lease_network=None;self.last_lease=0
if enabled:
android('ip','link','set','wlan0','up')
if not Path(WPA).exists():
android('setprop','ctl.start','wpa_supplicant')
for _ in range(30):
if Path(WPA).exists():break
time.sleep(.1)
command('RECONNECT')
else:
command('DISCONNECT',False)
android('ip','link','set','wlan0','down')
return self.state()
def connect(self,value):
if not self.enabled():raise RuntimeError('WIFI_DISABLED')
if value['operation']=='connectHidden':
ssid=value.get('ssid');mode=value.get('security')
else:
target=next((n for n in self.last_scan if n['id']==value.get('id')),None)
if target is None:raise ValueError('WIFI_INVALID_REQUEST')
ssid,mode=target['ssid'],target['security']
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32:raise ValueError('WIFI_INVALID_REQUEST')
if mode not in ('open','wpa-psk'):raise ValueError('WIFI_UNSUPPORTED_SECURITY')
password=value.get('password','')
if not isinstance(password,str):raise ValueError('WIFI_INVALID_PASSWORD')
saved=next((n for n in self.known() if n['ssid']==ssid and n['security']==mode),None)
created=False
if saved and not password: ident=saved['id']
else:
if mode=='wpa-psk':
if re.fullmatch('[a-fA-F0-9]{64}',password):psk=password.lower()
elif 8<=len(password.encode())<=63:psk=hashlib.pbkdf2_hmac('sha1',password.encode(),ssid.encode(),4096,32).hex()
else:raise ValueError('WIFI_INVALID_PASSWORD')
ident=command('ADD_NETWORK')
if not ident.isdigit():raise RuntimeError('WIFI_UNAVAILABLE')
created=True
try:
if created:
command('SET_NETWORK '+ident+' ssid '+ssid.encode().hex())
command('SET_NETWORK '+ident+' key_mgmt '+('WPA-PSK' if mode=='wpa-psk' else 'NONE'))
if mode=='wpa-psk':command('SET_NETWORK '+ident+' psk '+psk)
if value['operation']=='connectHidden':command('SET_NETWORK '+ident+' scan_ssid 1')
command('SELECT_NETWORK '+ident)
deadline=time.monotonic()+20
while time.monotonic()<deadline:
if self.stopping:raise RuntimeError('WIFI_UNAVAILABLE')
state=self.state()
if state['state']=='connected' and state['network']['ssid']==ssid:
self.lease_network=None;self.last_lease=0
self.lease(state)
while time.monotonic()<deadline and not self.stopping:
if android('getprop','dhcp.wlan0.result')=='ok':break
time.sleep(.25)
else:raise RuntimeError('WIFI_DHCP_FAILED')
self.last_lease=0;self.lease(state)
if created and command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return self.state()
time.sleep(.25)
raise RuntimeError('WIFI_CONNECTION_FAILED')
except Exception:
if created:command('REMOVE_NETWORK '+ident,False)
command('ENABLE_NETWORK all',False);command('RECONNECT',False)
raise
def lease(self,state):
if time.monotonic()-self.last_lease<3:return
self.last_lease=time.monotonic()
if state['state']!='connected':
self.lease_network=None;self.route=None
return
network_id=(state['network']['ssid'],state['network'].get('savedId'))
if network_id!=self.lease_network:
self.lease_network=network_id;self.route=None
# init.qcom.rc supplies -ABKLG but no interface. ctl.restart drops
# the dynamic argument; ctl.start must explicitly append wlan0.
android('setprop','ctl.stop','dhcpcd_wlan0')
for _ in range(20):
if android('getprop','init.svc.dhcpcd_wlan0')=='stopped':break
time.sleep(.1)
android('setprop','dhcp.wlan0.result','')
android('setprop','ctl.start','dhcpcd_wlan0:wlan0')
return
if android('getprop','dhcp.wlan0.result')!='ok':return
servers=[]
for name in ('dns1','dns2'):
try: servers.append(str(ipaddress.ip_address(android('getprop','dhcp.wlan0.'+name))))
except ValueError: pass
if servers:
content=''.join('nameserver '+ip+'\n' for ip in dict.fromkeys(servers))+'options timeout:2 attempts:2\n'
path=Path('/etc/resolv.conf')
if not path.exists() or path.read_text()!=content:
temporary=path.with_suffix('.tmp');temporary.write_text(content);temporary.chmod(0o644);temporary.replace(path)
# glibc does not use Android netd's per-socket routing marks. Install a
# dedicated table only for unmarked browser uid 2000; retain vendor tables.
try:
addr=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.ipaddress'))
gateway=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.gateway'))
mask=android('getprop','dhcp.wlan0.mask')
network=ipaddress.IPv4Network(str(addr)+'/'+mask,strict=False)
if gateway not in network:return
except ValueError:return
route=(str(network),str(gateway))
if route==self.route and android('ip','route','show','table','21052'):return
if not self.rule_owned:
if android('ip','route','show','table','21052') or '21900:' in android('ip','rule'):return
android('ip','rule','add','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052')
self.rule_owned=True
android('ip','route','flush','table','21052')
android('ip','route','add',route[0],'dev','wlan0','table','21052')
android('ip','route','add','default','via',route[1],'dev','wlan0','table','21052')
self.route=route
def publish(self):
try:
state=self.state();self.lease(state)
except (OSError,RuntimeError,subprocess.SubprocessError):
state={'available':False,'enabled':False,'state':'unavailable','network':None}
STATE.parent.mkdir(parents=True,exist_ok=True)
temporary=STATE.with_suffix('.tmp');temporary.write_text(json.dumps(state));temporary.chmod(0o644);temporary.replace(STATE)
def request(self,value):
if not isinstance(value,dict):raise ValueError('WIFI_INVALID_REQUEST')
op=value.get('operation')
if op=='status':return self.state()
if op=='scan':return self.scan()
if op=='known':return {'networks':self.known()}
if op=='setEnabled':return self.set_enabled(value.get('enabled'))
if op in ('connect','connectHidden'):return self.connect(value)
if op=='disconnect':command('DISCONNECT');return self.state()
if op=='forget':
ident=value.get('id')
if not isinstance(ident,str) or not ident.isdigit() or len(ident)>6:raise ValueError('WIFI_INVALID_REQUEST')
command('REMOVE_NETWORK '+ident)
if command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return True
raise ValueError('NOT_SUPPORTED')
def restore(self):
if self.original_enabled is not None:
try:self.set_enabled(self.original_enabled)
except Exception:pass
if self.rule_owned:
android('ip','rule','del','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052',check=False)
android('ip','route','flush','table','21052',check=False)
resolver=Path('/etc/resolv.conf')
if self.initial_resolver is None:resolver.unlink(missing_ok=True)
else:resolver.write_bytes(self.initial_resolver)
def receive(conn, limit=8192):
data=b''
while b'\n' not in data:
part=conn.recv(4096)
if not part:break
data+=part
if len(data)>limit:raise ValueError('WIFI_INVALID_REQUEST')
return json.loads(data)
def main():
if sys.argv[1:]==['--client']:
raw=sys.stdin.buffer.read(8193)
if len(raw)>8192:raise ValueError('WIFI_INVALID_REQUEST')
value=json.loads(raw)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(40);conn.connect(SOCKET);conn.sendall(json.dumps(value).encode()+b'\n')
print(json.dumps(receive(conn,65536)))
return
if sys.argv[1:] or os.getuid()!=0:raise SystemExit('Root broker or --client required')
os.umask(0o022);wifi=Wifi();running=True
def stop(*_):
nonlocal running
running=False;wifi.stopping=True
signal.signal(signal.SIGTERM,stop)
Path(SOCKET).unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.settimeout(1)
print('Nokia Wi-Fi broker ready',flush=True)
try:
while running:
wifi.publish()
try:conn,_=server.accept()
except socket.timeout:continue
with conn:
conn.settimeout(2)
_,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,2000):continue
try:reply={'result':wifi.request(receive(conn))}
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
reply={'error':str(error) if str(error) in ERRORS else 'WIFI_UNAVAILABLE'}
try:conn.sendall(json.dumps(reply).encode()+b'\n')
except OSError:pass
finally:
wifi.restore();Path(SOCKET).unlink(missing_ok=True)
if __name__=='__main__':main()