diff --git a/config.yaml b/config.yaml index 1d4f1b73..028352be 100644 --- a/config.yaml +++ b/config.yaml @@ -30,13 +30,15 @@ homeassistant: true homeassistant_api: true # Ports exposed by the add-on +# 8504 is listed without a host port, so it stays unpublished by default. Users that run +# an external reverse proxy can map it in the add-on network settings. ports: 8503/tcp: 8503 -# 8504/tcp: 8504 + 8504/tcp: null ports_description: 8503/tcp: "EOS REST server" -# 8504/tcp: "EOSdash dashboard server" + 8504/tcp: "EOSdash dashboard server (optional, needed for an external reverse proxy)" # EOSdash interface (if not ingress) # webui: "http://[HOST]:[PORT:8504]" diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 1d46008d..71aca761 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -392,6 +392,7 @@ "startup_eosdash": true, "eosdash_host": "127.0.0.1", "eosdash_port": 8504, + "eosdash_public_url": "https://energy.example.com/dashboard", "eosdash_supervise_interval_sec": 10, "run_as_user": null, "reload": true diff --git a/docs/_generated/configserver.md b/docs/_generated/configserver.md index 6890ebba..4ff57aee 100644 --- a/docs/_generated/configserver.md +++ b/docs/_generated/configserver.md @@ -9,6 +9,7 @@ | ---- | -------------------- | ---- | --------- | ------- | ----------- | | eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `str` | `rw` | `127.0.0.1` | EOSdash server IP address. Defaults to EOS server IP address. | | eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `int` | `rw` | `8504` | EOSdash server IP port number. Defaults to 8504. | +| eosdash_public_url | `EOS_SERVER__EOSDASH_PUBLIC_URL` | `Optional[str]` | `rw` | `None` | Public EOSdash base URL for redirects and error-page links, including an optional proxy path prefix. Set this for reverse proxies or mapped ports; it does not change the bind address. Without it, direct access uses the request host and EOSdash port. Raw forwarded headers are not used. | | eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. | | host | `EOS_SERVER__HOST` | `str` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. | | port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. | @@ -33,6 +34,7 @@ "startup_eosdash": true, "eosdash_host": "127.0.0.1", "eosdash_port": 8504, + "eosdash_public_url": "https://energy.example.com/dashboard", "eosdash_supervise_interval_sec": 10, "run_as_user": null, "reload": true diff --git a/docs/akkudoktoreos/serverapi.md b/docs/akkudoktoreos/serverapi.md index 7f0dfdaa..2e84da26 100644 --- a/docs/akkudoktoreos/serverapi.md +++ b/docs/akkudoktoreos/serverapi.md @@ -8,3 +8,24 @@ :relative-docs: .. :relative-images: ``` + +## Dashboard redirects behind a reverse proxy + +For direct access, EOS redirects to the request host with the configured +`server.eosdash_port`. IPv4, hostnames and bracketed IPv6 addresses are supported. + +If a reverse proxy exposes EOSdash through HTTPS, another public port or a path +prefix, set `server.eosdash_public_url` to the externally reachable dashboard base +URL, for example `https://energy.example.com` or +`https://energy.example.com:9443/dashboard`. EOS preserves this scheme, port and +prefix for redirects and the dashboard link on error pages. Configure the proxy +to route that base URL to EOSdash; this setting does not configure the proxy or +change the dashboard's bind address. + +The base URL must not include credentials, a query or a fragment. A request for +`/eosdash/health` with the second example redirects to +`https://energy.example.com:9443/dashboard/eosdash/health`. Raw +`X-Forwarded-Host` and `X-Forwarded-Proto` headers do not override the configured +URL. Without an explicit public URL, scheme handling follows the ASGI server's +trusted-proxy configuration; EOS cannot infer an external dashboard route from +forwarding headers. diff --git a/docs/develop/install.md b/docs/develop/install.md index ed5a5d65..3899428c 100644 --- a/docs/develop/install.md +++ b/docs/develop/install.md @@ -337,6 +337,25 @@ In the dashboard, go to: Config ``` +### 6) Access EOSdash through an external reverse proxy (M5) + +Home Assistant Ingress needs no further setup. An external reverse proxy needs two +settings, because EOSdash runs on its own port: + +1. Map the optional add-on port `8504` in: + + ```bash + Settings → Add-ons → Akkudoktor-EOS → Configuration → Network + ``` + + The port is unpublished by default. Route the proxy to it and set + `server.eosdash_host` to `0.0.0.0`, so EOSdash accepts connections from the proxy. + +2. Set `server.eosdash_public_url` to the address the browser uses, for example + `https://eos.example.com:8504`. EOS redirects to that address instead of guessing one + from the request. Without it, EOS only redirects to hosts it knows, such as + `localhost` or its own IP address, and answers with an error page otherwise. + ## Helpful Docker Commands ### View logs diff --git a/openapi.json b/openapi.json index 362f12ee..661705d0 100644 --- a/openapi.json +++ b/openapi.json @@ -15076,6 +15076,21 @@ 8504 ] }, + "eosdash_public_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Eosdash Public Url", + "description": "Public EOSdash base URL for redirects and error-page links, including an optional proxy path prefix. Set this for reverse proxies or mapped ports; it does not change the bind address. Without it, direct access uses the request host and EOSdash port. Raw forwarded headers are not used.", + "examples": [ + "https://energy.example.com/dashboard" + ] + }, "eosdash_supervise_interval_sec": { "type": "integer", "title": "Eosdash Supervise Interval Sec", diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 8bf834fd..621e98dc 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,6 +10,7 @@ import traceback from contextlib import asynccontextmanager from enum import Enum from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union +from urllib.parse import urlsplit import psutil import uvicorn @@ -2376,38 +2377,96 @@ def _sanitize_redirect_path(path: str) -> Optional[str]: return "/".join(parts) +def _trusted_request_hosts() -> set[str]: + """Host names that may be taken from a request to address EOSdash. + + The `Host` header is sent by the client and is not trusted. Reflecting it into an + absolute redirect would turn every EOS server into an open redirect. Only hosts that + the configuration already knows are accepted. Deployments behind a reverse proxy + announce their public address by `server.eosdash_public_url` instead. + + Returns: + set[str]: Lower case host names, without brackets around IPv6 addresses. + """ + settings = get_config().server + hosts = {"localhost", "127.0.0.1", "::1"} + for host in (settings.host, settings.eosdash_host): + if host and str(host) not in ("0.0.0.0", "::"): # noqa: S104 + hosts.add(str(host).lower()) + hosts.add(get_host_ip()) + return hosts + + +def _eosdash_base_url(request: Request) -> Optional[str]: + """Return the configured public dashboard URL or a direct-access URL. + + A proxy's public dashboard route cannot be inferred from its EOS API route. + Configure eosdash_public_url for TLS termination, port mappings or prefixes. + + Args: + request: The request to take the host from for direct access. + + Returns: + Optional[str]: Base URL of EOSdash without trailing slash. `None` if the request + host is not a trusted host and no public URL is configured. + """ + settings = get_config().server + if settings.eosdash_public_url: + return settings.eosdash_public_url.rstrip("/") + port = settings.eosdash_port or 8504 + scheme = request.url.scheme + if scheme not in ("http", "https"): + scheme = "http" + # Request.url honours the Host header and parses bracketed IPv6 correctly. + # Proxy scheme handling belongs to the ASGI server's trusted proxy middleware; + # do not trust arbitrary raw X-Forwarded-* headers here. + host = urlsplit(str(request.url)).hostname or "" + if not host or host in ("0.0.0.0", "::"): # noqa: S104 + host = str(settings.eosdash_host or settings.host) + if host in ("0.0.0.0", "::"): # noqa: S104 + host = get_host_ip() + if host.lower() not in _trusted_request_hosts(): + return None + if ":" in host: + host = f"[{host}]" + return f"{scheme}://{host}:{port}" + + +def _eosdash_unknown_page(request: Request, status_code: int) -> HTMLResponse: + """Error page for a request that can not be answered with an EOSdash address.""" + error_page = create_error_page( + status_code=str(status_code), + error_title="EOSdash Address Unknown", + error_message=( + f"URL is unknown: '{request.url}'. EOSdash can not be addressed for host " + f"'{request.url.netloc}'. Set 'server.eosdash_public_url' to the public " + "address of EOSdash." + ), + error_details="Untrusted request host", + ) + return HTMLResponse(content=error_page, status_code=status_code) + + def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]: + base_url = _eosdash_base_url(request) + # Path is not for EOSdash if not (path.startswith("eosdash") or path == ""): - host = get_config().server.eosdash_host - if host is None: - host = get_config().server.host - host = str(host) - port = get_config().server.eosdash_port - if port is None: - port = 8504 - if host == "0.0.0.0": # noqa: S104 - # Use IP of EOS host - host = get_host_ip() - url = f"http://{host}:{port}/" + if base_url is None: + return _eosdash_unknown_page(request, 404) error_page = create_error_page( status_code="404", error_title="Page Not Found", - error_message=f"""
-URL is unknown: '{request.url}'
-Did you want to connect to EOSdash?
-
-""",
+ error_message=f"URL is unknown: '{request.url}'. Did you want to connect to EOSdash?",
error_details="Unknown URL",
+ link_url=f"{base_url}/",
+ link_label="Open EOSdash",
)
return HTMLResponse(content=error_page, status_code=404)
- host = str(get_config().server.eosdash_host)
- if host == "0.0.0.0": # noqa: S104
- # Use IP of EOS host
- host = get_host_ip()
- if host and get_config().server.eosdash_port:
- base_url = f"http://{host}:{get_config().server.eosdash_port}"
+ if get_config().server.eosdash_port:
+ if base_url is None:
+ return _eosdash_unknown_page(request, 404)
safe_path = _sanitize_redirect_path(path) or ""
url = f"{base_url}/{safe_path}"
return RedirectResponse(url=url, status_code=303)
diff --git a/src/akkudoktoreos/server/rest/error.py b/src/akkudoktoreos/server/rest/error.py
index 651f7234..8969d0b1 100644
--- a/src/akkudoktoreos/server/rest/error.py
+++ b/src/akkudoktoreos/server/rest/error.py
@@ -1,7 +1,7 @@
import html
import traceback
from dataclasses import dataclass
-from typing import cast
+from typing import Optional, cast
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, RequestValidationError
@@ -186,6 +186,7 @@ ERROR_PAGE_TEMPLATE = """