505 lines
20 KiB
Python
505 lines
20 KiB
Python
import asyncio
|
|
import errno
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import posixpath
|
|
import sys
|
|
import time
|
|
from math import floor
|
|
from typing import List
|
|
|
|
import multidict
|
|
from packaging.version import Version
|
|
|
|
import ray
|
|
import ray.dashboard.optional_utils as dashboard_optional_utils
|
|
import ray.dashboard.timezone_utils as timezone_utils
|
|
import ray.dashboard.utils as dashboard_utils
|
|
from ray import ray_constants
|
|
from ray._common.network_utils import build_address, parse_address
|
|
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray._private.authentication import (
|
|
authentication_constants,
|
|
authentication_utils as auth_utils,
|
|
)
|
|
from ray._private.authentication.http_token_authentication import (
|
|
get_token_auth_middleware,
|
|
)
|
|
from ray._raylet import get_authentication_mode
|
|
from ray.dashboard.dashboard_metrics import DashboardPrometheusMetrics
|
|
from ray.dashboard.head import DashboardHeadModule
|
|
|
|
# All third-party dependencies that are not included in the minimal Ray
|
|
# installation must be included in this file. This allows us to determine if
|
|
# the agent has the necessary dependencies to be started.
|
|
from ray.dashboard.optional_deps import aiohttp, hdrs
|
|
from ray.dashboard.subprocesses.handle import SubprocessModuleHandle
|
|
from ray.dashboard.subprocesses.routes import SubprocessRouteTable
|
|
|
|
# Logger for this module. It should be configured at the entry point
|
|
# into the program using Ray. Ray provides a default configuration at
|
|
# entry/init points.
|
|
logger = logging.getLogger(__name__)
|
|
routes = dashboard_optional_utils.DashboardHeadRouteTable
|
|
|
|
# Env var that enables follow_symlinks for serving UI static files.
|
|
# This is an advanced setting that should only be used with special Ray installations
|
|
# where the dashboard build files are symlinked to a different directory.
|
|
# This is not recommended for most users and can pose a security risk.
|
|
# Please reference the aiohttp docs here:
|
|
# https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.UrlDispatcher.add_static
|
|
ENV_VAR_FOLLOW_SYMLINKS = "RAY_DASHBOARD_BUILD_FOLLOW_SYMLINKS"
|
|
FOLLOW_SYMLINKS_ENABLED = os.environ.get(ENV_VAR_FOLLOW_SYMLINKS) == "1"
|
|
if FOLLOW_SYMLINKS_ENABLED:
|
|
logger.warning(
|
|
"Enabling RAY_DASHBOARD_BUILD_FOLLOW_SYMLINKS is not recommended as it "
|
|
"allows symlinks to directories outside the dashboard build folder. "
|
|
"You may accidentally expose files on your system outside of the "
|
|
"build directory."
|
|
)
|
|
|
|
|
|
def setup_static_dir():
|
|
build_dir = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "client", "build"
|
|
)
|
|
module_name = os.path.basename(os.path.dirname(__file__))
|
|
if not os.path.isdir(build_dir):
|
|
raise dashboard_utils.FrontendNotFoundError(
|
|
errno.ENOENT,
|
|
"Dashboard build directory not found. If installing "
|
|
"from source, please follow the additional steps "
|
|
"required to build the dashboard"
|
|
f"(cd python/ray/{module_name}/client "
|
|
"&& npm ci "
|
|
"&& npm run build)",
|
|
build_dir,
|
|
)
|
|
|
|
static_dir = os.path.join(build_dir, "static")
|
|
routes.static("/static", static_dir, follow_symlinks=FOLLOW_SYMLINKS_ENABLED)
|
|
return build_dir
|
|
|
|
|
|
class HttpServerDashboardHead:
|
|
def __init__(
|
|
self,
|
|
ip: str,
|
|
http_host: str,
|
|
http_port: int,
|
|
http_port_retries: int,
|
|
gcs_address: str,
|
|
session_name: str,
|
|
metrics: DashboardPrometheusMetrics,
|
|
proxy_server_url: str | None = None,
|
|
):
|
|
self.ip = ip
|
|
self.http_host = http_host
|
|
self.http_port = http_port
|
|
self.http_port_retries = http_port_retries
|
|
self.head_node_ip = parse_address(gcs_address)[0]
|
|
self.metrics = metrics
|
|
self._session_name = session_name
|
|
self.proxy_server_url = proxy_server_url
|
|
|
|
# Below attirubtes are filled after `run` API is invoked.
|
|
self.runner = None
|
|
|
|
# Setup Dashboard Routes
|
|
try:
|
|
build_dir = setup_static_dir()
|
|
logger.info("Setup static dir for dashboard: %s", build_dir)
|
|
except dashboard_utils.FrontendNotFoundError as ex:
|
|
# Not to raise FrontendNotFoundError due to NPM incompatibilities
|
|
# with Windows.
|
|
# Please refer to ci.sh::build_dashboard_front_end()
|
|
if sys.platform in ["win32", "cygwin"]:
|
|
logger.warning(ex)
|
|
else:
|
|
raise ex
|
|
dashboard_optional_utils.DashboardHeadRouteTable.bind(self)
|
|
|
|
# Create a http session for all modules.
|
|
# aiohttp<4.0.0 uses a 'loop' variable, aiohttp>=4.0.0 doesn't anymore
|
|
if Version(aiohttp.__version__) < Version("4.0.0"):
|
|
self.http_session = aiohttp.ClientSession(loop=get_or_create_event_loop())
|
|
else:
|
|
self.http_session = aiohttp.ClientSession()
|
|
|
|
@routes.get("/")
|
|
async def get_index(self, req) -> aiohttp.web.FileResponse:
|
|
try:
|
|
# This API will be no-op after the first report.
|
|
# Note: We always record the usage, but it is not reported
|
|
# if the usage stats is disabled.
|
|
record_extra_usage_tag(TagKey.DASHBOARD_USED, "True")
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to record the dashboard usage. "
|
|
"This error message is harmless and can be ignored. "
|
|
f"Error: {e}"
|
|
)
|
|
resp = aiohttp.web.FileResponse(
|
|
os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "client/build/index.html"
|
|
)
|
|
)
|
|
resp.headers["Cache-Control"] = "no-store"
|
|
return resp
|
|
|
|
@routes.get("/favicon.ico")
|
|
async def get_favicon(self, req) -> aiohttp.web.FileResponse:
|
|
return aiohttp.web.FileResponse(
|
|
os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "client/build/favicon.ico"
|
|
)
|
|
)
|
|
|
|
@routes.get("/timezone")
|
|
async def get_timezone(self, req) -> aiohttp.web.Response:
|
|
try:
|
|
current_timezone = timezone_utils.get_current_timezone_info()
|
|
return aiohttp.web.json_response(current_timezone)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting timezone: {e}")
|
|
return aiohttp.web.Response(
|
|
status=500, text="Internal Server Error:" + str(e)
|
|
)
|
|
|
|
@routes.get("/api/authentication_mode")
|
|
async def get_authentication_mode(self, req) -> aiohttp.web.Response:
|
|
try:
|
|
mode = get_authentication_mode()
|
|
mode_str = auth_utils.get_authentication_mode_name(mode)
|
|
|
|
response = aiohttp.web.json_response({"authentication_mode": mode_str})
|
|
|
|
# If auth is disabled, clear any existing authentication cookie
|
|
if mode_str == "disabled":
|
|
response.set_cookie(
|
|
authentication_constants.AUTHENTICATION_TOKEN_COOKIE_NAME,
|
|
"",
|
|
max_age=0,
|
|
path="/",
|
|
)
|
|
|
|
return response
|
|
except Exception as e:
|
|
logger.error(f"Error getting authentication mode: {e}")
|
|
return aiohttp.web.Response(
|
|
status=500, text="Internal Server Error: " + str(e)
|
|
)
|
|
|
|
@routes.post("/api/authenticate")
|
|
async def authenticate(self, req) -> aiohttp.web.Response:
|
|
"""
|
|
Authenticate a user by validating their token and setting a secure HttpOnly cookie.
|
|
|
|
This endpoint accepts a token via the Authorization header, validates it,
|
|
and if valid, sets an HttpOnly cookie for subsequent requests from the web dashboard.
|
|
"""
|
|
try:
|
|
# Check if token authentication is enabled
|
|
if not auth_utils.is_token_auth_enabled():
|
|
return aiohttp.web.Response(
|
|
status=401,
|
|
text="Unauthorized: Token authentication is not enabled",
|
|
)
|
|
|
|
# Get token from Authorization header
|
|
auth_header = req.headers.get(
|
|
authentication_constants.AUTHORIZATION_HEADER_NAME, ""
|
|
)
|
|
|
|
if not auth_header:
|
|
return aiohttp.web.Response(
|
|
status=401,
|
|
text="Unauthorized: Missing authentication token",
|
|
)
|
|
|
|
# Validate the token
|
|
if not auth_utils.validate_request_token(auth_header):
|
|
return aiohttp.web.Response(
|
|
status=403,
|
|
text="Forbidden: Invalid authentication token",
|
|
)
|
|
|
|
# Token is valid - extract the token value (remove "Bearer " prefix if present)
|
|
token = auth_header
|
|
if auth_header.lower().startswith(
|
|
authentication_constants.AUTHORIZATION_BEARER_PREFIX.lower()
|
|
):
|
|
token = auth_header[
|
|
len(authentication_constants.AUTHORIZATION_BEARER_PREFIX) :
|
|
] # Remove "Bearer " prefix
|
|
|
|
# Create successful response
|
|
response = aiohttp.web.json_response(
|
|
{"status": "authenticated", "message": "Token is valid"}
|
|
)
|
|
|
|
# Set secure HttpOnly cookie
|
|
# Check if the connection is secure (HTTPS)
|
|
is_secure = req.scheme == "https"
|
|
|
|
response.set_cookie(
|
|
authentication_constants.AUTHENTICATION_TOKEN_COOKIE_NAME,
|
|
token,
|
|
max_age=authentication_constants.AUTHENTICATION_TOKEN_COOKIE_MAX_AGE, # 30 days (matching previous behavior)
|
|
path="/",
|
|
httponly=True, # Prevents JavaScript access (XSS protection)
|
|
samesite="Strict", # Prevents CSRF attacks
|
|
secure=is_secure, # Only send over HTTPS if connection is secure
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during authentication: {e}")
|
|
return aiohttp.web.Response(
|
|
status=500, text="Internal Server Error: " + str(e)
|
|
)
|
|
|
|
def get_address(self):
|
|
assert self.http_host and self.http_port
|
|
return self.http_host, self.http_port
|
|
|
|
@aiohttp.web.middleware
|
|
async def path_clean_middleware(self, request, handler):
|
|
if request.path.startswith("/static") or request.path.startswith("/logs"):
|
|
parent = pathlib.PurePosixPath(
|
|
"/logs" if request.path.startswith("/logs") else "/static"
|
|
)
|
|
|
|
# If the destination is not relative to the expected directory,
|
|
# then the user is attempting path traversal, so deny the request.
|
|
request_path = pathlib.PurePosixPath(posixpath.realpath(request.path))
|
|
if request_path != parent and parent not in request_path.parents:
|
|
logger.info(
|
|
f"Rejecting {request_path=} because it is not relative to {parent=}"
|
|
)
|
|
raise aiohttp.web.HTTPForbidden()
|
|
return await handler(request)
|
|
|
|
@aiohttp.web.middleware
|
|
async def proxy_server_middleware(self, request, handler):
|
|
"""Redirects the request to the proxy server if the related proxy server
|
|
url env var is set.
|
|
"""
|
|
# paths/endpoints that should not be directed when RAY_API_PROXY_URL_ENV_VAR is set
|
|
ignored_path = (
|
|
"/api/gcs_healthz",
|
|
"/usage_stats_enabled",
|
|
"/api/healthz",
|
|
"/api/local_raylet_healthz",
|
|
)
|
|
|
|
rel_path = str(request.rel_url)
|
|
body_data = None
|
|
if self.proxy_server_url and not rel_path.startswith(ignored_path):
|
|
logger.debug(f"Redirecting request to {self.proxy_server_url}")
|
|
body_data = await request.read()
|
|
|
|
# Headers that should be removed from the new request
|
|
# Case sensitive, keep everything lowercase for checking purposes.
|
|
headers_to_remove = {
|
|
"content-encoding", # This could cause the browser to try to unzip plain-text
|
|
"content-length", # Let aiohttp calculate instead
|
|
"host",
|
|
"connection",
|
|
"keep-alive",
|
|
"te",
|
|
"trailers",
|
|
"upgrade",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"transfer-encoding",
|
|
}
|
|
# We have to use multidict here since there are multiple cookies being set
|
|
proxy_headers = multidict.CIMultiDict(
|
|
(k, v)
|
|
for k, v in request.headers.items()
|
|
if k.lower() not in headers_to_remove
|
|
)
|
|
|
|
# This is to fix 415 error caused by content-type not in headers
|
|
proxy_headers.setdefault("Content-Type", "application/json")
|
|
|
|
timeout = aiohttp.ClientTimeout(total=30)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
# Manual concatenation to preserve potential paths in proxy_server_url
|
|
full_url = f"{self.proxy_server_url.rstrip('/')}{rel_path}"
|
|
logger.debug(
|
|
f"Sending request to proxy server instead. {full_url=}"
|
|
)
|
|
|
|
async with session.request(
|
|
method=request.method,
|
|
url=full_url,
|
|
headers=proxy_headers,
|
|
data=body_data,
|
|
) as resp:
|
|
body = await resp.read()
|
|
# only fallback if the reponse from the proxy server is not found
|
|
if not resp.status == 404:
|
|
resp_headers = multidict.CIMultiDict(
|
|
(k, v)
|
|
for k, v in resp.headers.items()
|
|
if k.lower()
|
|
not in {
|
|
"transfer-encoding",
|
|
"content-encoding",
|
|
"content-length",
|
|
}
|
|
)
|
|
|
|
return aiohttp.web.Response(
|
|
body=body, status=resp.status, headers=resp_headers
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"Calling endpoint {request.rel_url} returned status {resp.status} and {body}, falling back"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to use proxy server endpoint: {e}. Falling back to original handler."
|
|
)
|
|
|
|
return await handler(request)
|
|
|
|
@aiohttp.web.middleware
|
|
async def metrics_middleware(self, request, handler):
|
|
start_time = time.monotonic()
|
|
|
|
try:
|
|
response = await handler(request)
|
|
status_tag = f"{floor(response.status / 100)}xx"
|
|
return response
|
|
except (Exception, asyncio.CancelledError):
|
|
status_tag = "5xx"
|
|
raise
|
|
finally:
|
|
resp_time = time.monotonic() - start_time
|
|
try:
|
|
self.metrics.metrics_request_duration.labels(
|
|
endpoint=handler.__name__,
|
|
http_status=status_tag,
|
|
Version=ray.__version__,
|
|
SessionName=self._session_name,
|
|
Component="dashboard",
|
|
).observe(resp_time)
|
|
self.metrics.metrics_request_count.labels(
|
|
method=request.method,
|
|
endpoint=handler.__name__,
|
|
http_status=status_tag,
|
|
Version=ray.__version__,
|
|
SessionName=self._session_name,
|
|
Component="dashboard",
|
|
).inc()
|
|
except Exception as e:
|
|
logger.exception(f"Error emitting api metrics: {e}")
|
|
|
|
@aiohttp.web.middleware
|
|
async def cache_control_static_middleware(self, request, handler):
|
|
if request.path.startswith("/static"):
|
|
response = await handler(request)
|
|
response.headers["Cache-Control"] = "max-age=31536000"
|
|
return response
|
|
return await handler(request)
|
|
|
|
async def run(
|
|
self,
|
|
dashboard_head_modules: List[DashboardHeadModule],
|
|
subprocess_module_handles: List[SubprocessModuleHandle],
|
|
):
|
|
# Bind http routes of each module.
|
|
for m in dashboard_head_modules:
|
|
dashboard_optional_utils.DashboardHeadRouteTable.bind(m)
|
|
|
|
for h in subprocess_module_handles:
|
|
SubprocessRouteTable.bind(h)
|
|
|
|
# Public endpoints that don't require authentication.
|
|
# These are needed for the dashboard to load and request an auth token.
|
|
public_exact_paths = {
|
|
"/", # Root index.html
|
|
"/favicon.ico",
|
|
"/api/authentication_mode",
|
|
"/api/authenticate", # Token authentication endpoint
|
|
"/api/healthz", # General healthcheck
|
|
"/api/gcs_healthz", # GCS health check
|
|
"/api/local_raylet_healthz", # Raylet health check
|
|
"/-/healthz", # Serve health check
|
|
}
|
|
public_path_prefixes = ("/static/",) # Static assets (JS, CSS, images)
|
|
|
|
# Http server should be initialized after all modules loaded.
|
|
# working_dir uploads for job submission can be up to 100MiB.
|
|
|
|
app = aiohttp.web.Application(
|
|
client_max_size=ray_constants.DASHBOARD_CLIENT_MAX_SIZE,
|
|
middlewares=[
|
|
self.metrics_middleware,
|
|
get_token_auth_middleware(
|
|
aiohttp, public_exact_paths, public_path_prefixes
|
|
),
|
|
self.path_clean_middleware,
|
|
dashboard_optional_utils.get_browser_request_middleware(
|
|
aiohttp,
|
|
allowed_methods={"GET", "HEAD", "OPTIONS"},
|
|
allowed_paths=["/api/authenticate"],
|
|
),
|
|
self.cache_control_static_middleware,
|
|
self.proxy_server_middleware,
|
|
],
|
|
)
|
|
app.add_routes(routes=routes.bound_routes())
|
|
app.add_routes(routes=SubprocessRouteTable.bound_routes())
|
|
|
|
self.runner = aiohttp.web.AppRunner(
|
|
app,
|
|
access_log_format=(
|
|
"%a %t '%r' %s %b bytes %D us '%{Referer}i' '%{User-Agent}i'"
|
|
),
|
|
)
|
|
await self.runner.setup()
|
|
last_ex = None
|
|
for i in range(1 + self.http_port_retries):
|
|
try:
|
|
site = aiohttp.web.TCPSite(self.runner, self.http_host, self.http_port)
|
|
await site.start()
|
|
break
|
|
except OSError as e:
|
|
last_ex = e
|
|
self.http_port += 1
|
|
logger.warning("Try to use port %s: %s", self.http_port, e)
|
|
else:
|
|
raise Exception(
|
|
f"Failed to find a valid port for dashboard after "
|
|
f"{self.http_port_retries} retries: {last_ex}"
|
|
)
|
|
self.http_host, self.http_port, *_ = site._server.sockets[0].getsockname()
|
|
self.http_host = (
|
|
self.ip
|
|
if ipaddress.ip_address(self.http_host).is_unspecified
|
|
else self.http_host
|
|
)
|
|
logger.info(
|
|
"Dashboard head http address: %s",
|
|
build_address(self.http_host, self.http_port),
|
|
)
|
|
# Dump registered http routes.
|
|
dump_routes = [r for r in app.router.routes() if r.method != hdrs.METH_HEAD]
|
|
for r in dump_routes:
|
|
logger.info(r)
|
|
logger.info("Registered %s routes.", len(dump_routes))
|
|
|
|
async def cleanup(self):
|
|
# Wait for finish signal.
|
|
await self.runner.cleanup()
|