fix: various fixes to things + simply app proxy sentry setup

This commit is contained in:
Hampus Kraft
2026-02-19 00:29:58 +00:00
parent ff1d15f7aa
commit 528e4e0d7f
44 changed files with 441 additions and 1042 deletions

View File

@@ -30,15 +30,10 @@ export interface AppProxyResult {
export async function createAppProxyApp(options: CreateAppProxyAppOptions): Promise<AppProxyResult> {
const {
assetsPath = '/assets',
config,
cspDirectives,
customMiddleware = [],
logger,
metricsCollector,
rateLimitService = null,
sentryProxyEnabled = true,
sentryProxyPath = '/sentry',
sentryProxyRouteEnabled = true,
staticCDNEndpoint,
staticDir,
tracing,
@@ -48,13 +43,9 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
applyAppProxyMiddleware({
app,
config,
customMiddleware,
logger,
metricsCollector,
rateLimitService,
sentryProxyEnabled,
sentryProxyPath,
tracing,
});
@@ -63,9 +54,6 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
assetsPath,
cspDirectives,
logger,
sentryProxy: config.sentry_proxy,
sentryProxyPath,
sentryProxyRouteEnabled,
staticCDNEndpoint,
staticDir,
});

View File

@@ -22,34 +22,13 @@ import type {SentryConfig, TelemetryConfig} from '@fluxer/config/src/MasterZodSc
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {Logger} from '@fluxer/logger/src/Logger';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {MiddlewareHandler} from 'hono';
export interface AppProxySentryProxyConfig {
project_id: string;
public_key: string;
target_url: string;
path_prefix: string;
}
export interface AppProxyConfig {
env: string;
port: number;
static_cdn_endpoint: string;
sentry_proxy_path: string;
sentry_report_host: string;
sentry_proxy: AppProxySentryProxyConfig | null;
assets_dir: string;
kv: {
url: string;
timeout_ms: number;
};
rate_limit: {
sentry: {
limit: number;
window_ms: number;
};
};
telemetry: TelemetryConfig;
sentry: SentryConfig;
}
@@ -57,7 +36,6 @@ export interface AppProxyConfig {
export interface AppProxyContext {
config: AppProxyConfig;
logger: Logger;
rateLimitService: IRateLimitService | null;
}
export interface AppProxyHonoEnv {
@@ -69,15 +47,11 @@ export type AppProxyMiddleware = MiddlewareHandler<AppProxyHonoEnv>;
export interface CreateAppProxyAppOptions {
config: AppProxyConfig;
logger: Logger;
rateLimitService?: IRateLimitService | null;
metricsCollector?: MetricsCollector;
tracing?: TracingOptions;
customMiddleware?: Array<AppProxyMiddleware>;
sentryProxyPath?: string;
assetsPath?: string;
staticCDNEndpoint?: string;
staticDir?: string;
cspDirectives?: CSPOptions;
sentryProxyEnabled?: boolean;
sentryProxyRouteEnabled?: boolean;
}

View File

@@ -17,44 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AppProxyConfig, AppProxyHonoEnv, AppProxyMiddleware} from '@fluxer/app_proxy/src/AppProxyTypes';
import {createProxyRateLimitMiddleware} from '@fluxer/app_proxy/src/app_proxy/middleware/ProxyRateLimit';
import {createSentryHostProxyMiddleware} from '@fluxer/app_proxy/src/app_proxy/middleware/SentryHostProxy';
import {proxySentry} from '@fluxer/app_proxy/src/app_proxy/proxy/SentryProxy';
import {resolveSentryHost} from '@fluxer/app_proxy/src/app_proxy/utils/Host';
import type {AppProxyHonoEnv, AppProxyMiddleware} from '@fluxer/app_proxy/src/AppProxyTypes';
import {isExpectedError} from '@fluxer/app_proxy/src/ErrorClassification';
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {Logger} from '@fluxer/logger/src/Logger';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {captureException} from '@fluxer/sentry/src/Sentry';
import type {Context, Hono} from 'hono';
interface ApplyAppProxyMiddlewareOptions {
app: Hono<AppProxyHonoEnv>;
config: AppProxyConfig;
customMiddleware: Array<AppProxyMiddleware>;
logger: Logger;
metricsCollector?: MetricsCollector;
rateLimitService: IRateLimitService | null;
sentryProxyEnabled: boolean;
sentryProxyPath: string;
tracing?: TracingOptions;
}
export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions): void {
const {
app,
config,
customMiddleware,
logger,
metricsCollector,
rateLimitService,
sentryProxyEnabled,
sentryProxyPath,
tracing,
} = options;
const {app, customMiddleware, logger, metricsCollector, tracing} = options;
applyMiddlewareStack(app, {
requestId: {},
@@ -81,7 +62,7 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
skip: ['/_health'],
},
errorHandler: {
includeStack: !sentryProxyEnabled,
includeStack: true,
logger: (error: Error, ctx: Context) => {
if (!isExpectedError(error)) {
captureException(error, {
@@ -103,58 +84,4 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
},
customMiddleware,
});
applySentryHostProxyMiddleware({
app,
config,
logger,
rateLimitService,
sentryProxyEnabled,
sentryProxyPath,
});
}
interface ApplySentryHostProxyMiddlewareOptions {
app: Hono<AppProxyHonoEnv>;
config: AppProxyConfig;
logger: Logger;
rateLimitService: IRateLimitService | null;
sentryProxyEnabled: boolean;
sentryProxyPath: string;
}
function applySentryHostProxyMiddleware(options: ApplySentryHostProxyMiddlewareOptions): void {
const {app, config, logger, rateLimitService, sentryProxyEnabled, sentryProxyPath} = options;
const sentryHost = resolveSentryHost(config);
const sentryProxy = config.sentry_proxy;
if (!sentryProxyEnabled || !sentryHost || !sentryProxy) {
return;
}
const sentryRateLimitMiddleware = createProxyRateLimitMiddleware({
rateLimitService,
bucketPrefix: 'sentry-proxy',
config: {
enabled: config.rate_limit.sentry.limit > 0,
limit: config.rate_limit.sentry.limit,
windowMs: config.rate_limit.sentry.window_ms,
skipPaths: ['/_health'],
},
logger,
});
const sentryHostMiddleware = createSentryHostProxyMiddleware({
sentryHost,
rateLimitMiddleware: sentryRateLimitMiddleware,
proxyHandler: (c) =>
proxySentry(c, {
enabled: true,
logger,
sentryProxy,
sentryProxyPath,
}),
});
app.use('*', sentryHostMiddleware);
}

View File

@@ -18,9 +18,8 @@
*/
import {resolve} from 'node:path';
import type {AppProxyHonoEnv, AppProxySentryProxyConfig} from '@fluxer/app_proxy/src/AppProxyTypes';
import type {AppProxyHonoEnv} from '@fluxer/app_proxy/src/AppProxyTypes';
import {proxyAssets} from '@fluxer/app_proxy/src/app_proxy/proxy/AssetsProxy';
import {proxySentry} from '@fluxer/app_proxy/src/app_proxy/proxy/SentryProxy';
import {createSpaIndexRoute} from '@fluxer/app_proxy/src/app_server/routes/SpaIndexRoute';
import type {CSPOptions} from '@fluxer/app_proxy/src/app_server/utils/CSP';
import type {Logger} from '@fluxer/logger/src/Logger';
@@ -31,46 +30,15 @@ interface RegisterAppProxyRoutesOptions {
assetsPath: string;
cspDirectives?: CSPOptions;
logger: Logger;
sentryProxy: AppProxySentryProxyConfig | null;
sentryProxyPath: string;
sentryProxyRouteEnabled: boolean;
staticCDNEndpoint: string | undefined;
staticDir?: string;
}
export function registerAppProxyRoutes(options: RegisterAppProxyRoutesOptions): void {
const {
app,
assetsPath,
cspDirectives,
logger,
sentryProxy,
sentryProxyPath,
sentryProxyRouteEnabled,
staticCDNEndpoint,
staticDir,
} = options;
const {app, assetsPath, cspDirectives, logger, staticCDNEndpoint, staticDir} = options;
app.get('/_health', (c) => c.text('OK'));
app.all(sentryProxyPath, (c) =>
proxySentry(c, {
enabled: sentryProxyRouteEnabled,
logger,
sentryProxy,
sentryProxyPath,
}),
);
app.all(`${sentryProxyPath}/*`, (c) =>
proxySentry(c, {
enabled: sentryProxyRouteEnabled,
logger,
sentryProxy,
sentryProxyPath,
}),
);
if (staticCDNEndpoint) {
app.get(`${assetsPath}/*`, (c) =>
proxyAssets(c, {

View File

@@ -1,72 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {Logger} from '@fluxer/logger/src/Logger';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {createRateLimitMiddleware} from '@fluxer/rate_limit/src/middleware/RateLimitMiddleware';
interface ProxyRateLimitConfig {
enabled?: boolean;
limit: number;
windowMs: number;
skipPaths?: Array<string>;
}
export interface CreateProxyRateLimitMiddlewareOptions {
rateLimitService: IRateLimitService | null;
bucketPrefix: string;
config: ProxyRateLimitConfig;
logger: Logger;
}
export function createProxyRateLimitMiddleware(options: CreateProxyRateLimitMiddlewareOptions) {
const {bucketPrefix, config, logger, rateLimitService} = options;
return createRateLimitMiddleware({
rateLimitService: () => rateLimitService,
config: {
get enabled() {
const hasRateLimitService = Boolean(rateLimitService);
return config.enabled !== undefined ? hasRateLimitService && config.enabled : hasRateLimitService;
},
limit: config.limit,
windowMs: config.windowMs,
skipPaths: config.skipPaths,
},
getClientIdentifier: (req) => {
const realIp = extractClientIp(req);
return realIp || 'unknown';
},
getBucketName: (identifier, _ctx) => `${bucketPrefix}:ip:${identifier}`,
onRateLimitExceeded: (c, retryAfter) => {
const identifier = extractClientIp(c.req.raw) || 'unknown';
logger.warn(
{
ip: identifier,
path: c.req.path,
retryAfter,
},
'proxy rate limit exceeded',
);
return c.text('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
},
});
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {normalizeHost} from '@fluxer/app_proxy/src/app_proxy/utils/Host';
import type {Context, Next} from 'hono';
interface SentryHostProxyMiddlewareOptions {
sentryHost: string;
// biome-ignore lint/suspicious/noConfusingVoidType: hono middleware may return void
rateLimitMiddleware: (c: Context, next: Next) => Promise<Response | undefined | void>;
proxyHandler: (c: Context) => Promise<Response>;
}
export function createSentryHostProxyMiddleware(options: SentryHostProxyMiddlewareOptions) {
const {proxyHandler, rateLimitMiddleware, sentryHost} = options;
return async function sentryHostProxy(c: Context, next: Next): Promise<Response | undefined> {
const incomingHost = normalizeHost(c.req.raw.headers.get('host') ?? '');
if (incomingHost && incomingHost === sentryHost) {
const result = await rateLimitMiddleware(c, async () => {
c.res = await proxyHandler(c);
});
if (result) {
return result;
}
return c.res;
}
await next();
return undefined;
};
}

View File

@@ -1,77 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AppProxySentryProxyConfig} from '@fluxer/app_proxy/src/AppProxyTypes';
import {createProxyRequestHeaders, forwardProxyRequest} from '@fluxer/app_proxy/src/app_proxy/proxy/ProxyRequest';
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import type {Logger} from '@fluxer/logger/src/Logger';
import type {Context} from 'hono';
export interface ProxySentryOptions {
enabled: boolean;
sentryProxy: AppProxySentryProxyConfig | null;
sentryProxyPath: string;
logger: Logger;
}
export async function proxySentry(c: Context, options: ProxySentryOptions): Promise<Response> {
const {enabled, logger, sentryProxy, sentryProxyPath} = options;
if (!enabled || !sentryProxy) {
return c.text('Sentry proxy not configured', HttpStatus.SERVICE_UNAVAILABLE);
}
const targetPath = resolveSentryTargetPath(c.req.path, sentryProxyPath, sentryProxy.path_prefix);
const targetUrl = new URL(targetPath, sentryProxy.target_url);
const upstreamSentryEndpoint = new URL(sentryProxy.target_url);
const headers = createProxyRequestHeaders({
incomingHeaders: c.req.raw.headers,
upstreamHost: upstreamSentryEndpoint.host,
});
try {
return await forwardProxyRequest({
targetUrl,
method: c.req.method,
headers,
body: c.req.raw.body,
bufferResponseBody: true,
});
} catch (error) {
logger.error(
{
path: c.req.path,
targetUrl: targetUrl.toString(),
error,
},
'sentry proxy error',
);
return c.text('Bad Gateway', HttpStatus.BAD_GATEWAY);
}
}
function resolveSentryTargetPath(requestPath: string, sentryProxyPath: string, sentryPathPrefix: string): string {
let normalizedPath = requestPath;
if (normalizedPath.startsWith(sentryProxyPath)) {
normalizedPath = normalizedPath.slice(sentryProxyPath.length) || '/';
}
if (!normalizedPath.startsWith('/')) {
normalizedPath = `/${normalizedPath}`;
}
return `${sentryPathPrefix}${normalizedPath}`;
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export function normalizeHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return '';
}
const primary = trimmed.split(',')[0]?.trim() ?? '';
if (primary.startsWith('[')) {
const end = primary.indexOf(']');
return end > 0 ? primary.slice(1, end) : primary;
}
return primary.split(':')[0] ?? primary;
}
export function resolveSentryHost(config: {
sentry_proxy: {target_url: string} | null;
sentry_report_host: string;
}): string | null {
if (!config.sentry_proxy || !config.sentry_report_host) {
return null;
}
const normalizedSentryHost = normalizeHostValue(config.sentry_report_host);
return normalizedSentryHost || null;
}
function normalizeHostValue(rawHost: string): string {
try {
return normalizeHost(new URL(rawHost).host);
} catch {
return normalizeHost(rawHost);
}
}

View File

@@ -18,7 +18,7 @@
*/
import {randomBytes} from 'node:crypto';
import type {SentryDSN} from '@fluxer/app_proxy/src/app_server/utils/SentryDSN';
import {parseSentryDSN} from '@fluxer/app_proxy/src/app_server/utils/SentryDSN';
export const CSP_HOSTS = {
FRAME: [
@@ -71,8 +71,6 @@ export const CSP_HOSTS = {
'https://*.fluxer.workers.dev',
'https://fluxerusercontent.com',
'https://fluxerstatic.com',
'https://sentry.web.fluxer.app',
'https://sentry.web.canary.fluxer.app',
'https://fluxer.media',
'http://127.0.0.1:21863',
'http://127.0.0.1:21864',
@@ -95,33 +93,26 @@ export interface CSPOptions {
reportUri?: string;
}
export interface SentryProxyConfig {
sentryProxy: SentryDSN | null;
sentryProxyPath: string;
sentryReportHost: string;
export interface SentryCSPConfig {
sentryDsn: string;
}
export function generateNonce(): string {
return randomBytes(16).toString('hex');
}
export function buildSentryReportURI(config: SentryProxyConfig): string {
const sentry = config.sentryProxy;
export function buildSentryReportURI(config: SentryCSPConfig): string {
const sentry = parseSentryDSN(config.sentryDsn);
if (!sentry) {
return '';
}
const pathPrefix = config.sentryProxyPath.replace(/\/+$/, '');
let uri = `${pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
let uri = `${sentry.targetUrl}${sentry.pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
if (sentry.publicKey) {
uri += `&sentry_key=${sentry.publicKey}`;
}
if (config.sentryReportHost) {
return config.sentryReportHost + uri;
}
return uri;
}
@@ -160,8 +151,13 @@ export function buildCSP(nonce: string, options?: CSPOptions): string {
return directives.join('; ');
}
export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
export function buildFluxerCSPOptions(config: SentryCSPConfig): CSPOptions {
const reportURI = buildSentryReportURI(config);
const sentry = parseSentryDSN(config.sentryDsn);
const connectSrc: Array<string> = [...CSP_HOSTS.CONNECT];
if (sentry) {
connectSrc.push(sentry.targetUrl);
}
return {
scriptSrc: [...CSP_HOSTS.SCRIPT],
@@ -169,7 +165,7 @@ export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
imgSrc: [...CSP_HOSTS.IMAGE],
mediaSrc: [...CSP_HOSTS.MEDIA],
fontSrc: [...CSP_HOSTS.FONT],
connectSrc: [...CSP_HOSTS.CONNECT],
connectSrc: Array.from(new Set(connectSrc)),
frameSrc: [...CSP_HOSTS.FRAME],
workerSrc: [...CSP_HOSTS.WORKER],
manifestSrc: [...CSP_HOSTS.MANIFEST],
@@ -177,6 +173,6 @@ export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
};
}
export function buildFluxerCSP(nonce: string, config: SentryProxyConfig): string {
export function buildFluxerCSP(nonce: string, config: SentryCSPConfig): string {
return buildCSP(nonce, buildFluxerCSPOptions(config));
}