mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
fix: allow EOSdash through an external reverse proxy (#1355)
Support externally proxied EOSdash (#1320) * test: verify optional dashboard port stays unpublished * docs: describe optional proxy access and public URL
This commit is contained in:
+4
-2
@@ -30,13 +30,15 @@ homeassistant: true
|
|||||||
homeassistant_api: true
|
homeassistant_api: true
|
||||||
|
|
||||||
# Ports exposed by the add-on
|
# 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:
|
ports:
|
||||||
8503/tcp: 8503
|
8503/tcp: 8503
|
||||||
# 8504/tcp: 8504
|
8504/tcp: null
|
||||||
|
|
||||||
ports_description:
|
ports_description:
|
||||||
8503/tcp: "EOS REST server"
|
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)
|
# EOSdash interface (if not ingress)
|
||||||
# webui: "http://[HOST]:[PORT:8504]"
|
# webui: "http://[HOST]:[PORT:8504]"
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"startup_eosdash": true,
|
"startup_eosdash": true,
|
||||||
"eosdash_host": "127.0.0.1",
|
"eosdash_host": "127.0.0.1",
|
||||||
"eosdash_port": 8504,
|
"eosdash_port": 8504,
|
||||||
|
"eosdash_public_url": "https://energy.example.com/dashboard",
|
||||||
"eosdash_supervise_interval_sec": 10,
|
"eosdash_supervise_interval_sec": 10,
|
||||||
"run_as_user": null,
|
"run_as_user": null,
|
||||||
"reload": true
|
"reload": true
|
||||||
|
|||||||
@@ -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_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_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]. |
|
| 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. |
|
| 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. |
|
| port | `EOS_SERVER__PORT` | `int` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"startup_eosdash": true,
|
"startup_eosdash": true,
|
||||||
"eosdash_host": "127.0.0.1",
|
"eosdash_host": "127.0.0.1",
|
||||||
"eosdash_port": 8504,
|
"eosdash_port": 8504,
|
||||||
|
"eosdash_public_url": "https://energy.example.com/dashboard",
|
||||||
"eosdash_supervise_interval_sec": 10,
|
"eosdash_supervise_interval_sec": 10,
|
||||||
"run_as_user": null,
|
"run_as_user": null,
|
||||||
"reload": true
|
"reload": true
|
||||||
|
|||||||
@@ -8,3 +8,24 @@
|
|||||||
:relative-docs: ..
|
:relative-docs: ..
|
||||||
:relative-images:
|
: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.
|
||||||
|
|||||||
@@ -337,6 +337,25 @@ In the dashboard, go to:
|
|||||||
Config
|
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
|
## Helpful Docker Commands
|
||||||
|
|
||||||
### View logs
|
### View logs
|
||||||
|
|||||||
@@ -15076,6 +15076,21 @@
|
|||||||
8504
|
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": {
|
"eosdash_supervise_interval_sec": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"title": "Eosdash Supervise Interval Sec",
|
"title": "Eosdash Supervise Interval Sec",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import traceback
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -2376,38 +2377,96 @@ def _sanitize_redirect_path(path: str) -> Optional[str]:
|
|||||||
return "/".join(parts)
|
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]:
|
def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]:
|
||||||
|
base_url = _eosdash_base_url(request)
|
||||||
|
|
||||||
# Path is not for EOSdash
|
# Path is not for EOSdash
|
||||||
if not (path.startswith("eosdash") or path == ""):
|
if not (path.startswith("eosdash") or path == ""):
|
||||||
host = get_config().server.eosdash_host
|
if base_url is None:
|
||||||
if host is None:
|
return _eosdash_unknown_page(request, 404)
|
||||||
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}/"
|
|
||||||
error_page = create_error_page(
|
error_page = create_error_page(
|
||||||
status_code="404",
|
status_code="404",
|
||||||
error_title="Page Not Found",
|
error_title="Page Not Found",
|
||||||
error_message=f"""<pre>
|
error_message=f"URL is unknown: '{request.url}'. Did you want to connect to EOSdash?",
|
||||||
URL is unknown: '{request.url}'
|
|
||||||
Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
|
|
||||||
</pre>
|
|
||||||
""",
|
|
||||||
error_details="Unknown URL",
|
error_details="Unknown URL",
|
||||||
|
link_url=f"{base_url}/",
|
||||||
|
link_label="Open EOSdash",
|
||||||
)
|
)
|
||||||
return HTMLResponse(content=error_page, status_code=404)
|
return HTMLResponse(content=error_page, status_code=404)
|
||||||
|
|
||||||
host = str(get_config().server.eosdash_host)
|
if get_config().server.eosdash_port:
|
||||||
if host == "0.0.0.0": # noqa: S104
|
if base_url is None:
|
||||||
# Use IP of EOS host
|
return _eosdash_unknown_page(request, 404)
|
||||||
host = get_host_ip()
|
|
||||||
if host and get_config().server.eosdash_port:
|
|
||||||
base_url = f"http://{host}:{get_config().server.eosdash_port}"
|
|
||||||
safe_path = _sanitize_redirect_path(path) or ""
|
safe_path = _sanitize_redirect_path(path) or ""
|
||||||
url = f"{base_url}/{safe_path}"
|
url = f"{base_url}/{safe_path}"
|
||||||
return RedirectResponse(url=url, status_code=303)
|
return RedirectResponse(url=url, status_code=303)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import html
|
import html
|
||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import cast
|
from typing import Optional, cast
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||||
@@ -186,6 +186,7 @@ ERROR_PAGE_TEMPLATE = """
|
|||||||
<h2 class="error-title">ERROR_TITLE</h2>
|
<h2 class="error-title">ERROR_TITLE</h2>
|
||||||
<p class="error-message">ERROR_MESSAGE</p>
|
<p class="error-message">ERROR_MESSAGE</p>
|
||||||
<div class="error-details">ERROR_DETAILS</div>
|
<div class="error-details">ERROR_DETAILS</div>
|
||||||
|
ERROR_LINK
|
||||||
<a href="/docs" class="back-button">Back to Home</a>
|
<a href="/docs" class="back-button">Back to Home</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -194,11 +195,39 @@ ERROR_PAGE_TEMPLATE = """
|
|||||||
|
|
||||||
|
|
||||||
def create_error_page(
|
def create_error_page(
|
||||||
status_code: str, error_title: str, error_message: str, error_details: str
|
status_code: str,
|
||||||
|
error_title: str,
|
||||||
|
error_message: str,
|
||||||
|
error_details: str,
|
||||||
|
link_url: Optional[str] = None,
|
||||||
|
link_label: str = "Open link",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create an error page by replacing placeholders in the template."""
|
"""Create an error page by replacing placeholders in the template.
|
||||||
|
|
||||||
|
The message and the details are escaped, so they are always shown as text. Markup in
|
||||||
|
them is not rendered. Use `link_url` to offer a link on the page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_code: The HTTP status code to display.
|
||||||
|
error_title: The title of the error.
|
||||||
|
error_message: The error message, shown as text.
|
||||||
|
error_details: The error details, shown as text.
|
||||||
|
link_url: Target of an extra link on the page. No link is added for `None`.
|
||||||
|
link_label: The label of the extra link.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The error page as HTML.
|
||||||
|
"""
|
||||||
|
link = ""
|
||||||
|
if link_url:
|
||||||
|
link = (
|
||||||
|
f'<a href="{html.escape(link_url, quote=True)}" class="back-button">'
|
||||||
|
f"{html.escape(link_label)}</a>"
|
||||||
|
)
|
||||||
|
# Insert the link first, so escaped text of the other placeholders is never replaced.
|
||||||
return (
|
return (
|
||||||
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
|
ERROR_PAGE_TEMPLATE.replace("ERROR_LINK", link)
|
||||||
|
.replace("STATUS_CODE", status_code)
|
||||||
.replace("ERROR_TITLE", error_title)
|
.replace("ERROR_TITLE", error_title)
|
||||||
.replace("ERROR_MESSAGE", html.escape(error_message))
|
.replace("ERROR_MESSAGE", html.escape(error_message))
|
||||||
.replace("ERROR_DETAILS", html.escape(error_details))
|
.replace("ERROR_DETAILS", html.escape(error_details))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import socket
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Only available on Linux/Unix type systems
|
# Only available on Linux/Unix type systems
|
||||||
@@ -429,6 +430,18 @@ class ServerCommonSettings(SettingsBaseModel):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
eosdash_public_url: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"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: int = Field(
|
eosdash_supervise_interval_sec: int = Field(
|
||||||
default=10,
|
default=10,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@@ -464,6 +477,31 @@ class ServerCommonSettings(SettingsBaseModel):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("eosdash_public_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_eosdash_public_url(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Require an absolute HTTP(S) base URL without credentials or query data."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if (
|
||||||
|
parsed.scheme not in ("http", "https")
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or "?" in value
|
||||||
|
or "#" in value
|
||||||
|
or "\\" in value
|
||||||
|
or any(character.isspace() or ord(character) < 32 for character in value)
|
||||||
|
):
|
||||||
|
raise ValueError("EOSdash public URL must be an absolute HTTP(S) base URL.")
|
||||||
|
# Accessing port also validates its numeric range.
|
||||||
|
if parsed.port == 0:
|
||||||
|
raise ValueError("EOSdash public URL port must be positive.")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
@field_validator("host", "eosdash_host", mode="before")
|
@field_validator("host", "eosdash_host", mode="before")
|
||||||
def validate_server_host(cls, value: Optional[str]) -> Optional[str]:
|
def validate_server_host(cls, value: Optional[str]) -> Optional[str]:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|||||||
@@ -284,11 +284,11 @@ class TestHomeAssistantAddon:
|
|||||||
assert isinstance(ingress_port, int), "ingress_port must be an integer"
|
assert isinstance(ingress_port, int), "ingress_port must be an integer"
|
||||||
assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number"
|
assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number"
|
||||||
|
|
||||||
# Ingress port should NOT be in ports section
|
# The dashboard port is optional and has no host mapping by default.
|
||||||
ports = cfg.get("ports", {})
|
ports = cfg.get("ports", {})
|
||||||
port_key = f"{ingress_port}/tcp"
|
port_key = f"{ingress_port}/tcp"
|
||||||
assert port_key not in ports, \
|
assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'"
|
||||||
f"Port {ingress_port} is used for ingress and should not be in 'ports' section"
|
assert ports[port_key] is None, "Ingress port must be unpublished by default"
|
||||||
|
|
||||||
# Validate URL if present
|
# Validate URL if present
|
||||||
if "url" in cfg:
|
if "url" in cfg:
|
||||||
@@ -330,14 +330,10 @@ class TestHomeAssistantAddon:
|
|||||||
|
|
||||||
ingress_port = cfg["ingress_port"]
|
ingress_port = cfg["ingress_port"]
|
||||||
|
|
||||||
# The ingress port should NOT be in the ports section
|
# A null mapping lets users opt in to host publication while preserving ingress.
|
||||||
ports = cfg.get("ports", {})
|
ports = cfg.get("ports", {})
|
||||||
port_key = f"{ingress_port}/tcp"
|
port_key = f"{ingress_port}/tcp"
|
||||||
|
assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'"
|
||||||
if port_key in ports:
|
assert ports[port_key] is None, "Ingress port must be unpublished by default"
|
||||||
pytest.fail(
|
|
||||||
f"Port {ingress_port} is used for ingress but also listed in 'ports' section. "
|
|
||||||
f"Remove it from 'ports' to avoid conflicts."
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"✓ Ingress configuration valid (port {ingress_port})")
|
print(f"✓ Ingress configuration valid (port {ingress_port})")
|
||||||
|
|||||||
+162
-10
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +19,7 @@ from akkudoktoreos.server.server import (
|
|||||||
ServerCommonSettings,
|
ServerCommonSettings,
|
||||||
get_default_host,
|
get_default_host,
|
||||||
get_default_port,
|
get_default_port,
|
||||||
|
get_host_ip,
|
||||||
wait_for_port_free,
|
wait_for_port_free,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,23 +58,27 @@ class TestServerSettingsValidation:
|
|||||||
|
|
||||||
def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch):
|
def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch):
|
||||||
"""Default ports are accepted in HA addon mode."""
|
"""Default ports are accepted in HA addon mode."""
|
||||||
monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True)
|
monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True)
|
||||||
assert config_eos.server.port == get_default_port() # 8503
|
assert config_eos.server.port == get_default_port() # 8503
|
||||||
assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504
|
assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504
|
||||||
|
|
||||||
def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
|
def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
|
||||||
"""Server port must be the default (8503) in HA addon mode."""
|
"""Server port must be the default (8503) in HA addon mode."""
|
||||||
monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True)
|
monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True)
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
with pytest.raises(ValidationError) as excinfo:
|
||||||
config_eos.server.port = 9000
|
config_eos.server.port = 9000
|
||||||
assert "Server port number `8503` for Home Assistant add-on can not be changed" in str(excinfo.value)
|
assert "Server port number `8503` for Home Assistant add-on can not be changed" in str(
|
||||||
|
excinfo.value
|
||||||
|
)
|
||||||
|
|
||||||
def test_eosdash_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
|
def test_eosdash_port_restriction_in_ha_addon(self, config_eos, monkeypatch):
|
||||||
"""EOSdash port must be the default (8504) in HA addon mode."""
|
"""EOSdash port must be the default (8504) in HA addon mode."""
|
||||||
monkeypatch.setattr('akkudoktoreos.server.server.is_home_assistant_addon', lambda: True)
|
monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True)
|
||||||
with pytest.raises(ValidationError) as excinfo:
|
with pytest.raises(ValidationError) as excinfo:
|
||||||
config_eos.server.eosdash_port = 9001
|
config_eos.server.eosdash_port = 9001
|
||||||
assert "EOSdash port number `8504` for Home Assistant add-on can not be changed" in str(excinfo.value)
|
assert "EOSdash port number `8504` for Home Assistant add-on can not be changed" in str(
|
||||||
|
excinfo.value
|
||||||
|
)
|
||||||
|
|
||||||
def test_ports_allowed_when_not_ha_addon(self, config_eos):
|
def test_ports_allowed_when_not_ha_addon(self, config_eos):
|
||||||
"""Custom ports are allowed when not in HA addon mode."""
|
"""Custom ports are allowed when not in HA addon mode."""
|
||||||
@@ -84,7 +90,6 @@ class TestServerSettingsValidation:
|
|||||||
|
|
||||||
|
|
||||||
class TestServerStartStop:
|
class TestServerStartStop:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path):
|
async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path):
|
||||||
"""Test logging from EOSdash can also handle very long lines."""
|
"""Test logging from EOSdash can also handle very long lines."""
|
||||||
@@ -180,7 +185,9 @@ class TestServerStartStop:
|
|||||||
eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
|
eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
|
||||||
|
|
||||||
# Port may be blocked
|
# Port may be blocked
|
||||||
assert wait_for_port_free(config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash")
|
assert wait_for_port_free(
|
||||||
|
config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash"
|
||||||
|
)
|
||||||
|
|
||||||
owned_processes: list[psutil.Process] = []
|
owned_processes: list[psutil.Process] = []
|
||||||
try:
|
try:
|
||||||
@@ -359,7 +366,9 @@ class TestServerWithEnv:
|
|||||||
"""Ensure server is started with environment passed to configuration."""
|
"""Ensure server is started with environment passed to configuration."""
|
||||||
server = server_setup_for_class["server"]
|
server = server_setup_for_class["server"]
|
||||||
|
|
||||||
assert server_setup_for_class["eosdash_port"] == int(self.eos_env["EOS_SERVER__EOSDASH_PORT"])
|
assert server_setup_for_class["eosdash_port"] == int(
|
||||||
|
self.eos_env["EOS_SERVER__EOSDASH_PORT"]
|
||||||
|
)
|
||||||
|
|
||||||
result = requests.get(f"{server}/v1/config")
|
result = requests.get(f"{server}/v1/config")
|
||||||
assert result.status_code == HTTPStatus.OK
|
assert result.status_code == HTTPStatus.OK
|
||||||
@@ -368,4 +377,147 @@ class TestServerWithEnv:
|
|||||||
config_json = result.json()
|
config_json = result.json()
|
||||||
|
|
||||||
# Assure config got configuration from environment
|
# Assure config got configuration from environment
|
||||||
assert config_json["server"]["eosdash_port"] == int(self.eos_env["EOS_SERVER__EOSDASH_PORT"])
|
assert config_json["server"]["eosdash_port"] == int(
|
||||||
|
self.eos_env["EOS_SERVER__EOSDASH_PORT"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEosdashRedirect:
|
||||||
|
"""Redirects to EOSdash must target an address the client can reach.
|
||||||
|
|
||||||
|
See https://github.com/Akkudoktor-EOS/EOS/issues/1320: the redirect was built from
|
||||||
|
the EOSdash bind address, so remote clients were sent to their own localhost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, config_eos):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from akkudoktoreos.server.eos import app
|
||||||
|
|
||||||
|
config_eos.server.eosdash_host = "127.0.0.1"
|
||||||
|
config_eos.server.eosdash_port = 8504
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["localhost:8503", "127.0.0.1:8503"])
|
||||||
|
def test_root_redirect_uses_request_host(self, client, host):
|
||||||
|
"""The root redirect points to the host the client used, not to the bind address."""
|
||||||
|
response = client.get("/", headers={"Host": host})
|
||||||
|
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||||
|
assert response.headers["location"] == f"http://{host.split(':')[0]}:8504/"
|
||||||
|
|
||||||
|
def test_root_redirect_uses_host_ip_of_the_eos_machine(self, client):
|
||||||
|
"""Access by the IP address of the EOS machine redirects to that address."""
|
||||||
|
host_ip = get_host_ip()
|
||||||
|
response = client.get("/", headers={"Host": f"{host_ip}:8503"})
|
||||||
|
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||||
|
assert response.headers["location"] == f"http://{host_ip}:8504/"
|
||||||
|
|
||||||
|
def test_root_redirect_ignores_untrusted_forwarded_headers(self, client):
|
||||||
|
"""Raw forwarding headers cannot override the public dashboard address."""
|
||||||
|
response = client.get(
|
||||||
|
"/",
|
||||||
|
headers={
|
||||||
|
"Host": "localhost",
|
||||||
|
"X-Forwarded-Host": "eos.example.com",
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||||
|
assert response.headers["location"] == "http://localhost:8504/"
|
||||||
|
|
||||||
|
def test_root_redirect_keeps_local_host(self, client):
|
||||||
|
"""Local access still redirects to the local EOSdash."""
|
||||||
|
response = client.get("/", headers={"Host": "127.0.0.1:8503"})
|
||||||
|
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||||
|
assert response.headers["location"] == "http://127.0.0.1:8504/"
|
||||||
|
|
||||||
|
def test_untrusted_request_host_is_not_reflected(self, client):
|
||||||
|
"""An unknown Host header must not become the redirect target."""
|
||||||
|
response = client.get("/", headers={"Host": "attacker.example"})
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
assert "attacker.example:8504" not in response.text
|
||||||
|
assert "eosdash_public_url" in response.text
|
||||||
|
|
||||||
|
def test_untrusted_request_host_on_unknown_path(self, client):
|
||||||
|
"""The 404 page offers no link for an unknown Host header."""
|
||||||
|
response = client.get("/no-such-page", headers={"Host": "attacker.example"})
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
assert "attacker.example:8504" not in response.text
|
||||||
|
|
||||||
|
def test_eosdash_path_redirect_keeps_path(self, client):
|
||||||
|
"""The path is preserved when redirecting to EOSdash."""
|
||||||
|
response = client.get("/eosdash/health", headers={"Host": "localhost:8503"})
|
||||||
|
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||||
|
assert response.headers["location"] == "http://localhost:8504/eosdash/health"
|
||||||
|
|
||||||
|
def test_unknown_path_error_page_links_to_request_host(self, client):
|
||||||
|
"""The 404 page links to EOSdash on the host the client used."""
|
||||||
|
response = client.get("/no-such-page", headers={"Host": "localhost:8503"})
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
# The link must be real HTML, the error page escapes the message it is given.
|
||||||
|
assert "<a href" not in response.text
|
||||||
|
# Compare the whole link target, a substring check would also accept a foreign host.
|
||||||
|
hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"]
|
||||||
|
assert hrefs == ["http://localhost:8504/"]
|
||||||
|
|
||||||
|
def test_error_page_escapes_request_url(self, client):
|
||||||
|
"""A crafted URL is shown as text, never as markup."""
|
||||||
|
response = client.get(
|
||||||
|
"/%3Cscript%3Ealert(1)%3C/script%3E", headers={"Host": "localhost:8503"}
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
assert "<script>alert(1)</script>" not in response.text
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["[::1]", "[::1]:8503"])
|
||||||
|
def test_direct_ipv6_preserves_address(self, client, host):
|
||||||
|
"""An IPv6 address keeps its brackets in the redirect."""
|
||||||
|
response = client.get("/", headers={"Host": host})
|
||||||
|
assert response.headers["location"] == "http://[::1]:8504/"
|
||||||
|
|
||||||
|
def test_untrusted_ipv6_host_is_not_reflected(self, client):
|
||||||
|
"""An IPv6 address that the configuration does not know is not reflected."""
|
||||||
|
response = client.get("/", headers={"Host": "[2001:db8::1234]:8503"})
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
assert "2001:db8::1234" not in response.headers.get("location", "")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"public_url",
|
||||||
|
[
|
||||||
|
"https://energy.example.com",
|
||||||
|
"https://energy.example.com:443",
|
||||||
|
"https://energy.example.com:9443/dashboard",
|
||||||
|
"https://[2001:db8::1234]/dashboard",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_public_url_preserves_proxy_port_and_prefix(self, client, config_eos, public_url):
|
||||||
|
config_eos.server.eosdash_public_url = public_url + "/"
|
||||||
|
headers = {"Host": "internal:8503", "X-Forwarded-Host": "wrong.example"}
|
||||||
|
response = client.get("/", headers=headers)
|
||||||
|
assert response.headers["location"] == public_url + "/"
|
||||||
|
response = client.get("/eosdash/health", headers=headers)
|
||||||
|
assert response.headers["location"] == public_url + "/eosdash/health"
|
||||||
|
response = client.get("/missing", headers=headers)
|
||||||
|
hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"]
|
||||||
|
assert hrefs == [public_url + "/"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"/dashboard",
|
||||||
|
"//example.com",
|
||||||
|
"ftp://example.com",
|
||||||
|
"https://user:password@example.com",
|
||||||
|
"https://example.com?token=a",
|
||||||
|
"https://example.com#fragment",
|
||||||
|
"https://example.com:99999",
|
||||||
|
"https://example.com:0",
|
||||||
|
"https://example.com\\evil",
|
||||||
|
"https://example.com/\nheader",
|
||||||
|
"https://example.com/a b",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_public_url_rejected(self, config_eos, value):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
config_eos.server.eosdash_public_url = value
|
||||||
|
|||||||
Reference in New Issue
Block a user