import aiohttp
from aiohttp import hdrs, web, WSMsgType
from aiohttp.helpers import must_be_empty_body
import asyncio
from functools import lru_cache
from homeassistant.components import frontend, http
from ipaddress import ip_address
import json
from multidict import CIMultiDict
import re
from typing import TYPE_CHECKING, cast
from urllib.parse import urlencode, quote
from yarl import URL
from .const import DOMAIN, LOGGER as _LOGGER, API_BASE, URL_BASE, WorkMode, UIMode, RewriteMode
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from typing import Any, Callable, Iterable
from .config import IngressStore, IngressCfg, RewriteCfg, UserInfo
X_INGRESS_NAME = "X-Ingress-Name"
X_ORIGINAL_URL = "X-Original-Url"
X_HASS_ORIGIN = "X-Hass-Origin"
X_INGRESS_PATH = "X-Ingress-Path"
X_INGRESS_SUBPATH = "X-Ingress-Subpath"
HEADER_AUTO_PH = "$auto"
HEADER_USER_ID_PH = "$user_id"
HEADER_USER_NAME_PH = "$user_name"
HEADER_USERNAME_PH = "$username"
INGRESS_MODES = (WorkMode.INGRESS, WorkMode.SUBAPP)
METH_ALLOW_REDIRECT = (hdrs.METH_GET, hdrs.METH_HEAD)
INIT_HEADERS_FILTER = {
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_ENCODING,
hdrs.TRANSFER_ENCODING,
hdrs.SEC_WEBSOCKET_EXTENSIONS,
hdrs.SEC_WEBSOCKET_PROTOCOL,
hdrs.SEC_WEBSOCKET_VERSION,
hdrs.SEC_WEBSOCKET_KEY,
}
RESPONSE_HEADERS_FILTER = {
hdrs.TRANSFER_ENCODING,
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_TYPE,
hdrs.CONTENT_ENCODING,
}
MAX_SIMPLE_REQUEST_SIZE = 4194000
DISABLED_TIMEOUT = aiohttp.ClientTimeout(total=None)
class IngressView(http.HomeAssistantView): # type: ignore
"""ingress view to handle request."""
name = "api:ingress:proxy"
url = API_BASE + "/{name}/{path:.*}"
requires_auth = False
def __init__(
self, hass: "HomeAssistant", config: "IngressStore", websession: aiohttp.ClientSession
):
self._hass = hass
self._config = config
self._websession = websession
async def _handle_auth(self, request: web.Request) -> web.Response:
# check required header
name = request.headers.get(X_INGRESS_NAME)
url = request.headers.get(X_ORIGINAL_URL)
if not name or not url:
raise web.HTTPNotFound
# check ingress_token from query
url = URL(url)
cookie_name = self._config.cookie_name(name)
if cookie_name in url.query:
cfg, token = self._config.check_token(self._hass, url.query[cookie_name])
if cfg:
# valid, remove ingressToken if has X-Hass-Origin else return 200
hass_origin = request.headers.get(X_HASS_ORIGIN)
if hass_origin and request.method in METH_ALLOW_REDIRECT:
query = url.query.copy()
query.pop(cookie_name)
resp = web.HTTPUnauthorized(headers={hdrs.LOCATION: str(url.with_query(query))})
else:
resp = web.Response(headers=cfg.headers)
resp.set_cookie(cfg.cookie_name, token, httponly=True)
return resp
# check ingress_token from cookie
token = request.cookies.get(cookie_name, "")
if cfg := self._config.check_token(self._hass, token, False)[0]:
# valid, return 200
return web.Response(headers=cfg.headers)
# cookie invalid, try redirect to entry
if cfg := self._config.get(name):
params = {"replace": ""}
root = cfg.sub_path + "/"
if url.path.startswith(root):
url = URL.build(
path=url.path[len(root) :], query_string=url.query_string, fragment=url.fragment
)
params["index"] = str(url)
hass_origin = request.headers.get(X_HASS_ORIGIN, "")
url = f"{hass_origin}/{cfg.entry}?{urlencode(params)}"
raise web.HTTPUnauthorized(headers={hdrs.LOCATION: url})
raise web.HTTPNotFound
async def _handle_redirect(self, cfg: "IngressCfg", path: str) -> web.Response | None:
# find frontend config
hass_data, token = self._hass.data, cfg.token
def get_front_config():
def get_config(config: dict[str, "Any"]):
fields = ("url", "index")
config = {k: config[k] for k in fields if k in config}
if "index" in config:
config["index"] = path.lstrip("/")
config["ui_mode"] = UIMode.REPLACE
return config
for panel in hass_data[DOMAIN]["panels"]:
panel = hass_data[frontend.DATA_PANELS].get(panel)
if not panel:
continue
if panel.config.get("token") is token:
return get_config(panel.config)
for child in panel.config.get("children", {}).values():
if child.get("token") is token:
return get_config(child)
if not (config := get_front_config()):
return
# redirect to target url
html = f"""\
"""
return web.Response(text=html, content_type="text/html")
async def _handle(
self, request: web.Request, name: str, path: str
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
if name == "_" and path == "auth":
return await self._handle_auth(request)
cfg, token = self._config.check_token(self._hass, name)
if cfg and request.method in METH_ALLOW_REDIRECT:
# only redirect when get or head method
url = f"{API_BASE}/{cfg.name}/"
path = quote(path) + (f"?{request.query_string}" if request.query_string else "")
resp = web.HTTPFound(url + path)
# set self cookie
resp.set_cookie(cfg.cookie_name, token, path=url, httponly=True)
# set subapp's cookies
for cfg in cfg.sub_apps:
resp.headers.add(
hdrs.SET_COOKIE,
f"{cfg.cookie_name}={cfg.token['value']}; HttpOnly; Path={API_BASE}/{cfg.name}/",
)
raise resp
if not cfg:
token = request.cookies.get(self._config.cookie_name(name), "")
cfg = self._config.check_token(self._hass, token, False)[0]
user: UserInfo | None = None
if cfg and cfg.mode in INGRESS_MODES:
user = self._config.check_user_token(
request.cookies.get(self._config.user_cookie_name(), "")
)
if not user and not cfg.static_token:
cfg = None
if not cfg or cfg.mode not in INGRESS_MODES:
# cookie invalid, try redirect to entry
if cfg := cfg or self._config.get(name):
path = quote(path) + (f"?{request.query_string}" if request.query_string else "")
if cfg.mode == WorkMode.AUTH:
if (resp := await self._handle_redirect(cfg, path)) is not None:
return resp
path = urlencode({"replace": "", "index": path})
raise web.HTTPFound(f"/{cfg.entry}?{path}")
raise web.HTTPNotFound
url = _create_url(cfg, path)
try:
# Websocket
if _is_websocket(request):
return await self._handle_websocket(request, cfg, user, url)
# Request
return await self._handle_request(request, cfg, user, url)
except aiohttp.ClientError as err:
_LOGGER.debug("Ingress error with %s / %s: %s", cfg.name, url, err)
raise web.HTTPBadGateway from None
get = _handle
post = _handle
put = _handle
delete = _handle
patch = _handle
# options = _handle
head = _handle
async def _handle_websocket(
self, request: web.Request, cfg: "IngressCfg", user: "UserInfo | None", url: URL
) -> web.WebSocketResponse:
"""Ingress route for websocket."""
req_protocols: Iterable[str]
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
req_protocols = [
str(proto.strip())
for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
]
else:
req_protocols = ()
ws_server = web.WebSocketResponse(protocols=req_protocols, autoclose=False, autoping=False)
await ws_server.prepare(request)
# Support GET query
if request.query_string:
url = url.with_query(request.query_string)
# Start proxy
async with self._websession.ws_connect(
url,
headers=_init_header(request, cfg, user),
protocols=req_protocols,
autoclose=False,
autoping=False,
) as ws_client:
# Proxy requests
ws_client = cast(web.WebSocketResponse, ws_client)
await asyncio.wait(
[
asyncio.create_task(_websocket_forward(ws_server, ws_client)),
asyncio.create_task(_websocket_forward(ws_client, ws_server)),
],
return_when=asyncio.FIRST_COMPLETED,
)
return ws_server
async def _handle_request(
self, request: web.Request, cfg: "IngressCfg", user: "UserInfo | None", url: URL
) -> web.Response | web.StreamResponse:
"""Ingress route for request."""
data = request.content
if not request.body_exists or (
(clen := request.headers.get(hdrs.CONTENT_LENGTH))
and (clen := int(clen)) <= MAX_SIMPLE_REQUEST_SIZE
):
data = await data.read()
async with self._websession.request(
request.method,
url,
headers=_init_header(request, cfg, user),
params=request.query,
allow_redirects=False,
data=data,
timeout=DISABLED_TIMEOUT,
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)
if ctype := result.headers.get(hdrs.CONTENT_TYPE):
ctype = ctype.partition(";")[0].strip()
else:
ctype = "application/octet-stream"
rewrite_body = None
if cfg.rewrites:
path = url.path
for rule in cfg.rewrites:
if rule.path and not re.match(rule.path, path, re.I):
continue
if rule.mode == RewriteMode.HEADER:
for name, value in headers.items():
if rule.name and not re.match(rule.name, name, re.I):
continue
for i in range(len(value)):
value[i] = re.sub(rule.match, rule.replace, value[i])
elif rule.mode == RewriteMode.BODY:
if rule.name and not re.match(rule.name, ctype, re.I):
continue
rewrite_body = _make_rewrite(rule, rewrite_body)
headers = CIMultiDict((k, v) for k, vs in headers.items() for v in vs if v)
# Simple request
if rewrite_body or must_be_empty_body(request.method, result.status):
# Return Response
body = await result.read()
if rewrite_body:
body = rewrite_body(body)
return web.Response(
headers=headers, status=result.status, content_type=ctype, body=body
)
# Stream response
response = web.StreamResponse(status=result.status, headers=headers)
response.content_type = ctype
try:
await response.prepare(request)
async for data, _ in result.content.iter_chunks():
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError, ConnectionResetError) as err:
_LOGGER.debug("Stream error with %s / %s: %s", cfg.name, url, err)
return response
def _make_rewrite(
rule: "RewriteCfg", rewrite_body: "Callable[[bytes], bytes] | None"
) -> "Callable[[bytes], bytes]":
if rewrite_body is None:
rewrite_body = lambda body: body
return lambda body: re.sub(rule.match.encode(), rule.replace.encode(), rewrite_body(body))
@lru_cache
def _create_url(cfg: "IngressCfg", path: str) -> URL:
"""Create URL to service."""
base_path = f"{cfg.sub_path}/"
try:
url = cfg.origin.join(URL(base_path + quote(path.lstrip("/"))))
except ValueError as err:
raise web.HTTPBadRequest from err
if not url.path.startswith(base_path):
raise web.HTTPBadRequest
return url
@lru_cache(maxsize=32)
def _forwarded_for_header(forward_for: str | None, peer_name: str) -> str:
"""Create X-Forwarded-For header."""
connected_ip = ip_address(peer_name)
return f"{forward_for}, {connected_ip!s}" if forward_for else f"{connected_ip!s}"
def _init_header(
request: web.Request, cfg: "IngressCfg", user: "UserInfo | None"
) -> dict[str, str]:
"""Create initial header."""
headers: dict[str, str] = {}
for name, value in request.headers.items():
name = std_header_name(name)
if name in INIT_HEADERS_FILTER:
continue
if name == hdrs.COOKIE:
if not (value := cfg.remove_token_from_cookie(value)):
continue
headers[name] = value
for name, value in cfg.headers.items():
if value == HEADER_USERNAME_PH:
if value := user["username"] if user else None:
headers[name] = value
elif value == HEADER_USER_ID_PH:
if value := user["id"] if user else None:
headers[name] = value
elif value == HEADER_USER_NAME_PH:
if value := user["name"] if user else None:
headers[name] = value
elif value != HEADER_AUTO_PH:
headers[name] = value
# Ingress information
headers[X_INGRESS_PATH] = f"{API_BASE}/{cfg.name}"
headers[X_INGRESS_SUBPATH] = cfg.sub_path
# Set X-Forwarded-For
assert request.transport
if (peername := request.transport.get_extra_info("peername")) is None:
_LOGGER.error("Can't set forward_for header, missing peername")
raise web.HTTPBadRequest
headers[hdrs.X_FORWARDED_FOR] = _forwarded_for_header(
request.headers.get(hdrs.X_FORWARDED_FOR), peername[0]
)
# Set X-Forwarded-Host
if not (forward_host := request.headers.get(hdrs.X_FORWARDED_HOST)):
forward_host = request.host
headers[hdrs.X_FORWARDED_HOST] = forward_host
# Set X-Forwarded-Proto
if not (forward_proto := request.headers.get(hdrs.X_FORWARDED_PROTO)):
forward_proto = request.scheme
headers[hdrs.X_FORWARDED_PROTO] = forward_proto
# Replace Origin placeholder
if hdrs.ORIGIN in headers and cfg.headers.get(hdrs.ORIGIN) == HEADER_AUTO_PH:
headers[hdrs.ORIGIN] = f"{forward_proto}://{forward_host}"
return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, list[str]]:
"""Create response header."""
headers: dict[str, list[str]] = {}
for name, value in response.headers.items():
name = std_header_name(name)
if name in RESPONSE_HEADERS_FILTER:
continue
headers.setdefault(name, []).append(value)
return headers
def _is_websocket(request: web.Request) -> bool:
"""Return True if request is a websocket."""
headers = request.headers
return bool(
"upgrade" in headers.get(hdrs.CONNECTION, "").lower()
and headers.get(hdrs.UPGRADE, "").lower() == "websocket"
)
async def _websocket_forward(ws_from: web.WebSocketResponse, ws_to: web.WebSocketResponse) -> None:
"""Handle websocket message directly."""
try:
async for msg in ws_from:
if msg.type is WSMsgType.TEXT:
await ws_to.send_str(msg.data)
elif msg.type is WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type is WSMsgType.PING:
await ws_to.ping()
elif msg.type is WSMsgType.PONG:
await ws_to.pong()
elif ws_to.closed:
await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore
except RuntimeError:
_LOGGER.debug("Ingress Websocket runtime error")
except ConnectionResetError:
_LOGGER.debug("Ingress Websocket Connection Reset")
def _init():
from multidict import istr
special_hdrs = {}
for name in dir(hdrs):
if type(value := getattr(hdrs, name)) != istr or (key := value.title()) == value:
continue
special_hdrs[key] = value
def std_header_name(name: str):
name = name.title()
return special_hdrs.get(name, name)
return std_header_name
std_header_name = _init()