diff --git a/README.md b/README.md index b8aee5f..92ed8cc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Another **serverless (and free!)** uptime monitoring & status page on Cloudflare #### Monitoring - Up to 50 **2-minute** interval check on free plan (Up to unlimited 1-min check on paid Workers plan) +- **Geo-specific checks** from more than [310 cities](https://www.cloudflare.com/network/) around the world - Monitoring for **HTTP/HTTPS/TCP port** - Up to 90-day uptime history and uptime percentage - Custom request method & headers & body for HTTP(s) @@ -36,8 +37,8 @@ Please refer to [Quickstart](https://github.com/lyc8503/UptimeFlare/wiki/Quickst ## TODOs -- SSL certificate checks -- Slack / Telegram webhook example -- Incident timeline -- Email notification via Cloudflare Email Workers -- Specify region for monitors +- [ ] SSL certificate checks +- [ ] Slack / Telegram webhook example +- [ ] Incident timeline +- [ ] Email notification via Cloudflare Email Workers +- [x] Specify region for monitors diff --git a/uptime.config.ts b/uptime.config.ts index 507a175..8b90e6c 100644 --- a/uptime.config.ts +++ b/uptime.config.ts @@ -48,6 +48,9 @@ const config = { body: 'Hello, world!', // [OPTIONAL] if specified, the response must contains the keyword to be considered as operational. responseKeyword: 'success', + // [OPTIONAL] if specified, the check will run in your specified region, + // refer to docs https://github.com/lyc8503/UptimeFlare/wiki/Geo-specific-checks-setup before setting this value + checkLocationWorkerRoute: 'https://xxx.example.com' }, // Example TCP Monitor { diff --git a/uptime.types.ts b/uptime.types.ts index ced046d..78085c3 100644 --- a/uptime.types.ts +++ b/uptime.types.ts @@ -34,6 +34,7 @@ type MonitorTarget = { method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.) target: string // url for http, hostname:port for tcp tooltip?: string + checkLocationWorkerRoute?: string // HTTP Code expectedCodes?: number[] diff --git a/worker/src/index.ts b/worker/src/index.ts index 642e97b..e8f38ea 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,111 +1,43 @@ -import { connect } from 'cloudflare:sockets' import config from '../../uptime.config' -import { fetchTimeout, getWorkerLocation, withTimeout } from './util' -import { MonitorState, MonitorTarget } from '../../uptime.types' +import { getWorkerLocation } from './util' +import { MonitorState } from '../../uptime.types' +import { getStatus } from './monitor' export interface Env { UPTIMEFLARE_STATE: KVNamespace } -async function getStatus( - monitor: MonitorTarget -): Promise<{ ping: number; up: boolean; err: string }> { - let status = { - ping: 0, - up: false, - err: 'Unknown', - } - - const startTime = Date.now() - - if (monitor.method === 'TCP_PING') { - // TCP port endpoint monitor - try { - const [hostname, port] = monitor.target.split(':') - - // Write "PING\n" - const socket = connect({ hostname: hostname, port: Number(port) }) - const writer = socket.writable.getWriter() - const reader = socket.readable.getReader() - await writer.write(new TextEncoder().encode('PING\n')) - await withTimeout(monitor.timeout || 10000, reader.read()) - await socket.close() - - // Not having an `opened` promise now... - // https://github.com/cloudflare/workerd/issues/1305 - // await withTimeout(monitor.timeout || 10000, socket.closed) - - console.log(`${monitor.name} connected to ${monitor.target}`) - - status.ping = Date.now() - startTime - status.up = true - status.err = '' - } catch (e: Error | any) { - console.log(`${monitor.name} errored with ${e.name}: ${e.message}`) - status.up = false - status.err = e.name + ': ' + e.message - } - } else { - // HTTP endpoint monitor - try { - const response = await fetchTimeout(monitor.target, monitor.timeout || 10000, { - method: monitor.method, - headers: monitor.headers as any, - body: monitor.body, - cf: { - cacheTtlByStatus: { - '100-599': -1, // Don't cache any status code, from https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties - }, - }, - }) - - console.log(`${monitor.name} responded with ${response.status}`) - status.ping = Date.now() - startTime - - if (monitor.expectedCodes) { - if (!monitor.expectedCodes.includes(response.status)) { - status.up = false - status.err = `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${ - response.status - }` - return status - } - } else { - if (response.status < 200 || response.status > 299) { - status.up = false - status.err = `Expected codes: 2xx, Got: ${response.status}` - return status - } - } - - if (monitor.responseKeyword) { - const responseBody = await response.text() - if (!responseBody.includes(monitor.responseKeyword)) { - status.up = false - status.err = "HTTP response doesn't contain the configured keyword" - return status - } - } - - status.up = true - status.err = '' - } catch (e: any) { - console.log(`${monitor.name} errored with ${e.name}: ${e.message}`) - if (e.name === 'AbortError') { - status.ping = monitor.timeout || 10000 - status.up = false - status.err = `Timeout after ${status.ping}ms` - } else { - status.up = false - status.err = e.name + ': ' + e.message - } - } - } - - return status -} - export default { + async fetch(request: Request): Promise { + const workerLocation = request.cf?.colo + console.log(`Handling request event at ${workerLocation}...`) + + if (request.method !== 'POST') { + return new Response('Remote worker is working...', { status: 405 }) + } + + const targetId = (await request.json<{ target: string }>())['target'] + const target = config.monitors.find((m) => m.id === targetId) + + if (target === undefined) { + return new Response('Target Not Found', { status: 404 }) + } + + const status = await getStatus(target) + + return new Response( + JSON.stringify({ + location: workerLocation, + status: status, + }), + { + headers: { + 'content-type': 'application/json;charset=UTF-8', + }, + } + ) + }, + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { const workerLocation = (await getWorkerLocation()) || 'ERROR' console.log(`Running scheduled event on ${workerLocation}...`) @@ -128,12 +60,37 @@ export default { // Check each monitor // TODO: callback exception handler - // TODO: advanced status check // TODO: concurrent status check for (const monitor of config.monitors) { console.log(`[${workerLocation}] Checking ${monitor.name}...`) - const status = await getStatus(monitor) + let checkLocation = workerLocation + let status + + if (monitor.checkLocationWorkerRoute) { + // Initiate a check from a different location + try { + console.log('Calling worker: ' + monitor.checkLocationWorkerRoute) + const resp = await ( + await fetch(monitor.checkLocationWorkerRoute, { + method: 'POST', + body: JSON.stringify({ + target: monitor.id, + }), + }) + ).json<{ location: string; status: { ping: number; up: boolean; err: string } }>() + checkLocation = resp.location + status = resp.status + } catch (err) { + console.log('Error calling worker: ' + err) + status = { ping: 0, up: false, err: 'Error initiating check from remote worker' } + } + } else { + // Initiate a check from the current location + status = await getStatus(monitor) + } + + // const status = await getStatus(monitor) const currentTimeSecond = Math.round(Date.now() / 1000) // Update counters @@ -198,7 +155,7 @@ export default { } const record = { - loc: workerLocation, + loc: checkLocation, ping: status.ping, time: currentTimeSecond, } diff --git a/worker/src/monitor.ts b/worker/src/monitor.ts new file mode 100644 index 0000000..aad0b1c --- /dev/null +++ b/worker/src/monitor.ts @@ -0,0 +1,101 @@ +import { connect } from "cloudflare:sockets"; +import { MonitorTarget } from "../../uptime.types"; +import { withTimeout, fetchTimeout } from "./util"; + +export async function getStatus( + monitor: MonitorTarget +): Promise<{ ping: number; up: boolean; err: string }> { + let status = { + ping: 0, + up: false, + err: 'Unknown', + } + + const startTime = Date.now() + + if (monitor.method === 'TCP_PING') { + // TCP port endpoint monitor + try { + const [hostname, port] = monitor.target.split(':') + + // Write "PING\n" + const socket = connect({ hostname: hostname, port: Number(port) }) + const writer = socket.writable.getWriter() + const reader = socket.readable.getReader() + await writer.write(new TextEncoder().encode('PING\n')) + await withTimeout(monitor.timeout || 10000, reader.read()) + await socket.close() + + // Not having an `opened` promise now... + // https://github.com/cloudflare/workerd/issues/1305 + // await withTimeout(monitor.timeout || 10000, socket.closed) + + console.log(`${monitor.name} connected to ${monitor.target}`) + + status.ping = Date.now() - startTime + status.up = true + status.err = '' + } catch (e: Error | any) { + console.log(`${monitor.name} errored with ${e.name}: ${e.message}`) + status.up = false + status.err = e.name + ': ' + e.message + } + } else { + // HTTP endpoint monitor + try { + const response = await fetchTimeout(monitor.target, monitor.timeout || 10000, { + method: monitor.method, + headers: monitor.headers as any, + body: monitor.body, + cf: { + cacheTtlByStatus: { + '100-599': -1, // Don't cache any status code, from https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties + }, + }, + }) + + console.log(`${monitor.name} responded with ${response.status}`) + status.ping = Date.now() - startTime + + if (monitor.expectedCodes) { + if (!monitor.expectedCodes.includes(response.status)) { + status.up = false + status.err = `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${ + response.status + }` + return status + } + } else { + if (response.status < 200 || response.status > 299) { + status.up = false + status.err = `Expected codes: 2xx, Got: ${response.status}` + return status + } + } + + if (monitor.responseKeyword) { + const responseBody = await response.text() + if (!responseBody.includes(monitor.responseKeyword)) { + status.up = false + status.err = "HTTP response doesn't contain the configured keyword" + return status + } + } + + status.up = true + status.err = '' + } catch (e: any) { + console.log(`${monitor.name} errored with ${e.name}: ${e.message}`) + if (e.name === 'AbortError') { + status.ping = monitor.timeout || 10000 + status.up = false + status.err = `Timeout after ${status.ping}ms` + } else { + status.up = false + status.err = e.name + ': ' + e.message + } + } + } + + return status +}