Compare commits

...
25 changed files with 206 additions and 11 deletions

No files matched your search

+1
View File
@@ -40,6 +40,7 @@ export const useMonitorForm = ({
matchMethod: data?.matchMethod || "", matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
customUserAgent: data?.customUserAgent || "",
}; };
break; break;
case "ping": case "ping":
+1
View File
@@ -40,6 +40,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
: 80, : 80,
}, },
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
defaultUserAgent: data?.defaultUserAgent || "",
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
}; };
+34
View File
@@ -795,6 +795,40 @@ const CreateMonitorPage = () => {
/> />
)} )}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.userAgent.title")}
subtitle={t("pages.createMonitor.form.userAgent.description")}
rightContent={
<Controller
name="customUserAgent"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t("pages.createMonitor.form.userAgent.option.label")}
placeholder={t("pages.createMonitor.form.userAgent.option.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
onChange={(e) => {
field.onChange(
e.target.value
.replace(/[<>]/g, "")
.replace(/(?:javascript|data|vbscript):/gi, "")
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only
.slice(0, 500)
);
}}
/>
)}
/>
}
/>
)}
{watchedType === "http" && ( {watchedType === "http" && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.advanced.title")} title={t("pages.createMonitor.form.advanced.title")}
+33
View File
@@ -425,6 +425,39 @@ export const SettingsPage = () => {
} }
/> />
{/* Default User-Agent - Admin Only */}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.userAgent.title")}
subtitle={t("pages.settings.form.userAgent.description")}
rightContent={
<Controller
name="defaultUserAgent"
control={form.control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t("pages.settings.form.userAgent.option.label")}
placeholder={t("pages.settings.form.userAgent.option.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
onChange={(e) => {
field.onChange(
e.target.value
.replace(/[<>]/g, "")
.replace(/(?:javascript|data|vbscript):/gi, "")
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only
.slice(0, 500)
);
}}
/>
)}
/>
}
/>
)}
{/* Clear All Stats */} {/* Clear All Stats */}
{isAdmin && ( {isAdmin && (
<ConfigBox <ConfigBox
+1
View File
@@ -73,6 +73,7 @@ export interface Monitor {
gameId?: string; gameId?: string;
grpcServiceName?: string; grpcServiceName?: string;
group: string | null; group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
+1
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
defaultUserAgent?: string;
singleton: boolean; singleton: boolean;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
createdAt: string; createdAt: string;
+10
View File
@@ -38,6 +38,16 @@ const httpSchema = baseSchema.extend({
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(), expectedValue: z.string().optional(),
jsonPath: z.string().optional(), jsonPath: z.string().optional(),
customUserAgent: z
.string()
.max(500)
.regex(
/^[\t\x20-\x7E\x80-\xFF]*$/,
"Only printable characters, spaces, and tabs are allowed (RFC 7230 §3.2.6)"
)
.transform((val) => (val.trim() === "" ? null : val.trim()))
.nullable()
.optional(),
}); });
// Ping monitor schema // Ping monitor schema
+9
View File
@@ -12,6 +12,15 @@ export const settingsSchema = z.object({
systemEmailSecure: z.boolean().optional(), systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(), systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(), showURL: z.boolean().optional(),
defaultUserAgent: z
.string()
.max(500)
.regex(
/^[\t\x20-\x7E\x80-\xFF]*$/,
"Only printable characters, spaces, and tabs are allowed (RFC 7230)"
)
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
checkTTL: z checkTTL: z
.number() .number()
.int() .int()
+16
View File
@@ -527,6 +527,14 @@
}, },
"title": "TLS/SSL settings" "title": "TLS/SSL settings"
}, },
"userAgent": {
"title": "Custom User-Agent",
"description": "Override the User-Agent header sent with HTTP requests for this monitor. Useful for identifying Checkmate in WAF logs. Leave blank to use the global default.",
"option": {
"label": "User-Agent",
"placeholder": "Checkmate/X.X (uptime monitor)"
}
},
"incidents": { "incidents": {
"description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", "description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.",
"option": { "option": {
@@ -1051,6 +1059,14 @@
} }
} }
}, },
"userAgent": {
"title": "Default User-Agent",
"description": "Set a default User-Agent header sent with all HTTP uptime monitor requests. Individual monitors can override this. Useful for identifying Checkmate in WAF logs and whitelists.",
"option": {
"label": "Default User-Agent",
"placeholder": "Checkmate/X.X (uptime monitor)"
}
},
"stats": { "stats": {
"title": "Monitor history", "title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -0,0 +1,21 @@
import type { Pool } from "pg";
export const addUserAgentFields = async (pool: Pool) => {
await pool.query(`
ALTER TABLE monitors
ADD COLUMN IF NOT EXISTS custom_user_agent TEXT;
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS default_user_agent TEXT;
`);
};
export const dropUserAgentFields = async (pool: Pool) => {
await pool.query(`
ALTER TABLE monitors
DROP COLUMN IF EXISTS custom_user_agent;
ALTER TABLE app_settings
DROP COLUMN IF EXISTS default_user_agent;
`);
};
@@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addUserAgentFields, dropUserAgentFields } from "./0022_add_user_agent_fields.js";
const SERVICE_NAME = "TimescaleDB Migrations"; const SERVICE_NAME = "TimescaleDB Migrations";
@@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields },
]; ];
const ensureMigrationsTable = async (pool: Pool) => { const ensureMigrationsTable = async (pool: Pool) => {
+1
View File
@@ -36,6 +36,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailRequireTLS: { type: Boolean, default: false }, systemEmailRequireTLS: { type: Boolean, default: false },
systemEmailRejectUnauthorized: { type: Boolean, default: true }, systemEmailRejectUnauthorized: { type: Boolean, default: true },
showURL: { type: Boolean, default: false }, showURL: { type: Boolean, default: false },
defaultUserAgent: { type: String },
singleton: { type: Boolean, required: true, unique: true, default: true }, singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 }, version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema }, globalThresholds: { type: thresholdsSchema },
+3
View File
@@ -339,6 +339,9 @@ const MonitorSchema = new Schema<MonitorDocument>(
return value && value.trim() ? value.trim() : null; return value && value.trim() ? value.trim() : null;
}, },
}, },
customUserAgent: {
type: String,
},
geoCheckEnabled: { geoCheckEnabled: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -387,6 +387,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -446,6 +447,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -37,6 +37,7 @@ interface MonitorRow {
game_id: string | null; game_id: string | null;
grpc_service_name: string | null; grpc_service_name: string | null;
monitor_group: string | null; monitor_group: string | null;
custom_user_agent: string | null;
geo_check_enabled: boolean; geo_check_enabled: boolean;
geo_check_locations: GeoContinent[] | null; geo_check_locations: GeoContinent[] | null;
geo_check_interval_ms: number; geo_check_interval_ms: number;
@@ -49,7 +50,7 @@ const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status,
interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage,
cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter,
disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms,
created_at, updated_at`; created_at, updated_at`;
export class TimescaleMonitorsRepository implements IMonitorsRepository { export class TimescaleMonitorsRepository implements IMonitorsRepository {
@@ -62,8 +63,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
interval_ms, is_active, status_window, status_window_size, status_window_threshold, interval_ms, is_active, status_window, status_window_size, status_window_threshold,
cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter,
disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35)
RETURNING ${MONITOR_COLUMNS}`, RETURNING ${MONITOR_COLUMNS}`,
[ [
userId, userId,
@@ -97,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
monitor.gameId ?? null, monitor.gameId ?? null,
monitor.grpcServiceName ?? null, monitor.grpcServiceName ?? null,
monitor.group ?? null, monitor.group ?? null,
monitor.customUserAgent ?? null,
monitor.geoCheckEnabled ?? false, monitor.geoCheckEnabled ?? false,
monitor.geoCheckLocations ?? [], monitor.geoCheckLocations ?? [],
monitor.geoCheckInterval ?? 300000, monitor.geoCheckInterval ?? 300000,
@@ -595,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
["gameId", "game_id"], ["gameId", "game_id"],
["grpcServiceName", "grpc_service_name"], ["grpcServiceName", "grpc_service_name"],
["group", "monitor_group"], ["group", "monitor_group"],
["customUserAgent", "custom_user_agent"],
["geoCheckEnabled", "geo_check_enabled"], ["geoCheckEnabled", "geo_check_enabled"],
["geoCheckLocations", "geo_check_locations"], ["geoCheckLocations", "geo_check_locations"],
["geoCheckInterval", "geo_check_interval_ms"], ["geoCheckInterval", "geo_check_interval_ms"],
@@ -1034,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
gameId: row.game_id ?? undefined, gameId: row.game_id ?? undefined,
grpcServiceName: row.grpc_service_name ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined,
group: row.monitor_group, group: row.monitor_group,
customUserAgent: row.custom_user_agent ?? undefined,
geoCheckEnabled: row.geo_check_enabled, geoCheckEnabled: row.geo_check_enabled,
geoCheckLocations: row.geo_check_locations ?? [], geoCheckLocations: row.geo_check_locations ?? [],
geoCheckInterval: row.geo_check_interval_ms, geoCheckInterval: row.geo_check_interval_ms,
@@ -38,6 +38,7 @@ class MongoSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false, systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true, systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
showURL: doc.showURL ?? false, showURL: doc.showURL ?? false,
defaultUserAgent: doc.defaultUserAgent ?? undefined,
singleton: doc.singleton, singleton: doc.singleton,
version: doc.version ?? 1, version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined, globalThresholds: doc.globalThresholds ?? undefined,
@@ -21,6 +21,7 @@ interface SettingsRow {
system_email_require_tls: boolean | null; system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null; system_email_reject_unauthorized: boolean | null;
show_url: boolean | null; show_url: boolean | null;
default_user_agent: string | null;
version: string | null; version: string | null;
threshold_cpu_usage: number | null; threshold_cpu_usage: number | null;
threshold_memory_usage: number | null; threshold_memory_usage: number | null;
@@ -34,7 +35,7 @@ const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
created_at, updated_at`; created_at, updated_at`;
export class TimescaleSettingsRepository implements ISettingsRepository { export class TimescaleSettingsRepository implements ISettingsRepository {
@@ -46,8 +47,8 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
RETURNING ${COLUMNS}`, RETURNING ${COLUMNS}`,
[ [
settings.checkTTL ?? 30, settings.checkTTL ?? 30,
@@ -67,6 +68,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
settings.systemEmailRequireTLS ?? false, settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true, settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false, settings.showURL ?? false,
settings.defaultUserAgent ?? null,
settings.version ?? 1, settings.version ?? 1,
settings.globalThresholds?.cpu ?? null, settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null, settings.globalThresholds?.memory ?? null,
@@ -113,6 +115,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
["systemEmailRequireTLS", "system_email_require_tls"], ["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"], ["showURL", "show_url"],
["defaultUserAgent", "default_user_agent"],
["version", "version"], ["version", "version"],
]; ];
@@ -197,6 +200,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: row.system_email_require_tls ?? false, systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false, showURL: row.show_url ?? false,
defaultUserAgent: row.default_user_agent ?? undefined,
singleton: true, singleton: true,
version: Number(row.version ?? 1), version: Number(row.version ?? 1),
globalThresholds: globalThresholds:
@@ -132,7 +132,15 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
} }
// Step 2. Request monitor status // Step 2. Request monitor status
const status = await this.networkService.requestStatus(monitor); // Resolve default user agent for HTTP monitors (cached, invalidated on settings update)
let effectiveMonitor: Monitor = monitor;
if (monitor.type === "http" && !monitor.customUserAgent) {
const defaultUserAgent = await this.settingsService.getDefaultUserAgent();
if (defaultUserAgent) {
effectiveMonitor = { ...monitor, customUserAgent: defaultUserAgent };
}
}
const status = await this.networkService.requestStatus(effectiveMonitor);
if (!status) { if (!status) {
throw new Error("No network response"); throw new Error("No network response");
} }
@@ -56,15 +56,30 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
}; };
} }
private sanitizeHeaderValue(value: string): string {
if (!value) return "";
return value
.replace(/[<>]/g, "")
.replace(/(?:javascript|data|vbscript):/gi, "")
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // allow only RFC 7230 header-safe characters
.trim()
.slice(0, 500);
}
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> { async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
const { url, secret, jsonPath, ignoreTlsErrors } = monitor; const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor;
if (!url) { if (!url) {
throw new Error("URL is required for HTTP monitor"); throw new Error("URL is required for HTTP monitor");
} }
const headers: Record<string, string> = {};
if (secret) headers["Authorization"] = `Bearer ${secret}`;
if (customUserAgent) headers["User-Agent"] = this.sanitizeHeaderValue(customUserAgent);
const options: Record<string, unknown> = { const options: Record<string, unknown> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, headers: Object.keys(headers).length > 0 ? headers : undefined,
}; };
options.agent = { options.agent = {
+16 -1
View File
@@ -20,6 +20,7 @@ export interface ISettingsService {
loadSettings(): EnvConfig; loadSettings(): EnvConfig;
getSettings(): EnvConfig; getSettings(): EnvConfig;
getDBSettings(): Promise<Settings>; getDBSettings(): Promise<Settings>;
getDefaultUserAgent(): Promise<string | undefined>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>; updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
} }
@@ -27,6 +28,7 @@ export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig; private settings: EnvConfig;
private settingsRepository: ISettingsRepository | null = null; private settingsRepository: ISettingsRepository | null = null;
private cachedDefaultUserAgent: string | null | undefined = undefined;
constructor(env: ValidatedEnv) { constructor(env: ValidatedEnv) {
this.settings = { this.settings = {
@@ -70,8 +72,21 @@ export class SettingsService implements ISettingsService {
return this.settingsRepository; return this.settingsRepository;
} }
getDefaultUserAgent = async (): Promise<string | undefined> => {
if (this.cachedDefaultUserAgent !== undefined) {
return this.cachedDefaultUserAgent ?? undefined;
}
const settings = await this.getDBSettings();
this.cachedDefaultUserAgent = settings.defaultUserAgent ?? null;
return this.cachedDefaultUserAgent ?? undefined;
};
updateDbSettings = async (newSettings: SettingsUpdate) => { updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.getRepository().update(newSettings); const updated = await this.getRepository().update(newSettings);
if ("defaultUserAgent" in newSettings) {
this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null;
}
return updated;
}; };
getDBSettings = async () => { getDBSettings = async () => {
+1
View File
@@ -50,6 +50,7 @@ export interface Monitor {
gameId?: string; gameId?: string;
grpcServiceName?: string; grpcServiceName?: string;
group: string | null; group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
+1
View File
@@ -31,6 +31,7 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
defaultUserAgent?: string;
singleton: boolean; singleton: boolean;
version: number; version: number;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
+4 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { booleanCoercion } from "./shared.js"; import { booleanCoercion, httpHeaderValueSchema } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js"; import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
@@ -75,6 +75,7 @@ export const createMonitorBodyValidation = z.object({
grpcServiceName: z.union([z.string(), z.literal("")]).default(""), grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
selectedDisks: z.array(z.string()).optional(), selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(), geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(), geoCheckInterval: z.number().min(300000).optional(),
@@ -104,6 +105,7 @@ export const editMonitorBodyValidation = z.object({
grpcServiceName: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
selectedDisks: z.array(z.string()).optional(), selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(), geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(), geoCheckInterval: z.number().min(300000).optional(),
@@ -157,6 +159,7 @@ const importedMonitorSchema = z.object({
gameId: z.union([z.string(), z.literal("")]).optional(), gameId: z.union([z.string(), z.literal("")]).optional(),
grpcServiceName: z.union([z.string(), z.literal("")]).default(""), grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
group: z.union([z.string().max(50).trim(), z.null()]).default(null), group: z.union([z.string().max(50).trim(), z.null()]).default(null),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().default(false), geoCheckEnabled: z.boolean().default(false),
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000), geoCheckInterval: z.number().min(300000).default(300000),
@@ -1,5 +1,6 @@
import { CHECK_TTL_SENTINEL } from "@/types/check.js"; import { CHECK_TTL_SENTINEL } from "@/types/check.js";
import { z } from "zod"; import { z } from "zod";
import { httpHeaderValueSchema } from "@/validation/shared.js";
//**************************************** //****************************************
// Settings Validations // Settings Validations
@@ -20,6 +21,7 @@ export const updateAppSettingsBodyValidation = z
systemEmailTLSServername: z.string().nullable().optional(), systemEmailTLSServername: z.string().nullable().optional(),
showURL: z.boolean().optional(), showURL: z.boolean().optional(),
defaultUserAgent: httpHeaderValueSchema.nullable().optional(),
systemEmailSecure: z.boolean().optional(), systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(), systemEmailPool: z.boolean().optional(),
systemEmailIgnoreTLS: z.boolean().optional(), systemEmailIgnoreTLS: z.boolean().optional(),
+6
View File
@@ -21,6 +21,12 @@ export const booleanCoercion = z.preprocess((val) => {
return val; // Let Zod validation handle invalid values return val; // Let Zod validation handle invalid values
}, z.boolean()); }, z.boolean());
// RFC 7230: validate HTTP header field-value characters
export const httpHeaderValueSchema = z
.string()
.max(500)
.regex(/^[\t\x20-\x7E\x80-\xFF]*$/, "Must contain only valid HTTP header characters (RFC 7230)");
export const roleValidator = (allowedRoles: UserRole[]) => { export const roleValidator = (allowedRoles: UserRole[]) => {
return z.array(z.custom<UserRole>()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), { return z.array(z.custom<UserRole>()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), {
message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`, message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,