diff --git a/data/splash.html b/data/splash.html index c3acb36..47079a5 100644 --- a/data/splash.html +++ b/data/splash.html @@ -10,6 +10,7 @@
{{ status_code }} - {{ container_status }}
+{% if upstream_json is not none %}{% endif %} ' sequences are escaped so + they cannot prematurely close the surrounding ' injection by escaping the forward slash after '<' + json_str = json_str.replace("", "<\\/") + return json_str + + +def render_splash_response( + behaviour: SplashBehaviour, status_code: int +) -> HTMLResponse: + template = get_template_splash() + return HTMLResponse( + content=template.render(**behaviour.model_dump()), status_code=status_code + ) -async def get_splash_behaviour(request: Request): +async def get_splash_behaviour( + request: Request, upstream_json: Optional[str] = None +) -> SplashBehaviour: # todo: add special splash screen for app that is not size compatible status_code = int(request.path_params["status"]) app_name = get_app_name(request) @@ -55,6 +83,7 @@ async def get_splash_behaviour(request: Request): icon_data=data_url(app_name), display_status="Unknown Status...", do_reload=True, + upstream_json=upstream_json, ) if disk.current_disk_usage.disk_space_low: behaviour.display_status = "Low Disk Space" @@ -76,6 +105,53 @@ async def get_splash_behaviour(request: Request): return behaviour +async def make_splash_behaviour_for_proxy( + app_name: str, + status_code: int, + container_status: str, + upstream_json: Optional[str], +) -> SplashBehaviour: + """Build SplashBehaviour for use by the app proxy (no request path params needed).""" + try: + app_meta = get_app_metadata(app_name) + icon = data_url(app_name) + size_ok = await size_is_compatible(app_meta.minimum_portal_size) + display_size = app_meta.minimum_portal_size.value.upper() + except MetadataNotFound: + app_meta = None + icon = PLACEHOLDER_DATA + size_ok = True + display_size = None + + behaviour = SplashBehaviour( + status_code=status_code, + app_name=app_name, + container_status=container_status, + icon_data=icon, + display_status="Unknown Status...", + do_reload=True, + upstream_json=upstream_json, + ) + + if disk.current_disk_usage.disk_space_low: + behaviour.display_status = "Low Disk Space" + behaviour.do_reload = False + elif app_meta and not size_ok: + behaviour.display_status = f"VM too small, need at least {display_size}" + behaviour.do_reload = False + elif status_code == 401: + behaviour.display_status = "Access Denied" + behaviour.do_reload = False + elif status_code == 500: + behaviour.display_status = "Error" + elif container_status == "running": + behaviour.display_status = "Starting..." + elif container_status == "unknown": + behaviour.display_status = "Initializing..." + + return behaviour + + def get_container_status(app_name): docker_client = get_docker_client() try: @@ -118,4 +194,4 @@ def data_url(app_name): return f"data:image/{image_type};base64,{b64}" -PLACEHOLDER_DATA = "data:image/png;base64,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" # noqa: E501 +PLACEHOLDER_DATA = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAzNUlEQVR42u3dC6xlV33f8f9+3PedGXvGY4eR3zNm7IEQjKZJsVzVSQhErRIUuUAU0gQWPahRRZO0CVGlpq2ES1xUY4hJQggEhySgOAkQXBeHhIbEUDsxaezysp36D66HR0ttMI+Uh5mp9vScyZkzd+7d++xzzn/91/pe6Uixwnx091r/tX/rnv85a8tznnPd8nOec92S9Php/v3QWcbDw8PDw8Nz4DE4eHh4eHh4GXoMDh4eHh4eXoYeg4OHh4eHh5ehx+Dg4eHh4eHhMTh4eHh4eHiEP4ODh4eHh4dH+DPYeHh4eHh4hD+DjYeHh4eHR/jj4eHh4eHhEf54eHh4eHh4hD8eHh4eHh5eRB6Dg4eHh4eHl6HH4ODh4eHh4WXoMTh4eHh4eHgZegwOHh4eHh4eHoODh4eHh4dH+DM4eHh4eHh4hD+DjYeHh4eHR/gz2Hh4eHh4eIQ/Hh4eHh4enhOPwcHDw8PDw8vQY3Dw8PDw8PAy9BgcPDw8PDy8DD0GBw8PDw8PD4/BwcPDw8PDI/wZHDw8PDw8PMKfwcbDw8PDwyP8GWw8PDw8PDzCHw8PDw8PD4/wx8PDw8PDwyP88fDw8PDw8CLyGBw8PDw8PLwMPQYHDw8PDw8vQ4/BwcPDw8PDy9BjcPDw8PDw8PAYHDw8PDw8PMKfwcHDw8PDwyP8GWw8PDw8PDzCn8HGw8PDw8Mj/PHw8PDw8PCceAwOHh4eHh5ehh6Dg4eHh4eHl6HH4ODh4eHh4WXoMTh4eHh4eHh4DA4eHh4eHl4O3v8DyOlLVWYaMhkAAAAASUVORK5CYII=" # noqa: E501 diff --git a/shard_core/web/internal/app_proxy.py b/shard_core/web/internal/app_proxy.py new file mode 100644 index 0000000..32d5ae0 --- /dev/null +++ b/shard_core/web/internal/app_proxy.py @@ -0,0 +1,170 @@ +"""Reverse proxy that sits between Traefik and app containers. + +Traefik routes app-subdomain traffic here (via the `app-proxy-prefix` +addPrefix middleware) instead of directly to each app container. This lets +shard_core capture the full upstream response body so it can be embedded, +hidden, in the splash HTML for developer inspection. + +For non-error responses the body is streamed through unchanged. +For 4xx / 5xx responses the upstream body is captured, embedded in a hidden + in the body cannot close the embedding " + result = build_upstream_json(status=200, content_type="text/html", body=body) + assert "" not in result + # The JSON is still valid and the body is recoverable. + parsed = json.loads(result) + assert parsed["body"] == body + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_upstream_response( + status_code: int, + body: str, + content_type: str = "application/json", +) -> MagicMock: + """Build a fake httpx.Response-like mock.""" + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.headers = httpx.Headers({"content-type": content_type}) + response.text = body + response.content = body.encode() + return response + + +def _mock_client(response: MagicMock): + """Return a context-manager mock that yields a client whose request() returns *response*.""" + client = AsyncMock() + client.request = AsyncMock(return_value=response) + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=client) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + +# --------------------------------------------------------------------------- +# Integration tests for the proxy endpoint +# --------------------------------------------------------------------------- + + +async def test_proxy_error_response_embeds_upstream_body(api_client, mocker): + """When the app returns a 4xx error, the splash contains the upstream body.""" + await install_app(api_client, "mock_app") + + upstream_body = ( + '{"detail": [{"loc": ["body", "name"], "msg": "field required",' + ' "type": "value_error.missing"}]}' + ) + fake_response = _make_upstream_response(422, upstream_body) + mocker.patch( + "shard_core.web.internal.app_proxy.httpx.AsyncClient", + return_value=_mock_client(fake_response), + ) + + response = await api_client.get( + "internal/app_proxy/api/shards/1/update", + headers={"host": "mock_app.myshard.org"}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + html = response.text + assert 'id="upstream-response"' in html + + # The embedded JSON should contain the original status and body. + script_start = html.index('id="upstream-response">') + script_end = html.index("", script_start) + embedded = html[script_start + len('id="upstream-response">') : script_end] + parsed = json.loads(embedded) + assert parsed["status"] == 422 + assert "field required" in parsed["body"] + assert parsed["content_type"] == "application/json" + + +async def test_proxy_success_passes_through(api_client, mocker): + """For 2xx responses the proxy passes through the response without a splash.""" + await install_app(api_client, "mock_app") + + fake_response = _make_upstream_response(200, '{"result": "ok"}') + mocker.patch( + "shard_core.web.internal.app_proxy.httpx.AsyncClient", + return_value=_mock_client(fake_response), + ) + + response = await api_client.get( + "internal/app_proxy/api/data", + headers={"host": "mock_app.myshard.org"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"result": "ok"} + # No splash rendered for successful responses. + assert "upstream-response" not in response.text + + +async def test_proxy_connection_error_shows_splash_without_upstream(api_client, mocker): + """When the app container is unreachable the splash shows without upstream data.""" + await install_app(api_client, "mock_app") + + client_mock = AsyncMock() + client_mock.request = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=client_mock) + cm.__aexit__ = AsyncMock(return_value=False) + mocker.patch( + "shard_core.web.internal.app_proxy.httpx.AsyncClient", + return_value=cm, + ) + + response = await api_client.get( + "internal/app_proxy/", + headers={"host": "mock_app.myshard.org"}, + ) + + assert response.status_code == status.HTTP_502_BAD_GATEWAY + html = response.text + assert "mock_app" in html + # No upstream data when the container is unreachable. + assert 'id="upstream-response"' not in html + + +async def test_proxy_500_error_shows_splash_with_upstream_body(api_client, mocker): + """Server errors (5xx) also embed the upstream body in the splash.""" + await install_app(api_client, "mock_app") + + upstream_body = "Internal Server Error: unhandled exception in view" + fake_response = _make_upstream_response( + 500, upstream_body, content_type="text/plain" + ) + mocker.patch( + "shard_core.web.internal.app_proxy.httpx.AsyncClient", + return_value=_mock_client(fake_response), + ) + + response = await api_client.get( + "internal/app_proxy/crash", + headers={"host": "mock_app.myshard.org"}, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + html = response.text + assert 'id="upstream-response"' in html + script_start = html.index('id="upstream-response">') + script_end = html.index("", script_start) + embedded = html[script_start + len('id="upstream-response">') : script_end] + parsed = json.loads(embedded) + assert parsed["status"] == 500 + assert parsed["body"] == upstream_body diff --git a/tests/test_call_peer.py b/tests/test_call_peer.py index fa62b2b..bea6adc 100644 --- a/tests/test_call_peer.py +++ b/tests/test_call_peer.py @@ -6,7 +6,11 @@ from requests_http_signature import HTTPSignatureAuth from shard_core.data_model.identity import OutputIdentity -from tests.util import verify_signature_auth, modify_request_like_traefik_forward_auth, install_app +from tests.util import ( + verify_signature_auth, + modify_request_like_traefik_forward_auth, + install_app, +) async def test_call_peer_from_app_basic(app_client, peer_mock_requests): diff --git a/tests/test_migration/test_migration.py b/tests/test_migration/test_migration.py index e38606e..d3f35e5 100644 --- a/tests/test_migration/test_migration.py +++ b/tests/test_migration/test_migration.py @@ -15,7 +15,6 @@ ) from shard_core.database.tinydb_migration import migrate_tinydb_data - SAMPLE_TINYDB = Path(__file__).parent.parent / "fixtures" / "sample_tinydb.json" diff --git a/tests/test_traefik_dyn_spec.py b/tests/test_traefik_dyn_spec.py index 349e8d1..d220067 100644 --- a/tests/test_traefik_dyn_spec.py +++ b/tests/test_traefik_dyn_spec.py @@ -14,6 +14,7 @@ async def test_template_is_written(api_client): assert set(out_middlewares.keys()) == { "app-error", + "app-proxy-prefix", "auth", "strip", "auth-public", @@ -45,4 +46,4 @@ async def test_template_is_written(api_client): "paperless-ngx_http", "immich_http", } - assert out_routers_http["filebrowser_http"]["service"] == "filebrowser_http" + assert out_routers_http["filebrowser_http"]["service"] == "shard_core" diff --git a/uv.lock b/uv.lock index a94159d..3016f70 100644 --- a/uv.lock +++ b/uv.lock @@ -1764,7 +1764,7 @@ wheels = [ [[package]] name = "shard-core" -version = "0.38.4" +version = "0.39.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },