From f5632a5da5f1ad00c27ee423344dada6c099844e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 30 Sep 2018 14:45:48 +0200 Subject: [PATCH] Add webhook + IFTTT example (#16817) * Add webhook + IFTTT example * Abort if not externally accessible * Abort on local url * Add description to create entry * Make body optional * Allow ifttt setup without config * Add tests * Lint * Fix Lint + Tests * Fix typing --- homeassistant/components/auth/indieauth.py | 19 +-- homeassistant/components/ifttt.py | 74 ---------- .../components/ifttt/.translations/en.json | 18 +++ homeassistant/components/ifttt/__init__.py | 135 ++++++++++++++++++ homeassistant/components/ifttt/strings.json | 18 +++ homeassistant/components/webhook.py | 94 ++++++++++++ homeassistant/config_entries.py | 1 + homeassistant/data_entry_flow.py | 7 +- homeassistant/util/network.py | 22 +++ .../components/config/test_config_entries.py | 4 + tests/components/ifttt/__init__.py | 1 + tests/components/ifttt/test_init.py | 48 +++++++ tests/components/test_webhook.py | 98 +++++++++++++ 13 files changed, 448 insertions(+), 91 deletions(-) delete mode 100644 homeassistant/components/ifttt.py create mode 100644 homeassistant/components/ifttt/.translations/en.json create mode 100644 homeassistant/components/ifttt/__init__.py create mode 100644 homeassistant/components/ifttt/strings.json create mode 100644 homeassistant/components/webhook.py create mode 100644 homeassistant/util/network.py create mode 100644 tests/components/ifttt/__init__.py create mode 100644 tests/components/ifttt/test_init.py create mode 100644 tests/components/test_webhook.py diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index bcf73258ffa..30432a612a4 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,24 +1,13 @@ """Helpers to resolve client ID/secret.""" import asyncio +from ipaddress import ip_address from html.parser import HTMLParser -from ipaddress import ip_address, ip_network from urllib.parse import urlparse, urljoin import aiohttp from aiohttp.client_exceptions import ClientError -# IP addresses of loopback interfaces -ALLOWED_IPS = ( - ip_address('127.0.0.1'), - ip_address('::1'), -) - -# RFC1918 - Address allocation for Private Internets -ALLOWED_NETWORKS = ( - ip_network('10.0.0.0/8'), - ip_network('172.16.0.0/12'), - ip_network('192.168.0.0/16'), -) +from homeassistant.util.network import is_local async def verify_redirect_uri(hass, client_id, redirect_uri): @@ -185,9 +174,7 @@ def _parse_client_id(client_id): # Not an ip address pass - if (address is None or - address in ALLOWED_IPS or - any(address in network for network in ALLOWED_NETWORKS)): + if address is None or is_local(address): return parts raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py deleted file mode 100644 index 9497282ab21..00000000000 --- a/homeassistant/components/ifttt.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Support to trigger Maker IFTTT recipes. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/ifttt/ -""" -import logging - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pyfttt==0.3'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_EVENT = 'event' -ATTR_VALUE1 = 'value1' -ATTR_VALUE2 = 'value2' -ATTR_VALUE3 = 'value3' - -CONF_KEY = 'key' - -DOMAIN = 'ifttt' - -SERVICE_TRIGGER = 'trigger' - -SERVICE_TRIGGER_SCHEMA = vol.Schema({ - vol.Required(ATTR_EVENT): cv.string, - vol.Optional(ATTR_VALUE1): cv.string, - vol.Optional(ATTR_VALUE2): cv.string, - vol.Optional(ATTR_VALUE3): cv.string, -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_KEY): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) - - -def trigger(hass, event, value1=None, value2=None, value3=None): - """Trigger a Maker IFTTT recipe.""" - data = { - ATTR_EVENT: event, - ATTR_VALUE1: value1, - ATTR_VALUE2: value2, - ATTR_VALUE3: value3, - } - hass.services.call(DOMAIN, SERVICE_TRIGGER, data) - - -def setup(hass, config): - """Set up the IFTTT service component.""" - key = config[DOMAIN][CONF_KEY] - - def trigger_service(call): - """Handle IFTTT trigger service calls.""" - event = call.data[ATTR_EVENT] - value1 = call.data.get(ATTR_VALUE1) - value2 = call.data.get(ATTR_VALUE2) - value3 = call.data.get(ATTR_VALUE3) - - try: - import pyfttt - pyfttt.send_event(key, event, value1, value2, value3) - except requests.exceptions.RequestException: - _LOGGER.exception("Error communicating with IFTTT") - - hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service, - schema=SERVICE_TRIGGER_SCHEMA) - - return True diff --git a/homeassistant/components/ifttt/.translations/en.json b/homeassistant/components/ifttt/.translations/en.json new file mode 100644 index 00000000000..dae4b24de47 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages.", + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + }, + "step": { + "user": { + "description": "Are you sure you want to set up IFTTT?", + "title": "Set up the IFTTT Webhook Applet" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py new file mode 100644 index 00000000000..534217a7ba2 --- /dev/null +++ b/homeassistant/components/ifttt/__init__.py @@ -0,0 +1,135 @@ +""" +Support to trigger Maker IFTTT recipes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ifttt/ +""" +from ipaddress import ip_address +import logging +from urllib.parse import urlparse + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.util.network import is_local + +REQUIREMENTS = ['pyfttt==0.3'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +EVENT_RECEIVED = 'ifttt_webhook_received' + +ATTR_EVENT = 'event' +ATTR_VALUE1 = 'value1' +ATTR_VALUE2 = 'value2' +ATTR_VALUE3 = 'value3' + +CONF_KEY = 'key' +CONF_WEBHOOK_ID = 'webhook_id' + +DOMAIN = 'ifttt' + +SERVICE_TRIGGER = 'trigger' + +SERVICE_TRIGGER_SCHEMA = vol.Schema({ + vol.Required(ATTR_EVENT): cv.string, + vol.Optional(ATTR_VALUE1): cv.string, + vol.Optional(ATTR_VALUE2): cv.string, + vol.Optional(ATTR_VALUE3): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Required(CONF_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the IFTTT service component.""" + if DOMAIN not in config: + return True + + key = config[DOMAIN][CONF_KEY] + + def trigger_service(call): + """Handle IFTTT trigger service calls.""" + event = call.data[ATTR_EVENT] + value1 = call.data.get(ATTR_VALUE1) + value2 = call.data.get(ATTR_VALUE2) + value3 = call.data.get(ATTR_VALUE3) + + try: + import pyfttt + pyfttt.send_event(key, event, value1, value2, value3) + except requests.exceptions.RequestException: + _LOGGER.exception("Error communicating with IFTTT") + + hass.services.async_register(DOMAIN, SERVICE_TRIGGER, trigger_service, + schema=SERVICE_TRIGGER_SCHEMA) + + return True + + +async def handle_webhook(hass, webhook_id, data): + """Handle webhook callback.""" + if isinstance(data, dict): + data['webhook_id'] = webhook_id + hass.bus.async_fire(EVENT_RECEIVED, data) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + entry.data['webhook_id'], handle_webhook) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data['webhook_id']) + return True + + +@config_entries.HANDLERS.register(DOMAIN) +class ConfigFlow(config_entries.ConfigFlow): + """Handle an IFTTT config flow.""" + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + try: + url_parts = urlparse(self.hass.config.api.base_url) + + if is_local(ip_address(url_parts.hostname)): + return self.async_abort(reason='not_internet_accessible') + except ValueError: + # If it's not an IP address, it's very likely publicly accessible + pass + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + return self.async_create_entry( + title='IFTTT Webhook', + data={ + CONF_WEBHOOK_ID: webhook_id + }, + description_placeholders={ + 'applet_url': 'https://ifttt.com/maker_webhooks', + 'webhook_url': webhook_url, + 'docs_url': + 'https://www.home-assistant.io/components/ifttt/' + } + ) diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json new file mode 100644 index 00000000000..9fc47504b9b --- /dev/null +++ b/homeassistant/components/ifttt/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "IFTTT", + "step": { + "user": { + "title": "Set up the IFTTT Webhook Applet", + "description": "Are you sure you want to set up IFTTT?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary.", + "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages." + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + } + } +} diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py new file mode 100644 index 00000000000..0e44ffbab25 --- /dev/null +++ b/homeassistant/components/webhook.py @@ -0,0 +1,94 @@ +"""Webhooks for Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/webhook/ +""" +import json +import logging + +from aiohttp.web import Response + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.auth.util import generate_secret +from homeassistant.components.http.view import HomeAssistantView + +DOMAIN = 'webhook' +DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) + + +@callback +@bind_hass +def async_register(hass, webhook_id, handler): + """Register a webhook.""" + handlers = hass.data.setdefault(DOMAIN, {}) + + if webhook_id in handlers: + raise ValueError('Handler is already defined!') + + handlers[webhook_id] = handler + + +@callback +@bind_hass +def async_unregister(hass, webhook_id): + """Remove a webhook.""" + handlers = hass.data.setdefault(DOMAIN, {}) + handlers.pop(webhook_id, None) + + +@callback +def async_generate_id(): + """Generate a webhook_id.""" + return generate_secret(entropy=32) + + +@callback +@bind_hass +def async_generate_url(hass, webhook_id): + """Generate a webhook_id.""" + return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) + + +async def async_setup(hass, config): + """Initialize the webhook component.""" + hass.http.register_view(WebhookView) + return True + + +class WebhookView(HomeAssistantView): + """Handle incoming webhook requests.""" + + url = "/api/webhook/{webhook_id}" + name = "api:webhook" + requires_auth = False + + async def post(self, request, webhook_id): + """Handle webhook call.""" + hass = request.app['hass'] + handlers = hass.data.setdefault(DOMAIN, {}) + handler = handlers.get(webhook_id) + + # Always respond successfully to not give away if a hook exists or not. + if handler is None: + _LOGGER.warning( + 'Received message for unregistered webhook %s', webhook_id) + return Response(status=200) + + body = await request.text() + try: + data = json.loads(body) if body else {} + except ValueError: + _LOGGER.warning( + 'Received webhook %s with invalid JSON', webhook_id) + return Response(status=200) + + try: + response = await handler(hass, webhook_id, data) + if response is None: + response = Response(status=200) + return response + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error processing webhook %s", webhook_id) + return Response(status=200) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 83bf9d22de3..fcc8a1f92ac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -141,6 +141,7 @@ FLOWS = [ 'deconz', 'homematicip_cloud', 'hue', + 'ifttt', 'ios', 'mqtt', 'nest', diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index ecf9850a67c..57265cf696d 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -153,7 +153,10 @@ class FlowHandler: } @callback - def async_create_entry(self, *, title: str, data: Dict) -> Dict: + def async_create_entry(self, *, title: str, data: Dict, + description: Optional[str] = None, + description_placeholders: Optional[Dict] = None) \ + -> Dict: """Finish config flow and create a config entry.""" return { 'version': self.VERSION, @@ -162,6 +165,8 @@ class FlowHandler: 'handler': self.handler, 'title': title, 'data': data, + 'description': description, + 'description_placeholders': description_placeholders, } @callback diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py new file mode 100644 index 00000000000..48840f339c1 --- /dev/null +++ b/homeassistant/util/network.py @@ -0,0 +1,22 @@ +"""Network utilities.""" +from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network +from typing import Union + +# IP addresses of loopback interfaces +LOCAL_IPS = ( + ip_address('127.0.0.1'), + ip_address('::1'), +) + +# RFC1918 - Address allocation for Private Internets +LOCAL_NETWORKS = ( + ip_network('10.0.0.0/8'), + ip_network('172.16.0.0/12'), + ip_network('192.168.0.0/16'), +) + + +def is_local(address: Union[IPv4Address, IPv6Address]) -> bool: + """Check if an address is local.""" + return address in LOCAL_IPS or \ + any(address in network for network in LOCAL_NETWORKS) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 1e3b507727c..67d7eebbfec 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -206,6 +206,8 @@ def test_create_account(hass, client): 'title': 'Test Entry', 'type': 'create_entry', 'version': 1, + 'description': None, + 'description_placeholders': None, } @@ -266,6 +268,8 @@ def test_two_step_flow(hass, client): 'type': 'create_entry', 'title': 'user-title', 'version': 1, + 'description': None, + 'description_placeholders': None, } diff --git a/tests/components/ifttt/__init__.py b/tests/components/ifttt/__init__.py new file mode 100644 index 00000000000..2fe2f40276c --- /dev/null +++ b/tests/components/ifttt/__init__.py @@ -0,0 +1 @@ +"""Tests for the IFTTT component.""" diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py new file mode 100644 index 00000000000..61d6654ba55 --- /dev/null +++ b/tests/components/ifttt/test_init.py @@ -0,0 +1,48 @@ +"""Test the init file of IFTTT.""" +from unittest.mock import Mock, patch + +from homeassistant import data_entry_flow +from homeassistant.core import callback +from homeassistant.components import ifttt + + +async def test_config_flow_registers_webhook(hass, aiohttp_client): + """Test setting up IFTTT and sending webhook.""" + with patch('homeassistant.util.get_local_ip', return_value='example.com'): + result = await hass.config_entries.flow.async_init('ifttt', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + ifttt_events = [] + + @callback + def handle_event(event): + """Handle IFTTT event.""" + ifttt_events.append(event) + + hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), json={ + 'hello': 'ifttt' + }) + + assert len(ifttt_events) == 1 + assert ifttt_events[0].data['webhook_id'] == webhook_id + assert ifttt_events[0].data['hello'] == 'ifttt' + + +async def test_config_flow_aborts_external_url(hass, aiohttp_client): + """Test setting up IFTTT and sending webhook.""" + hass.config.api = Mock(base_url='http://192.168.1.10') + result = await hass.config_entries.flow.async_init('ifttt', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'not_internet_accessible' diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py new file mode 100644 index 00000000000..c87687292a8 --- /dev/null +++ b/tests/components/test_webhook.py @@ -0,0 +1,98 @@ +"""Test the webhook component.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.setup import async_setup_component + + +@pytest.fixture +def mock_client(hass, aiohttp_client): + """Create http client for webhooks.""" + hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +async def test_unregistering_webhook(hass, mock_client): + """Test unregistering a webhook.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + + hass.components.webhook.async_unregister(webhook_id) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + + +async def test_generate_webhook_url(hass): + """Test we generate a webhook url correctly.""" + hass.config.api = Mock(base_url='https://example.com') + url = hass.components.webhook.async_generate_url('some_id') + + assert url == 'https://example.com/api/webhook/some_id' + + +async def test_posting_webhook_nonexisting(hass, mock_client): + """Test posting to a nonexisting webhook.""" + resp = await mock_client.post('/api/webhook/non-existing') + assert resp.status == 200 + + +async def test_posting_webhook_invalid_json(hass, mock_client): + """Test posting to a nonexisting webhook.""" + hass.components.webhook.async_register('hello', None) + resp = await mock_client.post('/api/webhook/hello', data='not-json') + assert resp.status == 200 + + +async def test_posting_webhook_json(hass, mock_client): + """Test posting a webhook with JSON data.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={ + 'data': True + }) + assert resp.status == 200 + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2] == { + 'data': True + } + + +async def test_posting_webhook_no_data(hass, mock_client): + """Test posting a webhook with no data.""" + hooks = [] + webhook_id = hass.components.webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append(args) + + hass.components.webhook.async_register(webhook_id, handle) + + resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) + assert resp.status == 200 + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2] == {}