Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
29
tundra/runtime/audio-devices.py
Normal file
29
tundra/runtime/audio-devices.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Give the audio group access to Sargo's calibration and amplifier devices."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import time
|
||||
|
||||
DEVICES = ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback')
|
||||
|
||||
|
||||
def prepare(root=Path('/dev')):
|
||||
for name in DEVICES:
|
||||
path = root / name
|
||||
if not stat.S_ISCHR(path.lstat().st_mode):
|
||||
raise RuntimeError('Not an audio character device: ' + name)
|
||||
os.chown(path, 0, 1005, follow_symlinks=False)
|
||||
path.chmod(0o660)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for attempt in range(60):
|
||||
try:
|
||||
prepare()
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt == 59:
|
||||
raise
|
||||
time.sleep(1)
|
||||
323
tundra/runtime/bluetooth-control.py
Normal file
323
tundra/runtime/bluetooth-control.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""BlueZ broker. The persistent bus owns discovery and the pairing agent."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
SOCKET = Path('/run/tundra-bluetooth/control.sock')
|
||||
LIMIT = 65536
|
||||
|
||||
|
||||
def validate(value):
|
||||
if not isinstance(value, dict) or value.get('version') != 1:
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
op = value.get('operation')
|
||||
if op not in ('status', 'power', 'discoverable', 'rename', 'scan', 'stopScan',
|
||||
'pair', 'unpair', 'connect', 'disconnect', 'reply', 'cancelPair'):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair') and not re.fullmatch(
|
||||
r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', str(value.get('address', ''))):
|
||||
raise ValueError('BLUETOOTH_INVALID_ADDRESS')
|
||||
if op in ('power', 'discoverable') and not isinstance(value.get('enabled'), bool):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op == 'rename' and (not isinstance(value.get('name'), str) or
|
||||
not 1 <= len(value['name'].encode()) <= 248 or
|
||||
any(ord(c) < 32 for c in value['name'])):
|
||||
raise ValueError('BLUETOOTH_INVALID_NAME')
|
||||
if op == 'reply' and (not isinstance(value.get('accept'), bool) or
|
||||
not isinstance(value.get('id'), str) or len(value['id']) > 40):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
return op
|
||||
|
||||
|
||||
def device_record(props):
|
||||
cod = int(props.get('Class', 0))
|
||||
return dict(address=str(props['Address']), name=str(props.get('Alias', props['Address'])),
|
||||
paired=bool(props.get('Paired')), connected=bool(props.get('Connected')),
|
||||
uuids=[str(u) for u in props.get('UUIDs', [])],
|
||||
cod=dict(majorDeviceClass=(cod >> 8) & 31, minorDeviceClass=(cod >> 2) & 63,
|
||||
majorServiceClass=(cod >> 13) & 2047))
|
||||
|
||||
|
||||
def serve():
|
||||
import dbus
|
||||
import dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SystemBus(private=True)
|
||||
pending = {}
|
||||
discovery = {'owned': False, 'expires': 0}
|
||||
|
||||
def interface(path, name):
|
||||
return dbus.Interface(bus.get_object('org.bluez', path), name)
|
||||
|
||||
def objects():
|
||||
return interface('/', 'org.freedesktop.DBus.ObjectManager').GetManagedObjects()
|
||||
|
||||
def adapter():
|
||||
for path, interfaces in objects().items():
|
||||
if 'org.bluez.Adapter1' in interfaces:
|
||||
return str(path), interfaces['org.bluez.Adapter1']
|
||||
raise ValueError('BLUETOOTH_UNAVAILABLE')
|
||||
|
||||
def snapshot():
|
||||
path, props = adapter()
|
||||
return dict(available=True, address=str(props['Address']), name=str(props.get('Alias', 'Vulpes OS')),
|
||||
enabled=bool(props['Powered']), discoverable=bool(props['Discoverable']),
|
||||
discovering=bool(props['Discovering']),
|
||||
devices=[device_record(i['org.bluez.Device1']) for p, i in objects().items()
|
||||
if 'org.bluez.Device1' in i and str(i['org.bluez.Device1']['Adapter']) == path],
|
||||
pairing=[p['info'] for p in pending.values()])
|
||||
|
||||
def remote(address):
|
||||
path, _ = adapter()
|
||||
for p, interfaces in objects().items():
|
||||
props = interfaces.get('org.bluez.Device1', {})
|
||||
if str(props.get('Address', '')).upper() == address.upper() and str(props.get('Adapter')) == path:
|
||||
return str(p), props
|
||||
raise ValueError('BLUETOOTH_DEVICE_GONE')
|
||||
|
||||
class Rejected(dbus.DBusException):
|
||||
_dbus_error_name = 'org.bluez.Error.Rejected'
|
||||
|
||||
def drop(identity, error=True):
|
||||
row = pending.pop(identity, None)
|
||||
if row and error and row['fail']:
|
||||
row['fail'](Rejected('Pairing rejected or expired'))
|
||||
return False
|
||||
|
||||
def prompt(path, method, ok=None, fail=None, passkey=''):
|
||||
# One original Gaia pairing panel at a time, never auto-accept a peer.
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
props = interface(path, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
identity = str(uuid.uuid4())
|
||||
pending[identity] = dict(ok=ok, fail=fail, path=str(path),
|
||||
info=dict(id=identity, method=method, address=str(props['Address']),
|
||||
deviceName=str(props.get('Alias', props['Address'])), passkey=str(passkey)))
|
||||
GLib.timeout_add_seconds(90, drop, identity)
|
||||
|
||||
def check_sender(sender):
|
||||
if sender != bus.get_name_owner('org.bluez'):
|
||||
raise Rejected('Only BlueZ may request pairing')
|
||||
|
||||
def clear_pending():
|
||||
for identity in list(pending):drop(identity)
|
||||
|
||||
class Agent(dbus.service.Object):
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestConfirmation(self, device, passkey, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'confirmation', ok, fail, f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='s', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPinCode(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpincode', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPasskey(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpasskey', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestAuthorization(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def AuthorizeService(self, device, service, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
props = interface(device, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
if props.get('Trusted') and props.get('Paired'):
|
||||
ok()
|
||||
else:
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ouq', out_signature='', sender_keyword='sender')
|
||||
def DisplayPasskey(self, device, passkey, entered, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender')
|
||||
def DisplayPinCode(self, device, pincode, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=pincode)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Cancel(self, sender=None):
|
||||
check_sender(sender)
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Release(self, sender=None):
|
||||
check_sender(sender)
|
||||
clear_pending()
|
||||
|
||||
agent = Agent(bus, '/org/vulpes/BluetoothAgent')
|
||||
manager = interface('/org/bluez', 'org.bluez.AgentManager1')
|
||||
manager.RegisterAgent('/org/vulpes/BluetoothAgent', 'KeyboardDisplay')
|
||||
manager.RequestDefaultAgent('/org/vulpes/BluetoothAgent')
|
||||
|
||||
def stop_scan():
|
||||
if discovery['owned']:
|
||||
try:
|
||||
path, _ = adapter()
|
||||
interface(path, 'org.bluez.Adapter1').StopDiscovery()
|
||||
finally:
|
||||
discovery['owned'] = False
|
||||
|
||||
def expire_scan():
|
||||
if discovery['owned'] and time.monotonic() >= discovery['expires']:
|
||||
try:
|
||||
stop_scan()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
GLib.timeout_add_seconds(5, expire_scan)
|
||||
|
||||
def dispatch(value, finish):
|
||||
try:
|
||||
op = validate(value)
|
||||
path, props = adapter()
|
||||
prop = interface(path, 'org.freedesktop.DBus.Properties')
|
||||
if op == 'power':
|
||||
if not value['enabled']:
|
||||
stop_scan()
|
||||
prop.Set('org.bluez.Adapter1', 'Powered', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
if not value['enabled']:
|
||||
clear_pending()
|
||||
elif op == 'discoverable':
|
||||
prop.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
prop.Set('org.bluez.Adapter1', 'Discoverable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
elif op == 'rename':
|
||||
prop.Set('org.bluez.Adapter1', 'Alias', dbus.String(value['name'], variant_level=1))
|
||||
elif op == 'scan':
|
||||
if not discovery['owned']:
|
||||
interface(path, 'org.bluez.Adapter1').StartDiscovery()
|
||||
discovery['owned'] = True
|
||||
discovery['expires'] = time.monotonic() + 60
|
||||
elif op == 'stopScan':
|
||||
stop_scan()
|
||||
elif op == 'reply':
|
||||
row = pending.get(value['id'])
|
||||
if row is None:
|
||||
raise ValueError('BLUETOOTH_PAIRING_EXPIRED')
|
||||
method = row['info']['method']
|
||||
code = value.get('code', '')
|
||||
if value['accept'] and method in ('enterpincode', 'enterpasskey'):
|
||||
valid = isinstance(code, str) and 1 <= len(code) <= 16 and code.isascii() and code.isprintable()
|
||||
if method == 'enterpasskey':
|
||||
valid = isinstance(code, str) and re.fullmatch(r'[0-9]{1,6}', code)
|
||||
if not valid:
|
||||
raise ValueError('BLUETOOTH_INVALID_PIN')
|
||||
pending.pop(value['id'])
|
||||
if not value['accept'] and row['fail']:
|
||||
row['fail'](Rejected('Rejected by user'))
|
||||
elif row['ok']:
|
||||
if method == 'enterpincode': row['ok'](dbus.String(code))
|
||||
elif method == 'enterpasskey': row['ok'](dbus.UInt32(int(code)))
|
||||
else: row['ok']()
|
||||
elif op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair'):
|
||||
device, dp = remote(value['address'])
|
||||
obj = interface(device, 'org.bluez.Device1')
|
||||
if op == 'unpair':
|
||||
interface(path, 'org.bluez.Adapter1').RemoveDevice(dbus.ObjectPath(device))
|
||||
elif op == 'cancelPair':
|
||||
obj.CancelPairing()
|
||||
elif op in ('connect', 'disconnect'):
|
||||
if not dp.get('Paired'):
|
||||
raise ValueError('BLUETOOTH_NOT_PAIRED')
|
||||
getattr(obj, 'Connect' if op == 'connect' else 'Disconnect')(
|
||||
reply_handler=lambda: finish(True), error_handler=lambda e: finish(error(e)), timeout=60)
|
||||
return False
|
||||
else:
|
||||
stop_scan()
|
||||
def paired():
|
||||
# Trust follows an explicit successful Pair initiated from Gaia.
|
||||
try:
|
||||
interface(device, 'org.freedesktop.DBus.Properties').Set(
|
||||
'org.bluez.Device1', 'Trusted', dbus.Boolean(True, variant_level=1))
|
||||
for identity, row in list(pending.items()):
|
||||
if row['path']==device:drop(identity,False)
|
||||
finish(True)
|
||||
except Exception as exc:finish(error(exc))
|
||||
obj.Pair(reply_handler=paired, error_handler=lambda e: finish(error(e)), timeout=95)
|
||||
return False
|
||||
finish(snapshot())
|
||||
except Exception as e:
|
||||
finish(error(e))
|
||||
return False
|
||||
|
||||
def error(exc):
|
||||
name = str(exc).split(':', 1)[0]
|
||||
if hasattr(exc, 'get_dbus_name'):
|
||||
name = exc.get_dbus_name().rsplit('.', 1)[-1]
|
||||
return {'error': name if re.fullmatch(r'[A-Za-z_]+', name) else 'BLUETOOTH_FAILED'}
|
||||
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
def connection(sock):
|
||||
try:
|
||||
with sock:
|
||||
sock.settimeout(110)
|
||||
data = b''
|
||||
while not data.endswith(b'\n') and len(data) <= 4096:
|
||||
chunk = sock.recv(4097-len(data))
|
||||
if not chunk: break
|
||||
data += chunk
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
value = json.loads(data)
|
||||
validate(value)
|
||||
done = threading.Event(); reply = []
|
||||
def finish(result):
|
||||
reply.append(result); done.set()
|
||||
GLib.idle_add(dispatch, value, finish)
|
||||
if not done.wait(100): raise ValueError('BLUETOOTH_TIMEOUT')
|
||||
result = reply[0]
|
||||
sock.sendall(json.dumps(result if isinstance(result, dict) and 'error' in result else {'result':result}).encode()+b'\n')
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
slots.release()
|
||||
|
||||
def listen():
|
||||
import pwd
|
||||
SOCKET.parent.mkdir(parents=True, exist_ok=True)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET)); os.chmod(SOCKET, 0o660)
|
||||
os.chown(SOCKET, 0, pwd.getpwnam('droidian').pw_gid)
|
||||
server.listen(8)
|
||||
while True:
|
||||
sock, _ = server.accept()
|
||||
if not slots.acquire(blocking=False): sock.close(); continue
|
||||
threading.Thread(target=connection, args=(sock,), daemon=True).start()
|
||||
threading.Thread(target=listen, daemon=True).start()
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def client():
|
||||
data = sys.stdin.read(4097)
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
validate(json.loads(data))
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(110); sock.connect(str(SOCKET)); sock.sendall(data.encode()+b'\n')
|
||||
result = b''
|
||||
while not result.endswith(b'\n') and len(result) <= LIMIT:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk: break
|
||||
result += chunk
|
||||
if len(result) > LIMIT: raise ValueError('BLUETOOTH_INVALID_REPLY')
|
||||
print(result.decode())
|
||||
|
||||
if __name__ == '__main__':
|
||||
client() if '--client' in sys.argv else serve()
|
||||
|
|
@ -6,6 +6,7 @@ The parent owns stdin. EOF or five seconds without a request closes the sensor.
|
|||
A pbuffer and external texture are required even for the software NV21 callback.
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import struct
|
||||
import ctypes as C
|
||||
import json
|
||||
|
|
@ -214,6 +215,12 @@ def main():
|
|||
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
|
||||
args = parser.parse_args()
|
||||
output = os.fdopen(os.dup(1), 'wb', buffering=0)
|
||||
try:
|
||||
# A preview packet is 450 KiB. A larger pipe avoids waking the parent
|
||||
# for each 64 KiB fragment; unsupported kernels retain their default.
|
||||
fcntl.fcntl(output.fileno(), fcntl.F_SETPIPE_SZ, 1024 * 1024)
|
||||
except OSError:
|
||||
pass
|
||||
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
|
||||
camera = None
|
||||
try:
|
||||
|
|
@ -236,8 +243,12 @@ def main():
|
|||
last_request = time.monotonic()
|
||||
if command == 'frame\n':
|
||||
data = camera.frame or b''
|
||||
output.write(struct.pack('<II', camera.count & 0xffffffff, len(data)))
|
||||
output.write(data)
|
||||
packet = memoryview(struct.pack('<II', camera.count & 0xffffffff, len(data)) + data)
|
||||
while packet:
|
||||
written = output.write(packet)
|
||||
if not written:
|
||||
raise BrokenPipeError('Camera preview pipe closed')
|
||||
packet = packet[written:]
|
||||
else:
|
||||
try:
|
||||
result = camera.control(json.loads(command))
|
||||
|
|
|
|||
3
tundra/runtime/clock-service.conf
Normal file
3
tundra/runtime/clock-service.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[Service]
|
||||
StateDirectory=tundra/clock
|
||||
ReadWritePaths=/var/lib/tundra/clock
|
||||
|
|
@ -20,8 +20,10 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
|
|||
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
|
||||
|
||||
|
||||
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'vendor.google.radioext@1.0',
|
||||
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
|
||||
# PulseAudio opens the primary audio HAL through libhybris. A second Android
|
||||
# audio service competes for its ACDB calibration buffers on Sargo.
|
||||
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'netmgrd', 'netd-hal-1-1-stub', 'ipastart_sh', 'vendor.google.radioext@1.0',
|
||||
'vendor.camera-provider-2-4',
|
||||
'camera_service', 'minimedia', 'minisf', 'miniaf'))
|
||||
|
||||
def is_init_script(path):
|
||||
|
|
|
|||
3
tundra/runtime/hotspot-firewall.conf
Normal file
3
tundra/runtime/hotspot-firewall.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Sargo's 4.9 kernel provides the legacy netfilter backend.
|
||||
[main]
|
||||
firewall-backend=iptables
|
||||
23
tundra/runtime/mms-codec.py
Normal file
23
tundra/runtime/mms-codec.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Build the separate GPL-3.0 Nuntium codec helper from locked Go modules."""
|
||||
import hashlib,json,os,shutil,subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def stage(root):
|
||||
project=Path(__file__).resolve().parents[2]
|
||||
source=project/'tundra/mms-codec'
|
||||
out=project/'tundra/out/mms-codec-build';out.mkdir(parents=True,exist_ok=True)
|
||||
binary=out/'tundra-mms-codec'
|
||||
env={**os.environ,'GOMODCACHE':str(project/'tundra/downloads/go-modules'),
|
||||
'GOCACHE':str(project/'tundra/out/go-cache'),'GOOS':'linux','GOARCH':'arm64','CGO_ENABLED':'0'}
|
||||
subprocess.run(['go','build','-mod=readonly','-trimpath','-ldflags=-s -w','-buildvcs=false','-o',str(binary),'.'],cwd=source,env=env,check=True)
|
||||
target=root/'usr/libexec/tundra-mms-codec';target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(binary,target)
|
||||
doc=root/'usr/share/doc/tundra-mms-codec';doc.mkdir(parents=True,exist_ok=True)
|
||||
for name in ['main.go','go.mod','go.sum','COPYING']:shutil.copy2(source/name,doc/name)
|
||||
(doc/'provenance.json').write_text(json.dumps({'license':'GPL-3.0-only',
|
||||
'source':'https://gitlab.com/ubports/development/core/nuntium',
|
||||
'revision':'d051757417a42872abb99c7583a8a4af6e3108f5',
|
||||
'sha256':hashlib.sha256(binary.read_bytes()).hexdigest()},indent=2)+'\n')
|
||||
return target
|
||||
326
tundra/runtime/mms-control.py
Normal file
326
tundra/runtime/mms-control.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Durable MMS queue, oFono bearer and WAP-push agent. No Wi-Fi fallback."""
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
|
||||
ROOT = Path('/var/lib/tundra/mms')
|
||||
SOCKET = Path('/run/tundra-mms/mms.sock')
|
||||
CODEC = '/usr/libexec/tundra-mms-codec'
|
||||
LIMIT = 600 * 1024
|
||||
WIRE_LIMIT = 2 * LIMIT
|
||||
LOCK = threading.RLock()
|
||||
MODEM = '/ril_0'
|
||||
|
||||
|
||||
def save(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
temp = path.with_suffix('.tmp')
|
||||
with open(temp, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
|
||||
json.dump(data, stream); stream.flush(); os.fsync(stream.fileno())
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
def records():
|
||||
path = ROOT/'queue.json'
|
||||
return json.loads(path.read_text()) if path.exists() else []
|
||||
|
||||
|
||||
def codec(operation, **data):
|
||||
result = subprocess.run([CODEC], input=json.dumps({'operation': operation, **data}),
|
||||
text=True, capture_output=True, timeout=10, env={**os.environ, 'GOMEMLIMIT': '64MiB'})
|
||||
if result.returncode: raise ValueError('MMS_CODEC_ERROR')
|
||||
value = json.loads(result.stdout)
|
||||
if 'error' in value: raise ValueError(value['error'])
|
||||
return value
|
||||
|
||||
|
||||
def validate_send(value):
|
||||
recipients = value.get('recipients')
|
||||
if not isinstance(recipients,list) or not 1 <= len(recipients) <= 10 or any(
|
||||
not isinstance(n,str) or not re.fullmatch(r'\+?[0-9]{3,20}',n) for n in recipients):
|
||||
raise ValueError('INVALID_NUMBER')
|
||||
parts = value.get('parts')
|
||||
if not isinstance(parts,list) or not 1 <= len(parts) <= 20: raise ValueError('INVALID_MMS')
|
||||
total = 0
|
||||
for part in parts:
|
||||
if not isinstance(part,dict): raise ValueError('INVALID_MMS')
|
||||
if not isinstance(part.get('id'),str) or not re.fullmatch(r'[\w .@+-]{1,100}',part['id']): raise ValueError('INVALID_MMS')
|
||||
if not isinstance(part.get('type'),str) or not re.fullmatch(r'[a-zA-Z0-9.+-]+/[a-zA-Z0-9.+-]+',part['type']): raise ValueError('INVALID_MMS')
|
||||
try: total += len(base64.b64decode(part['data'],validate=True))
|
||||
except (ValueError,TypeError,KeyError): raise ValueError('INVALID_MMS')
|
||||
if total > LIMIT: raise ValueError('MMS_TOO_LARGE')
|
||||
subject=value.get('subject','')
|
||||
if not isinstance(subject,str) or len(subject)>200: raise ValueError('INVALID_MMS')
|
||||
return {'recipients':recipients,'parts':parts,'subject':subject}
|
||||
|
||||
|
||||
def request(value):
|
||||
if not isinstance(value,dict) or value.get('version')!=1: raise ValueError('INVALID_REQUEST')
|
||||
op=value.get('operation')
|
||||
with LOCK:
|
||||
rows=records()
|
||||
if op=='send':
|
||||
message=validate_send(value)
|
||||
# Encode before accepting the queue entry so malformed content isn't queued.
|
||||
encoded=codec('encode',**message)
|
||||
identity=str(uuid.uuid4())
|
||||
rows.append({'id':identity,'direction':'outgoing','state':'pending','encoded':encoded,'created':time.time()})
|
||||
save(ROOT/'queue.json',rows)
|
||||
return {'path':identity,'state':'pending'}
|
||||
if op=='state':
|
||||
row=next((r for r in rows if r['id']==value.get('path')),None)
|
||||
return {k:row[k] for k in ['state','error'] if k in row} if row else {'state':'unknown'}
|
||||
if op=='incoming':
|
||||
return [r['message'] for r in rows if r['direction']=='incoming' and r['state']=='received'][:1]
|
||||
if op=='ack':
|
||||
ids=value.get('ids')
|
||||
if not isinstance(ids,list) or len(ids)>100: raise ValueError('INVALID_REQUEST')
|
||||
for row in rows:
|
||||
if row['direction']=='incoming' and row['id'] in ids and row['state']=='received':
|
||||
row['state']='stored';row.pop('message',None)
|
||||
save(ROOT/'queue.json',rows);return True
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def bearer():
|
||||
import dbus
|
||||
bus=dbus.SystemBus(private=True)
|
||||
# This short-lived connection is closed after each transfer. With GLib,
|
||||
# the bus default would terminate the whole daemon on that disconnect.
|
||||
bus.set_exit_on_disconnect(False)
|
||||
try:
|
||||
conn=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.ConnectionManager')
|
||||
contexts=[(str(p),v) for p,v in conn.GetContexts() if str(v.get('Type'))=='mms']
|
||||
if not contexts: raise ValueError('MMS_APN_MISSING')
|
||||
path,props=contexts[0]
|
||||
# Only provision an incomplete default context, never replace user APNs.
|
||||
if not props.get('MessageCenter'):
|
||||
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
|
||||
imsi=str(sim.get('SubscriberIdentity',''))
|
||||
if imsi.startswith('20801') and str(props.get('AccessPointName')) in ('','mms'):
|
||||
cfg={'AccessPointName':'orange.acte','Username':'orange','Password':'orange',
|
||||
'AuthenticationMethod':'pap','Protocol':'ip','MessageCenter':'http://mms.orange.fr',
|
||||
'MessageProxy':'192.168.10.200:8080'}
|
||||
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
|
||||
for key,val in cfg.items():ctx.SetProperty(key,dbus.String(val,variant_level=1))
|
||||
props=ctx.GetProperties()
|
||||
else: raise ValueError('MMS_APN_MISSING')
|
||||
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
|
||||
activated=False;manager=None;previous=None;cleanup=[]
|
||||
try:
|
||||
try:
|
||||
manager=dbus.Interface(bus.get_object('org.ofono','/'),'org.nemomobile.ofono.ModemManager')
|
||||
previous=manager.GetMmsSim()
|
||||
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
|
||||
manager.SetMmsSim(sim['SubscriberIdentity'])
|
||||
except dbus.DBusException:manager=None
|
||||
if not props.get('Active'):
|
||||
try:ctx.SetProperty('Active',dbus.Boolean(True,variant_level=1),timeout=45)
|
||||
except dbus.DBusException:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
activated=True
|
||||
props=ctx.GetProperties();settings=props.get('Settings',{})
|
||||
interface=str(settings.get('Interface',''));address=str(settings.get('Address',''))
|
||||
if not re.fullmatch(r'[\w.-]{1,15}',interface) or not address:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
ip=ipaddress.ip_address(address)
|
||||
if ip.version!=4:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
proxy=str(props.get('MessageProxy',''));url=str(props.get('MessageCenter',''))
|
||||
parsed=urlsplit(url)
|
||||
if parsed.scheme!='http' or not parsed.hostname or parsed.username:raise ValueError('MMS_APN_MISSING')
|
||||
proxyurl=urlsplit(proxy if '://' in proxy else '//'+proxy)
|
||||
host=proxyurl.hostname or parsed.hostname;port=proxyurl.port or (8080 if proxy else parsed.port or 80)
|
||||
# Operator MMS profiles normally expose an IPv4 proxy. Avoid DNS via Wi-Fi.
|
||||
try: destination=str(ipaddress.IPv4Address(host))
|
||||
except ValueError:raise ValueError('MMS_PROXY_REQUIRED')
|
||||
subprocess.run(['ip','link','set',interface,'up'],check=True,stdout=subprocess.DEVNULL)
|
||||
prefix=ipaddress.IPv4Network('0.0.0.0/'+str(settings.get('Netmask','255.255.255.255'))).prefixlen
|
||||
current=json.loads(subprocess.check_output(['ip','-j','addr','show','dev',interface]))
|
||||
if not any(a.get('local')==address for link in current for a in link.get('addr_info',[])):
|
||||
address_args=[address+'/'+str(prefix),'dev',interface]
|
||||
subprocess.run(['ip','addr','add',*address_args],check=True,stdout=subprocess.DEVNULL)
|
||||
cleanup.append(['ip','addr','del',*address_args])
|
||||
routes=json.loads(subprocess.check_output(['ip','-j','route','show',destination+'/32']))
|
||||
if routes and not any(r.get('dev')==interface for r in routes):raise ValueError('MMS_ROUTE_CONFLICT')
|
||||
if not routes:
|
||||
route_args=[destination+'/32','dev',interface,'src',address]
|
||||
subprocess.run(['ip','route','add',*route_args],check=True,stdout=subprocess.DEVNULL)
|
||||
cleanup.append(['ip','route','del',*route_args])
|
||||
yield {'interface':interface,'address':address,'host':destination,'port':port,'url':url,'proxy':bool(proxy)}
|
||||
finally:
|
||||
for command in reversed(cleanup):
|
||||
subprocess.run(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
if activated:
|
||||
try:ctx.SetProperty('Active',dbus.Boolean(False,variant_level=1),timeout=20)
|
||||
except dbus.DBusException:pass
|
||||
if manager is not None and previous is not None:
|
||||
try:manager.SetMmsSim(previous)
|
||||
except dbus.DBusException:pass
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
|
||||
def transfer(connection, url, body=None):
|
||||
parsed=urlsplit(url)
|
||||
if parsed.scheme!='http' or not parsed.hostname or parsed.username or parsed.fragment:raise ValueError('MMS_INVALID_URL')
|
||||
if not connection['proxy'] and parsed.hostname!=urlsplit(connection['url']).hostname:raise ValueError('MMS_INVALID_URL')
|
||||
client=http.client.HTTPConnection(connection['host'],connection['port'],timeout=35)
|
||||
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM);client.sock=sock
|
||||
target=url if connection['proxy'] else (parsed.path or '/')+('?' + parsed.query if parsed.query else '')
|
||||
try:
|
||||
sock.settimeout(35)
|
||||
sock.setsockopt(socket.SOL_SOCKET,socket.SO_BINDTODEVICE,connection['interface'].encode()+b'\0')
|
||||
sock.bind((connection['address'],0));sock.connect((connection['host'],connection['port']))
|
||||
client.request('POST' if body is not None else 'GET',target,body,
|
||||
{'Content-Type':'application/vnd.wap.mms-message','Accept':'application/vnd.wap.mms-message',
|
||||
'User-Agent':'VulpesOS/2.7 MMS/1.1','Host':parsed.netloc})
|
||||
response=client.getresponse();data=response.read(LIMIT+1)
|
||||
if response.status!=200:raise ValueError('MMS_HTTP_ERROR')
|
||||
if len(data)>LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
return data
|
||||
finally:client.close()
|
||||
|
||||
|
||||
def process(row):
|
||||
with bearer() as connection:
|
||||
if row['direction']=='outgoing':
|
||||
encoded=row['encoded'];data=transfer(connection,connection['url'],base64.b64decode(encoded['data']))
|
||||
response=codec('response',data=base64.b64encode(data).decode(),transaction=encoded['transaction'])
|
||||
if not response['accepted']:raise ValueError('MMS_REJECTED')
|
||||
return {'state':'sent','encoded':None,'acceptedByMMSC':True}
|
||||
notification=row['notification']
|
||||
if row.get('ackPending'):
|
||||
ack=codec('ack',transaction=notification['transaction'])
|
||||
transfer(connection,connection['url'],base64.b64decode(ack['data']))
|
||||
return {'ackPending':False}
|
||||
if notification['expires'] and notification['expires']<time.time():raise ValueError('MMS_EXPIRED')
|
||||
data=transfer(connection,notification['url']);message=codec('retrieve',data=base64.b64encode(data).decode())
|
||||
message.update({'receipt':row['id'],'type':'mms'})
|
||||
# Persist before acknowledging download to the operator.
|
||||
with LOCK:
|
||||
rows=records();target=next(r for r in rows if r['id']==row['id']);target.update({'message':message,'state':'received','ackPending':True});save(ROOT/'queue.json',rows)
|
||||
ack=codec('ack',transaction=notification['transaction'])
|
||||
transfer(connection,connection['url'],base64.b64decode(ack['data']))
|
||||
return {'ackPending':False}
|
||||
|
||||
|
||||
def process_once():
|
||||
with LOCK:
|
||||
pending=next((r.copy() for r in records() if
|
||||
(r['state']=='pending' or r.get('ackPending')) and r.get('retryAt',0)<=time.time()),None)
|
||||
if not pending:return False
|
||||
if pending['direction']=='outgoing':
|
||||
rows=records();next(r for r in rows if r['id']==pending['id'])['state']='submitting';save(ROOT/'queue.json',rows)
|
||||
try:result=process(pending)
|
||||
except Exception as e:
|
||||
code=str(e) if re.fullmatch(r'MMS_[A-Z_]+',str(e)) else 'MMS_TRANSPORT_ERROR'
|
||||
result={'error':code}
|
||||
if pending['direction']=='outgoing':result['state']='failed'
|
||||
else:
|
||||
attempts=pending.get('attempts',0)+1
|
||||
result.update(attempts=attempts,retryAt=time.time()+min(3600,30*2**min(attempts,7)))
|
||||
if code=='MMS_EXPIRED':result.update(state='failed',ackPending=False)
|
||||
with LOCK:
|
||||
rows=records();row=next((r for r in rows if r['id']==pending['id']),None)
|
||||
if row:
|
||||
row.update(result)
|
||||
if 'error' not in result:row.pop('error',None);row.pop('retryAt',None)
|
||||
save(ROOT/'queue.json',rows)
|
||||
return True
|
||||
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
if not process_once():time.sleep(1)
|
||||
|
||||
|
||||
def serve():
|
||||
SOCKET.parent.mkdir(parents=True,exist_ok=True);SOCKET.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET));os.chmod(SOCKET,0o660)
|
||||
import pwd
|
||||
os.chown(SOCKET,0,pwd.getpwnam('droidian').pw_gid)
|
||||
server.listen(4)
|
||||
while True:
|
||||
conn,_=server.accept()
|
||||
with conn:
|
||||
conn.settimeout(15);data=b''
|
||||
try:
|
||||
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
|
||||
chunk=conn.recv(min(65536,WIRE_LIMIT+1-len(data)))
|
||||
if not chunk:break
|
||||
data+=chunk
|
||||
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
result={'ok':True,'result':request(json.loads(data))}
|
||||
except Exception as e:
|
||||
code=str(e) if re.fullmatch(r'[A-Z_]+',str(e)) else 'INVALID_REQUEST'
|
||||
result={'ok':False,'error':code}
|
||||
try:conn.sendall(json.dumps(result).encode()+b'\n')
|
||||
except OSError:pass
|
||||
|
||||
|
||||
def agent():
|
||||
import dbus,dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True);bus=dbus.SystemBus();path='/org/vulpes/MmsPush'
|
||||
class Push(dbus.service.Object):
|
||||
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='aya{sv}',out_signature='',sender_keyword='sender')
|
||||
def ReceiveNotification(self,data,info,sender=None):
|
||||
if sender!=bus.get_name_owner('org.ofono'):raise dbus.DBusException('Denied')
|
||||
try:notification=codec('notification',data=base64.b64encode(bytes(data)).decode())
|
||||
except Exception:return
|
||||
identity=hashlib.sha256((notification['transaction']+'\n'+notification['url']).encode()).hexdigest()
|
||||
with LOCK:
|
||||
rows=records()
|
||||
if not any(r['id']==identity for r in rows):
|
||||
rows.append({'id':identity,'direction':'incoming','state':'pending','notification':notification});save(ROOT/'queue.json',rows)
|
||||
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='',out_signature='',sender_keyword='sender')
|
||||
def Release(self,sender=None):
|
||||
if sender==bus.get_name_owner('org.ofono'):GLib.timeout_add_seconds(3,register)
|
||||
push=Push(bus,path)
|
||||
def register():
|
||||
try:dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.PushNotification').RegisterAgent(dbus.ObjectPath(path));return False
|
||||
except dbus.DBusException:return True
|
||||
if register():GLib.timeout_add_seconds(10,register)
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def client():
|
||||
raw=sys.stdin.read(WIRE_LIMIT+1)
|
||||
if len(raw)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(20);sock.connect(str(SOCKET));sock.sendall(raw.encode()+b'\n');data=b''
|
||||
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
|
||||
chunk=sock.recv(65536)
|
||||
if not chunk:break
|
||||
data+=chunk
|
||||
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
print(data.decode())
|
||||
|
||||
if __name__=='__main__':
|
||||
if '--client' in sys.argv:client()
|
||||
else:
|
||||
os.umask(0o077);ROOT.mkdir(parents=True,exist_ok=True)
|
||||
rows=records()
|
||||
for row in rows:
|
||||
if row['state']=='submitting':row.update({'state':'failed','error':'MMS_DELIVERY_UNKNOWN'})
|
||||
save(ROOT/'queue.json',rows)
|
||||
threading.Thread(target=worker,daemon=True).start()
|
||||
threading.Thread(target=serve,daemon=True).start()
|
||||
agent()
|
||||
316
tundra/runtime/mobile-settings.py
Normal file
316
tundra/runtime/mobile-settings.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Settings-only oFono queries. Never return IMSI, PIN retries or device serials."""
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
|
||||
|
||||
def clock_automatic():
|
||||
try:
|
||||
return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
def save_clock_automatic(enabled):
|
||||
CLOCK_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = CLOCK_CONFIG.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps({"automatic": enabled}) + "\n")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(CLOCK_CONFIG)
|
||||
|
||||
def network_time():
|
||||
result = subprocess.run(["/usr/bin/busctl", "--system", "--timeout=5", "--json=short", "call",
|
||||
"org.ofono", "/ril_0", "org.ofono.NetworkTime", "GetNetworkTime"], capture_output=True, text=True, timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
values = {key: value.get("data") for key, value in json.loads(result.stdout)["data"][0].items()}
|
||||
utc, received = values.get("UTC"), values.get("Received")
|
||||
now = time.monotonic()
|
||||
if type(utc) is not int or type(received) is not int or not 0 <= received <= now:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
seconds = utc + now - received
|
||||
if not 946684800 <= seconds <= 4102444800:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
return seconds
|
||||
|
||||
def sync_network_clock():
|
||||
if not clock_automatic():
|
||||
return
|
||||
seconds = network_time()
|
||||
if abs(time.time() - seconds) > 2:
|
||||
# oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
|
||||
time.clock_settime(time.CLOCK_REALTIME, seconds)
|
||||
|
||||
|
||||
|
||||
def properties(interface, timeout=5):
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), '--json=short',
|
||||
'call', 'org.ofono', '/ril_0', 'org.ofono.' + interface,
|
||||
'GetProperties'], capture_output=True, text=True, timeout=timeout+2)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_NOT_READY')
|
||||
return {key: value.get('data') for key, value in json.loads(result.stdout)['data'][0].items()}
|
||||
|
||||
|
||||
def request(value):
|
||||
if value.get('action') in ('calls','setCallWaiting','setCallerId','forwarding','setForwarding'):
|
||||
return supplementary_request(value)
|
||||
if value.get('action') in ('radioMode', 'setRadioMode', 'apns', 'editApn'):
|
||||
return connection_request(value)
|
||||
if set(value) - {'version', 'operation', 'action', 'enabled'}:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
action = value.get('action')
|
||||
if action not in ('deliveryReports','dataEnabled','roaming') and 'enabled' in value:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('network', 'dataEnabled', 'roaming'):
|
||||
if action != 'network':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
name = 'Powered' if action == 'dataEnabled' else 'RoamingAllowed'
|
||||
set_property('ConnectionManager', name, 'b', value['enabled'])
|
||||
data = properties('ConnectionManager')
|
||||
if action != 'network' and data.get(name) is not value['enabled']:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
network = properties('NetworkRegistration')
|
||||
return {'enabled':data.get('Powered'), 'roaming':data.get('RoamingAllowed'),
|
||||
'attached':data.get('Attached'), 'status':network.get('Status'),
|
||||
'operator':network.get('Name'), 'technology':network.get('Technology'), 'mode':network.get('Mode')}
|
||||
if action == 'sim':
|
||||
sim = properties('SimManager')
|
||||
modem = properties('Modem')
|
||||
# The slot key stays private to Gaia Settings; subscriber identity is not needed.
|
||||
return {'radioEnabled':modem.get('Powered') is True and modem.get('Online') is True,
|
||||
'present': sim.get('Present') is True, 'id': sim.get('CardIdentifier'),
|
||||
'operator': sim.get('ServiceProviderName', ''), 'pin': sim.get('PinRequired'),
|
||||
'fixedDialing': sim.get('FixedDialing'), 'pinEnabled':'pin' in sim.get('LockedPins', []), 'numbers': sim.get('SubscriberNumbers', [])}
|
||||
if action in ('smsc', 'messages'):
|
||||
props = properties('MessageManager')
|
||||
return {'address': props.get('ServiceCenterAddress'), 'deliveryReports': props.get('UseDeliveryReports')}
|
||||
if action == 'deliveryReports':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout=5', 'call',
|
||||
'org.ofono', '/ril_0', 'org.ofono.MessageManager', 'SetProperty',
|
||||
'sv', 'UseDeliveryReports', 'b', str(value['enabled']).lower()],
|
||||
capture_output=True, text=True, timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
props = properties('MessageManager')
|
||||
if props.get('UseDeliveryReports') is not value['enabled']:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
return {'deliveryReports': props['UseDeliveryReports']}
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def set_property(interface, name, signature, value, timeout=5):
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), 'call',
|
||||
'org.ofono', '/ril_0', 'org.ofono.' + interface,
|
||||
'SetProperty', 'sv', name, signature, str(value).lower() if signature == 'b' else value],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
|
||||
|
||||
def device_request(value):
|
||||
"""Clock service only; callers cannot supply commands, paths or D-Bus members."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
allowed = {'version', 'operation', 'action', 'timezone', 'milliseconds', 'enabled'}
|
||||
if set(value) - allowed:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
action = value.get('action')
|
||||
extras = {'setTimezone': {'timezone'}, 'setTime': {'milliseconds'}, 'clock': set(), 'setAutomatic': {'enabled'}}
|
||||
if action in extras and set(value) - {'version', 'operation', 'action'} - extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action == 'setTimezone':
|
||||
zone = value.get('timezone')
|
||||
if not isinstance(zone, str) or zone.startswith('/') or '..' in zone.split('/') or len(zone) > 80:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
root = Path('/usr/share/zoneinfo').resolve()
|
||||
target = (root / zone).resolve()
|
||||
if not target.is_relative_to(root) or not target.is_file() or target.read_bytes()[:4] != b'TZif':
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
args = ['set-timezone', zone]
|
||||
elif action == 'setAutomatic':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
save_clock_automatic(value['enabled'])
|
||||
if value['enabled']:
|
||||
try:
|
||||
sync_network_clock()
|
||||
except RuntimeError:
|
||||
pass # Keep the requested mode while waiting for the network.
|
||||
args = None
|
||||
elif action == 'setTime':
|
||||
ms = value.get('milliseconds')
|
||||
if type(ms) not in (int, float) or not math.isfinite(ms) or not 946684800000 <= ms <= 4102444800000:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if clock_automatic():
|
||||
raise RuntimeError('AUTOMATIC_TIME_ENABLED')
|
||||
args = ['set-time', '@' + str(int(ms / 1000))]
|
||||
elif action == 'clock':
|
||||
args = None
|
||||
else:
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
if args:
|
||||
result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise RuntimeError('CLOCK_REQUEST_FAILED')
|
||||
result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
raise RuntimeError('CLOCK_UNAVAILABLE')
|
||||
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if action == 'setTimezone' and state.get('Timezone') != value['timezone']:
|
||||
raise RuntimeError('CLOCK_REQUEST_FAILED')
|
||||
return {'timezone':state.get('Timezone'), 'automatic':clock_automatic(), 'automaticAvailable':True}
|
||||
|
||||
|
||||
# Only existing user-facing Internet/MMS contexts are editable. IMS is owned by
|
||||
# the telephony stack and must never be rewritten by the Settings form.
|
||||
APN_FIELDS = {'apn':'AccessPointName', 'user':'Username', 'password':'Password',
|
||||
'protocol':'Protocol', 'authtype':'AuthenticationMethod',
|
||||
'mmsc':'MessageCenter', 'mmsproxy':'MessageProxy'}
|
||||
|
||||
|
||||
def contexts():
|
||||
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=5','--json=short',
|
||||
'call','org.ofono','/ril_0','org.ofono.ConnectionManager','GetContexts'],
|
||||
capture_output=True,text=True,timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_NOT_READY')
|
||||
return {path: {key:item['data'] for key,item in props.items()}
|
||||
for path,props in json.loads(result.stdout)['data'][0]
|
||||
if re.fullmatch(r'/ril_0/context[0-9]+',path)
|
||||
and props.get('Type',{}).get('data') in ('internet','mms')}
|
||||
|
||||
|
||||
def apn_view(path, props):
|
||||
fields={key:str(props.get(name,'')) for key,name in APN_FIELDS.items() if key!='password'}
|
||||
return {'id':path,'type':'default' if props['Type']=='internet' else 'mms',
|
||||
'active':props.get('Active') is True, 'hasPassword':bool(props.get('Password')),
|
||||
'fields':fields}
|
||||
|
||||
|
||||
def set_context(path, name, value):
|
||||
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=3','call','org.ofono',path,
|
||||
'org.ofono.ConnectionContext','SetProperty','sv',name,'s',value],
|
||||
capture_output=True,text=True,timeout=4)
|
||||
if result.returncode:
|
||||
raise RuntimeError('APN_WRITE_FAILED')
|
||||
|
||||
|
||||
def connection_request(value):
|
||||
action=value.get('action')
|
||||
extras={'radioMode':set(),'setRadioMode':{'mode'},'apns':set(),'editApn':{'id','fields'}}
|
||||
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('radioMode','setRadioMode'):
|
||||
before=properties('RadioSettings')
|
||||
available=[mode for mode in before.get('AvailableTechnologies',[]) if mode in ('gsm','umts','lte')]
|
||||
if action=='setRadioMode':
|
||||
mode=value.get('mode')
|
||||
if mode not in ['any',*available]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if mode != before.get('TechnologyPreference'):
|
||||
set_property('RadioSettings','TechnologyPreference','s',mode)
|
||||
before=properties('RadioSettings')
|
||||
if before.get('TechnologyPreference')!=mode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
return {'mode':before.get('TechnologyPreference'),'available':['any',*available]}
|
||||
if action=='apns':
|
||||
return [apn_view(path,props) for path,props in contexts().items()]
|
||||
path=value.get('id'); fields=value.get('fields')
|
||||
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/context[0-9]+',path):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if not isinstance(fields,dict) or not fields or set(fields)-set(APN_FIELDS):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
for key,field in fields.items():
|
||||
if not isinstance(field,str) or len(field.encode())>512 or any(ord(c)<32 for c in field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='apn' and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,99}',field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='protocol' and field not in ('ip','ipv6','dual'):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='authtype' and field not in ('none','pap','chap','any'):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='mmsc' and field:
|
||||
from urllib.parse import urlsplit
|
||||
url=urlsplit(field)
|
||||
if url.scheme not in ('http','https') or not url.hostname or url.username or url.password:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='mmsproxy' and field and not re.fullmatch(r'[A-Za-z0-9.\[\]:-]{1,255}',field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
before=contexts().get(path)
|
||||
if before is None:
|
||||
raise ValueError('APN_NOT_FOUND')
|
||||
if before['Type']!='mms' and any(k in fields for k in ('mmsc','mmsproxy')):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
changes={APN_FIELDS[key]:field for key,field in fields.items() if before.get(APN_FIELDS[key],'')!=field}
|
||||
if not changes:
|
||||
return apn_view(path,before)
|
||||
if before.get('Active'):
|
||||
raise RuntimeError('APN_IN_USE')
|
||||
changed=[]
|
||||
try:
|
||||
for name,field in changes.items():
|
||||
# Include the attempted property: a D-Bus timeout may arrive after
|
||||
# the modem has already applied it.
|
||||
changed.append(name)
|
||||
set_context(path,name,field)
|
||||
after=contexts().get(path)
|
||||
if after is None or any(after.get(name,'')!=field for name,field in changes.items()):
|
||||
raise RuntimeError('APN_WRITE_FAILED')
|
||||
return apn_view(path,after)
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):
|
||||
failed=False
|
||||
for name in reversed(changed):
|
||||
try:set_context(path,name,str(before.get(name,'')))
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):failed=True
|
||||
try:restored=contexts().get(path)
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):restored=None
|
||||
if failed or restored is None or any(restored.get(name,'')!=before.get(name,'') for name in changed):
|
||||
raise RuntimeError('APN_ROLLBACK_FAILED') from None
|
||||
raise RuntimeError('APN_WRITE_FAILED') from None
|
||||
|
||||
|
||||
FORWARDING = {'unconditional':'VoiceUnconditional', 'busy':'VoiceBusy',
|
||||
'noReply':'VoiceNoReply', 'notReachable':'VoiceNotReachable'}
|
||||
|
||||
|
||||
def supplementary_request(value):
|
||||
action=value.get('action')
|
||||
extras={'calls':set(), 'setCallWaiting':{'enabled'}, 'setCallerId':{'mode'},
|
||||
'forwarding':set(), 'setForwarding':{'reason','enabled','number'}}
|
||||
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('calls','setCallWaiting','setCallerId'):
|
||||
name=None
|
||||
if action=='setCallWaiting':
|
||||
if type(value.get('enabled')) is not bool:raise ValueError('INVALID_REQUEST')
|
||||
name='VoiceCallWaiting'; desired='enabled' if value['enabled'] else 'disabled'
|
||||
elif action=='setCallerId':
|
||||
desired=value.get('mode')
|
||||
if desired not in ('default','enabled','disabled'):raise ValueError('INVALID_REQUEST')
|
||||
name='HideCallerId'
|
||||
if name:set_property('CallSettings',name,'s',desired,timeout=20)
|
||||
# Network supplementary-service queries take several seconds on this RIL.
|
||||
props=properties('CallSettings',timeout=20)
|
||||
if name and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
|
||||
return {key:props.get(key) for key in ('HideCallerId','VoiceCallWaiting')}
|
||||
if action=='setForwarding':
|
||||
name=FORWARDING.get(value.get('reason'))
|
||||
number=value.get('number','');enabled=value.get('enabled')
|
||||
if name is None or type(enabled) is not bool or not isinstance(number,str):raise ValueError('INVALID_REQUEST')
|
||||
if enabled and not re.fullmatch(r'\+?[0-9]{1,20}',number):raise ValueError('INVALID_REQUEST')
|
||||
if not enabled and number:raise ValueError('INVALID_REQUEST')
|
||||
desired=number if enabled else ''
|
||||
before=properties('CallForwarding',timeout=20)
|
||||
if name not in before:raise RuntimeError('RADIO_NOT_READY')
|
||||
if before[name]!=desired:set_property('CallForwarding',name,'s',desired,timeout=20)
|
||||
props=properties('CallForwarding',timeout=20)
|
||||
if action=='setForwarding' and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
|
||||
return {key:props.get(member) for key,member in FORWARDING.items()}
|
||||
|
|
@ -2,10 +2,34 @@
|
|||
"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
|
||||
|
||||
def prepare(root, write):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
shutil.copy2(Path(__file__).with_name('bluetooth-control.py'),root/'etc/tundra/bluetooth-control.py')
|
||||
write('etc/systemd/system/tundra-bluetooth.service', """[Unit]
|
||||
Description=Tundra Bluetooth pairing and discovery broker
|
||||
Requires=bluetooth.service
|
||||
After=bluetooth.service
|
||||
PartOf=bluetooth.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/bluetooth-control.py
|
||||
RuntimeDirectory=tundra-bluetooth
|
||||
RuntimeDirectoryMode=0755
|
||||
UMask=0077
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
""")
|
||||
|
||||
write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig><policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy></busconfig>
|
||||
<busconfig>
|
||||
<policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy>
|
||||
<policy user="droidian"><allow send_destination="org.bluez"/></policy>
|
||||
</busconfig>
|
||||
''')
|
||||
write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
|
||||
write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
|
||||
|
|
@ -31,4 +55,5 @@ After=tundra-bluebinder.service dbus.service
|
|||
''')
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
wants.mkdir(parents=True, exist_ok=True)
|
||||
(wants/'tundra-bluetooth.service').symlink_to('/etc/systemd/system/tundra-bluetooth.service')
|
||||
(wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ def prepare(root, write):
|
|||
write('etc/tundra/hardware-enabled', 'sargo\n')
|
||||
runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
|
||||
runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
|
||||
shutil.copy2(Path(__file__).with_name('audio-devices.py'), root/'etc/tundra/audio-devices.py')
|
||||
write('etc/udev/rules.d/99-tundra-audio.rules', '\n'.join(
|
||||
'KERNEL=="'+name+'", GROUP="1005", MODE="0660"'
|
||||
for name in ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback'))+'\n')
|
||||
shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
|
||||
root/'etc/tundra/modem-recovery.py')
|
||||
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]
|
||||
|
|
@ -60,14 +64,39 @@ StateDirectory=tundra/sms
|
|||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
|
||||
''')
|
||||
write('etc/pulse/client.conf', 'autospawn = no\n')
|
||||
write('etc/systemd/system/tundra-radio.service.d/clock.conf', Path(__file__).with_name('clock-service.conf').read_text())
|
||||
runpy.run_path(str(Path(__file__).with_name('mms-codec.py')))['stage'](root)
|
||||
shutil.copy2(Path(__file__).with_name('mms-control.py'),root/'etc/tundra/mms-control.py')
|
||||
write('etc/systemd/system/tundra-mms.service', """[Unit]
|
||||
Description=Tundra MMS queue and oFono push agent
|
||||
Requires=ofono.service
|
||||
After=ofono.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/mms-control.py
|
||||
UMask=0077
|
||||
RuntimeDirectory=tundra-mms
|
||||
RuntimeDirectoryMode=0755
|
||||
StateDirectory=tundra/mms
|
||||
StateDirectoryMode=0700
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/var/lib/tundra/mms /run/tundra-mms
|
||||
MemoryMax=256M
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
""")
|
||||
write('etc/pulse/client.conf' , 'autospawn = no\n')
|
||||
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
|
||||
write('etc/pulse/default.pa', '''.fail
|
||||
load-module module-native-protocol-unix
|
||||
load-module module-dbus-protocol
|
||||
load-module module-droid-card rate=48000 voice_virtual_stream=true
|
||||
load-module module-droid-hidl
|
||||
load-module module-default-device-restore
|
||||
load-module module-suspend-on-idle
|
||||
load-module module-bluetooth-policy auto_switch=0
|
||||
load-module module-bluetooth-discover headset=native
|
||||
''')
|
||||
# Prefer AAL for the next camera integration tests. The GStreamer probe
|
||||
# requests libstagefright_ccodec.so, absent from this Android 9 image.
|
||||
|
|
@ -77,11 +106,12 @@ load-module module-suspend-on-idle
|
|||
write('etc/systemd/system/tundra-audio.service', '''[Unit]
|
||||
Description=Tundra Sargo PulseAudio HAL bridge
|
||||
Requires=tundra-hal.service systemd-logind.service
|
||||
After=tundra-hal.service systemd-logind.service
|
||||
After=tundra-hal.service systemd-logind.service bluetooth.service
|
||||
[Service]
|
||||
User=droidian
|
||||
PAMName=tundra
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/32011
|
||||
ExecStartPre=+/usr/bin/python3 /etc/tundra/audio-devices.py
|
||||
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
|
@ -95,8 +125,10 @@ ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
|
|||
RuntimeDirectory=tundra-call-audio
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
ProtectHome=yes
|
||||
ProtectHome=tmpfs
|
||||
ProtectSystem=strict
|
||||
# The audio socket lives under /run/user, which ProtectHome also hides.
|
||||
BindReadOnlyPaths=/run/user/32011
|
||||
ReadWritePaths=/run/tundra-call-audio
|
||||
''')
|
||||
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
|
|
@ -108,5 +140,5 @@ ReadWritePaths=/run/tundra-call-audio
|
|||
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
|
||||
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
|
||||
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms', 'tundra-mms'):
|
||||
(wants/(name+'.service')).symlink_to('../'+name+'.service')
|
||||
|
|
|
|||
|
|
@ -118,9 +118,11 @@ for _ in range(120):
|
|||
time.sleep(.5)
|
||||
else:raise RuntimeError('Display HAL did not start')
|
||||
''')
|
||||
write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
|
||||
# Cellular HAL and oFono must see the same rmnet interfaces. Display-only
|
||||
# diagnostics retain a separate network namespace. No Android network manager runs.
|
||||
write('etc/tundra/lxc.conf',f'''lxc.rootfs.path = /android
|
||||
lxc.uts.name = tundra-hal
|
||||
lxc.net.0.type = empty
|
||||
lxc.net.0.type = {'none' if hardware else 'empty'}
|
||||
lxc.arch = aarch64
|
||||
lxc.init.cmd = /init
|
||||
lxc.apparmor.profile = unconfined
|
||||
|
|
@ -137,7 +139,7 @@ lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
|
|||
lxc.mount.entry = /android/data data none bind 0 0
|
||||
lxc.mount.entry = /mnt mnt none rbind 0 0
|
||||
''')
|
||||
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
|
||||
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','mobile-settings.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
|
||||
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
|
||||
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
|
||||
Description=Tundra read-only battery and network state
|
||||
|
|
@ -172,6 +174,10 @@ NoNewPrivileges=yes
|
|||
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
|
||||
# Older immutable images may not contain the networking packages yet.
|
||||
if network:
|
||||
write('etc/NetworkManager/conf.d/80-vulpes-hotspot.conf',Path(__file__).with_name('hotspot-firewall.conf').read_text())
|
||||
for name in ('iptables','ip6tables'):
|
||||
alias=root/'usr/sbin'/name
|
||||
if not alias.exists() and not alias.is_symlink():alias.symlink_to(name+'-legacy')
|
||||
write('etc/tundra/network-enabled', 'sargo\n')
|
||||
display = root/'etc/systemd/system/tundra-display.service'
|
||||
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ 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'
|
||||
|
|
@ -54,12 +56,20 @@ 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-f_]+',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:
|
||||
|
|
@ -119,9 +129,100 @@ def request(value):
|
|||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def reply(stream, value):
|
||||
stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
|
||||
stream.flush()
|
||||
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():
|
||||
|
|
@ -139,27 +240,28 @@ def serve():
|
|||
except (KeyError, OSError):
|
||||
pass
|
||||
server.listen(8)
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
conn.settimeout(20)
|
||||
line = b''
|
||||
while not line.endswith(b'\n') and len(line) < 8192:
|
||||
chunk = conn.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
line += chunk
|
||||
try:
|
||||
result = request(json.loads(line.decode()))
|
||||
reply(conn.makefile('wb'), {'ok': True, 'result': result})
|
||||
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
|
||||
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:
|
||||
sock.settimeout(20)
|
||||
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''
|
||||
|
|
|
|||
|
|
@ -12,10 +12,19 @@ import tempfile
|
|||
import urllib.request
|
||||
|
||||
PACKAGES = (
|
||||
('pulseaudio-module-bluetooth_14.2-2+droidian4+git20250504201345.dc4fdb8.next.production_arm64.deb',
|
||||
'https://production.repo.droidian.org/pool/main/p/pulseaudio',
|
||||
'f0a6fa15835f1e22a5708dc46356bd30bb492d57655f769aaf4df78b6838f4f3'),
|
||||
('libsbc1_2.1-1+b2_arm64.deb', 's/sbc',
|
||||
'62dd7210ff6500e3ee4976d865826d603127787f588c4c4a8f93556104c95634'),
|
||||
('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
|
||||
('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
|
||||
('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
|
||||
('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
|
||||
('mobile-broadband-provider-info_20251101-1_all.deb', 'm/mobile-broadband-provider-info', 'c34e6bad952a320078fa91faf29a6c754d57963e7280998a01ccb058e60b87cc'),
|
||||
('audiosystem-passthrough_1.3.0-1+droidian1+git20240519112654.f49813c.next.production_arm64.deb',
|
||||
'https://production.repo.droidian.org/pool/main/a/audiosystem-passthrough',
|
||||
'bb4dc41ee66b3b00e8fbd3e0bb8559ab0b98675836219efef7b02958c47b815d'),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -27,7 +36,8 @@ def stage(root, cache=None):
|
|||
cache.mkdir(parents=True, exist_ok=True)
|
||||
records = []
|
||||
for name, directory, sha in PACKAGES:
|
||||
url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
|
||||
base = directory if directory.startswith('https://') else 'https://deb.debian.org/debian/pool/main/'+directory
|
||||
url = base+'/'+name
|
||||
package = cache/name
|
||||
if not package.exists():
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
|
|
@ -59,13 +69,28 @@ def stage(root, cache=None):
|
|||
selected = [source/'usr/share/doc/ca-certificates/copyright']
|
||||
else:
|
||||
selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
|
||||
# The packet-capture utility is not used by the runtime. Keep it in
|
||||
# the pinned package cache rather than enlarging the boot ramdisk.
|
||||
excluded = ['usr/bin/btmon'] if name.startswith('bluez_') else []
|
||||
selected = [p for p in selected if str(p.relative_to(source)) not in excluded]
|
||||
for path in selected:
|
||||
target = root/path.relative_to(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink():
|
||||
if target.exists() or target.is_symlink():
|
||||
if not target.is_symlink() or target.readlink() != path.readlink():
|
||||
if not target.is_symlink():
|
||||
raise ValueError('Conflicting package link: '+str(target))
|
||||
# The reference runtime ships the compatibility link
|
||||
# for libelf 0.194. The pinned 0.196 package keeps the
|
||||
# same ABI name but points it at the newer SONAME.
|
||||
# Replace that one known compatibility link while
|
||||
# retaining the strict conflict checks for everything
|
||||
# else.
|
||||
if target.readlink() != path.readlink():
|
||||
if str(target.relative_to(root)) != 'usr/lib/aarch64-linux-gnu/libelf.so.1':
|
||||
raise ValueError('Conflicting package link: '+str(target))
|
||||
target.unlink()
|
||||
target.symlink_to(path.readlink())
|
||||
else:
|
||||
target.symlink_to(path.readlink())
|
||||
else:
|
||||
|
|
@ -75,7 +100,7 @@ def stage(root, cache=None):
|
|||
raise ValueError('Conflicting package file: '+str(target))
|
||||
shutil.copyfile(path, target)
|
||||
target.chmod(path.stat().st_mode & 0o777)
|
||||
records.append({'file': name, 'url': url, 'sha256': sha})
|
||||
records.append({'file': name, 'url': url, 'sha256': sha, 'excluded': excluded})
|
||||
record = root/'usr/share/vulpes/runtime-packages.json'
|
||||
record.parent.mkdir(parents=True, exist_ok=True)
|
||||
record.write_text(json.dumps(records, indent=2)+'\n')
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ def main():
|
|||
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
|
||||
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
|
||||
p.write_text(filtered);report[str(p)]=record
|
||||
if Path('/etc/tundra/hardware-enabled').is_file():
|
||||
# The stock boot-completed trigger never runs without Android framework.
|
||||
with Path('/android/init.rc').open('a') as init:
|
||||
init.write('\non boot\n start ipastart_sh\n')
|
||||
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
|
||||
# The container cannot open block devices. Its data and metadata live in RAM.
|
||||
for p in ('/android/data','/android/metadata'):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
|
|
@ -17,6 +19,7 @@ SOCKET = '/run/tundra-wifi/control.sock'
|
|||
MAX_REQUEST = 16384
|
||||
DEVICE = 'wlan0'
|
||||
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
|
||||
MUTATION_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class WifiError(Exception):
|
||||
|
|
@ -82,6 +85,29 @@ def networks(rescan=False):
|
|||
return sorted(found, key=lambda row: -row['signal'])[:128]
|
||||
|
||||
|
||||
def connection_info():
|
||||
info = {}
|
||||
address = nm('-g','IP4.ADDRESS','device','show',DEVICE,timeout=5)
|
||||
if address.returncode == 0:
|
||||
import ipaddress
|
||||
for line in address.stdout.splitlines():
|
||||
try:info['ipAddress'] = str(ipaddress.ip_interface(line).ip);break
|
||||
except ValueError:continue
|
||||
device = nm('-g','GENERAL.DBUS-PATH','device','show',DEVICE,timeout=5)
|
||||
path=device.stdout.strip() if device.returncode==0 else ''
|
||||
if re.fullmatch(r'/org/freedesktop/NetworkManager/Devices/[0-9]+',path):
|
||||
speed=subprocess.run(['/usr/bin/busctl','--system','--timeout=2','--json=short','get-property',
|
||||
'org.freedesktop.NetworkManager',path,'org.freedesktop.NetworkManager.Device.Wireless','Bitrate'],
|
||||
capture_output=True,text=True,timeout=3)
|
||||
if speed.returncode==0:
|
||||
try:
|
||||
kbps=json.loads(speed.stdout)['data']
|
||||
if isinstance(kbps,list):kbps=kbps[0]
|
||||
if type(kbps) is int and kbps>0:info['linkSpeed']=kbps/1000
|
||||
except (ValueError,KeyError,TypeError,IndexError):pass
|
||||
return info
|
||||
|
||||
|
||||
def saved_profiles(profile_dir):
|
||||
result = []
|
||||
for path in profile_dir.glob('vulpes-*.nmconnection'):
|
||||
|
|
@ -131,14 +157,89 @@ def profile_text(network, password, uid):
|
|||
'cloned-mac-address=permanent\nssid=' +
|
||||
''.join(str(b) + ';' for b in ssid.encode()) +
|
||||
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
|
||||
if network.get('hidden'): text = text.replace('[wifi]\n','[wifi]\nhidden=true\n')
|
||||
if mode != 'open':
|
||||
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
|
||||
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
|
||||
return text
|
||||
|
||||
|
||||
HOTSPOT_UUID = '5e962c71-d3d0-4c39-b4de-460bb63fc045'
|
||||
HOTSPOT_RETURN = Path('/run/tundra-wifi/hotspot-return.json')
|
||||
|
||||
|
||||
def active_wifi_uuid():
|
||||
result=nm('-g','GENERAL.CON-UUID','device','show',DEVICE,timeout=5)
|
||||
value=result.stdout.strip() if result.returncode==0 else ''
|
||||
try:return str(uuid.UUID(value))
|
||||
except ValueError:return None
|
||||
|
||||
|
||||
def hotspot_state(profile_dir):
|
||||
path=profile_dir/'vulpes-hotspot.nmconnection'
|
||||
result={'enabled':active_wifi_uuid()==HOTSPOT_UUID,'configured':False,'ssid':'Vulpes OS','security':'wpa2-psk','password':''}
|
||||
if path.exists() and not path.is_symlink():
|
||||
config=configparser.ConfigParser(interpolation=None);config.read(path)
|
||||
result.update(configured=True,ssid=bytes(int(b) for b in config['wifi']['ssid'].split(';') if b).decode(),
|
||||
security='wpa2-psk' if config.has_section('wifi-security') else 'open',
|
||||
password=re.sub(r'\\([\\s])',lambda match:' ' if match[1]=='s' else '\\',config.get('wifi-security','psk',fallback='')))
|
||||
return result
|
||||
|
||||
|
||||
def hotspot(request, profile_dir):
|
||||
action=request.get('operation')
|
||||
allowed={'hotspotStatus':set(),'hotspotSave':{'ssid','security','password'},'hotspotPower':{'enabled'}}
|
||||
if action not in allowed or set(request)-{'operation'}-allowed[action]:raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if action=='hotspotStatus':return hotspot_state(profile_dir)
|
||||
target=profile_dir/'vulpes-hotspot.nmconnection'
|
||||
if action=='hotspotSave':
|
||||
ssid=request.get('ssid');security=request.get('security');password=request.get('password')
|
||||
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or any(ord(c)<32 for c in ssid):raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if security not in ('open','wpa2-psk') or not isinstance(password,str):raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if any(ord(c)<32 for c in password) or (security=='wpa2-psk' and not 8<=len(password.encode())<=63):raise WifiError('WIFI_INVALID_PASSWORD')
|
||||
if active_wifi_uuid()==HOTSPOT_UUID:raise WifiError('HOTSPOT_IN_USE')
|
||||
previous=target.read_text() if target.exists() and not target.is_symlink() else None
|
||||
value=profile_text({'ssid':ssid,'security':'wpa-psk' if security=='wpa2-psk' else 'open'},password,HOTSPOT_UUID)
|
||||
value=value.replace('id=Vulpes Wi-Fi','id=Vulpes hotspot',1).replace('autoconnect=true','autoconnect=false',1).replace('mode=infrastructure','mode=ap\nband=bg',1).replace('[ipv4]\nmethod=auto','[ipv4]\nmethod=shared',1).replace('[ipv6]\nmethod=auto','[ipv6]\nmethod=disabled',1)
|
||||
if security=='wpa2-psk':value=value.replace('[wifi-security]\n','[wifi-security]\nproto=rsn\n')
|
||||
profile_dir.mkdir(mode=0o700,parents=True,exist_ok=True);private_write(target,value)
|
||||
if nm('connection','load',str(target)).returncode:
|
||||
if previous is not None:private_write(target,previous);nm('connection','load',str(target))
|
||||
else:
|
||||
nm('connection','delete','uuid',HOTSPOT_UUID)
|
||||
target.unlink(missing_ok=True)
|
||||
raise WifiError('HOTSPOT_SAVE_FAILED')
|
||||
return hotspot_state(profile_dir)
|
||||
enabled=request.get('enabled')
|
||||
if type(enabled) is not bool:raise WifiError('WIFI_INVALID_REQUEST')
|
||||
current=active_wifi_uuid()
|
||||
if enabled:
|
||||
if not target.is_file() or target.is_symlink():raise WifiError('HOTSPOT_NOT_CONFIGURED')
|
||||
if current==HOTSPOT_UUID:return hotspot_state(profile_dir)
|
||||
HOTSPOT_RETURN.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
private_write(HOTSPOT_RETURN,json.dumps({'uuid':current}))
|
||||
if nm('connection','up','uuid',HOTSPOT_UUID).returncode or active_wifi_uuid()!=HOTSPOT_UUID:
|
||||
nm('connection','down','uuid',HOTSPOT_UUID)
|
||||
if current:nm('connection','up','uuid',current)
|
||||
HOTSPOT_RETURN.unlink(missing_ok=True)
|
||||
raise WifiError('HOTSPOT_START_FAILED')
|
||||
elif current==HOTSPOT_UUID:
|
||||
if nm('connection','down','uuid',HOTSPOT_UUID).returncode:raise WifiError('HOTSPOT_STOP_FAILED')
|
||||
previous=None
|
||||
try:previous=str(uuid.UUID(json.loads(HOTSPOT_RETURN.read_text()).get('uuid')))
|
||||
except (OSError,ValueError,TypeError,AttributeError):pass
|
||||
HOTSPOT_RETURN.unlink(missing_ok=True)
|
||||
if previous and previous!=HOTSPOT_UUID:
|
||||
if nm('connection','up','uuid',previous).returncode:raise WifiError('WIFI_RECONNECT_FAILED')
|
||||
result=hotspot_state(profile_dir)
|
||||
if result['enabled']!=enabled:raise WifiError('HOTSPOT_STATE_MISMATCH')
|
||||
return result
|
||||
|
||||
|
||||
def dispatch(request, profile_dir=PROFILE_DIR):
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
|
||||
if isinstance(request,dict) and request.get('operation') in ('hotspotStatus','hotspotSave','hotspotPower'):
|
||||
return hotspot(request,profile_dir)
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled', 'ssid', 'security'}:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
operation = request.get('operation')
|
||||
saved = saved_profiles(profile_dir)
|
||||
|
|
@ -168,15 +269,25 @@ def dispatch(request, profile_dir=PROFILE_DIR):
|
|||
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}
|
||||
info={}
|
||||
if any(n['connected'] for n in found):
|
||||
try:info=connection_info()
|
||||
except (WifiError,OSError,subprocess.TimeoutExpired):pass
|
||||
return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
|
||||
if operation == 'disconnect':
|
||||
if nm('device', 'disconnect', DEVICE).returncode:
|
||||
raise WifiError('WIFI_DISCONNECT_FAILED')
|
||||
return {'disconnected': True}
|
||||
if operation != 'connect':
|
||||
if operation not in ('connect','connectHidden'):
|
||||
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 operation == 'connectHidden':
|
||||
ssid=request.get('ssid');security=request.get('security')
|
||||
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or security not in ('open','wpa-psk','sae'):
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
network={'ssid':ssid,'security':security,'hidden':True}
|
||||
else:
|
||||
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)
|
||||
|
|
@ -184,6 +295,8 @@ def dispatch(request, profile_dir=PROFILE_DIR):
|
|||
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)
|
||||
if network.get('hidden') and 'hidden=true' not in text:
|
||||
text = text.replace('[wifi]\n', '[wifi]\nhidden=true\n')
|
||||
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
|
||||
connected = False
|
||||
|
|
@ -226,7 +339,12 @@ def handle(stream):
|
|||
_, 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))}
|
||||
request = receive(stream, MAX_REQUEST)
|
||||
if isinstance(request,dict) and request.get('operation') in ('connect','connectHidden','forget','disconnect','setEnabled','hotspotSave','hotspotPower'):
|
||||
with MUTATION_LOCK:
|
||||
result = {'result': dispatch(request)}
|
||||
else:
|
||||
result = {'result': dispatch(request)}
|
||||
except WifiError as error:
|
||||
result = {'error': str(error)}
|
||||
except Exception:
|
||||
|
|
@ -247,7 +365,8 @@ def main():
|
|||
if len(data) > MAX_REQUEST:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
with socket.socket(socket.AF_UNIX) as stream:
|
||||
stream.settimeout(90)
|
||||
value=json.loads(data)
|
||||
stream.settimeout(180 if isinstance(value,dict) and value.get('operation')=='hotspotPower' else 90)
|
||||
stream.connect(SOCKET)
|
||||
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
|
||||
result = receive(stream, 65536)
|
||||
|
|
@ -265,10 +384,18 @@ def main():
|
|||
os.chown(SOCKET, 0, 32011)
|
||||
path.chmod(0o660)
|
||||
server.listen(4)
|
||||
while True:
|
||||
stream, _ = server.accept()
|
||||
with stream:
|
||||
handle(stream)
|
||||
# Reading saved profiles must not wait behind a radio scan or connection.
|
||||
# Bound both workers and queued sockets; mutations remain serialized.
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
def serve_client(stream):
|
||||
try:
|
||||
with stream: handle(stream)
|
||||
finally: slots.release()
|
||||
with ThreadPoolExecutor(max_workers=4) as workers:
|
||||
while True:
|
||||
stream, _ = server.accept()
|
||||
if slots.acquire(blocking=False): workers.submit(serve_client,stream)
|
||||
else: stream.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
Loading…
Reference in a new issue