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.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
275
tundra/runtime/wifi-control.py
Normal file
275
tundra/runtime/wifi-control.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/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()
|
||||
Loading…
Reference in a new issue