You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
211 lines
7.4 KiB
211 lines
7.4 KiB
#
|
|
###############################################################################
|
|
# Copyright (C) 2023 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
|
|
#
|
|
# 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 dmr_utils3.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))
|