#!/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']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()