From 872b472f4a28b2bbd85038725faff4d4e46bbf7f Mon Sep 17 00:00:00 2001 From: Alexander Couzens Date: Wed, 21 Jan 2026 17:27:49 +0100 Subject: [PATCH] feat: add USSD support for 2G/3G via GSUP - Introduced USSD configuration handling in `config.yaml`. - Added support for GSUP USSD operation in `request_dispatcher.py`. - Implemented `SSController` class to process USSD requests. - Integrated ASN.1 USSD definitions for encoding/decoding. - Added new dependencies: `asn1tools` and `smspdudecoder`. The primary author of this is Alexander Couzens. I (Lennart) only contributed the dynamic configuration of the USSD message, which was hard-coded in the original implementation. Co-authored-by: Lennart Rosam --- config.yaml | 9 ++ docker/config.yaml | 9 ++ lib/gsup/controller/ss.py | 187 +++++++++++++++++++++++++++++++++ lib/gsup/controller/ussd.asn1 | 186 ++++++++++++++++++++++++++++++++ lib/gsup/request_dispatcher.py | 7 +- pyproject.toml | 3 + requirements.txt | 4 +- tests/config.yaml | 9 ++ 8 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 lib/gsup/controller/ss.py create mode 100644 lib/gsup/controller/ussd.asn1 diff --git a/config.yaml b/config.yaml index caf56988..834480d1 100644 --- a/config.yaml +++ b/config.yaml @@ -92,6 +92,15 @@ hss: gsup: bind_ip: "0.0.0.0" bind_port: 4222 + # Simple 2G / 3G USSD support via GSUP. + # Define USSD codes and messages here. The %msisdn% and %imsi% variables can be used in messages. + ussd: + unknown_code_msg: "The USSD code you have entered is not recognized." + codes: + - code: "*#100#" + msg: "Your MSISDN is %msisdn%" + - code: "*#101#" + msg: "Your IMSI is %imsi%" api: page_size: 200 diff --git a/docker/config.yaml b/docker/config.yaml index 3644cc99..77ab0f9d 100644 --- a/docker/config.yaml +++ b/docker/config.yaml @@ -92,6 +92,15 @@ hss: gsup: bind_ip: "${HSS_GSUP_BIND_IP:-0.0.0.0}" bind_port: ${HSS_GSUP_BIND_PORT:-4222} + # Simple 2G / 3G USSD support via GSUP. + # Define USSD codes and messages here. The %msisdn% and %imsi% variables can be used in messages. + ussd: + unknown_code_msg: "The USSD code you have entered is not recognized." + codes: + - code: "*#100#" + msg: "Your MSISDN is: %msisdn%" + - code: "*#101#" + msg: "Your IMSI is: %imsi%" api: page_size: ${API_PAGE_SIZE:-200} diff --git a/lib/gsup/controller/ss.py b/lib/gsup/controller/ss.py new file mode 100644 index 00000000..8a296a80 --- /dev/null +++ b/lib/gsup/controller/ss.py @@ -0,0 +1,187 @@ +# PyHSS GSUP SS Controller +# Copyright 2025-2026 Alexander Couzens +# Copyright 2026 Lennart Rosam +# SPDX-License-Identifier: AGPL-3.0-or-later + +from collections import OrderedDict +from pathlib import Path + +import asn1tools +import binascii +from osmocom.gsup.message import MsgType +from smspdudecoder.codecs import GSM + +from gsup.controller.abstract_controller import GsupController +from gsup.protocol.gsup_msg import GsupMessageUtil, GsupMessageBuilder +from pyhss_config import config + + +class UnknownUSSD(RuntimeError): + """ Unknown USSD message """ + pass + +asn1path = Path(__file__).with_name("ussd.asn1").resolve() +USSD = asn1tools.compile_files([str(asn1path)]) + +class SSController(GsupController): + def __init__(self, logger, database): + super().__init__(logger, database) + + ussd_config = config.get('hss', {}).get('gsup', {}).get('ussd', {}) + if not ussd_config or not ussd_config.get('codes', []): + self.targets = {} + self.unknown_ussd_message = "USSD is not supported on this network." + else: + ussd_targets = ussd_config.get('codes', []) + self.targets = {code['code']: code['msg'] for code in ussd_targets} + self.unknown_ussd_message = ussd_config.get('unknown_code_msg', "The USSD code you have entered is not recognized.") + + + @staticmethod + def error_from_request(message: dict): + """ Generate a SS Error by using the old message """ + response = GsupMessageBuilder().with_msg_type(MsgType.PROC_SS_RESULT) + + def copy_field(key: str): + field = GsupMessageUtil.get_first_ie_by_name(key, message) + if field: + response.with_ie(key, field) + + copy_field('imsi') + copy_field('session_id') + + return response.with_ie('session_state', 'end').build() + + @staticmethod + def gsup_from_ussd(message: dict, ussd_encoded: bytes): + """ Generate a full GSUP message """ + response = GsupMessageBuilder().with_msg_type(MsgType.PROC_SS_RESULT) + + def copy_field(key: str): + field = GsupMessageUtil.get_first_ie_by_name(key, message) + if field: + response.with_ie(key, field) + + copy_field('imsi') + copy_field('session_id') + + return response.with_ie('session_state', 'end').with_ie('supplementary_service_info', ussd_encoded).build() + + @staticmethod + def encode_ussd_arg(answer: str) -> bytes: + """ + Encode USSD-Arg of MAP into bytes + + OrderedDict([('ussd-DataCodingScheme', b'\x0f'), + ('ussd-String', b'\xaaQ\x0c\x06\x1b\x01')]) + """ + attr = USSD.modules['Foo']['USSD-Arg'] + data = OrderedDict() + data['ussd-DataCodingScheme'] = b'\x0f' + data['ussd-String'] = binascii.a2b_hex(GSM().encode(answer)) + + return attr.encode(data) + + @staticmethod + def encode_component(invoke_id: int, answer: str): + """ + Generate a full response which only needs to be encoded into GSUP + FIXME: clean this up more + + The result should look like this: + ('returnResultLast', + OrderedDict( + ('invokeID', 1), + ('resultretres', + OrderedDict(('opCode', ('localValue', 59)), + ('returnparameter', + bytearray(b'0\x1e\x04\x01\x0f\x04\x19\xd9w]\x0eJ' + b'6\xa7IPz\x0e\x92\xd9d4\x99\xed' + b'F\xbb\xe1f0\x99\xad\x06')))))) + """ + comp = USSD.modules['Foo']['Component'] + outer = OrderedDict() + outer['invokeID'] = invoke_id + + inner = OrderedDict() + inner['opCode'] = ('localValue', 59) + inner['returnparameter'] = SSController.encode_ussd_arg(answer) + outer['resultretres'] = inner + + answer = comp.encode(('returnResultLast', outer)) + return answer + + async def handle_ussd(self, peer, answer, subscriber, ussd_data): + try: + op, data = USSD.decode('Component', ussd_data) + if op == "invoke": + if data['opCode'] != ('localValue', 59): + raise UnknownUSSD(f"Invalid opCode in invoke {data}") + + invoke_id = data['invokeID'] + ussd = USSD.decode('USSD-Arg', data['invokeparameter']) + target = GSM().decode(str(binascii.b2a_hex(ussd['ussd-String']), 'utf-8')) + await self._logger.logAsync(service='GSUP', level='INFO', message=f"Received USSD request {target}") + + answer = self.targets.get(target, self.unknown_ussd_message) + if "%imsi%" in answer: + answer = answer.replace("%imsi%", subscriber['imsi']) + if "%msisdn%" in answer: + answer = answer.replace("%msisdn%", subscriber['msisdn']) + + component = self.encode_component(invoke_id, answer) + response = self.gsup_from_ussd(answer, component) + await self._send_gsup_response(peer, response) + return + elif op == "returnResultLast": + pass + else: + raise UnknownUSSD(f"Invalid class or constructed {op} with {data}") + + response = self.error_from_request(answer) + await self._send_gsup_response(peer, response) + + except Exception as e: + await self._logger.logAsync(service='GSUP', level='ERROR', message=f"Error while handling ussd in handle_ussd: {str(e)}") + raise UnknownUSSD("Invalid class or constructed") + + async def handle_message(self, peer, message): + message = message.to_dict() + imsi = GsupMessageUtil.get_first_ie_by_name('imsi', message) + if imsi is None: + await self._logger.logAsync(service='GSUP', level='WARN', message=f"IMSI not found in SS message from {peer}") + response = self.error_from_request(message) + await self._send_gsup_response(peer, response) + return + + # Currently, we only support non-continuous sessions + session_state = GsupMessageUtil.get_first_ie_by_name('session_state', message) + if session_state is None: + await self._logger.logAsync(service='GSUP', level='WARN', message=f"Session state not found in SS message from {peer}") + response = self.error_from_request(message) + await self._send_gsup_response(peer, response) + return + + session_id = GsupMessageUtil.get_first_ie_by_name('session_id', message) + if session_id is None: + await self._logger.logAsync(service='GSUP', level='WARN', message=f"Session id not found in SS message from {peer}") + response = self.error_from_request(message) + await self._send_gsup_response(peer, response) + return + + try: + subscriber = self._database.Get_Subscriber(imsi=imsi) + if subscriber is None: + await self._logger.logAsync(service='GSUP', level='WARN', message=f"No subscriber for IMSI found. WTF?! {peer}") + response = self.error_from_request(message) + await self._send_gsup_response(peer, response) + return + + ussd_data = GsupMessageUtil.get_first_ie_by_name('supplementary_service_info', message) + await self.handle_ussd(peer, message, subscriber, ussd_data) + + except Exception as e: + await self._logger.logAsync(service='GSUP', level='ERROR', message=f"Error while handling ussd: {str(e)}") + response = self.error_from_request(message) + await self._send_gsup_response(peer, response) + return diff --git a/lib/gsup/controller/ussd.asn1 b/lib/gsup/controller/ussd.asn1 new file mode 100644 index 00000000..b44b96ce --- /dev/null +++ b/lib/gsup/controller/ussd.asn1 @@ -0,0 +1,186 @@ + +Foo DEFINITIONS ::= BEGIN + +MAP-OPERATION ::= CHOICE { + localValue OperationLocalvalue, + globalValue OBJECT IDENTIFIER } + + +GSMMAPOperationLocalvalue ::= INTEGER{ + updateLocation (2), + cancelLocation (3), + provideRoamingNumber (4), + noteSubscriberDataModified (5), + resumeCallHandling (6), + insertSubscriberData (7), + deleteSubscriberData (8), + sendParameters (9), + registerSS (10), + eraseSS (11), + activateSS (12), + deactivateSS (13), + interrogateSS (14), + notifySS (16), + registerPassword (17), + getPassword (18), + processUnstructuredSS-Data (19), + processUnstructuredSS-Request (59) +} + +OperationLocalvalue ::= GSMMAPOperationLocalvalue + + +MAP-ERROR ::= CHOICE { + localValue LocalErrorcode, + globalValue OBJECT IDENTIFIER } + +GSMMAPLocalErrorcode ::= INTEGER{ + unknownSubscriber (1), + unknownBaseStation (2), + unknownMSC (3), + secureTransportError (4), + unidentifiedSubscriber (5), + absentSubscriberSM (6), + unknownEquipment (7), + roamingNotAllowed (8), + illegalSubscriber (9), + bearerServiceNotProvisioned (10), + teleserviceNotProvisioned (11), + illegalEquipment (12), + callBarred (13), + forwardingViolation (14), + cug-Reject (15), + illegalSS-Operation (16), + ss-ErrorStatus (17), + ss-NotAvailable (18), + ss-SubscriptionViolation (19), + ss-Incompatibility (20), + facilityNotSupported (21), + ongoingGroupCall (22), + invalidTargetBaseStation (23), + noRadioResourceAvailable (24), + noHandoverNumberAvailable (25), + subsequentHandoverFailure (26), + absentSubscriber (27), + incompatibleTerminal (28), + shortTermDenial (29), + longTermDenial (30), + subscriberBusyForMT-SMS (31), + sm-DeliveryFailure (32), + messageWaitingListFull (33), + systemFailure (34), + dataMissing (35), + unexpectedDataValue (36), + pw-RegistrationFailure (37), + negativePW-Check (38), + noRoamingNumberAvailable (39), + tracingBufferFull (40), + targetCellOutsideGroupCallArea (42), + numberOfPW-AttemptsViolation (43), + numberChanged (44), + busySubscriber (45), + noSubscriberReply (46), + forwardingFailed (47), + or-NotAllowed (48), + ati-NotAllowed (49), + noGroupCallNumberAvailable (50), + resourceLimitation (51), + unauthorizedRequestingNetwork (52), + unauthorizedLCSClient (53), + positionMethodFailure (54), + unknownOrUnreachableLCSClient (58), + mm-EventNotSupported (59), + atsi-NotAllowed (60), + atm-NotAllowed (61), + informationNotAvailable (62), + unknownAlphabet (71), + ussd-Busy (72) +} + +LocalErrorcode ::= GSMMAPLocalErrorcode + +Component ::= CHOICE { + invoke [1] IMPLICIT Invoke, + returnResultLast [2] IMPLICIT ReturnResult, + returnError [3] IMPLICIT ReturnError, + -- TCAP adds returnResultNotLast to allow for the segmentation of a result. + returnResultNotLast [7] IMPLICIT ReturnResult +} + +Invoke ::= SEQUENCE { + invokeID InvokeIdType, + linkedID [0] InvokeIdType OPTIONAL, + opCode MAP-OPERATION, + invokeparameter InvokeParameter OPTIONAL +} +InvokeParameter ::= ANY + +ReturnResult ::= SEQUENCE { + invokeID InvokeIdType, + resultretres SEQUENCE { + opCode MAP-OPERATION, + returnparameter ReturnResultParameter OPTIONAL + } OPTIONAL + } +ReturnResultParameter ::= ANY + + +ReturnError ::= SEQUENCE { + invokeID InvokeIdType, + errorCode MAP-ERROR, + parameter ReturnErrorParameter OPTIONAL } + +ReturnErrorParameter ::= ANY + + +InvokeIdType ::= INTEGER (-128..127) + + +ReturnResult ::= SEQUENCE { + invokeID InvokeIdType, + resultretres SEQUENCE { + opCode MAP-OPERATION, + returnparameter ReturnResultParameter OPTIONAL + } OPTIONAL + } + +ReturnResultParameter ::= ANY + +-- ANY is filled by the single ASN.1 data type following the keyword RESULT in the type definition +-- of a particular operation. + +ReturnError ::= SEQUENCE { + invokeID InvokeIdType, + errorCode MAP-ERROR, + parameter ReturnErrorParameter OPTIONAL } + +ReturnErrorParameter ::= ANY + + +InvokeIdType ::= INTEGER (-128..127) + + + + USSD-Arg ::= SEQUENCE { + ussd-DataCodingScheme USSD-DataCodingScheme, + ussd-String USSD-String, + ... , + alertingPattern AlertingPattern OPTIONAL, + msisdn [0] ISDN-AddressString OPTIONAL } + + USSD-Res ::= SEQUENCE { + ussd-DataCodingScheme USSD-DataCodingScheme, + ussd-String USSD-String, + ...} + USSD-DataCodingScheme ::= OCTET STRING (SIZE (1)) + + USSD-String ::= OCTET STRING (SIZE (1..maxUSSD-StringLength)) + + maxUSSD-StringLength INTEGER ::= 160 + AlertingPattern ::= OCTET STRING (SIZE (1) ) + ISDN-AddressString ::= + AddressString (SIZE (1..maxISDN-AddressLength)) + maxISDN-AddressLength INTEGER ::= 9 + AddressString ::= OCTET STRING (SIZE (1..maxAddressLength)) + maxAddressLength INTEGER ::= 20 +END diff --git a/lib/gsup/request_dispatcher.py b/lib/gsup/request_dispatcher.py index 40b15a0c..900a7073 100644 --- a/lib/gsup/request_dispatcher.py +++ b/lib/gsup/request_dispatcher.py @@ -13,6 +13,7 @@ from gsup.controller.isr import ISRController, ISDTransaction from gsup.controller.noop import NoopController from gsup.controller.pur import PURController +from gsup.controller.ss import SSController from gsup.controller.ulr import ULRTransaction, ULRController from gsup.protocol.gsup_msg import GsupMessageBuilder, GsupMessageUtil from gsup.protocol.ipa_peer import IPAPeer @@ -37,6 +38,11 @@ def __init__(self, logger: LogTool, database: Database, all_peers: Dict[str, IPA MsgType.LOCATION_CANCEL_ERROR: NoopController(logger, database), MsgType.AUTH_FAIL_REPORT: NoopController(logger, database), MsgType.PURGE_MS_REQUEST: PURController(logger, database), + + # USSD + MsgType.PROC_SS_REQUEST: SSController(logger, database), + MsgType.PROC_SS_ERROR: SSController(logger, database), + MsgType.PROC_SS_RESULT: SSController(logger, database), } @@ -70,7 +76,6 @@ async def __handle_gsup_unhandled_request(self, peer: IPAPeer, gsup: GsupMessage MsgType.LOCATION_CANCEL_REQUEST: MsgType.LOCATION_CANCEL_ERROR, MsgType.MO_FORWARD_SM_REQUEST: MsgType.MO_FORWARD_SM_ERROR, MsgType.MT_FORWARD_SM_REQUEST: MsgType.MT_FORWARD_SM_ERROR, - MsgType.PROC_SS_REQUEST: MsgType.PROC_SS_ERROR, MsgType.READY_FOR_SM_REQUEST: MsgType.READY_FOR_SM_ERROR, } diff --git a/pyproject.toml b/pyproject.toml index 936090ef..8b023f29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ packages = [ [tool.setuptools.package-dir] pyhss = "" +[tool.setuptools.package-data] +pyhss = ["*.asn1"] + [tool.pytest.ini_options] addopts = "--tb=native" log_level = "DEBUG" diff --git a/requirements.txt b/requirements.txt index 019ac8c1..80606877 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,4 +24,6 @@ pydantic_core==2.20.1 tzlocal==4.3 influxdb==5.3.1 xmltodict==0.14.2 -StrEnum==0.4.15; python_version < '3.11' \ No newline at end of file +StrEnum==0.4.15; python_version < '3.11' +asn1tools==0.167.0 +smspdudecoder==2.2.0 \ No newline at end of file diff --git a/tests/config.yaml b/tests/config.yaml index d400a6bc..463f5809 100644 --- a/tests/config.yaml +++ b/tests/config.yaml @@ -36,6 +36,15 @@ hss: gsup: bind_ip: "127.0.0.1" bind_port: 4222 + # Simple 2G / 3G USSD support via GSUP. + # Define USSD codes and messages here. The %msisdn% and %imsi% variables can be used in messages. + ussd: + unknown_code_msg: "The USSD code you have entered is not recognized." + codes: + - code: "*#100#" + msg: "Your MSISDN is: %msisdn%" + - code: "*#101#" + msg: "Your IMSI is: %imsi%" api: page_size: 200