From 51120be32a34ecafff7ad710e4c894e2cadb31c0 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 2 Oct 2026 20:08:19 -0500 Subject: [PATCH] fix(my_egeria): serve_my_* page URLs follow the browser's address textual-serve builds every asset and WebSocket URL in the page from one fixed public_url. With the egeria-workspaces portal configured for https://laz.local:8843/my-egeria, a browser that reached it as https://localhost:8843 was pointed at another origin; the page's CSP blocked the script and My Profile showed only its title. So one container could only work from one hostname -- not workable for localhost plus a demo site. serve_my_profile / serve_my_egeria now use a Server subclass whose handle_index computes the base URL per request: - behind a proxy (X-Forwarded-Host/-For): host = first *valid* X-Forwarded-Host entry, else Host; scheme = X-Forwarded-Proto, else the configured public URL's; path = the configured public URL's (/my-egeria) - direct connection: the request's own scheme and host (previously textual-serve pointed browsers at 0.0.0.0) Skipping invalid entries matters: egeria-workspaces' Apache sends "X-Forwarded-Host: i=99, localhost:8843" (a misconfigured RequestHeader, reported to that repo). Verified in the live portal container: localhost and laz.local each get same-origin URLs, load the script and render My Profile over the WebSocket. Tests run real textual-serve pages through aiohttp's test server; plain textual-serve fails the localhost case. Signed-off-by: Dan Wolfson --- docs/my_profile_app_manual.md | 2 +- my_egeria/EGERIA_WORKSPACES_INTEGRATION.md | 4 +- .../HANDOFF_egeria_workspaces_my_egeria.md | 2 +- my_egeria/my_egeria/serve.py | 69 +++++++++++++++++-- tests/micro-tests/test_my_egeria_serve.py | 61 ++++++++++++++++ 5 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/micro-tests/test_my_egeria_serve.py diff --git a/docs/my_profile_app_manual.md b/docs/my_profile_app_manual.md index 9cfb1287..0bd49f6d 100644 --- a/docs/my_profile_app_manual.md +++ b/docs/my_profile_app_manual.md @@ -88,7 +88,7 @@ To serve the app in a web browser instead of a terminal: serve_my_profile # textual-serve on $MY_EGERIA_HOST:$MY_PROFILE_PORT (default 0.0.0.0:8020) ``` -Behind a reverse proxy, set `MY_PROFILE_PUBLIC_URL` (or `MY_EGERIA_PUBLIC_URL`) to the URL browsers use, e.g. `https://localhost:8843/my-egeria`, so the page's asset and WebSocket URLs are same-origin. +Behind a reverse proxy, set `MY_PROFILE_PUBLIC_URL` (or `MY_EGERIA_PUBLIC_URL`) to where the app is published, e.g. `https://localhost:8843/my-egeria`. Only its path (and default scheme) is used: the host in the page's asset and WebSocket URLs follows the address each browser actually used, so the same setting works from `localhost`, the machine's name or a demo hostname. ## The Splash Screen and Changing User diff --git a/my_egeria/EGERIA_WORKSPACES_INTEGRATION.md b/my_egeria/EGERIA_WORKSPACES_INTEGRATION.md index 42ad3879..e45e915a 100644 --- a/my_egeria/EGERIA_WORKSPACES_INTEGRATION.md +++ b/my_egeria/EGERIA_WORKSPACES_INTEGRATION.md @@ -16,7 +16,9 @@ with pyegeria) to turn the Textual app into a browser app — each WebSocket connection gets its own isolated app instance. The portal button simply links to the app's URL (new tab or iframe). No code difference between terminal and browser modes; the same installed app serves both. Behind a proxy, set -`MY_EGERIA_PUBLIC_URL` (or `MY_PROFILE_PUBLIC_URL`) to the URL browsers use, so +`MY_EGERIA_PUBLIC_URL` (or `MY_PROFILE_PUBLIC_URL`) to where the app is published (e.g. +`https:///my-egeria`). Only its path and default scheme are used: the host follows +each browser's own address, so one setting works whatever hostname users arrive by, and the page's asset and WebSocket URLs are same-origin. --- diff --git a/my_egeria/HANDOFF_egeria_workspaces_my_egeria.md b/my_egeria/HANDOFF_egeria_workspaces_my_egeria.md index f63c61b3..69edd339 100644 --- a/my_egeria/HANDOFF_egeria_workspaces_my_egeria.md +++ b/my_egeria/HANDOFF_egeria_workspaces_my_egeria.md @@ -130,7 +130,7 @@ file. They must be present in the `my-egeria` service environment: | `EGERIA_USER_PASSWORD` | `secret` | Password (quickstart) | | `MY_EGERIA_HOST` | `0.0.0.0` | Listen host for textual-serve | | `MY_EGERIA_PORT` | `8021` | Listen port for textual-serve | -| `MY_EGERIA_PUBLIC_URL` | `https:///my-egeria` | URL browsers use when behind a proxy, so page and WebSocket URLs are same-origin. `MY_PROFILE_PUBLIC_URL` is the My Profile equivalent; `MY_EGERIA_PUBLIC_URL` is used for either app if the app's own is not set. | +| `MY_EGERIA_PUBLIC_URL` | `https:///my-egeria` | Where the app is published behind a proxy. Only its **path** (and default scheme) is used: the host follows each browser's own address (from `X-Forwarded-Host`/`Host`), so one setting works for localhost, the machine name and a demo hostname. `MY_PROFILE_PUBLIC_URL` is the My Profile equivalent; `MY_EGERIA_PUBLIC_URL` is used for either app if the app's own is not set. | --- diff --git a/my_egeria/my_egeria/serve.py b/my_egeria/my_egeria/serve.py index 9d1c6a3e..9a088cde 100644 --- a/my_egeria/my_egeria/serve.py +++ b/my_egeria/my_egeria/serve.py @@ -9,15 +9,25 @@ MY_EGERIA_HOST (default: 0.0.0.0) -- shared by all served apps MY_EGERIA_PORT (default: 8021) -- main MyEgeria app MY_PROFILE_PORT (default: 8020) -- my_profile demo app - MY_EGERIA_PUBLIC_URL / MY_PROFILE_PUBLIC_URL (optional) -- the URL browsers use, - when it differs from host:port (e.g. behind a reverse proxy); + MY_EGERIA_PUBLIC_URL / MY_PROFILE_PUBLIC_URL (optional) -- where the app is published + behind a reverse proxy, e.g. https://example.com/my-egeria; MY_EGERIA_PUBLIC_URL applies to either app if the app's own isn't set +The page's asset and WebSocket URLs follow the address each browser actually used, so +the same container works whatever hostname it is reached by (localhost, the machine's +name, a demo site). Behind a proxy (X-Forwarded-Host present) only the *path* and the +default scheme come from the public URL; the host comes from the request. + The apps run with the same Python interpreter as this entry point, so this works wherever pyegeria is installed (no `textual` CLI from textual-dev needed). """ import os +import re import sys +from urllib.parse import urlsplit + +# host[:port] or [ipv6][:port] +_VALID_HOST = re.compile(r"^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(:\d{1,5})?$") APPS = { # name: (module run with `python -m`, port env var, default port, public URL env var) @@ -30,11 +40,61 @@ def _command(module: str) -> str: return f'"{sys.executable}" -m {module}' -def _serve(name: str) -> None: +def public_url_for_request(scheme: str, host: str, headers, configured_public_url: str | None) -> str: + """The base URL a browser should use for this page's assets and WebSocket. + + textual-serve builds every URL in the page from one fixed public_url, so a page + reached by a different hostname than the configured one points the browser at + another origin, which the page's CSP blocks: the page then shows only its title. + Behind a proxy (X-Forwarded-Host), keep the configured path and default scheme + but use the host the browser used; on a direct connection use the request's own + scheme and host, with no path prefix. + """ + forwarded_host = headers.get("X-Forwarded-Host") + if forwarded_host or headers.get("X-Forwarded-For"): + # Use the first X-Forwarded-Host entry that is a real host name: proxies append + # entries, and a misconfigured one can add junk (egeria-workspaces' Apache sends + # "i=99, localhost:8843" from `RequestHeader set X-Forwarded-Host "%{Host}i"`). + # Fall back to Host, which is the browser's host under ProxyPreserveHost On. + candidates = [h.strip() for h in (forwarded_host or "").split(",")] + public_host = next((h for h in candidates if _VALID_HOST.match(h)), host) + configured = urlsplit(configured_public_url) if configured_public_url else None + forwarded_proto = headers.get("X-Forwarded-Proto") + base_scheme = (forwarded_proto.split(",")[0].strip() if forwarded_proto else None) \ + or (configured.scheme if configured and configured.scheme else None) or scheme + path = configured.path.rstrip("/") if configured else "" + return f"{base_scheme}://{public_host}{path}" + return f"{scheme}://{host}" + + +def _server_class(): from textual_serve.server import Server + class _RequestAwareServer(Server): + """textual-serve Server whose page URLs follow the address the browser used.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._configured_public_url = kwargs.get("public_url") + + async def handle_index(self, request): + # Server.handle_index builds every URL from self.public_url synchronously + # (no await while building), so swapping it per request can't interleave + # with another request. + configured = self.public_url + self.public_url = public_url_for_request( + request.scheme, request.host, request.headers, self._configured_public_url) + try: + return await super().handle_index(request) + finally: + self.public_url = configured + + return _RequestAwareServer + + +def _serve(name: str) -> None: module, port_env, default_port, public_url_env = APPS[name] - Server( + _server_class()( _command(module), host=os.getenv("MY_EGERIA_HOST", "0.0.0.0"), port=int(os.getenv(port_env, default_port)), @@ -51,4 +111,3 @@ def serve_my_profile() -> None: def serve_my_egeria() -> None: _serve("My Egeria") - diff --git a/tests/micro-tests/test_my_egeria_serve.py b/tests/micro-tests/test_my_egeria_serve.py new file mode 100644 index 00000000..b616b538 --- /dev/null +++ b/tests/micro-tests/test_my_egeria_serve.py @@ -0,0 +1,61 @@ +""" +serve_my_profile / serve_my_egeria: the page's asset and WebSocket URLs follow the +address each browser used, so one container works whatever hostname it is reached by. + +textual-serve builds every URL in the page from one fixed public_url. When the portal +was reached as https://localhost:8843 while the container was configured for +https://laz.local:8843/my-egeria, the page pointed the browser at another origin, its +CSP blocked the script, and the page showed only its title. +""" +import pytest +from aiohttp.test_utils import TestClient, TestServer + +from my_egeria.serve import _server_class, public_url_for_request + +CONFIGURED = "https://laz.local:8843/my-egeria" + + +@pytest.mark.parametrize("headers, expected", [ + ({"X-Forwarded-Host": "localhost:8843"}, "https://localhost:8843/my-egeria"), + ({"X-Forwarded-Host": "laz.local:8843"}, "https://laz.local:8843/my-egeria"), + ({"X-Forwarded-Host": "demo.example.org", "X-Forwarded-Proto": "https"}, "https://demo.example.org/my-egeria"), + ({"X-Forwarded-Host": "a.example, b.internal", "X-Forwarded-Proto": "http, https"}, "http://a.example/my-egeria"), + # egeria-workspaces' Apache really sends this (junk first, real host appended by mod_proxy) + ({"X-Forwarded-Host": "i=99, localhost:8843", "X-Forwarded-Proto": "https"}, "https://localhost:8843/my-egeria"), + ({"X-Forwarded-Host": "i=99"}, "https://127.0.0.1:8820/my-egeria"), # no valid entry: fall back to Host + ({"X-Forwarded-For": "10.0.0.1"}, "https://127.0.0.1:8820/my-egeria"), # proxied without X-Forwarded-Host + ({"X-Forwarded-Host": "[::1]:8843"}, "https://[::1]:8843/my-egeria"), + ({}, "http://127.0.0.1:8820"), # direct connection: the request's own address, no prefix +]) +def test_public_url_for_request(headers, expected): + assert public_url_for_request("http", "127.0.0.1:8820", headers, CONFIGURED) == expected + + +def test_no_configured_url_behind_proxy_uses_request_scheme(): + assert public_url_for_request("http", "x:1", {"X-Forwarded-Host": "h:2"}, None) == "http://h:2" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("headers, base", [ + ({"X-Forwarded-Host": "localhost:8843"}, "localhost:8843/my-egeria"), + ({"X-Forwarded-Host": "laz.local:8843"}, "laz.local:8843/my-egeria"), + ({"X-Forwarded-Host": "i=99, localhost:8843", "X-Forwarded-Proto": "https"}, "localhost:8843/my-egeria"), +]) +async def test_served_page_uses_the_browsers_host(headers, base): + server = _server_class()("true", host="127.0.0.1", port=0, title="My Profile", public_url=CONFIGURED) + async with TestClient(TestServer(await server._make_app())) as client: + page = await (await client.get("/", headers=headers)).text() + assert f'src="https://{base}/static/js/textual.js"' in page + assert f'data-session-websocket-url="wss://{base}/ws"' in page + assert server.public_url == CONFIGURED # restored after the request + + +@pytest.mark.asyncio +async def test_direct_connection_uses_its_own_address(): + server = _server_class()("true", host="127.0.0.1", port=0, title="My Profile", public_url=CONFIGURED) + async with TestClient(TestServer(await server._make_app())) as client: + response = await client.get("/") + page = await response.text() + host = f"{client.host}:{client.port}" + assert f'src="http://{host}/static/js/textual.js"' in page + assert f'data-session-websocket-url="ws://{host}/ws"' in page