#!/usr/bin/env python3 # SPDX-License-Identifier: MPL-2.0 """Small, allow-listed oFono broker used by the native Tundra session. The Gaia process never receives access to the system D-Bus modem service. The root service accepts one JSON request per Unix-socket connection and invokes only fixed oFono methods with validated values. This keeps the diagnostic image useful while avoiding a general purpose command bridge. """ import json import importlib.util import os from pathlib import Path import re import socket import subprocess import sys import time import threading from concurrent.futures import ThreadPoolExecutor SOCKET = Path('/run/tundra-radio/radio.sock') MODEM = '/ril_0' NUMBER = re.compile(r'^[0-9+#*(), .-]{3,32}$') VOICE = re.compile(r'^/ril_0/voicecall[0-9]+$') def bus(*args): """Call one fixed system-bus method and return busctl's JSON data.""" result = subprocess.run( ['/usr/bin/busctl', '--system', '--json=short', 'call', *args], capture_output=True, text=True, timeout=15, check=False, ) if result.returncode: # Keep modem errors distinct from transport failures, without returning # command lines, phone numbers or subscriber identifiers to Gaia. detail = result.stderr.lower() if 'not registered' in detail or 'notregistered' in detail: raise RuntimeError('RADIO_NOT_REGISTERED') if 'in progress' in detail or 'inprogress' in detail or 'busy' in detail: raise RuntimeError('RADIO_BUSY') if 'not available' in detail or 'notavailable' in detail: raise RuntimeError('RADIO_NOT_READY') raise RuntimeError('RADIO_REQUEST_FAILED') # Successful void D-Bus methods (Hangup, Answer) produce no JSON in busctl. if not result.stdout.strip(): return None try: payload = json.loads(result.stdout) return payload.get('data') except (ValueError, AttributeError): raise RuntimeError('INVALID_OFONO_REPLY') def request(value): if not isinstance(value, dict) or value.get('version') != 1: raise ValueError('INVALID_REQUEST') operation = value.get('operation') if operation == 'deviceSettings': spec = importlib.util.spec_from_file_location('mobile_settings', Path(__file__).with_name('mobile-settings.py')) module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) return module.device_request(value) if operation == 'mobileSettings': spec = importlib.util.spec_from_file_location('mobile_settings',Path(__file__).with_name('mobile-settings.py')) module = importlib.util.module_from_spec(spec);spec.loader.exec_module(module) return module.request(value) if operation in ('incomingMessages','ackMessages','messageState'): spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py')) inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox) if operation == 'messageState': path = value.get('path', '') if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-fA-F_]+',path): raise ValueError('INVALID_SMS_PATH') state = inbox.outgoing_state(path) if state is None: try: props = bus('org.ofono',path,'org.ofono.Message','GetProperties')[0] state = props.get('State',{}).get('data','unknown') except RuntimeError: state = 'unknown' return {'state':state if state in ('pending','sent','failed') else 'unknown'} return inbox.pending() if operation == 'incomingMessages' else inbox.acknowledge(value.get('ids')) if operation == 'calls': data = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'GetCalls') calls = [] for path, properties in data[0]: if not VOICE.fullmatch(path): continue def field(name): item = properties.get(name, {}) return item.get('data') if isinstance(item, dict) else item state = field('State') if state not in ('dialing', 'alerting', 'active', 'held', 'incoming', 'waiting', 'disconnected'): continue calls.append({'path': path, 'state': state, 'number': str(field('LineIdentification') or '')[:80]}) return calls if operation == 'status': return { 'modem': bus('org.ofono', MODEM, 'org.ofono.Modem', 'GetProperties'), 'registration': bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties'), } if operation == 'dial': number = str(value.get('number', '')).strip() if not NUMBER.fullmatch(number): raise ValueError('INVALID_NUMBER') path = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'Dial', 'ss', number, 'default') return {'path': path[0] if isinstance(path, list) and path else path, 'startedAt': time.time()} if operation in ('hangup', 'answer'): path = value.get('path', '') if not isinstance(path, str) or not VOICE.fullmatch(path): raise ValueError('INVALID_CALL') bus('org.ofono', path, 'org.ofono.VoiceCall', 'Hangup' if operation == 'hangup' else 'Answer') return {'path': path} if operation == 'sms': number = str(value.get('number', '')).strip() body = value.get('body', '') if not NUMBER.fullmatch(number) or not isinstance(body, str) or not body or len(body) > 1600: raise ValueError('INVALID_SMS') registration = bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties')[0] if registration.get('Status',{}).get('data') not in ('registered','roaming'): raise RuntimeError('RADIO_NOT_REGISTERED') result = bus('org.ofono', MODEM, 'org.ofono.MessageManager', 'SendMessage', 'ss', number, body) return {'path': result[0] if isinstance(result, list) and result else result, 'state':'pending', 'number': number, 'bodyLength': len(body), 'submittedAt': time.time()} raise ValueError('NOT_SUPPORTED') def send_response(conn, response): try: conn.sendall((json.dumps(response, separators=(',', ':')) + '\n').encode()) except OSError: # Closing an app must not bring down the broker or retry an operation. pass def error_response(error): # TimeoutExpired includes command arguments, potentially an SMS body. reason = 'RADIO_TIMEOUT' if isinstance(error, subprocess.TimeoutExpired) else ( 'RADIO_ERROR' if isinstance(error, OSError) else str(error) or 'RADIO_ERROR') return {'ok': False, 'error': reason} def execute_request(conn, value): try: response = {'ok': True, 'result': request(value)} except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error: response = error_response(error) send_response(conn, response) class SettingsWorker: """Serialize Settings transactions without blocking the voice/SMS broker.""" def __init__(self): self.pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='settings') self.slots = threading.BoundedSemaphore(8) def submit(self, conn, value): if not self.slots.acquire(blocking=False): return False try: self.pool.submit(self.run, conn, value, time.monotonic()) except RuntimeError: self.slots.release() return False return True def run(self, conn, value, received): try: with conn: # Do not apply a stale preference after its queued client has # timed out. An already-started APN rollback still runs to completion. if time.monotonic() - received > 15: send_response(conn, {'ok':False, 'error':'RADIO_BUSY'}) else: execute_request(conn, value) finally: self.slots.release() def close(self): self.pool.shutdown(wait=True) def handle_connection(conn, settings_worker=None): conn.settimeout(20) line = b'' try: while not line.endswith(b'\n') and len(line) <= 8192: chunk = conn.recv(min(1024, 8193 - len(line))) if not chunk: return False line += chunk if len(line) > 8192: raise ValueError('INVALID_REQUEST') value = json.loads(line.decode()) except (socket.timeout, ConnectionError): return False except (ValueError, OSError) as error: send_response(conn, error_response(error)) return False if settings_worker and isinstance(value, dict) and value.get('operation') in ('mobileSettings','deviceSettings'): if settings_worker.submit(conn, value): return True # The worker now owns and closes this connection. send_response(conn, {'ok':False, 'error':'RADIO_BUSY'}) else: execute_request(conn, value) return False def network_clock_loop(stop, worker): # Use the same executor as manual date/time writes: disabling automatic # time cannot race a delayed network update. Voice/SMS keep their own loop. spec = importlib.util.spec_from_file_location('clock_settings', Path(__file__).with_name('mobile-settings.py')) module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) def tick(): try: module.sync_network_clock() except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): pass # No SIM or no NITZ yet; retry after registration. while not stop.is_set(): worker.pool.submit(tick) stop.wait(60) def serve(): SOCKET.parent.mkdir(mode=0o755, parents=True, exist_ok=True) try: SOCKET.unlink() except FileNotFoundError: pass with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: server.bind(str(SOCKET)) os.chmod(SOCKET, 0o660) try: import grp os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid) except (KeyError, OSError): pass server.listen(8) worker = SettingsWorker() clock_stop = threading.Event() clock_thread = threading.Thread(target=network_clock_loop, args=(clock_stop, worker), daemon=True) clock_thread.start() try: while True: conn, _ = server.accept() if not handle_connection(conn, worker): conn.close() finally: clock_stop.set() clock_thread.join() worker.close() def client(envelope=False): raw = sys.stdin.read(8192) with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: value = json.loads(raw) # Seven bounded writes plus their rollback/readback can exceed 20 s. slow_settings = isinstance(value, dict) and value.get('operation') == 'mobileSettings' and value.get('action') in ('editApn','calls','setCallWaiting','setCallerId','forwarding','setForwarding') sock.settimeout(100 if slow_settings else 20) sock.connect(str(SOCKET)) sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n')) data = b'' while not data.endswith(b'\n') and len(data) < 65536: chunk = sock.recv(4096) if not chunk: break data += chunk result = json.loads(data.decode()) if envelope: print(json.dumps(result, separators=(',', ':'))) return if not result.get('ok'): raise RuntimeError(result.get('error', 'RADIO_ERROR')) print(json.dumps(result.get('result'), separators=(',', ':'))) if __name__ == '__main__': if '--server' in sys.argv: serve() elif '--client' in sys.argv or '--client-envelope' in sys.argv: client(envelope='--client-envelope' in sys.argv) else: raise SystemExit('use --server or --client')