# ############################################################################### # Copyright (C) 2023 Simon Adlem, G7RZU # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ############################################################################### import json import logging from twisted.web.resource import Resource from utils import bytes_4 logger = logging.getLogger(__name__) MAX_API_BODY = 8192 class APIError(Exception): def __init__(self, status, message): self.status = status self.message = message super().__init__(message) class FD_APIController(object): """Small, non-blocking control-plane API for live FreeDMR state. The methods perform small in-memory operations; dial voice playback is scheduled through the router's existing worker-thread mechanism. Expensive snapshots of CONFIG/BRIDGES are not exposed here because this API shares the Twisted reactor with packet handling. """ version = 1 def __init__(self, CONFIG, BRIDGES, dial_control=None): self.CONFIG = CONFIG self.BRIDGES = BRIDGES self.dial_control = dial_control def dial(self, payload, peer_auth=False): # Separate endpoints keep browser peer credentials apart from backend keys. if peer_auth: authenticated_system = self.validateKey(payload.get('dmrid'), payload.get('key')) authenticated = bool(authenticated_system) else: authenticated = self.validateSystemKey(payload.get('systemkey')) if not authenticated: raise APIError(401, 'invalid_credentials') peer = payload.get('dmrid') slot = payload.get('slot') action = payload.get('action') tg = payload.get('tg') if type(peer) is not int or not 0 < peer <= 0xffffffff: raise APIError(400, 'invalid_dmrid') if type(slot) is not int or slot not in (1, 2): raise APIError(400, 'invalid_slot') if action not in ('link', 'unlink', 'status', 'reset', 'reset_tgs'): raise APIError(400, 'invalid_action') if action == 'link' and (type(tg) is not int or not 0 < tg <= 999999): raise APIError(400, 'invalid_tg') if action != 'link' and tg is not None: raise APIError(400, 'unexpected_tg') if self.dial_control is None: raise APIError(503, 'dial_unavailable') matches = [system for system, config in self.CONFIG['SYSTEMS'].items() if config['MODE'] == 'MASTER' and config['PEERS'].get(bytes_4(peer), {}).get('CONNECTION') == 'YES'] if not matches: raise APIError(404, 'peer_not_connected') if len(matches) != 1: raise APIError(409, 'ambiguous_peer') if peer_auth and matches[0] != authenticated_system: raise APIError(401, 'invalid_credentials') if sum(peer.get('CONNECTION') == 'YES' for peer in self.CONFIG['SYSTEMS'][matches[0]]['PEERS'].values()) != 1: raise APIError(409, 'shared_master') return self.dial_control(matches[0], slot, action, tg) def validateKey(self, dmrid, key): if not isinstance(key, str) or not key: return False try: peer_id = bytes_4(int(dmrid)) except (TypeError, ValueError, OverflowError): return False for system, system_config in self.CONFIG['SYSTEMS'].items(): if system_config['MODE'] != 'MASTER': continue if peer_id not in system_config['PEERS']: continue if key == system_config.get('_opt_key'): return system return False return False def validateSystemKey(self, systemkey): return (isinstance(systemkey, str) and bool(systemkey) and systemkey == self.CONFIG['GLOBAL'].get('SYSTEM_API_KEY')) def reset(self, system): self.CONFIG['SYSTEMS'][system]['_reset'] = True def options(self, system, options): self.CONFIG['SYSTEMS'][system]['OPTIONS'] = options def getoptions(self, system): options = self.CONFIG['SYSTEMS'][system].get('OPTIONS') has_options = options is not None if isinstance(options, bytes): options = options.decode('utf-8', 'ignore') elif options is None: options = '' else: options = str(options) return { 'connected': bool(self.CONFIG['SYSTEMS'][system]['PEERS']), 'has_options': has_options, 'options': options, } def killserver(self): self.CONFIG['GLOBAL']['_KILL_SERVER'] = True def resetAllConnections(self): for system in self.CONFIG['SYSTEMS']: self.CONFIG['SYSTEMS'][system]['_reset'] = True class FD_APIResource(Resource): isLeaf = True def __init__(self, controller): Resource.__init__(self) self.controller = controller def render_GET(self, request): path = self._path(request) if path == '/api/v1/version': return self._json(request, 200, {'ok': True, 'version': self.controller.version}) if path == '/api/v1/health': return self._json(request, 200, {'ok': True}) return self._json(request, 404, {'ok': False, 'error': 'not_found'}) def render_POST(self, request): try: payload = self._payload(request) path = self._path(request) if path == '/api/v1/dial': result = self.controller.dial(payload) return self._json(request, 200, {'ok': True, **result}) if path == '/api/v1/peer/dial': result = self.controller.dial(payload, peer_auth=True) return self._json(request, 200, {'ok': True, **result}) if path == '/api/v1/reset': return self._user_call(request, payload, self._reset) if path == '/api/v1/options/get': return self._user_call(request, payload, self._get_options) if path == '/api/v1/options/set': return self._user_call(request, payload, self._set_options) if path == '/api/v1/system/kill': return self._system_call(request, payload, self._kill_server) if path == '/api/v1/system/resetall': return self._system_call(request, payload, self._reset_all) return self._json(request, 404, {'ok': False, 'error': 'not_found'}) except APIError as exc: return self._json(request, exc.status, {'ok': False, 'error': exc.message}) except Exception: logger.exception('(API) Unhandled API error') return self._json(request, 500, {'ok': False, 'error': 'internal_error'}) def _path(self, request): return (b'/' + b'/'.join(request.postpath)).decode('utf-8', 'ignore') def _payload(self, request): content_length = None if hasattr(request, 'getHeader'): content_length = request.getHeader('content-length') if content_length is not None: try: if int(content_length) > MAX_API_BODY: raise APIError(413, 'request_too_large') except ValueError: raise APIError(400, 'invalid_content_length') body = request.content.read() if len(body) > MAX_API_BODY: raise APIError(413, 'request_too_large') if not body: return {} try: payload = json.loads(body.decode('utf-8')) except (TypeError, ValueError): raise APIError(400, 'invalid_json') if not isinstance(payload, dict): raise APIError(400, 'invalid_json') return payload def _user_call(self, request, payload, handler): dmrid = payload.get('dmrid') key = payload.get('key') system = self.controller.validateKey(dmrid, key) if not system: raise APIError(401, 'invalid_credentials') result = handler(system, payload) return self._json(request, 200, {'ok': True, **result}) def _system_call(self, request, payload, handler): if not self.controller.validateSystemKey(payload.get('systemkey')): raise APIError(401, 'invalid_credentials') result = handler(payload) return self._json(request, 200, {'ok': True, **result}) def _reset(self, system, payload): self.controller.reset(system) return {'reset': True, 'system': system} def _get_options(self, system, payload): return self.controller.getoptions(system) def _set_options(self, system, payload): options = payload.get('options') if not isinstance(options, str): raise APIError(400, 'missing_options') self.controller.options(system, options) return {'updated': True, 'system': system} def _kill_server(self, payload): self.controller.killserver() return {'killserver': True} def _reset_all(self, payload): self.controller.resetAllConnections() return {'resetall': True} def _json(self, request, status, payload): request.setResponseCode(status) request.setHeader(b'content-type', b'application/json') return json.dumps(payload, separators=(',', ':')).encode('utf-8') def make_api_resource(CONFIG, BRIDGES, dial_control=None): return FD_APIResource(FD_APIController(CONFIG, BRIDGES, dial_control))