From e43fefa8f62726796968370f93bf7664b5feb71a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 27 Oct 2017 10:15:47 +0200 Subject: [PATCH] Support for NO-IP (#10155) * Support for NO-IP * Update URL --- homeassistant/components/no_ip.py | 113 ++++++++++++++++++++++++++++++ homeassistant/const.py | 2 + tests/components/test_no_ip.py | 87 +++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 homeassistant/components/no_ip.py create mode 100644 tests/components/test_no_ip.py diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py new file mode 100644 index 00000000000..d92cd752aef --- /dev/null +++ b/homeassistant/components/no_ip.py @@ -0,0 +1,113 @@ +""" +Integrate with NO-IP Dynamic DNS service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/no_ip/ +""" +import asyncio +import base64 +import logging +from datetime import timedelta + +import aiohttp +import async_timeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, HTTP_HEADER_AUTH, + HTTP_HEADER_USER_AGENT, PROJECT_EMAIL) +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'no_ip' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +NO_IP_ERRORS = { + 'nohost': "Hostname supplied does not exist under specified account", + 'badauth': "Invalid username password combination", + 'badagent': "Client disabled", + '!donator': + "An update request was sent with a feature that is not available", + 'abuse': "Username is blocked due to abuse", + '911': "A fatal error on NO-IP's side such as a database outage", +} + +UPDATE_URL = 'https://dynupdate.noip.com/nic/update' +USER_AGENT = "{} {}".format(SERVER_SOFTWARE, PROJECT_EMAIL) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the NO-IP component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + auth_str = base64.b64encode('{}:{}'.format(user, password).encode('utf-8')) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_no_ip( + hass, session, domain, auth_str, timeout) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the NO-IP entry.""" + yield from _update_no_ip(hass, session, domain, auth_str, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +@asyncio.coroutine +def _update_no_ip(hass, session, domain, auth_str, timeout): + """Update NO-IP.""" + url = UPDATE_URL + + params = { + 'hostname': domain, + } + + headers = { + HTTP_HEADER_AUTH: "Basic {}".format(auth_str.decode('utf-8')), + HTTP_HEADER_USER_AGENT: USER_AGENT, + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = yield from session.get(url, params=params, headers=headers) + body = yield from resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning("Updating NO-IP failed: %s => %s", domain, + NO_IP_ERRORS[body.strip()]) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to NO-IP API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) + + return False diff --git a/homeassistant/const.py b/homeassistant/const.py index 5d871816439..e0e2691d44b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -414,6 +414,8 @@ HTTP_DIGEST_AUTHENTICATION = 'digest' HTTP_HEADER_HA_AUTH = 'X-HA-access' HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding' +HTTP_HEADER_AUTH = 'Authorization' +HTTP_HEADER_USER_AGENT = 'User-Agent' HTTP_HEADER_CONTENT_TYPE = 'Content-type' HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding' HTTP_HEADER_VARY = 'Vary' diff --git a/tests/components/test_no_ip.py b/tests/components/test_no_ip.py new file mode 100644 index 00000000000..8e4e2d3e5b1 --- /dev/null +++ b/tests/components/test_no_ip.py @@ -0,0 +1,87 @@ +"""Test the NO-IP component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import no_ip +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'test.example.com' + +PASSWORD = 'xyz789' + +UPDATE_URL = no_ip.UPDATE_URL + +USERNAME = 'abc@123.com' + + +@pytest.fixture +def setup_no_ip(hass, aioclient_mock): + """Fixture that sets up NO-IP.""" + aioclient_mock.get( + UPDATE_URL, params={'hostname': DOMAIN}, text='good 0.0.0.0') + + hass.loop.run_until_complete(async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get( + UPDATE_URL, params={'hostname': DOMAIN}, text='nochg 0.0.0.0') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='nohost') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert not result + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_setup_fails_if_wrong_auth(hass, aioclient_mock): + """Test setup fails if first update fails through wrong authentication.""" + aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='badauth') + + result = yield from async_setup_component(hass, no_ip.DOMAIN, { + no_ip.DOMAIN: { + 'domain': DOMAIN, + 'username': USERNAME, + 'password': PASSWORD, + } + }) + assert not result + assert aioclient_mock.call_count == 1