Add reverse proxy: origin mapping, HTML/redirect rewriting, frame-option stripping
This commit is contained in:
1 parent
137d528ad2
commit
9e6e7745d0
1 file changed
+187
@@ -0,0 +1,187 @@
|
||||
"""Reverse proxy.
|
||||
|
||||
Every framed service is reached through ``/proxy/<service-id>/...``. The proxy
|
||||
is the *only* way an upstream service is reachable, so the services themselves
|
||||
never need to be exposed.
|
||||
|
||||
Mapping rule: the upstream **origin** (``scheme://host[:port]``) maps onto the
|
||||
proxy prefix, so ``/proxy/grafana/d/abc`` -> ``http://internal:3000/d/abc``.
|
||||
Absolute URLs inside HTML and ``Location`` headers are rewritten back onto the
|
||||
proxy prefix so the framed app keeps talking to the dashboard instead of
|
||||
escaping to its own (private) origin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .config import Config
|
||||
from .models import Service
|
||||
from .store import ServiceStore
|
||||
|
||||
# Headers that must not be forwarded verbatim between hops.
|
||||
REQUEST_BLOCKED = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"host",
|
||||
"accept-encoding", # let httpx negotiate its own encoding
|
||||
}
|
||||
RESPONSE_BLOCKED = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length", # recomputed by the response class
|
||||
}
|
||||
|
||||
MAX_BODY_BYTES = 10 * 1024 * 1024
|
||||
|
||||
_FRAME_ANCESTORS = re.compile(r"frame-ancestors\s*=?[^;]*;?")
|
||||
|
||||
|
||||
class Proxy:
|
||||
def __init__(self, store: ServiceStore, config: Config) -> None:
|
||||
self.store = store
|
||||
self.config = config
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=config.proxy_timeout,
|
||||
follow_redirects=False,
|
||||
headers={"user-agent": "uwudash-mk3/0.1"},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.client.close()
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
@staticmethod
|
||||
def _prefix(service: Service) -> str:
|
||||
return f"/proxy/{service.id}"
|
||||
|
||||
@staticmethod
|
||||
def _netloc(service: Service) -> str:
|
||||
return service.origin.split("://", 1)[1]
|
||||
|
||||
def _upstream_url(self, service: Service, path: str, query: str) -> str:
|
||||
url = f"{service.origin}/{path.lstrip('/')}"
|
||||
return f"{url}?{query}" if query else url
|
||||
|
||||
def _request_headers(self, request: Request, service: Service) -> Dict[str, str]:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in REQUEST_BLOCKED
|
||||
}
|
||||
headers.update(service.headers)
|
||||
return headers
|
||||
|
||||
def _response_headers(self, upstream: httpx.Response, service: Service) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {}
|
||||
for key, value in upstream.headers.items():
|
||||
lowered = key.lower()
|
||||
if lowered in RESPONSE_BLOCKED:
|
||||
continue
|
||||
if lowered == "location":
|
||||
headers[key] = self._rewrite_location(value, service)
|
||||
elif self.config.strip_frame_options and lowered == "x-frame-options":
|
||||
continue
|
||||
elif self.config.strip_frame_options and lowered == "content-security-policy":
|
||||
headers[key] = _FRAME_ANCESTORS.sub("", value).strip("; ")
|
||||
else:
|
||||
headers[key] = value
|
||||
return headers
|
||||
|
||||
def _rewrite_location(self, location: str, service: Service) -> str:
|
||||
prefix = self._prefix(service)
|
||||
if location.startswith(service.origin):
|
||||
return prefix + location[len(service.origin) :]
|
||||
if location.startswith("//" + self._netloc(service)):
|
||||
return prefix + location[2 + len(self._netloc(service)) :]
|
||||
# Root-relative redirects resolve against the proxy prefix.
|
||||
if location.startswith("/"):
|
||||
return prefix + location
|
||||
return location
|
||||
|
||||
def _rewrite_html(self, text: str, service: Service) -> str:
|
||||
prefix = self._prefix(service)
|
||||
return text.replace(service.origin, prefix).replace(
|
||||
f"//{self._netloc(service)}", prefix
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- handler
|
||||
async def handle(self, service_id: str, path: str, request: Request) -> Response:
|
||||
service = self.store.get(service_id)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
|
||||
if not service.enabled:
|
||||
raise HTTPException(status_code=403, detail=f"service {service_id} is disabled")
|
||||
|
||||
upgrade = request.headers.get("upgrade", "")
|
||||
if upgrade.lower() == "websocket":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="websocket proxying is not supported by this dashboard",
|
||||
)
|
||||
|
||||
body: Optional[bytes] = None
|
||||
if request.headers.get("content-length"):
|
||||
body = await request.body()
|
||||
if len(body) > MAX_BODY_BYTES:
|
||||
raise HTTPException(status_code=413, detail="request body too large")
|
||||
|
||||
url = self._upstream_url(service, path, request.url.query)
|
||||
upstream_request = self.client.build_request(
|
||||
request.method, url, headers=self._request_headers(request, service), content=body
|
||||
)
|
||||
try:
|
||||
upstream = await self.client.send(upstream_request, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"upstream {service.origin} unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
headers = self._response_headers(upstream, service)
|
||||
|
||||
if content_type.startswith("text/html"):
|
||||
await upstream.aread()
|
||||
return Response(
|
||||
content=self._rewrite_html(upstream.text, service),
|
||||
status_code=upstream.status_code,
|
||||
headers=headers,
|
||||
media_type="text/html; charset=utf-8",
|
||||
)
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
stream(), status_code=upstream.status_code, headers=headers
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------- health check
|
||||
async def check(self, service: Service) -> Dict[str, object]:
|
||||
try:
|
||||
response = await self.client.get(
|
||||
service.url,
|
||||
headers=service.headers,
|
||||
timeout=min(10.0, self.config.proxy_timeout),
|
||||
)
|
||||
return {
|
||||
"reachable": response.status_code < 400,
|
||||
"status": response.status_code,
|
||||
"error": None,
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"reachable": False, "status": None, "error": str(exc)}
|
||||
Reference in new issue
Block a user