Openstatus www.openstatus.dev
at main 528 lines 14 kB view raw
1import { TRPCError } from "@trpc/server"; 2 3import { Events } from "@openstatus/analytics"; 4import { 5 deserialize, 6 dnsRecords, 7 headerAssertion, 8 jsonBodyAssertion, 9 recordAssertion, 10 statusAssertion, 11 textBodyAssertion, 12} from "@openstatus/assertions"; 13import { and, db, eq } from "@openstatus/db"; 14import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; 15import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; 16import { 17 type httpPayloadSchema, 18 type tpcPayloadSchema, 19 transformHeaders, 20} from "@openstatus/utils"; 21import { z } from "zod"; 22import { env } from "../env"; 23import { createTRPCRouter, protectedProcedure } from "../trpc"; 24 25const ABORT_TIMEOUT = 10000; 26 27// Input schemas 28const httpTestInput = z.object({ 29 url: z.url(), 30 method: z 31 .enum([ 32 "GET", 33 "HEAD", 34 "OPTIONS", 35 "POST", 36 "PUT", 37 "DELETE", 38 "PATCH", 39 "CONNECT", 40 "TRACE", 41 ]) 42 .prefault("GET"), 43 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 44 body: z.string().optional(), 45 region: monitorRegionSchema.optional().prefault("ams"), 46 assertions: z 47 .array( 48 z.discriminatedUnion("type", [ 49 statusAssertion, 50 headerAssertion, 51 textBodyAssertion, 52 jsonBodyAssertion, 53 recordAssertion, 54 ]), 55 ) 56 .prefault([]), 57}); 58 59const tcpTestInput = z.object({ 60 url: z.string(), 61 region: monitorRegionSchema.optional().prefault("ams"), 62}); 63 64const dnsTestInput = z.object({ 65 url: z.string(), 66 region: monitorRegionSchema.optional().prefault("ams"), 67 assertions: z 68 .array( 69 z.discriminatedUnion("type", [ 70 recordAssertion, 71 statusAssertion, 72 headerAssertion, 73 textBodyAssertion, 74 jsonBodyAssertion, 75 ]), 76 ) 77 .prefault([]), 78}); 79 80export const tcpOutput = z 81 .object({ 82 state: z.literal("success").prefault("success"), 83 type: z.literal("tcp").prefault("tcp"), 84 requestId: z.number().optional(), 85 workspaceId: z.number().optional(), 86 monitorId: z.number().optional(), 87 timestamp: z.number(), 88 timing: z.object({ 89 tcpStart: z.number(), 90 tcpDone: z.number(), 91 }), 92 error: z.string().optional(), 93 region: monitorRegionSchema, 94 latency: z.number().optional(), 95 }) 96 .or( 97 z.object({ 98 state: z.literal("error").prefault("error"), 99 message: z.string(), 100 }), 101 ); 102 103export const httpOutput = z 104 .object({ 105 state: z.literal("success").prefault("success"), 106 type: z.literal("http").prefault("http"), 107 status: z.number(), 108 latency: z.number(), 109 headers: z.record(z.string(), z.string()), 110 timestamp: z.number(), 111 timing: z.object({ 112 dnsStart: z.number(), 113 dnsDone: z.number(), 114 connectStart: z.number(), 115 connectDone: z.number(), 116 tlsHandshakeStart: z.number(), 117 tlsHandshakeDone: z.number(), 118 firstByteStart: z.number(), 119 firstByteDone: z.number(), 120 transferStart: z.number(), 121 transferDone: z.number(), 122 }), 123 body: z.string().optional().nullable(), 124 region: monitorRegionSchema, 125 }) 126 .or( 127 z.object({ 128 state: z.literal("error").prefault("error"), 129 message: z.string(), 130 }), 131 ); 132 133export const dnsOutput = z 134 .object({ 135 state: z.literal("success").prefault("success"), 136 type: z.literal("dns").prefault("dns"), 137 records: z 138 .partialRecord(z.enum(dnsRecords), z.array(z.string())) 139 .prefault({}), 140 latency: z.number().optional(), 141 timestamp: z.number(), 142 region: monitorRegionSchema, 143 }) 144 .or( 145 z.object({ 146 state: z.literal("error").prefault("error"), 147 message: z.string(), 148 }), 149 ); 150 151export async function testHttp(input: z.infer<typeof httpTestInput>) { 152 // Reject requests to our own domain to avoid loops 153 if (input.url.includes("openstatus.dev")) { 154 throw new TRPCError({ 155 code: "BAD_REQUEST", 156 message: "Self-requests are not allowed", 157 }); 158 } 159 160 try { 161 const res = await fetch( 162 `https://openstatus-checker.fly.dev/ping/${input.region}`, 163 { 164 method: "POST", 165 headers: { 166 Authorization: `Basic ${env.CRON_SECRET}`, 167 "Content-Type": "application/json", 168 "fly-prefer-region": input.region, 169 }, 170 body: JSON.stringify({ 171 url: input.url, 172 method: input.method, 173 headers: input.headers?.reduce( 174 (acc, { key, value }) => { 175 if (!key) return acc; 176 return { ...acc, [key]: value }; 177 }, 178 {} as Record<string, string>, 179 ), 180 body: input.body, 181 }), 182 signal: AbortSignal.timeout(ABORT_TIMEOUT), 183 }, 184 ); 185 186 const json = await res.json(); 187 const result = httpOutput.safeParse(json); 188 189 if (!result.success) { 190 console.error( 191 `Checker HTTP test failed for ${input.url}:`, 192 result.error.message, 193 ); 194 throw new TRPCError({ 195 code: "BAD_REQUEST", 196 message: 197 "Checker response is not valid. Please try again. If the problem persists, please contact support.", 198 }); 199 } 200 201 if (result.data.state === "error") { 202 throw new TRPCError({ 203 code: "BAD_REQUEST", 204 message: result.data.message, 205 }); 206 } 207 208 if (result.data.state === "success") { 209 const { body, headers, status } = result.data; 210 211 const assertions = deserialize(JSON.stringify(input.assertions)).map( 212 (assertion) => 213 assertion.assert({ 214 body: body ?? "", 215 header: headers ?? {}, 216 status: status, 217 }), 218 ); 219 220 if (assertions.some((assertion) => !assertion.success)) { 221 throw new TRPCError({ 222 code: "BAD_REQUEST", 223 message: `Assertion error: ${ 224 assertions.find((assertion) => !assertion.success)?.message 225 }`, 226 }); 227 } 228 229 if (assertions.length === 0 && (status < 200 || status >= 300)) { 230 throw new TRPCError({ 231 code: "BAD_REQUEST", 232 message: `Assertion error: The response status was not 2XX: ${status}.`, 233 }); 234 } 235 } 236 237 return result.data; 238 } catch (error) { 239 console.error("Checker HTTP test failed", error); 240 if (error instanceof TRPCError) { 241 throw error; 242 } 243 244 throw new TRPCError({ 245 code: "INTERNAL_SERVER_ERROR", 246 message: error instanceof Error ? error.message : "HTTP check failed", 247 }); 248 } 249} 250 251export async function testTcp(input: z.infer<typeof tcpTestInput>) { 252 try { 253 const res = await fetch( 254 `https://openstatus-checker.fly.dev/tcp/${input.region}`, 255 { 256 method: "POST", 257 headers: { 258 Authorization: `Basic ${env.CRON_SECRET}`, 259 "Content-Type": "application/json", 260 "fly-prefer-region": input.region, 261 }, 262 body: JSON.stringify({ uri: input.url }), 263 signal: AbortSignal.timeout(ABORT_TIMEOUT), 264 }, 265 ); 266 267 const json = await res.json(); 268 const result = tcpOutput.safeParse(json); 269 270 if (!result.success) { 271 console.error( 272 `Checker TCP test failed for ${input.url}:`, 273 result.error.message, 274 ); 275 throw new TRPCError({ 276 code: "BAD_REQUEST", 277 message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`, 278 }); 279 } 280 281 if (result.data.state === "error") { 282 throw new TRPCError({ 283 code: "BAD_REQUEST", 284 message: result.data.message, 285 }); 286 } 287 288 return result.data; 289 } catch (error) { 290 console.error("Checker TCP test failed", error); 291 if (error instanceof TRPCError) { 292 throw error; 293 } 294 295 throw new TRPCError({ 296 code: "INTERNAL_SERVER_ERROR", 297 message: "TCP check failed", 298 }); 299 } 300} 301 302export async function testDns(input: z.infer<typeof dnsTestInput>) { 303 try { 304 const res = await fetch( 305 `https://openstatus-checker.fly.dev/dns/${input.region}`, 306 { 307 method: "POST", 308 headers: { 309 Authorization: `Basic ${env.CRON_SECRET}`, 310 "Content-Type": "application/json", 311 "fly-prefer-region": input.region, 312 }, 313 body: JSON.stringify({ 314 uri: input.url, 315 }), 316 signal: AbortSignal.timeout(ABORT_TIMEOUT), 317 }, 318 ); 319 320 const json = await res.json(); 321 const result = dnsOutput.safeParse(json); 322 323 if (!result.success) { 324 console.error( 325 `Checker DNS test failed for ${input.url}:`, 326 result.error.message, 327 ); 328 throw new TRPCError({ 329 code: "BAD_REQUEST", 330 message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`, 331 }); 332 } 333 334 if (result.data.state === "error") { 335 throw new TRPCError({ 336 code: "BAD_REQUEST", 337 message: result.data.message, 338 }); 339 } 340 341 if (result.data.state === "success") { 342 const { records } = result.data; 343 344 const assertions = deserialize(JSON.stringify(input.assertions)).map( 345 (assertion) => assertion.assert({ records }), 346 ); 347 348 if (assertions.some((assertion) => !assertion.success)) { 349 throw new TRPCError({ 350 code: "BAD_REQUEST", 351 message: `Assertion error: ${ 352 assertions.find((assertion) => !assertion.success)?.message 353 }`, 354 }); 355 } 356 } 357 358 return result.data; 359 } catch (error) { 360 console.error("Checker DNS test failed", error); 361 if (error instanceof TRPCError) { 362 throw error; 363 } 364 365 throw new TRPCError({ 366 code: "INTERNAL_SERVER_ERROR", 367 message: "DNS check failed", 368 }); 369 } 370} 371 372export async function triggerChecker( 373 input: z.infer<typeof selectMonitorSchema>, 374) { 375 let payload: 376 | z.infer<typeof httpPayloadSchema> 377 | z.infer<typeof tpcPayloadSchema> 378 | null = null; 379 380 if (process.env.NODE_ENV !== "production") { 381 return; 382 } 383 384 const timestamp = Date.now(); 385 386 if (input.jobType === "http") { 387 payload = { 388 workspaceId: String(input.workspaceId), 389 monitorId: String(input.id), 390 url: input.url, 391 method: input.method || "GET", 392 cronTimestamp: timestamp, 393 body: input.body, 394 headers: input.headers, 395 status: "active", 396 assertions: input.assertions ? JSON.parse(input.assertions) : null, 397 degradedAfter: input.degradedAfter, 398 timeout: input.timeout, 399 trigger: "cron", 400 otelConfig: input.otelEndpoint 401 ? { 402 endpoint: input.otelEndpoint, 403 headers: transformHeaders(input.otelHeaders), 404 } 405 : undefined, 406 retry: input.retry || 3, 407 followRedirects: input.followRedirects || true, 408 }; 409 } 410 if (input.jobType === "tcp") { 411 payload = { 412 workspaceId: String(input.workspaceId), 413 monitorId: String(input.id), 414 uri: input.url, 415 status: "active", 416 assertions: input.assertions ? JSON.parse(input.assertions) : null, 417 cronTimestamp: timestamp, 418 degradedAfter: input.degradedAfter, 419 timeout: input.timeout, 420 trigger: "cron", 421 retry: input.retry || 3, 422 otelConfig: input.otelEndpoint 423 ? { 424 endpoint: input.otelEndpoint, 425 headers: transformHeaders(input.otelHeaders), 426 } 427 : undefined, 428 followRedirects: input.followRedirects || true, 429 }; 430 } 431 if (input.jobType === "dns") { 432 payload = { 433 workspaceId: String(input.workspaceId), 434 monitorId: String(input.id), 435 uri: input.url, 436 status: "active", 437 assertions: input.assertions ? JSON.parse(input.assertions) : null, 438 cronTimestamp: timestamp, 439 degradedAfter: input.degradedAfter, 440 timeout: input.timeout, 441 trigger: "cron", 442 retry: input.retry || 3, 443 otelConfig: input.otelEndpoint 444 ? { 445 endpoint: input.otelEndpoint, 446 headers: transformHeaders(input.otelHeaders), 447 } 448 : undefined, 449 followRedirects: input.followRedirects || true, 450 }; 451 } 452 const allResult = []; 453 454 for (const region of input.regions) { 455 const res = fetch(generateUrl({ row: input }), { 456 method: "POST", 457 headers: { 458 Authorization: `Basic ${env.CRON_SECRET}`, 459 "Content-Type": "application/json", 460 "fly-prefer-region": region, 461 }, 462 body: JSON.stringify(payload), 463 signal: AbortSignal.timeout(ABORT_TIMEOUT), 464 }); 465 allResult.push(res); 466 } 467 468 await Promise.allSettled(allResult); 469} 470 471function generateUrl({ row }: { row: z.infer<typeof selectMonitorSchema> }) { 472 switch (row.jobType) { 473 case "http": 474 return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}`; 475 case "tcp": 476 return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}`; 477 case "dns": 478 return `https://openstatus-checker.fly.dev/checker/dns?monitor_id=${row.id}`; 479 default: 480 throw new Error("Invalid jobType"); 481 } 482} 483 484export const checkerRouter = createTRPCRouter({ 485 testHttp: protectedProcedure 486 .meta({ track: Events.TestMonitor }) 487 .input(httpTestInput) 488 .mutation(async ({ input }) => { 489 return testHttp(input); 490 }), 491 492 testTcp: protectedProcedure 493 .meta({ track: Events.TestMonitor }) 494 .input(tcpTestInput) 495 .mutation(async ({ input }) => { 496 return testTcp(input); 497 }), 498 testDns: protectedProcedure 499 .meta({ track: Events.TestMonitor }) 500 .input(dnsTestInput) 501 .mutation(async ({ input }) => { 502 return testDns(input); 503 }), 504 505 triggerChecker: protectedProcedure 506 .input(z.object({ id: z.number() })) 507 .mutation(async (opts) => { 508 const m = await db 509 .select() 510 .from(monitor) 511 .where( 512 and( 513 eq(monitor.id, opts.input.id), 514 eq(monitor.workspaceId, opts.ctx.workspace.id), 515 ), 516 ) 517 .get(); 518 if (!m) { 519 throw new TRPCError({ 520 code: "NOT_FOUND", 521 message: "Monitor not found", 522 }); 523 } 524 const input = selectMonitorSchema.parse(m); 525 526 return await triggerChecker(input); 527 }), 528});