"""Python Web Thing server implementation.""" from zeroconf import ServiceInfo, Zeroconf import json import socket import tornado.concurrent import tornado.gen import tornado.httpserver import tornado.ioloop import tornado.web import tornado.websocket from .errors import PropertyError from .subscriber import Subscriber from .utils import get_addresses, get_ip @tornado.gen.coroutine def perform_action(action): """Perform an Action in a coroutine.""" action.start() class SingleThing: """A container for a single thing.""" def __init__(self, thing): """ Initialize the container. thing -- the thing to store """ self.thing = thing def get_thing(self, _=None): """Get the thing at the given index.""" return self.thing def get_things(self): """Get the list of things.""" return [self.thing] def get_name(self): """Get the mDNS server name.""" return self.thing.title class MultipleThings: """A container for multiple things.""" def __init__(self, things, name): """ Initialize the container. things -- the things to store name -- the mDNS server name """ self.things = things self.name = name def get_thing(self, idx): """ Get the thing at the given index. idx -- the index """ try: idx = int(idx) except ValueError: return None if idx < 0 or idx >= len(self.things): return None return self.things[idx] def get_things(self): """Get the list of things.""" return self.things def get_name(self): """Get the mDNS server name.""" return self.name class BaseHandler(tornado.web.RequestHandler): """Base handler that is initialized with a thing.""" def initialize(self, things, hosts, disable_host_validation): """ Initialize the handler. things -- list of Things managed by this server hosts -- list of allowed hostnames disable_host_validation -- whether or not to disable host validation -- note that this can lead to DNS rebinding attacks """ self.things = things self.hosts = hosts self.disable_host_validation = disable_host_validation def prepare(self): """Validate Host header.""" host = self.request.headers.get('Host', None) if self.disable_host_validation or ( host is not None and host in self.hosts): return raise tornado.web.HTTPError(403) def get_thing(self, thing_id): """ Get the thing this request is for. thing_id -- ID of the thing to get, in string form Returns the thing, or None if not found. """ return self.things.get_thing(thing_id) def set_default_headers(self, *args, **kwargs): """Set the default headers for all requests.""" self.set_header('Access-Control-Allow-Origin', '*') self.set_header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') self.set_header('Access-Control-Allow-Methods', 'GET, HEAD, PUT, POST, DELETE') def options(self, *args, **kwargs): """Handle an OPTIONS request.""" self.set_status(204) class ThingsHandler(BaseHandler): """Handle a request to / when the server manages multiple things.""" def get(self): """ Handle a GET request. property_name -- the name of the property from the URL path """ self.set_header('Content-Type', 'application/json') ws_href = '{}://{}'.format( 'wss' if self.request.protocol == 'https' else 'ws', self.request.headers.get('Host', '') ) descriptions = [] for thing in self.things.get_things(): description = thing.as_thing_description() description['href'] = thing.get_href() description['links'].append({ 'rel': 'alternate', 'href': '{}{}'.format(ws_href, thing.get_href()), }) description['base'] = '{}://{}{}'.format( self.request.protocol, self.request.headers.get('Host', ''), thing.get_href() ) description['securityDefinitions'] = { 'nosec_sc': { 'scheme': 'nosec', }, } description['security'] = 'nosec_sc' descriptions.append(description) self.write(json.dumps(descriptions)) class ThingHandler(tornado.websocket.WebSocketHandler, Subscriber): """Handle a request to /.""" def initialize(self, things, hosts, disable_host_validation): """ Initialize the handler. things -- list of Things managed by this server hosts -- list of allowed hostnames disable_host_validation -- whether or not to disable host validation -- note that this can lead to DNS rebinding attacks """ self.things = things self.hosts = hosts self.disable_host_validation = disable_host_validation def prepare(self): """Validate Host header.""" host = self.request.headers.get('Host', None) if self.disable_host_validation or ( host is not None and host in self.hosts): return raise tornado.web.HTTPError(403) def set_default_headers(self, *args, **kwargs): """Set the default headers for all requests.""" self.set_header('Access-Control-Allow-Origin', '*') self.set_header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') self.set_header('Access-Control-Allow-Methods', 'GET, HEAD, PUT, POST, DELETE') def options(self, *args, **kwargs): """Handle an OPTIONS request.""" self.set_status(204) def get_thing(self, thing_id): """ Get the thing this request is for. thing_id -- ID of the thing to get, in string form Returns the thing, or None if not found. """ return self.things.get_thing(thing_id) @tornado.gen.coroutine def get(self, thing_id='0'): """ Handle a GET request, including websocket requests. thing_id -- ID of the thing this request is for """ self.thing = self.get_thing(thing_id) if self.thing is None: self.set_status(404) self.finish() return if self.request.headers.get('Upgrade', '').lower() == 'websocket': yield tornado.websocket.WebSocketHandler.get(self) return self.set_header('Content-Type', 'application/json') ws_href = '{}://{}'.format( 'wss' if self.request.protocol == 'https' else 'ws', self.request.headers.get('Host', '') ) description = self.thing.as_thing_description() description['links'].append({ 'rel': 'alternate', 'href': '{}{}'.format(ws_href, self.thing.get_href()), }) description['base'] = '{}://{}{}'.format( self.request.protocol, self.request.headers.get('Host', ''), self.thing.get_href() ) description['securityDefinitions'] = { 'nosec_sc': { 'scheme': 'nosec', }, } description['security'] = 'nosec_sc' self.write(json.dumps(description)) self.finish() def open(self): """Handle a new connection.""" self.thing.add_subscriber(self) def on_message(self, message): """ Handle an incoming message. message -- message to handle """ try: message = json.loads(message) except ValueError: try: self.write_message(json.dumps({ 'messageType': 'error', 'data': { 'status': '400 Bad Request', 'message': 'Parsing request failed', }, })) except tornado.websocket.WebSocketClosedError: pass return if 'messageType' not in message or 'data' not in message: try: self.write_message(json.dumps({ 'messageType': 'error', 'data': { 'status': '400 Bad Request', 'message': 'Invalid message', }, })) except tornado.websocket.WebSocketClosedError: pass return msg_type = message['messageType'] if msg_type == 'setProperty': for property_name, property_value in message['data'].items(): try: self.thing.set_property(property_name, property_value) except PropertyError as e: self.write_message(json.dumps({ 'messageType': 'error', 'data': { 'status': '400 Bad Request', 'message': str(e), }, })) elif msg_type == 'requestAction': for action_name, action_params in message['data'].items(): input_ = None if 'input' in action_params: input_ = action_params['input'] action = self.thing.perform_action(action_name, input_) if action: tornado.ioloop.IOLoop.current().spawn_callback( perform_action, action, ) else: self.write_message(json.dumps({ 'messageType': 'error', 'data': { 'status': '400 Bad Request', 'message': 'Invalid action request', 'request': message, }, })) elif msg_type == 'addEventSubscription': for event_name in message['data'].keys(): self.thing.add_event_subscriber(event_name, self) else: try: self.write_message(json.dumps({ 'messageType': 'error', 'data': { 'status': '400 Bad Request', 'message': 'Unknown messageType: ' + msg_type, 'request': message, }, })) except tornado.websocket.WebSocketClosedError: pass def on_close(self): """Handle a close event on the socket.""" self.thing.remove_subscriber(self) def check_origin(self, origin): """Allow connections from all origins.""" return True def update_property(self, property_): """ Send an update about a Property. :param property_: Property """ message = json.dumps({ 'messageType': 'propertyStatus', 'data': { property_.name: property_.get_value(), } }) self.write_message(message) def update_action(self, action): """ Send an update about an Action. :param action: Action """ message = json.dumps({ 'messageType': 'actionStatus', 'data': action.as_action_description(), }) self.write_message(message) def update_event(self, event): """ Send an update about an Event. :param event: Event """ message = json.dumps({ 'messageType': 'event', 'data': event.as_event_description(), }) self.write_message(message) class PropertiesHandler(BaseHandler): """Handle a request to /properties.""" def get(self, thing_id='0'): """ Handle a GET request. thing_id -- ID of the thing this request is for """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(thing.get_properties())) class PropertyHandler(BaseHandler): """Handle a request to /properties/.""" def get(self, thing_id='0', property_name=None): """ Handle a GET request. thing_id -- ID of the thing this request is for property_name -- the name of the property from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return if thing.has_property(property_name): self.set_header('Content-Type', 'application/json') self.write(json.dumps({ property_name: thing.get_property(property_name), })) else: self.set_status(404) def put(self, thing_id='0', property_name=None): """ Handle a PUT request. thing_id -- ID of the thing this request is for property_name -- the name of the property from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return try: args = json.loads(self.request.body.decode()) except ValueError: self.set_status(400) return if property_name not in args: self.set_status(400) return if thing.has_property(property_name): try: thing.set_property(property_name, args[property_name]) except PropertyError: self.set_status(400) return self.set_header('Content-Type', 'application/json') self.write(json.dumps({ property_name: thing.get_property(property_name), })) else: self.set_status(404) class ActionsHandler(BaseHandler): """Handle a request to /actions.""" def get(self, thing_id='0'): """ Handle a GET request. thing_id -- ID of the thing this request is for """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(thing.get_action_descriptions())) def post(self, thing_id='0'): """ Handle a POST request. thing_id -- ID of the thing this request is for """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return try: message = json.loads(self.request.body.decode()) except ValueError: self.set_status(400) return keys = list(message.keys()) if len(keys) != 1: self.set_status(400) return action_name = keys[0] action_params = message[action_name] input_ = None if 'input' in action_params: input_ = action_params['input'] action = thing.perform_action(action_name, input_) if action: response = action.as_action_description() # Start the action tornado.ioloop.IOLoop.current().spawn_callback( perform_action, action, ) self.set_status(201) self.write(json.dumps(response)) else: self.set_status(400) class ActionHandler(BaseHandler): """Handle a request to /actions/.""" def get(self, thing_id='0', action_name=None): """ Handle a GET request. thing_id -- ID of the thing this request is for action_name -- name of the action from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(thing.get_action_descriptions( action_name=action_name))) def post(self, thing_id='0', action_name=None): """ Handle a POST request. thing_id -- ID of the thing this request is for """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return try: message = json.loads(self.request.body.decode()) except ValueError: self.set_status(400) return keys = list(message.keys()) if len(keys) != 1: self.set_status(400) return if keys[0] != action_name: self.set_status(400) return action_params = message[action_name] input_ = None if 'input' in action_params: input_ = action_params['input'] action = thing.perform_action(action_name, input_) if action: response = action.as_action_description() # Start the action tornado.ioloop.IOLoop.current().spawn_callback( perform_action, action, ) self.set_status(201) self.write(json.dumps(response)) else: self.set_status(400) class ActionIDHandler(BaseHandler): """Handle a request to /actions//.""" def get(self, thing_id='0', action_name=None, action_id=None): """ Handle a GET request. thing_id -- ID of the thing this request is for action_name -- name of the action from the URL path action_id -- the action ID from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return action = thing.get_action(action_name, action_id) if action is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(action.as_action_description())) def put(self, thing_id='0', action_name=None, action_id=None): """ Handle a PUT request. TODO: this is not yet defined in the spec thing_id -- ID of the thing this request is for action_name -- name of the action from the URL path action_id -- the action ID from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_status(200) def delete(self, thing_id='0', action_name=None, action_id=None): """ Handle a DELETE request. thing_id -- ID of the thing this request is for action_name -- name of the action from the URL path action_id -- the action ID from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return if thing.remove_action(action_name, action_id): self.set_status(204) else: self.set_status(404) class EventsHandler(BaseHandler): """Handle a request to /events.""" def get(self, thing_id='0'): """ Handle a GET request. thing_id -- ID of the thing this request is for """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(thing.get_event_descriptions())) class EventHandler(BaseHandler): """Handle a request to /events/.""" def get(self, thing_id='0', event_name=None): """ Handle a GET request. thing_id -- ID of the thing this request is for event_name -- name of the event from the URL path """ thing = self.get_thing(thing_id) if thing is None: self.set_status(404) return self.set_header('Content-Type', 'application/json') self.write(json.dumps(thing.get_event_descriptions( event_name=event_name))) class WebThingServer: """Server to represent a Web Thing over HTTP.""" def __init__(self, things, port=80, hostname=None, ssl_options=None, additional_routes=None, base_path='', disable_host_validation=False): """ Initialize the WebThingServer. For documentation on the additional route format, see: https://www.tornadoweb.org/en/stable/web.html#tornado.web.Application things -- things managed by this server -- should be of type SingleThing or MultipleThings port -- port to listen on (defaults to 80) hostname -- Optional host name, i.e. mything.com ssl_options -- dict of SSL options to pass to the tornado server additional_routes -- list of additional routes to add to the server base_path -- base URL path to use, rather than '/' disable_host_validation -- whether or not to disable host validation -- note that this can lead to DNS rebinding attacks """ self.things = things self.name = things.get_name() self.port = port self.hostname = hostname self.base_path = base_path.rstrip('/') self.disable_host_validation = disable_host_validation system_hostname = socket.gethostname().lower() self.hosts = [ 'localhost', 'localhost:{}'.format(self.port), '{}.local'.format(system_hostname), '{}.local:{}'.format(system_hostname, self.port), ] for address in get_addresses(): self.hosts.extend([ address, '{}:{}'.format(address, self.port), ]) if self.hostname is not None: self.hostname = self.hostname.lower() self.hosts.extend([ self.hostname, '{}:{}'.format(self.hostname, self.port), ]) if isinstance(self.things, MultipleThings): for idx, thing in enumerate(self.things.get_things()): thing.set_href_prefix('{}/{}'.format(self.base_path, idx)) handlers = [ [ r'/?', ThingsHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/?', ThingHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/properties/?', PropertiesHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/properties/' + r'(?P[^/]+)/?', PropertyHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/actions/?', ActionsHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/actions/(?P[^/]+)/?', ActionHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/actions/' + r'(?P[^/]+)/(?P[^/]+)/?', ActionIDHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/events/?', EventsHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/(?P\d+)/events/(?P[^/]+)/?', EventHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], ] else: self.things.get_thing().set_href_prefix(self.base_path) handlers = [ [ r'/?', ThingHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/properties/?', PropertiesHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/properties/(?P[^/]+)/?', PropertyHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/actions/?', ActionsHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/actions/(?P[^/]+)/?', ActionHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/actions/(?P[^/]+)/(?P[^/]+)/?', ActionIDHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/events/?', EventsHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], [ r'/events/(?P[^/]+)/?', EventHandler, dict( things=self.things, hosts=self.hosts, disable_host_validation=self.disable_host_validation, ), ], ] if isinstance(additional_routes, list): handlers = additional_routes + handlers if self.base_path: for h in handlers: h[0] = self.base_path + h[0] self.app = tornado.web.Application(handlers) self.app.is_tls = ssl_options is not None self.server = tornado.httpserver.HTTPServer(self.app, ssl_options=ssl_options) def start(self): """Start listening for incoming connections.""" args = [ '_webthing._tcp.local.', '{}._webthing._tcp.local.'.format(self.name), ] kwargs = { 'addresses': [socket.inet_aton(get_ip())], 'port': self.port, 'properties': { 'path': '/', }, 'server': '{}.local.'.format(socket.gethostname()), } if self.app.is_tls: kwargs['properties']['tls'] = '1' self.service_info = ServiceInfo(*args, **kwargs) self.zeroconf = Zeroconf() self.zeroconf.register_service(self.service_info) self.server.listen(self.port) tornado.ioloop.IOLoop.current().start() def stop(self): """Stop listening.""" self.zeroconf.unregister_service(self.service_info) self.zeroconf.close() self.server.stop()