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 || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUserAgent: data?.customUserAgent || "",
};
break;
case "ping":
+1
View File
@@ -40,6 +40,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
: 80,
},
checkTTL: data?.checkTTL ?? 30,
defaultUserAgent: data?.defaultUserAgent || "",
pagespeedApiKey: "",
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" && (
<ConfigBox
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 */}
{isAdmin && (
<ConfigBox
+1
View File
@@ -73,6 +73,7 @@ export interface Monitor {
gameId?: string;
grpcServiceName?: string;
group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
+1
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
showURL: boolean;
defaultUserAgent?: string;
singleton: boolean;
globalThresholds?: SettingsThresholds;
createdAt: string;
+10
View File
@@ -38,6 +38,16 @@ const httpSchema = baseSchema.extend({
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: 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
+9
View File
@@ -12,6 +12,15 @@ export const settingsSchema = z.object({
systemEmailSecure: z.boolean().optional(),
systemEmailPool: 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
.number()
.int()
+16
View File
@@ -527,6 +527,14 @@
},
"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": {
"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": {
@@ -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": {
"title": "Monitor history",
"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 { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.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";
@@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields },
];
const ensureMigrationsTable = async (pool: Pool) => {
+1
View File
@@ -36,6 +36,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailRequireTLS: { type: Boolean, default: false },
systemEmailRejectUnauthorized: { type: Boolean, default: true },
showURL: { type: Boolean, default: false },
defaultUserAgent: { type: String },
singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema },
+3
View File
@@ -339,6 +339,9 @@ const MonitorSchema = new Schema<MonitorDocument>(
return value && value.trim() ? value.trim() : null;
},
},
customUserAgent: {
type: String,
},
geoCheckEnabled: {
type: Boolean,
default: false,
@@ -387,6 +387,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -446,6 +447,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -37,6 +37,7 @@ interface MonitorRow {
game_id: string | null;
grpc_service_name: string | null;
monitor_group: string | null;
custom_user_agent: string | null;
geo_check_enabled: boolean;
geo_check_locations: GeoContinent[] | null;
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,
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,
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`;
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,
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,
game_id, grpc_service_name, monitor_group, 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)
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,$35)
RETURNING ${MONITOR_COLUMNS}`,
[
userId,
@@ -97,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
monitor.gameId ?? null,
monitor.grpcServiceName ?? null,
monitor.group ?? null,
monitor.customUserAgent ?? null,
monitor.geoCheckEnabled ?? false,
monitor.geoCheckLocations ?? [],
monitor.geoCheckInterval ?? 300000,
@@ -595,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
["gameId", "game_id"],
["grpcServiceName", "grpc_service_name"],
["group", "monitor_group"],
["customUserAgent", "custom_user_agent"],
["geoCheckEnabled", "geo_check_enabled"],
["geoCheckLocations", "geo_check_locations"],
["geoCheckInterval", "geo_check_interval_ms"],
@@ -1034,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
gameId: row.game_id ?? undefined,
grpcServiceName: row.grpc_service_name ?? undefined,
group: row.monitor_group,
customUserAgent: row.custom_user_agent ?? undefined,
geoCheckEnabled: row.geo_check_enabled,
geoCheckLocations: row.geo_check_locations ?? [],
geoCheckInterval: row.geo_check_interval_ms,
@@ -38,6 +38,7 @@ class MongoSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
showURL: doc.showURL ?? false,
defaultUserAgent: doc.defaultUserAgent ?? undefined,
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
@@ -21,6 +21,7 @@ interface SettingsRow {
system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null;
show_url: boolean | null;
default_user_agent: string | null;
version: string | null;
threshold_cpu_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_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
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`;
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_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
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,$23)
RETURNING ${COLUMNS}`,
[
settings.checkTTL ?? 30,
@@ -67,6 +68,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false,
settings.defaultUserAgent ?? null,
settings.version ?? 1,
settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null,
@@ -113,6 +115,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"],
["defaultUserAgent", "default_user_agent"],
["version", "version"],
];
@@ -197,6 +200,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false,
defaultUserAgent: row.default_user_agent ?? undefined,
singleton: true,
version: Number(row.version ?? 1),
globalThresholds:
@@ -132,7 +132,15 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
}
// 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) {
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>> {
const { url, secret, jsonPath, ignoreTlsErrors } = monitor;
const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor;
if (!url) {
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> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
headers: Object.keys(headers).length > 0 ? headers : undefined,
};
options.agent = {
+16 -1
View File
@@ -20,6 +20,7 @@ export interface ISettingsService {
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
getDBSettings(): Promise<Settings>;
getDefaultUserAgent(): Promise<string | undefined>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
}
@@ -27,6 +28,7 @@ export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig;
private settingsRepository: ISettingsRepository | null = null;
private cachedDefaultUserAgent: string | null | undefined = undefined;
constructor(env: ValidatedEnv) {
this.settings = {
@@ -70,8 +72,21 @@ export class SettingsService implements ISettingsService {
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) => {
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 () => {
+1
View File
@@ -50,6 +50,7 @@ export interface Monitor {
gameId?: string;
grpcServiceName?: string;
group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
+1
View File
@@ -31,6 +31,7 @@ export interface Settings {
systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean;
showURL: boolean;
defaultUserAgent?: string;
singleton: boolean;
version: number;
globalThresholds?: SettingsThresholds;
+4 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { booleanCoercion, httpHeaderValueSchema } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.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(""),
selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
@@ -104,6 +105,7 @@ export const editMonitorBodyValidation = z.object({
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
@@ -157,6 +159,7 @@ const importedMonitorSchema = z.object({
gameId: z.union([z.string(), z.literal("")]).optional(),
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().default(false),
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
@@ -1,5 +1,6 @@
import { CHECK_TTL_SENTINEL } from "@/types/check.js";
import { z } from "zod";
import { httpHeaderValueSchema } from "@/validation/shared.js";
//****************************************
// Settings Validations
@@ -20,6 +21,7 @@ export const updateAppSettingsBodyValidation = z
systemEmailTLSServername: z.string().nullable().optional(),
showURL: z.boolean().optional(),
defaultUserAgent: httpHeaderValueSchema.nullable().optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: 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
}, 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[]) => {
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(", ")}`,