mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
315 lines
13 KiB
Python
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()
|