diff --git a/.changeset/basepath-support.md b/.changeset/basepath-support.md new file mode 100644 index 000000000..4bacc2458 --- /dev/null +++ b/.changeset/basepath-support.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +'@hyperdx/common-utils': minor +--- + +feat: Add basePath support for subpath deployments via environment variables diff --git a/.env b/.env index 8b26d6283..9a24a515c 100644 --- a/.env +++ b/.env @@ -21,5 +21,10 @@ HYPERDX_APP_URL=http://localhost HYPERDX_LOG_LEVEL=debug HYPERDX_OPAMP_PORT=4320 +# Subpath support (leave empty for root) +HYPERDX_BASE_PATH= +HYPERDX_API_BASE_PATH= +HYPERDX_OTEL_BASE_PATH= + # Otel/Clickhouse config HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE=default diff --git a/docker-compose.yml b/docker-compose.yml index ce834d352..3d66f52a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE: ${HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE} HYPERDX_LOG_LEVEL: ${HYPERDX_LOG_LEVEL} OPAMP_SERVER_URL: 'http://app:${HYPERDX_OPAMP_PORT}' + HYPERDX_OTEL_BASE_PATH: ${HYPERDX_OTEL_BASE_PATH:-} ports: - '13133:13133' # health_check extension - '24225:24225' # fluentd receiver @@ -62,6 +63,9 @@ services: OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318' OTEL_SERVICE_NAME: 'hdx-oss-app' USAGE_STATS_ENABLED: ${USAGE_STATS_ENABLED:-true} + HYPERDX_BASE_PATH: ${HYPERDX_BASE_PATH:-} + HYPERDX_API_BASE_PATH: ${HYPERDX_API_BASE_PATH:-} + HYPERDX_OTEL_BASE_PATH: ${HYPERDX_OTEL_BASE_PATH:-} DEFAULT_CONNECTIONS: '[{"name":"Local ClickHouse","host":"http://ch-server:8123","username":"default","password":""}]' diff --git a/docker/hyperdx/Dockerfile b/docker/hyperdx/Dockerfile index 818b10f42..b8900ce8e 100644 --- a/docker/hyperdx/Dockerfile +++ b/docker/hyperdx/Dockerfile @@ -36,6 +36,10 @@ RUN yarn install --mode=skip-build && yarn cache clean ## API/APP Builder Image ########################################################################## FROM node_base AS builder +ARG HYPERDX_BASE_PATH +ARG HYPERDX_API_BASE_PATH +ARG HYPERDX_OTEL_BASE_PATH + WORKDIR /app COPY --from=api ./src ./packages/api/src @@ -48,6 +52,9 @@ COPY --from=app ./types ./packages/app/types ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_OUTPUT_STANDALONE true ENV NEXT_PUBLIC_IS_LOCAL_MODE false +ENV HYPERDX_BASE_PATH $HYPERDX_BASE_PATH +ENV HYPERDX_API_BASE_PATH $HYPERDX_API_BASE_PATH +ENV HYPERDX_OTEL_BASE_PATH $HYPERDX_OTEL_BASE_PATH ENV NX_DAEMON=false RUN npx nx run-many --target=build --projects=@hyperdx/common-utils,@hyperdx/api,@hyperdx/app diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 137fd71e0..08506eaaa 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -1,3 +1,4 @@ +import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath'; import compression from 'compression'; import MongoStore from 'connect-mongo'; import express from 'express'; @@ -20,6 +21,8 @@ import passport from './utils/passport'; const app: express.Application = express(); +const API_BASE_PATH = getApiBasePath(); + const sess: session.SessionOptions & { cookie: session.CookieOptions } = { resave: false, saveUninitialized: false, @@ -79,45 +82,88 @@ if (config.USAGE_STATS_ENABLED) { // --------------------------------------------------------------------- // ----------------------- Internal Routers ---------------------------- // --------------------------------------------------------------------- -// PUBLIC ROUTES -app.use('/', routers.rootRouter); - -// PRIVATE ROUTES -app.use('/alerts', isUserAuthenticated, routers.alertsRouter); -app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter); -app.use('/me', isUserAuthenticated, routers.meRouter); -app.use('/team', isUserAuthenticated, routers.teamRouter); -app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter); -app.use('/connections', isUserAuthenticated, connectionsRouter); -app.use('/sources', isUserAuthenticated, sourcesRouter); -app.use('/saved-search', isUserAuthenticated, savedSearchRouter); -app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter); -// --------------------------------------------------------------------- +if (API_BASE_PATH) { + const apiRouter = express.Router(); -// TODO: Separate external API routers from internal routers -// --------------------------------------------------------------------- -// ----------------------- External Routers ---------------------------- -// --------------------------------------------------------------------- -// API v2 -// Only initialize Swagger in development or if explicitly enabled -if ( - process.env.NODE_ENV !== 'production' && - process.env.ENABLE_SWAGGER === 'true' -) { - import('./utils/swagger') - .then(({ setupSwagger }) => { - console.log('Swagger UI setup and available at /api/v2/docs'); - setupSwagger(app); - }) - .catch(error => { - console.error( - 'Failed to dynamically load or setup Swagger. Swagger UI will not be available.', - error, - ); - }); -} + // PUBLIC ROUTES + apiRouter.use('/', routers.rootRouter); + + // PRIVATE ROUTES + apiRouter.use('/alerts', isUserAuthenticated, routers.alertsRouter); + apiRouter.use('/dashboards', isUserAuthenticated, routers.dashboardRouter); + apiRouter.use('/me', isUserAuthenticated, routers.meRouter); + apiRouter.use('/team', isUserAuthenticated, routers.teamRouter); + apiRouter.use('/webhooks', isUserAuthenticated, routers.webhooksRouter); + apiRouter.use('/connections', isUserAuthenticated, connectionsRouter); + apiRouter.use('/sources', isUserAuthenticated, sourcesRouter); + apiRouter.use('/saved-search', isUserAuthenticated, savedSearchRouter); + apiRouter.use( + '/clickhouse-proxy', + isUserAuthenticated, + clickhouseProxyRouter, + ); + apiRouter.use('/api/v2', externalRoutersV2); + + // Only initialize Swagger in development or if explicitly enabled + if ( + process.env.NODE_ENV !== 'production' && + process.env.ENABLE_SWAGGER === 'true' + ) { + import('./utils/swagger') + .then(({ setupSwagger }) => { + console.log('Swagger UI setup and available at /api/v2/docs'); + setupSwagger(app); + }) + .catch(error => { + console.error( + 'Failed to dynamically load or setup Swagger. Swagger UI will not be available.', + error, + ); + }); + } + + app.use(API_BASE_PATH, apiRouter); +} else { + // PUBLIC ROUTES + app.use('/', routers.rootRouter); -app.use('/api/v2', externalRoutersV2); + // PRIVATE ROUTES + app.use('/alerts', isUserAuthenticated, routers.alertsRouter); + app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter); + app.use('/me', isUserAuthenticated, routers.meRouter); + app.use('/team', isUserAuthenticated, routers.teamRouter); + app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter); + app.use('/connections', isUserAuthenticated, connectionsRouter); + app.use('/sources', isUserAuthenticated, sourcesRouter); + app.use('/saved-search', isUserAuthenticated, savedSearchRouter); + app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter); + + // TODO: Separate external API routers from internal routers + // --------------------------------------------------------------------- + // ----------------------- External Routers ---------------------------- + // --------------------------------------------------------------------- + // API v2 + // Only initialize Swagger in development or if explicitly enabled + if ( + process.env.NODE_ENV !== 'production' && + process.env.ENABLE_SWAGGER === 'true' + ) { + import('./utils/swagger') + .then(({ setupSwagger }) => { + console.log('Swagger UI setup and available at /api/v2/docs'); + setupSwagger(app); + }) + .catch(error => { + console.error( + 'Failed to dynamically load or setup Swagger. Swagger UI will not be available.', + error, + ); + }); + } + + app.use('/api/v2', externalRoutersV2); +} +// --------------------------------------------------------------------- // error handling app.use(appErrorHandler); diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile index 4c7525d2a..996c33d2b 100644 --- a/packages/app/Dockerfile +++ b/packages/app/Dockerfile @@ -28,9 +28,11 @@ FROM base AS builder ARG OTEL_EXPORTER_OTLP_ENDPOINT ARG OTEL_SERVICE_NAME ARG IS_LOCAL_MODE +ARG HYPERDX_BASE_PATH ENV NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT $OTEL_EXPORTER_OTLP_ENDPOINT ENV NEXT_PUBLIC_OTEL_SERVICE_NAME $OTEL_SERVICE_NAME ENV NEXT_PUBLIC_IS_LOCAL_MODE $IS_LOCAL_MODE +ENV HYPERDX_BASE_PATH $HYPERDX_BASE_PATH ENV NX_DAEMON false COPY ./packages/app/src ./packages/app/src diff --git a/packages/app/next.config.js b/packages/app/next.config.js index f4b04a4d5..3a8bf6ea7 100644 --- a/packages/app/next.config.js +++ b/packages/app/next.config.js @@ -1,5 +1,6 @@ const { configureRuntimeEnv } = require('next-runtime-env/build/configure'); const { version } = require('./package.json'); +const { getFrontendBasePath } = require('@hyperdx/common-utils/dist/basePath'); configureRuntimeEnv(); @@ -9,6 +10,7 @@ const withNextra = require('nextra')({ }); module.exports = { + basePath: getFrontendBasePath(), experimental: { instrumentationHook: true, // External packages to prevent bundling issues with Next.js 14 @@ -57,8 +59,8 @@ module.exports = { productionBrowserSourceMaps: false, ...(process.env.NEXT_OUTPUT_STANDALONE === 'true' ? { - output: 'standalone', - } + output: 'standalone', + } : {}), }), }; diff --git a/packages/app/pages/_document.tsx b/packages/app/pages/_document.tsx index bc8bef175..c363e5fb7 100644 --- a/packages/app/pages/_document.tsx +++ b/packages/app/pages/_document.tsx @@ -1,10 +1,11 @@ import { Head, Html, Main, NextScript } from 'next/document'; +import { getFrontendBasePath } from '@hyperdx/common-utils/dist/basePath'; export default function Document() { return ( - { const proxy = createProxyMiddleware({ changeOrigin: true, // logger: console, // DEBUG - pathRewrite: { '^/api': '' }, + pathRewrite: { '^/api': API_BASE_PATH }, target: process.env.SERVER_URL || DEFAULT_SERVER_URL, autoRewrite: true, // ...(IS_DEV && { diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 2448321f1..bd47b2232 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -2,6 +2,7 @@ import React from 'react'; import Router from 'next/router'; import type { HTTPError, Options, ResponsePromise } from 'ky'; import ky from 'ky-universal'; +import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath'; import type { Alert } from '@hyperdx/common-utils/dist/types'; import type { UseQueryOptions } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; @@ -47,8 +48,11 @@ export function loginHook(request: Request, options: any, response: Response) { } } +// Get basePath from runtime environment +const getApiPrefix = () => getApiBasePath(); + export const server = ky.create({ - prefixUrl: '/api', + prefixUrl: getApiPrefix(), credentials: 'include', hooks: { afterResponse: [loginHook], diff --git a/packages/app/src/clickhouse.ts b/packages/app/src/clickhouse.ts index 6d5def56d..2de1ea000 100644 --- a/packages/app/src/clickhouse.ts +++ b/packages/app/src/clickhouse.ts @@ -5,6 +5,7 @@ // please move app-specific functions elsewhere in the app // ================================ +import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath'; import { chSql, ClickhouseClientOptions, @@ -20,7 +21,7 @@ import { getLocalConnections } from '@/connection'; import api from './api'; import { DEFAULT_QUERY_TIMEOUT } from './defaults'; -const PROXY_CLICKHOUSE_HOST = '/api/clickhouse-proxy'; +const PROXY_CLICKHOUSE_HOST = `${getApiBasePath()}/clickhouse-proxy`; export const getClickhouseClient = ( options: ClickhouseClientOptions = {}, diff --git a/packages/common-utils/src/__tests__/basePath.test.ts b/packages/common-utils/src/__tests__/basePath.test.ts new file mode 100644 index 000000000..aaa6b5116 --- /dev/null +++ b/packages/common-utils/src/__tests__/basePath.test.ts @@ -0,0 +1,78 @@ +import { + getApiBasePath, + getFrontendBasePath, + getOtelBasePath, + joinPath, +} from '../basePath'; + +describe('basePath utilities', () => { + describe('getFrontendBasePath', () => { + it('returns empty string if no env var', () => { + delete process.env.HYPERDX_BASE_PATH; + expect(getFrontendBasePath()).toBe(''); + }); + + it('returns normalized path for valid input', () => { + process.env.HYPERDX_BASE_PATH = '/hyperdx'; + expect(getFrontendBasePath()).toBe('/hyperdx'); + + process.env.HYPERDX_BASE_PATH = 'hyperdx/'; + expect(getFrontendBasePath()).toBe('/hyperdx'); + + process.env.HYPERDX_BASE_PATH = '/hyperdx/'; + expect(getFrontendBasePath()).toBe('/hyperdx'); + }); + + it('returns empty for invalid paths', () => { + process.env.HYPERDX_BASE_PATH = '/../invalid'; + expect(getFrontendBasePath()).toBe(''); + + process.env.HYPERDX_BASE_PATH = 'http://example.com'; + expect(getFrontendBasePath()).toBe(''); + }); + }); + + describe('getApiBasePath', () => { + it('defaults to /api if no env var', () => { + delete process.env.HYPERDX_API_BASE_PATH; + expect(getApiBasePath()).toBe('/api'); + }); + + it('uses env var if set', () => { + process.env.HYPERDX_API_BASE_PATH = '/hyperdx/api'; + expect(getApiBasePath()).toBe('/hyperdx/api'); + }); + + it('normalizes input', () => { + process.env.HYPERDX_API_BASE_PATH = 'hyperdx/api/'; + expect(getApiBasePath()).toBe('/hyperdx/api'); + }); + }); + + describe('getOtelBasePath', () => { + it('returns empty if no env var', () => { + delete process.env.HYPERDX_OTEL_BASE_PATH; + expect(getOtelBasePath()).toBe(''); + }); + + it('returns normalized path', () => { + process.env.HYPERDX_OTEL_BASE_PATH = '/hyperdx/otel'; + expect(getOtelBasePath()).toBe('/hyperdx/otel'); + }); + }); + + describe('joinPath', () => { + it('joins empty base with relative', () => { + expect(joinPath('', '/test')).toBe('/test'); + }); + + it('joins valid base and relative', () => { + expect(joinPath('/hyperdx', '/api')).toBe('/hyperdx/api'); + expect(joinPath('/hyperdx', 'api')).toBe('/hyperdx/api'); + }); + + it('normalizes joined path', () => { + expect(joinPath('/hyperdx/', '/api/')).toBe('/hyperdx/api'); + }); + }); +}); diff --git a/packages/common-utils/src/basePath.ts b/packages/common-utils/src/basePath.ts new file mode 100644 index 000000000..5803bdc72 --- /dev/null +++ b/packages/common-utils/src/basePath.ts @@ -0,0 +1,82 @@ +import path from 'path'; + +/** + * Normalizes a base path: ensures it starts with '/', removes trailing '/', + * and validates against common issues like path traversal or absolute URLs. + * @param basePath - The raw base path from env var + * @returns Normalized path or empty string if invalid + */ +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath || typeof basePath !== 'string') { + return ''; + } + + const trimmed = basePath.trim(); + if (!trimmed) { + return ''; + } + + // Validate: prevent full URLs on original trimmed input + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + console.warn(`Invalid base path detected: ${basePath}. Using empty path.`); + return ''; + } + + // Ensure leading slash + let normalized = trimmed; + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + // Remove trailing slash if present (except for root '/') + if (normalized.endsWith('/') && normalized !== '/') { + normalized = normalized.slice(0, -1); + } + + // Validate: prevent path traversal + if (normalized.includes('..')) { + console.warn(`Invalid base path detected: ${basePath}. Using empty path.`); + return ''; + } + + return normalized; +} + +/** + * Joins a base path with a relative path, normalizing the result. + * @param base - Normalized base path + * @param relative - Relative path to join + * @returns Full joined path + */ +export function joinPath(base: string, relative: string): string { + if (!base) return normalizeBasePath(relative); + if (!relative.startsWith('/')) relative = '/' + relative; + let joined = path.posix.join(base, relative); + if (!joined.startsWith('/')) joined = '/' + joined; + if (joined.endsWith('/') && joined !== '/') joined = joined.slice(0, -1); + return joined; +} + +/** + * Gets the normalized frontend base path from HYPERDX_BASE_PATH env var. + */ +export function getFrontendBasePath(): string { + return normalizeBasePath(process.env.HYPERDX_BASE_PATH); +} + +/** + * Gets the normalized API base path from HYPERDX_API_BASE_PATH env var. + * Defaults to '/api' if empty, but allows override. + */ +export function getApiBasePath(): string { + let apiPath = process.env.HYPERDX_API_BASE_PATH || '/api'; + if (!apiPath.startsWith('/')) apiPath = '/' + apiPath; + return joinPath(normalizeBasePath(apiPath), ''); +} + +/** + * Gets the normalized OTEL base path from HYPERDX_OTEL_BASE_PATH env var. + */ +export function getOtelBasePath(): string { + return normalizeBasePath(process.env.HYPERDX_OTEL_BASE_PATH); +} diff --git a/proxy/nginx/nginx.conf b/proxy/nginx/nginx.conf new file mode 100644 index 000000000..7a52f19f0 --- /dev/null +++ b/proxy/nginx/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name localhost; + + # Frontend + location /hyperdx/ { + rewrite ^/hyperdx/(.*) /$1 break; + proxy_pass http://app:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; # For WebSockets (live tailing) + } + + # API + location /hyperdx/api/ { + rewrite ^/hyperdx/api/(.*) /api/$1 break; + proxy_pass http://app:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # OTEL (adjust ports as needed) + location /hyperdx/otel/ { + rewrite ^/hyperdx/otel/(.*) /otel/$1 break; + proxy_pass http://otel-collector:4318; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/proxy/traefik/config.yml b/proxy/traefik/config.yml new file mode 100644 index 000000000..01c81347d --- /dev/null +++ b/proxy/traefik/config.yml @@ -0,0 +1,45 @@ +http: + routers: + hyperdx: + rule: "PathPrefix(`/hyperdx`)" + service: hyperdx-app + middlewares: + - strip-frontend + hyperdx-api: + rule: "PathPrefix(`/hyperdx/api`)" + service: hyperdx-api + middlewares: + - strip-api + hyperdx-otel: + rule: "PathPrefix(`/hyperdx/otel`)" + service: hyperdx-otel + middlewares: + - strip-otel + + middlewares: + strip-frontend: + stripPrefix: + prefixes: + - "/hyperdx" + strip-api: + stripPrefix: + prefixes: + - "/hyperdx/api" + strip-otel: + stripPrefix: + prefixes: + - "/hyperdx/otel" + + services: + hyperdx-app: + loadBalancer: + servers: + - url: "http://app:8080" + hyperdx-api: + loadBalancer: + servers: + - url: "http://app:8000" + hyperdx-otel: + loadBalancer: + servers: + - url: "http://otel-collector:4318" \ No newline at end of file