feat: geo-specific check
parent
6b94f1c41c
commit
759a0f6159
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue