Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions data/splash.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ <h1>{{ app_name }}</h1>
<h3>{{ display_status }}</h3>
</div>
<p>{{ status_code }} - {{ container_status }}</p>
{% if upstream_json is not none %}<script type="application/json" id="upstream-response">{{ upstream_json }}</script>{% endif %}
<script>
function timeRefresh(time) {
const doReload = '{{ do_reload }}' === 'True';
Expand Down
9 changes: 7 additions & 2 deletions shard_core/service/traefik_dynamic_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ def _add_http_section(model: t.Model, portal: SafeIdentity):
)
)
),
"app-proxy-prefix": t.HttpMiddleware(
root=t.HttpMiddlewareItem(
addPrefix=t.AddPrefixMiddleware(prefix="/internal/app_proxy")
)
),
}
_services = {
"shard_core": t.HttpService(
Expand Down Expand Up @@ -167,8 +172,8 @@ def _add_router(
model.http.routers[f"{app.name}_{ep_value}"] = t.HttpRouter(
rule=f"Host(`{app.name}.{portal.domain}`)",
entryPoints=[http_entrypoint],
service=f"{app.name}_{ep_value}",
middlewares=["app-error", "auth"],
service="shard_core",
middlewares=["auth", "app-proxy-prefix"],
tls=make_http_cert_resolver(portal),
)
elif entrypoint.entrypoint_port == EntrypointPort.MQTTS_1883:
Expand Down
3 changes: 2 additions & 1 deletion shard_core/web/internal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from fastapi import APIRouter

from . import auth, app_error, call_backend, call_peer
from . import auth, app_error, app_proxy, call_backend, call_peer

router = APIRouter(
prefix="/internal",
tags=["/internal"],
)

router.include_router(app_error.router)
router.include_router(app_proxy.router)
router.include_router(auth.router)
router.include_router(call_backend.router)
router.include_router(call_peer.router)
88 changes: 82 additions & 6 deletions shard_core/web/internal/app_error.py

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions shard_core/web/internal/app_proxy.py
Original file line number Diff line number Diff line change
@@ -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
<script type="application/json" id="upstream-response"> element inside the
splash page, and the splash is returned to the client.
"""

import logging
from typing import Optional

import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, Response

from shard_core.data_model.app_meta import EntrypointPort
from shard_core.service.app_tools import get_app_metadata, MetadataNotFound
from shard_core.web.internal.app_error import (
build_upstream_json,
get_container_status,
make_splash_behaviour_for_proxy,
render_splash_response,
)
from shard_core.web.util import ALL_HTTP_METHODS

log = logging.getLogger(__name__)

router = APIRouter()

# Headers that must not be forwarded to the upstream app container.
# These are either hop-by-hop headers or headers that httpx manages itself.
_SKIP_REQUEST_HEADERS = frozenset(
{
"host",
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailers",
"upgrade",
"proxy-authorization",
"proxy-connection",
"content-length", # httpx recalculates this
}
)

# Headers from the upstream response that must not be forwarded to the client.
_SKIP_RESPONSE_HEADERS = frozenset(
{
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailers",
"content-encoding", # httpx decodes compressed responses automatically
}
)


@router.api_route("/app_proxy/{path:path}", methods=ALL_HTTP_METHODS)
async def app_proxy(path: str, request: Request) -> Response:
"""Proxy a request to the appropriate app container.

On error responses the original body is embedded (hidden) in the splash
page so developers can inspect it via browser dev tools.
"""
host_header: str = request.headers.get("host", "")
app_name = host_header.split(".")[0]

# Resolve the app's HTTP entrypoint from its metadata.
try:
app_meta = get_app_metadata(app_name)
except MetadataNotFound:
log.warning(f"app_proxy: metadata not found for app {app_name!r}")
return await _splash(app_name=app_name, status_code=404, upstream_json=None)

entrypoint = next(
(
ep
for ep in app_meta.entrypoints
if ep.entrypoint_port == EntrypointPort.HTTPS_443
),
None,
)
if entrypoint is None:
log.warning(f"app_proxy: no HTTP entrypoint for app {app_name!r}")
return await _splash(app_name=app_name, status_code=502, upstream_json=None)

target_base = f"http://{entrypoint.container_name}:{entrypoint.container_port}"
target_url = f"{target_base}/{path}"
if request.url.query:
target_url += f"?{request.url.query}"

# Filter request headers before forwarding.
forward_headers = {
k: v
for k, v in request.headers.items()
if k.lower() not in _SKIP_REQUEST_HEADERS
}

request_body = await request.body()

try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
upstream = await client.request(
method=request.method,
url=target_url,
headers=forward_headers,
content=request_body,
)
except httpx.ConnectError:
log.debug(f"app_proxy: connection refused for {app_name!r} at {target_url!r}")
return await _splash(app_name=app_name, status_code=502, upstream_json=None)
except httpx.TimeoutException:
log.debug(f"app_proxy: timeout for {app_name!r} at {target_url!r}")
return await _splash(app_name=app_name, status_code=504, upstream_json=None)
except httpx.HTTPError as exc:
log.warning(f"app_proxy: HTTP error for {app_name!r}: {exc}")
return await _splash(app_name=app_name, status_code=502, upstream_json=None)

if upstream.status_code >= 400:
# Error path: embed the original response body in the splash HTML.
try:
body_text = upstream.text
except Exception:
body_text = upstream.content.decode("utf-8", errors="replace")

upstream_json = build_upstream_json(
status=upstream.status_code,
content_type=upstream.headers.get("content-type"),
body=body_text,
)
return await _splash(
app_name=app_name,
status_code=upstream.status_code,
upstream_json=upstream_json,
)

# Success path: pass the response through.
response_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower() not in _SKIP_RESPONSE_HEADERS
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=response_headers,
)


async def _splash(
app_name: str,
status_code: int,
upstream_json: Optional[str],
) -> HTMLResponse:
"""Render the splash page, optionally with embedded upstream response data."""
container_status = get_container_status(app_name)
behaviour = await make_splash_behaviour_for_proxy(
app_name=app_name,
status_code=status_code,
container_status=container_status,
upstream_json=upstream_json,
)
return render_splash_response(behaviour, status_code)
4 changes: 1 addition & 3 deletions shard_core/web/protected/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ async def put_identity(i: InputIdentity):
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
update_data = {
k: v
for k, v in i.model_dump(exclude_unset=True).items()
if k != "id"
k: v for k, v in i.model_dump(exclude_unset=True).items() if k != "id"
}
return await identity_service.update_identity(i.id, update_data)
else:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_app_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ async def test_status_404(api_client):
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "404" in response.text
# No upstream data when called without proxy (Traefik error-page path)
assert 'id="upstream-response"' not in response.text


async def test_status_500(api_client):
Expand All @@ -21,3 +23,5 @@ async def test_status_500(api_client):
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "500" in response.text
# No upstream data when called without proxy (Traefik error-page path)
assert 'id="upstream-response"' not in response.text
Loading
Loading