feat: geo-specific check

pull/5/head
lyc8503 2023-12-08 15:19:38 +08:00
parent 6b94f1c41c
commit 759a0f6159
5 changed files with 172 additions and 109 deletions

View File

@ -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

View File

@ -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
{

View File

@ -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[]

View File

@ -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<Response> {
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<void> {
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,
}

101
worker/src/monitor.ts Normal file
View File

@ -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
}