# ############################################################################### # 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 intentionally perform only in-memory dictionary reads/writes. 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): self.CONFIG = CONFIG self.BRIDGES = BRIDGES def validateKey(self, dmrid, key): 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 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/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): return FD_APIResource(FD_APIController(CONFIG, BRIDGES))