async def get_listen(request: core_api.APIRequest, session_id: str, timeout: Optional[int], access_level: int) -> List[GenericJSONDict]: if session_id is None: raise core_api.APIError(400, 'missing field: session_id') if not re.match('[a-zA-Z0-9]{1,32}', session_id): raise core_api.APIError(400, 'invalid field: session_id') if timeout is not None: try: timeout = int(timeout) except Exception: raise core_api.APIError(400, 'invalid field: timeout') from None if timeout < 1 or timeout > 3600: raise core_api.APIError(400, 'invalid field: timeout') else: timeout = 60 session = core_sessions.get(session_id) events = await session.reset_and_wait(timeout, access_level) return [await e.to_json() for e in events]
async def put_device(request: core_api.APIRequest, params: Attributes) -> None: core_api_schema.validate(params, core_device_attrs.get_schema(loose=True)) # Password fields must explicitly be ignored, so we pop them from supplied data for f in ('admin', 'normal', 'viewonly'): params.pop(f'{f}_password', None) # Ignore the date attribute params.pop('date', None) # Reset device attributes await core_device.reset(preserve_attrs=[ 'admin_password_hash', 'normal_password_hash', 'viewonly_password_hash' ]) await core_device.load() try: core_device_attrs.set_attrs(params, ignore_extra=True) except core_device_attrs.DeviceAttributeError as e: raise core_api.APIError(400, e.error, attribute=e.attribute) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_device.save() await core_device_events.trigger_update()
async def patch_device(request: core_api.APIRequest, params: Attributes) -> None: def unexpected_field_code(field: str) -> str: if field in core_device_attrs.get_attrdefs(): return 'attribute-not-modifiable' else: return 'no-such-attribute' core_api_schema.validate(params, core_device_attrs.get_schema(), unexpected_field_code=unexpected_field_code, unexpected_field_name='attribute') try: reboot_required = core_device_attrs.set_attrs(params) except core_device_attrs.DeviceAttributeError as e: raise core_api.APIError(400, e.error, attribute=e.attribute) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_device.save() await core_device_events.trigger_update() if reboot_required: main.loop.call_later(2, system.reboot)
async def wrap_error_with_index(index: int, func: Callable, *args, **kwargs) -> Any: try: result = func(*args, **kwargs) if inspect.isawaitable(result): result = await result except core_api.APIError as e: raise core_api.APIError( status=e.status, code=e.code, index=index, **e.params ) except asyncio.TimeoutError: raise core_api.APIError( status=504, code='device-timeout', index=index ) except Exception as e: raise core_api.APIError( status=500, code='unexpected-error', message=str(e), index=index ) return result
async def get_listen(request: core_api.APIRequest) -> GenericJSONList: session_id = request.headers.get('Session-Id') if not session_id: raise core_api.APIError(400, 'missing-header', header='Session-Id') timeout = request.query.get('timeout') if timeout is not None: try: timeout = int(timeout) except ValueError: raise core_api.APIError(400, 'invalid-field', field='timeout') from None if timeout < 1 or timeout > 3600: raise core_api.APIError(400, 'invalid-field', field='timeout') else: timeout = 60 # default session = core_sessions.get(session_id) events = await session.reset_and_wait(timeout, request.access_level) return [await e.to_json() for e in events]
async def delete_port(request: core_api.APIRequest, port_id: str) -> None: port = core_ports.get(port_id) if not port: raise core_api.APIError(404, 'no-such-port') if not isinstance(port, core_vports.VirtualPort): raise core_api.APIError(400, 'port-not-removable') await port.remove() await core_vports.remove(port_id)
async def slave_device_forward( request: core_api.APIRequest, name: str, path: str, params: Optional[GenericJSONDict] = None, internal_use: bool = False ) -> Any: slave = slaves_devices.get(name) if not slave: raise core_api.APIError(404, 'no-such-device') if not internal_use: if not path.startswith('/'): path = '/' + path if path.startswith('/listen'): raise core_api.APIError(404, 'no-such-function') intercepted, response = await slave.intercept_request(request.method, path, params, request) if intercepted: return response override_disabled = request.query.get('override_disabled') if not slave.is_enabled() and (override_disabled != 'true'): raise core_api.APIError(404, 'device-disabled') override_offline = request.query.get('override_offline') if (not slave.is_online() or not slave.is_ready()) and (override_offline != 'true'): raise core_api.APIError(503, 'device-offline') timeout = request.query.get('timeout') if timeout is not None: try: timeout = int(timeout) except ValueError: raise core_api.APIError(400, 'invalid-field', field='timeout') from None else: # Use default slave timeout unless API call requires longer timeout timeout = settings.slaves.timeout for m, path_re in _LONG_TIMEOUT_API_CALLS: if request.method == m and path_re.fullmatch(path): timeout = settings.slaves.long_timeout break try: response = await slave.api_call(request.method, path, params, timeout=timeout, retry_counter=None) except Exception as e: raise slaves_exceptions.adapt_api_error(e) from e return response
async def patch_port(request: core_api.APIRequest, port_id: str, params: Attributes) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') await set_port_attrs(port, params, ignore_extra_attrs=False)
async def _handle_api_call_exception(self, func: Callable, kwargs: dict, error: Exception) -> None: kwargs = dict(kwargs) params = kwargs.pop('params', None) args = json_utils.dumps(kwargs) body = params and json_utils.dumps(params) or '{}' if isinstance(error, core_responses.HTTPError): error = core_api.APIError(error.code, error.msg) if isinstance(error, core_api.APIError): logger.error('api call %s failed: %s (args=%s, body=%s)', func.__name__, error, args, body) self.set_status(error.status) if not self._finished: # Avoid finishing an already finished request await self.finish_json(dict({'error': error.message}, **error.params)) elif isinstance(error, StreamClosedError) and func.__name__ == 'get_listen': logger.debug('api call get_listen could not complete: stream closed') else: logger.error('api call %s failed: %s (args=%s, body=%s)', func.__name__, error, args, body, exc_info=True) self.set_status(500) if not self._finished: # Avoid finishing an already finished request await self.finish_json({'error': str(error)})
def adapt_api_error(error: Exception) -> Exception: if isinstance( error, (core_responses.HostUnreachable, core_responses.NetworkUnreachable, core_responses.UnresolvableHostname)): return core_api.APIError(502, 'unreachable') elif isinstance(error, core_responses.ConnectionRefused): return core_api.APIError(502, 'connection refused') elif isinstance(error, core_responses.InvalidJson): return core_api.APIError(502, 'invalid device') elif isinstance(error, core_responses.Timeout): return core_api.APIError(504, 'device timeout') elif isinstance(error, core_responses.HTTPError): return core_api.APIError.from_http_error(error) elif isinstance(error, DeviceOffline): return core_api.APIError(503, 'device offline') elif isinstance(error, InvalidDevice): return core_api.APIError(502, 'invalid device') elif isinstance(error, core_responses.AuthError): return core_api.APIError( 400, 'forbidden') # Yes, 400, since it's a slave authorization issue elif isinstance(error, core_api.APIError): return error else: # Leave error unchanged since it's probably an internal exception return error
async def delete_port_history(request: core_api.APIRequest, port_id: str) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') query = request.query from_str = query.get('from') if from_str is None: raise core_api.APIError(400, 'missing-field', field='from') try: from_timestamp = int(from_str) except ValueError: raise core_api.APIError(400, 'invalid-field', field='from') from None if from_timestamp < 0: raise core_api.APIError(400, 'invalid-field', field='from') to_str = query.get('to') if to_str is None: raise core_api.APIError(400, 'missing-field', field='to') try: to_timestamp = int(to_str) except ValueError: raise core_api.APIError(400, 'invalid-field', field='to') from None if to_timestamp < 0: raise core_api.APIError(400, 'invalid-field', field='to') await core_history.remove_samples([port], from_timestamp, to_timestamp)
async def get_discovered(request: core_api.APIRequest) -> GenericJSONList: timeout = request.query.get('timeout') if timeout is None: raise core_api.APIError(400, 'missing-field', field='timeout') try: timeout = int(timeout) except ValueError: raise core_api.APIError(400, 'invalid-field', field='timeout') from None discovered_devices = slaves_discover.get_discovered_devices() if discovered_devices is None: await slaves_discover.discover(timeout) return [ d.to_json() for d in slaves_discover.get_discovered_devices().values() ]
async def patch_port_value(request: core_api.APIRequest, port_id: str, params: PortValue) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') try: core_api_schema.validate(params, await port.get_value_schema()) except core_api.APIError: # Transform any validation error into an invalid-field APIError for value raise core_api.APIError(400, 'invalid-value') from None value = params # Step validation step = await port.get_attr('step') min_ = await port.get_attr('min') if None not in (step, min_) and step != 0 and (value - min_) % step: raise core_api.APIError(400, 'invalid-value') if not port.is_enabled(): raise core_api.APIError(400, 'port-disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only-port') old_value = port.get_last_read_value() try: await port.write_transformed_value(value, reason=core_ports.CHANGE_REASON_API) except core_ports.PortTimeout as e: raise core_api.APIError(504, 'port-timeout') from e except core_ports.PortError as e: raise core_api.APIError(502, 'port-error', code=str(e)) from e except core_api.APIError: raise except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_main.update() # If port value hasn't really changed, use 202 Accepted to inform consumer that new value hasn't been applied yet current_value = port.get_last_read_value() if (old_value == current_value) and (old_value != value): port.debug("API supplied value hasn't been applied right away") raise core_api.APIAccepted()
async def wrap_error_with_port_id(port_id: str, func: Callable, *args, **kwargs) -> Any: try: result = func(*args, **kwargs) if inspect.isawaitable(result): result = await result except core_api.APIError as e: raise core_api.APIError(status=e.status, code=e.code, id=port_id, **e.params) except Exception as e: raise core_api.APIError(status=500, code='unexpected-error', message=str(e), id=port_id) return result
async def patch_webhooks(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_WEBHOOKS) try: core_webhooks.setup(**params) except core_webhooks.InvalidParamError as e: raise core_api.APIError(400, str(e)) from e core_webhooks.save()
def get_request_json(self) -> Any: if self._json is self._UNDEFINED: try: self._json = json_utils.loads(self.request.body) except ValueError as e: logger.error('could not decode json from request body: %s', e) raise core_api.APIError(400, 'malformed-body') from e return self._json
async def patch_reverse(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_REVERSE) try: core_reverse.setup(**params) except core_reverse.InvalidParamError as e: raise core_api.APIError(400, str(e)) from e core_reverse.save()
async def patch_slave_device(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> None: core_api_schema.validate(params, api_schema.PATCH_SLAVE_DEVICE) slave = slaves_devices.get(name) if not slave: raise core_api.APIError(404, 'no such device') if params.get('enabled') is True and not slave.is_enabled(): await slave.enable() elif params.get('enabled') is False and slave.is_enabled(): await slave.disable() if params.get('poll_interval') and params.get('listen_enabled'): raise core_api.APIError(400, 'listening and polling') if params.get('poll_interval') is not None: slave.set_poll_interval(params['poll_interval']) if params.get('listen_enabled') is not None: if params['listen_enabled']: # We need to know if device supports listening; we therefore call GET /device before enabling it if slave.is_enabled(): try: attrs = await slave.api_call('GET', '/device') except Exception as e: raise exceptions.adapt_api_error(e) from e if 'listen' not in attrs['flags']: raise core_api.APIError(400, 'no listen support') slave.enable_listen() else: slave.disable_listen() slave.save() slave.trigger_update()
async def put_webhooks(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_WEBHOOKS) # Also ensure that needed fields are not empty when webhooks are enabled if params['enabled']: if not params['host']: raise core_api.APIError(400, 'invalid-field', field='host') if not params['path']: raise core_api.APIError(400, 'invalid-field', field='path') if 'password' not in params and 'password_hash' not in params: raise core_api.APIError(400, 'missing-field', field='password') try: core_webhooks.setup(**params) except core_webhooks.InvalidParamError as e: raise core_api.APIError(400, 'invalid-field', field=e.param) from e await core_webhooks.save()
async def patch_firmware(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_FIRMWARE) status = await fwupdate.get_status() if status not in (fwupdate.STATUS_IDLE, fwupdate.STATUS_ERROR): raise core_api.APIError(503, 'busy') if params.get('url'): await fwupdate.update_to_url(params['url']) else: # Assuming params['version'] await fwupdate.update_to_version(params['version'])
async def add_virtual_port(attrs: GenericJSONDict) -> core_ports.BasePort: id_ = attrs['id'] type_ = attrs['type'] min_ = attrs.get('min') max_ = attrs.get('max') integer = attrs.get('integer') step = attrs.get('step') choices = attrs.get('choices') core_api.logger.debug('adding port "%s"', id_) if core_ports.get(id_): raise core_api.APIError(400, 'duplicate-port') if len(core_vports.all_port_args()) >= settings.core.virtual_ports: raise core_api.APIError(400, 'too-many-ports') await core_vports.add(id_, type_, min_, max_, integer, step, choices) port = await core_ports.load_one( 'qtoggleserver.core.vports.VirtualPort', { 'id_': id_, 'type_': type_, 'min_': min_, 'max_': max_, 'integer': integer, 'step': step, 'choices': choices }, trigger_add= False # Will trigger add event manually, later, after we've enabled the port ) # A virtual port is enabled by default await port.enable() await port.save() await port.trigger_add() return port
async def get_port_value(request: core_api.APIRequest, port_id: str) -> NullablePortValue: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') if not port.is_enabled(): return # TODO # Given the fact that get_last_read_value() simply returns last cached read value, and the fact that API specs # indicate that 502/504 be returned by GET /port/[id]/value in case of errors, we should remember the last error # generated by read_value() and return it here, if any. return port.get_last_read_value()
async def patch_port_sequence(request: core_api.APIRequest, port_id: str, params: GenericJSONDict) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') core_api_schema.validate(params, core_api_schema.PATCH_PORT_SEQUENCE) values = params['values'] delays = params['delays'] repeat = params['repeat'] if len(values) != len(delays): raise core_api.APIError(400, 'invalid-field', field='delays') value_schema = await port.get_value_schema() step = await port.get_attr('step') min_ = await port.get_attr('min') for value in values: # Translate any APIError generated when validating value schema into an invalid-field APIError on value try: core_api_schema.validate(value, value_schema) except core_api.APIError: raise core_api.APIError(400, 'invalid-field', field='values') from None # Step validation if None not in (step, min_) and step != 0 and (value - min_) % step: raise core_api.APIError(400, 'invalid-field', field='values') if not port.is_enabled(): raise core_api.APIError(400, 'port-disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only-port') if await port.get_attr('expression'): raise core_api.APIError(400, 'port-with-expression') try: await port.set_sequence(values, delays, repeat) except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e
async def post_ports(request: core_api.APIRequest, params: GenericJSONDict) -> Attributes: core_api_schema.validate(params, core_api_schema.POST_PORTS) port_id = params['id'] port_type = params['type'] _min = params.get('min') _max = params.get('max') integer = params.get('integer') step = params.get('step') choices = params.get('choices') if core_ports.get(port_id): raise core_api.APIError(400, 'duplicate port') if len(core_vports.all_settings()) >= settings.core.virtual_ports: raise core_api.APIError(400, 'too many ports') core_vports.add(port_id, port_type, _min, _max, integer, step, choices) port = await core_ports.load_one( 'qtoggleserver.core.vports.VirtualPort', { 'port_id': port_id, '_type': port_type, '_min': _min, '_max': _max, 'integer': integer, 'step': step, 'choices': choices } ) # A virtual port is enabled by default await port.enable() await port.save() return await port.to_json()
async def patch_port(request: core_api.APIRequest, port_id: str, params: Attributes) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') def unexpected_field_msg(field: str) -> str: if field in port.get_non_modifiable_attrs(): return 'attribute not modifiable: {field}' else: return 'no such attribute: {field}' core_api_schema.validate(params, await port.get_schema(), unexpected_field_msg=unexpected_field_msg) # Step validation for name, value in params.items(): attrdef = port.ATTRDEFS[name] step = attrdef.get('step') _min = attrdef.get('min') if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, f'invalid field: {name}') errors_by_name = {} async def set_attr(attr_name: str, attr_value: Attribute) -> None: core_api.logger.debug('setting attribute %s = %s on %s', attr_name, json_utils.dumps(attr_value), port) try: await port.set_attr(attr_name, attr_value) except Exception as e: errors_by_name[attr_name] = e if params: await asyncio.wait([set_attr(name, value) for name, value in params.items()]) if errors_by_name: name, error = next(iter(errors_by_name.items())) if isinstance(error, core_api.APIError): raise error elif isinstance(error, core_ports.InvalidAttributeValue): raise core_api.APIError(400, f'invalid field: {name}') elif isinstance(error, core_ports.PortTimeout): raise core_api.APIError(504, 'port timeout') elif isinstance(error, core_ports.PortError): raise core_api.APIError(502, f'port error: {error}') else: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(error)) await port.save()
async def patch_discovered_device(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> GenericJSONDict: core_api_schema.validate(params, api_schema.PATCH_DISCOVERED_DEVICE) discovered_devices = slaves_discover.get_discovered_devices() or {} discovered_device = discovered_devices.get(name) if not discovered_device: raise core_api.APIError(404, 'no-such-device') attrs = params['attrs'] try: discovered_device = await slaves_discover.configure( discovered_device, attrs) except Exception as e: raise slaves_exceptions.adapt_api_error(e) from e return discovered_device.to_json()
def prepare(self) -> None: # Disable cache self.set_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0') if not self.AUTH_ENABLED: return # Parse auth header auth = self.request.headers.get('Authorization') if auth: try: usr = core_api_auth.parse_auth_header( auth, core_api_auth.ORIGIN_CONSUMER, core_api_auth.consumer_password_hash_func ) except core_api_auth.AuthError as e: logger.warning(str(e)) return else: if core_device_attrs.admin_password_hash == core_device_attrs.EMPTY_PASSWORD_HASH: logger.debug('authenticating request as admin due to empty admin password') usr = '******' else: logger.warning('missing authorization header') return self.access_level = core_api.ACCESS_LEVEL_MAPPING[usr] self.username = usr logger.debug( 'granted access level %s (username=%s)', core_api.ACCESS_LEVEL_MAPPING[self.access_level], self.username ) # Validate session id session_id = self.request.headers.get('Session-Id') if session_id: if not SESSION_ID_RE.match(session_id): raise core_api.APIError(400, 'invalid-header', header='Session-Id')
async def patch_port_value(request: core_api.APIRequest, port_id: str, params: PortValue) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') core_api_schema.validate(params, await port.get_value_schema(), invalid_request_msg='invalid value') value = params # Step validation step = await port.get_attr('step') _min = await port.get_attr('min') if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, 'invalid field: value') if not port.is_enabled(): raise core_api.APIError(400, 'port disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only port') old_value = port.get_value() try: await port.write_transformed_value(value, reason=core_ports.CHANGE_REASON_API) except core_ports.PortTimeout as e: raise core_api.APIError(504, 'port timeout') from e except core_ports.PortError as e: raise core_api.APIError(502, f'port error: {e}') from e except core_api.APIError: raise except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(e)) from e await main.update() # If port value hasn't really changed, trigger a value-change to inform consumer that new value has been ignored current_value = port.get_value() if old_value == current_value: port.debug('API supplied value was ignored') port.trigger_value_change()
async def post_slave_device_events(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> None: slave = slaves_devices.get(name) if not slave: raise core_api.APIError(404, 'no-such-device') # Slave events endpoint has special privilege requirements: its token signature must be validated using slave admin # password auth = request.headers.get('Authorization') if not auth: slave.warning('missing authorization header') raise core_api.APIError(401, 'authentication-required') try: core_api_auth.parse_auth_header( auth, core_api_auth.ORIGIN_DEVICE, lambda u: slave.get_admin_password_hash(), require_usr=False ) except core_api_auth.AuthError as e: slave.warning(str(e)) raise core_api.APIError(401, 'authentication-required') from e core_api_schema.validate(params, api_schema.POST_SLAVE_DEVICE_EVENTS) if slave.get_poll_interval() > 0: raise core_api.APIError(400, 'polling-enabled') if slave.is_listen_enabled(): raise core_api.APIError(400, 'listening-enabled') # At this point we can be sure the slave is permanently offline try: await slave.handle_event(params) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e slave.update_last_sync() await slave.save() # As soon as we receive event requests (normally generated by a webhook), we can consider the device is temporarily # reachable, so we apply provisioning values and update data locally slave.schedule_provisioning_and_update(1)
async def post_port_sequence(request: core_api.APIRequest, port_id: str, params: GenericJSONDict) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') core_api_schema.validate(params, core_api_schema.POST_PORT_SEQUENCE) values = params['values'] delays = params['delays'] repeat = params['repeat'] if len(values) != len(delays): raise core_api.APIError(400, 'invalid field: delays') value_schema = await port.get_value_schema() step = await port.get_attr('step') _min = await port.get_attr('min') for value in values: core_api_schema.validate(value, value_schema, invalid_request_msg='invalid field: values') # Step validation if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, 'invalid field: values') if not port.is_enabled(): raise core_api.APIError(400, 'port disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only port') if await port.get_attr('expression'): raise core_api.APIError(400, 'port with expression') try: await port.set_sequence(values, delays, repeat) except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(e)) from e