Files
StarPilot/openpilot/starpilot/galaxy/hotspot_worker.py
T

315 lines
13 KiB
Python

"""Privileged, pipe-leased hotspot worker. Never reconfigures the station link.
The unprivileged manager child sends validated settings/heartbeats on stdin.
EOF, ten seconds without a heartbeat, or SIGTERM tears down only our AP.
"""
from __future__ import annotations
import ipaddress
import json
import os
from pathlib import Path
import select
import shutil
import subprocess
import sys
import time
from openpilot.starpilot.galaxy.hotspot import ADDRESS, INTERFACE, NETWORK, HotspotSettings, validate_config
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces
from openpilot.starpilot.system.pipe_worker import claim, owned, stop_event
ALIAS = 'starpilot-galaxy-hotspot'
RUNTIME = Path('/run/starpilot-galaxy-hotspot')
STANDALONE_CHANNEL = ('g', 6)
HOLD = 'hold'
# wlan0 first; aa0 is Android Auto's own car link when it runs on a dedicated interface.
STATIONS = ('wlan0', 'aa0')
# Android Auto drops and rejoins the car between attempts; stay put through those gaps.
STANDALONE_SETTLE_S = 30.0
NEGOTIATING = ('AUTHENTICATING', 'ASSOCIATING', 'ASSOCIATED', '4WAY_HANDSHAKE', 'GROUP_HANDSHAKE')
def radio_channel(status):
fields = dict(line.split('=', 1) for line in status.splitlines() if '=' in line)
# A disconnected station remains available for AA/Wi-Fi association. Do not
# depend on an Internet connection to offer local access. Association itself
# gets priority: leave the AP untouched while the station negotiates its channel.
if fields.get('wpa_state') in ('DISCONNECTED', 'INACTIVE', 'SCANNING'):
return STANDALONE_CHANNEL
if fields.get('wpa_state') in NEGOTIATING:
return HOLD
if fields.get('wpa_state') != 'COMPLETED':
return None
frequency = int(fields.get('freq', '0'))
if 2412 <= frequency <= 2472 and (frequency - 2407) % 5 == 0:
return 'g', (frequency - 2407) // 5
if 5180 <= frequency <= 5825 and frequency % 5 == 0:
return 'a', (frequency - 5000) // 5
return None
def band_channel(number):
return ('g' if number <= 14 else 'a'), number
def dfs(channel):
return channel[0] == 'a' and 52 <= channel[1] <= 144
def choose_channel(results):
"""Pick the AP channel from each station's radio_channel result, wlan0 first."""
if HOLD in results:
return HOLD
for result in results:
if isinstance(result, tuple) and result != STANDALONE_CHANNEL:
return result
return results[0] if results else None
def nm_activating(state):
"""NetworkManager device states 40-90 are the steps between disconnected and activated."""
try:
return 40 <= int(state.strip().split(' ', 1)[0]) < 100
except ValueError:
return False
def overlapping_routes(rows):
subnet = ipaddress.ip_network(NETWORK)
for row in rows:
destination = row.get('dst', 'default')
if destination == 'default' or row.get('dev') == INTERFACE:
continue
if subnet.overlaps(ipaddress.ip_network(destination, strict=False)):
return True
return False
def hostapd_config(config, channel):
validate_config(config)
band, number = channel
if band not in ('a', 'g') or type(number) is not int or not 1 <= number <= 165:
raise ValueError('Invalid channel')
return (f'interface={INTERFACE}\ndriver=nl80211\nssid={config["ssid"]}\nhw_mode={band}\nchannel={number}\n' +
f'wmm_enabled=1\nauth_algs=1\nwpa=2\nwpa_passphrase={config["password"]}\n' +
'wpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\nmax_num_sta=4\n')
class HotspotBackend:
def __init__(self, root=RUNTIME, run=subprocess.run, interfaces=WirelessInterfaces, popen=subprocess.Popen):
self.root, self.run, self.interfaces, self.popen = root, run, interfaces, popen
self.children = []
self.owned = False
self.firewall = shutil.which('iptables-legacy') or shutil.which('iptables')
self.connected = set() # channel numbers of the stations that are connected
self.idle = [] # stations that reported no connection; NetworkManager may be about to join one
def command(self, args, check=True):
return self.run(args, check=check, text=True, capture_output=True, timeout=5)
def channel(self):
results, self.connected, self.idle = [], set(), []
for station in STATIONS:
if station == STATIONS[0]:
result = radio_channel(self.command(['/usr/sbin/wpa_cli', '-i', station, 'status']).stdout)
else:
if not Path(f'/sys/class/net/{station}').exists():
continue
# Between creation and wpa_supplicant taking it, aa0 has no status yet; that is no reason to drop the AP.
reply = self.command(['/usr/sbin/wpa_cli', '-i', station, 'status'], check=False)
if reply.returncode != 0:
continue
result = radio_channel(reply.stdout)
if result == STANDALONE_CHANNEL:
self.idle.append(station)
elif isinstance(result, tuple):
self.connected.add(result[1])
results.append(result)
return choose_channel(results)
def activating(self):
for station in self.idle:
reply = self.command(['nmcli', '-t', '-g', 'GENERAL.STATE', 'device', 'show', station], check=False)
if reply.returncode == 0 and nm_activating(reply.stdout):
return True
return False
def alive(self):
return self.owned and len(self.children) == 2 and all(child.poll() is None for child in self.children)
def ap_status(self):
"""Whether the AP is up, and its current channel: the driver moves it by itself to follow a station."""
reply = self.command(['/usr/sbin/hostapd_cli', '-p', str(self.root / 'control'), '-i', INTERFACE, 'status'], check=False)
lines = reply.stdout.splitlines() if reply.returncode == 0 else []
channel = next((line.split('=', 1)[1] for line in lines if line.startswith('channel=')), '')
return {'ready': 'state=ENABLED' in lines, 'channel': int(channel) if channel.isdigit() else None}
def _wireless(self, method):
client = self.interfaces()
try:
getattr(client, method)(INTERFACE)
finally:
client.close()
def stale_owned(self):
return owned(INTERFACE, ALIAS)
def stop(self):
for child in self.children:
if child.poll() is None:
child.terminate()
for child in self.children:
try:
child.wait(timeout=2)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=2)
self.children.clear()
owned = self.owned or self.stale_owned()
if owned:
self._wireless('delete')
self.owned = False
if self.firewall:
# The comment distinguishes our rules even if the kernel already removed
# the AP after a worker crash. Never flush the shared forwarding chain.
for direction in ('-i', '-o'):
self.command(self.firewall_rule('-D', direction), check=False)
def firewall_rule(self, operation, direction):
return [self.firewall, '-w', '2', operation, 'FORWARD', direction, INTERFACE,
'-m', 'comment', '--comment', ALIAS, '-j', 'DROP']
def start(self, config, channel):
if not self.firewall:
raise RuntimeError('Firewall helper unavailable')
if overlapping_routes(json.loads(self.command(['ip', '-j', '-4', 'route', 'show', 'table', 'all']).stdout)):
raise RuntimeError('Hotspot subnet conflicts with an existing network')
self._wireless('create')
self.owned = True
try:
self.command(['ip', 'link', 'set', 'dev', INTERFACE, 'alias', ALIAS])
self.command(['nmcli', 'device', 'set', INTERFACE, 'managed', 'no'])
self.command(['sysctl', '-w', f'net.ipv6.conf.{INTERFACE}.disable_ipv6=1'])
# Local access only, even if the phone supplies its own default route.
for direction in ('-i', '-o'):
self.command(self.firewall_rule('-I', direction))
self.command(['ip', 'address', 'add', f'{ADDRESS}/24', 'dev', INTERFACE])
self.command(['ip', 'link', 'set', 'dev', INTERFACE, 'up'])
ap = self.root / 'hostapd.conf'
ap.write_text(hostapd_config(config, channel) + f'ctrl_interface={self.root / "control"}\n')
ap.chmod(0o600)
dhcp = self.root / 'dnsmasq.conf'
dhcp.write_text(f'interface={INTERFACE}\nbind-interfaces\nport=0\nno-resolv\nno-hosts\n' +
'dhcp-range=172.31.254.10,172.31.254.30,255.255.255.0,10m\n' +
'dhcp-option=3\ndhcp-option=6\nleasefile-ro\n')
for command in (['/usr/sbin/hostapd', str(ap)],
['/usr/sbin/dnsmasq', '--keep-in-foreground', '--conf-file=' + str(dhcp)]):
self.children.append(self.popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True))
except BaseException:
self.stop()
raise
class HotspotController:
def __init__(self, backend, clock=time.monotonic):
self.backend, self.clock = backend, clock
self.applied, self.retry_at, self.ready_deadline = None, 0.0, 0.0
self.station_seen = float('-inf')
self.attempted = None
self.refused = set() # DFS channels hostapd would not start on; use the standalone channel instead
def tick(self, config):
if not config['enabled']:
self.backend.stop()
self.applied = None
return {'active': False, 'state': 'disabled', 'reason': ''}
try:
channel = self.backend.channel()
running = self.applied is not None and self.backend.alive()
status = self.backend.ap_status() if running else {'ready': False, 'channel': None}
if running and isinstance(channel, tuple) and self.applied[0] == config:
current = status['channel']
# The driver moves the AP onto a station's channel by itself; restarting it there would only move it back.
if current is not None and (current == channel[1] or current in self.backend.connected):
channel = band_channel(current)
self.applied = (self.applied[0], channel)
if channel == STANDALONE_CHANNEL:
if self.clock() - self.station_seen < STANDALONE_SETTLE_S:
channel = HOLD
elif channel is not None:
self.station_seen = self.clock()
if isinstance(channel, tuple) and channel[1] in self.refused:
channel = STANDALONE_CHANNEL
if channel != HOLD and (not running or (dict(config), channel) != self.applied) and self.backend.activating():
self.station_seen = self.clock()
channel = HOLD
if channel is None:
self.backend.stop()
self.applied = None
return {'active': False, 'state': 'waiting', 'reason': 'Waiting for Wi-Fi radio readiness or a supported connection channel.'}
desired = (dict(config), channel)
if channel == HOLD:
# Starting, stopping or moving the AP while a station joins makes the join fail.
if not running or self.applied is None:
return {'active': False, 'state': 'waiting', 'reason': 'Waiting for Wi-Fi to finish connecting.'}
desired = self.applied
channel = desired[1]
if desired != self.applied or not running:
self.backend.stop()
self.applied = None
if self.clock() < self.retry_at:
return {'active': False, 'state': 'error', 'reason': 'Hotspot unavailable; retrying shortly.'}
self.retry_at = self.clock() + 15
self.attempted = channel
self.backend.start(config, channel)
self.applied = desired
self.ready_deadline = self.clock() + 15
return {'active': False, 'state': 'starting', 'reason': 'Starting the local Wi-Fi network.', 'channel': channel[1]}
if not status['ready']:
if self.clock() >= self.ready_deadline:
raise RuntimeError('Hotspot did not become ready')
return {'active': False, 'state': 'starting', 'reason': 'Waiting for the Wi-Fi access point.', 'channel': channel[1]}
self.attempted = None
return {'active': True, 'state': 'active', 'reason': '', 'channel': channel[1]}
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError):
if self.attempted is not None and dfs(self.attempted):
self.refused.add(self.attempted[1])
self.attempted = None
self.backend.stop()
self.applied = None
self.retry_at = self.clock() + 15
return {'active': False, 'state': 'error', 'reason': 'Hotspot could not start on this channel. Projection Wi-Fi was not reconfigured.'}
def main():
fd = claim(RUNTIME, 'Hotspot worker')
backend = HotspotBackend()
controller = HotspotController(backend)
stop = stop_event()
config, buffer, last = None, b'', time.monotonic()
try:
backend.stop() # clean an interface explicitly marked as ours after a crash
while not stop.is_set() and time.monotonic() - last < 10:
if select.select([sys.stdin], [], [], 2)[0]:
data = os.read(sys.stdin.fileno(), 4097)
if not data:
break
buffer += data
if len(buffer) > 4096:
raise ValueError('Oversized settings input')
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
config = validate_config(json.loads(line))
last = time.monotonic()
if config is not None:
print(json.dumps({**controller.tick(config), 'revision': HotspotSettings.revision(config)}), flush=True)
finally:
backend.stop()
os.close(fd)
if __name__ == '__main__':
main()