-
Notifications
You must be signed in to change notification settings - Fork 56
/
remote.py
376 lines (303 loc) · 11.8 KB
/
remote.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""Manage remote UI connections."""
import asyncio
from datetime import datetime, timedelta
import logging
import random
import ssl
from typing import Optional
import async_timeout
import attr
from snitun.exceptions import SniTunConnectionError
from snitun.utils.aes import generate_aes_keyset
from snitun.utils.aiohttp_client import SniTunClientAioHttp
from . import cloud_api, utils, const
from .acme import AcmeClientError, AcmeHandler
_LOGGER = logging.getLogger(__name__)
RENEW_IF_EXPIRES_DAYS = 25
WARN_RENEW_FAILED_DAYS = 18
class RemoteError(Exception):
"""General remote error."""
class RemoteBackendError(RemoteError):
"""Backend problem with nabucasa API."""
class RemoteNotConnected(RemoteError):
"""Raise if a request need connection and we are not ready."""
class SubscriptionExpired(RemoteError):
"""Raise if we cannot connect because subscription expired."""
@attr.s
class SniTunToken:
"""Handle snitun token."""
fernet = attr.ib(type=bytes)
aes_key = attr.ib(type=bytes)
aes_iv = attr.ib(type=bytes)
valid = attr.ib(type=datetime)
throttling = attr.ib(type=int)
@attr.s
class Certificate:
"""Handle certificate details."""
common_name = attr.ib(type=str)
expire_date = attr.ib(type=datetime)
fingerprint = attr.ib(type=str)
class RemoteUI:
"""Class to help manage remote connections."""
def __init__(self, cloud):
"""Initialize cloudhooks."""
self.cloud = cloud
self._acme = None
self._snitun = None
self._snitun_server = None
self._instance_domain = None
self._reconnect_task = None
self._acme_task = None
self._token = None
# Register start/stop
cloud.register_on_start(self.load_backend)
cloud.register_on_stop(self.close_backend)
@property
def snitun_server(self) -> Optional[str]:
"""Return connected snitun server."""
return self._snitun_server
@property
def instance_domain(self) -> Optional[str]:
"""Return instance domain."""
return self._instance_domain
@property
def is_connected(self) -> bool:
"""Return true if we are ready to connect."""
if not self._snitun:
return False
return self._snitun.is_connected
@property
def certificate(self) -> Optional[Certificate]:
"""Return certificate details."""
if not self._acme or not self._acme.certificate_available:
return None
return Certificate(
self._acme.common_name, self._acme.expire_date, self._acme.fingerprint
)
async def _create_context(self) -> ssl.SSLContext:
"""Create SSL context with acme certificate."""
context = utils.server_context_modern()
await self.cloud.run_executor(
context.load_cert_chain,
self._acme.path_fullchain,
self._acme.path_private_key,
)
return context
async def load_backend(self) -> None:
"""Load backend details."""
if self._snitun:
return
# Setup background task for ACME certification handler
if not self._acme_task:
self._acme_task = self.cloud.run_task(self._certificate_handler())
# Load instance data from backend
try:
async with async_timeout.timeout(30):
resp = await cloud_api.async_remote_register(self.cloud)
assert resp.status == 200
except (asyncio.TimeoutError, AssertionError):
_LOGGER.error("Can't update remote details from Home Assistant cloud")
return
data = await resp.json()
# Extract data
_LOGGER.debug("Retrieve instance data: %s", data)
domain = data["domain"]
email = data["email"]
server = data["server"]
# Cache data
self._instance_domain = domain
self._snitun_server = server
# Set instance details for certificate
self._acme = AcmeHandler(self.cloud, domain, email)
# Load exists certificate
await self._acme.load_certificate()
# Domain changed / revoke CA
ca_domain = self._acme.common_name
if ca_domain and ca_domain != domain:
_LOGGER.warning("Invalid certificate found: %s", ca_domain)
await self._acme.reset_acme()
self.cloud.run_task(self._finish_load_backend())
async def _finish_load_backend(self) -> None:
"""Finish loading the backend."""
# Issue a certificate
if not self._acme.is_valid_certificate:
try:
await self._acme.issue_certificate()
except AcmeClientError:
self.cloud.client.user_message(
"cloud_remote_acme",
"Home Assistant Cloud",
const.MESSAGE_REMOTE_SETUP,
)
return
else:
self.cloud.client.user_message(
"cloud_remote_acme",
"Home Assistant Cloud",
const.MESSAGE_REMOTE_READY,
)
await self._acme.hardening_files()
_LOGGER.debug("Waiting for aiohttp runner to come available")
# aiohttp_runner comes available when Home Assistant has started.
while self.cloud.client.aiohttp_runner is None:
await asyncio.sleep(1)
# Setup snitun / aiohttp wrapper
_LOGGER.debug("Initializing remote backend")
context = await self._create_context()
self._snitun = SniTunClientAioHttp(
self.cloud.client.aiohttp_runner,
context,
snitun_server=self._snitun_server,
snitun_port=443,
)
_LOGGER.debug("Starting remote backend")
await self._snitun.start()
self.cloud.client.dispatcher_message(const.DISPATCH_REMOTE_BACKEND_UP)
_LOGGER.debug(
"Connecting remote backend: %s", self.cloud.client.remote_autostart
)
# Connect to remote is autostart enabled
if self.cloud.client.remote_autostart:
self.cloud.run_task(self.connect())
async def close_backend(self) -> None:
"""Close connections and shutdown backend."""
# Close reconnect task
if self._reconnect_task:
self._reconnect_task.cancel()
# Close ACME certificate handler
if self._acme_task:
self._acme_task.cancel()
# Disconnect snitun
if self._snitun:
await self._snitun.stop()
# Cleanup
self._snitun = None
self._acme = None
self._token = None
self._instance_domain = None
self._snitun_server = None
self.cloud.client.dispatcher_message(const.DISPATCH_REMOTE_BACKEND_DOWN)
async def handle_connection_requests(self, caller_ip: str) -> None:
"""Handle connection requests."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
raise RemoteNotConnected()
if self._snitun.is_connected:
return
await self.connect()
async def _refresh_snitun_token(self) -> None:
"""Handle snitun token."""
if self._token and self._token.valid > utils.utcnow():
_LOGGER.debug("Don't need refresh snitun token")
return
if self.cloud.subscription_expired:
raise SubscriptionExpired()
# Generate session token
aes_key, aes_iv = generate_aes_keyset()
try:
async with async_timeout.timeout(30):
resp = await cloud_api.async_remote_token(self.cloud, aes_key, aes_iv)
if resp.status != 200:
raise RemoteBackendError()
except asyncio.TimeoutError:
raise RemoteBackendError() from None
data = await resp.json()
self._token = SniTunToken(
data["token"].encode(),
aes_key,
aes_iv,
utils.utc_from_timestamp(data["valid"]),
data["throttling"],
)
async def connect(self) -> None:
"""Connect to snitun server."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
raise RemoteNotConnected()
# Check if we already connected
if self._snitun.is_connected:
return
try:
await self._refresh_snitun_token()
await self._snitun.connect(
self._token.fernet,
self._token.aes_key,
self._token.aes_iv,
throttling=self._token.throttling,
)
self.cloud.client.dispatcher_message(const.DISPATCH_REMOTE_CONNECT)
except SniTunConnectionError:
_LOGGER.error("Connection problem to snitun server")
except RemoteBackendError:
_LOGGER.error("Can't refresh the snitun token")
except SubscriptionExpired:
pass
except AttributeError:
pass # Ignore because HA shutdown on snitun token refresh
finally:
# start retry task
if self._snitun and not self._reconnect_task:
self._reconnect_task = self.cloud.run_task(self._reconnect_snitun())
async def disconnect(self) -> None:
"""Disconnect from snitun server."""
if not self._snitun:
_LOGGER.error("Can't handle request-connection without backend")
raise RemoteNotConnected()
# Stop reconnect task
if self._reconnect_task:
self._reconnect_task.cancel()
# Check if we already connected
if not self._snitun.is_connected:
return
await self._snitun.disconnect()
self.cloud.client.dispatcher_message(const.DISPATCH_REMOTE_DISCONNECT)
async def _reconnect_snitun(self) -> None:
"""Reconnect after disconnect."""
try:
while True:
if self._snitun.is_connected:
await self._snitun.wait()
self.cloud.client.dispatcher_message(const.DISPATCH_REMOTE_DISCONNECT)
await asyncio.sleep(random.randint(1, 15))
await self.connect()
except asyncio.CancelledError:
pass
finally:
_LOGGER.debug("Close remote UI reconnect guard")
self._reconnect_task = None
async def _certificate_handler(self) -> None:
"""Handle certification ACME Tasks."""
try:
while True:
await asyncio.sleep(utils.next_midnight() + random.randint(1, 3600))
# Backend not initialize / No certificate issue now
if not self._snitun:
await self.load_backend()
continue
# Renew certificate?
if self._acme.expire_date > utils.utcnow() + timedelta(
days=RENEW_IF_EXPIRES_DAYS
):
continue
# Renew certificate
try:
await self._acme.issue_certificate()
await self.close_backend()
# Wait until backend is cleaned
await asyncio.sleep(5)
await self.load_backend()
except AcmeClientError:
# Only log as warning if we have a certain amount of days left
if (
self._acme.expire_date
> utils.utcnow()
< timedelta(days=WARN_RENEW_FAILED_DAYS)
):
meth = _LOGGER.warning
else:
meth = _LOGGER.debug
meth("Renewal of ACME certificate failed. Please try again later.")
except asyncio.CancelledError:
pass
finally:
self._acme_task = None