Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
275 lines
10 KiB
Python
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()
|