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.
249 lines
9.4 KiB
249 lines
9.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 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):
|
|
# This is a trusted backend operation, not peer OPTIONS authentication.
|
|
key = payload.get('systemkey')
|
|
if not isinstance(key, str) or not key or not self.validateSystemKey(key):
|
|
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 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):
|
|
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/dial':
|
|
result = self.controller.dial(payload)
|
|
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))
|