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:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

View 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)

View 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()

View file

@ -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))

View file

@ -0,0 +1,3 @@
[Service]
StateDirectory=tundra/clock
ReadWritePaths=/var/lib/tundra/clock

View file

@ -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):

View file

@ -0,0 +1,3 @@
# Sargo's 4.9 kernel provides the legacy netfilter backend.
[main]
firewall-backend=iptables

View 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

View 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()

View 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()}

View file

@ -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')

View file

@ -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')

View file

@ -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',

View file

@ -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''

View file

@ -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')

View file

@ -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'):

View file

@ -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__':