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
|
#### Monitoring
|
||||||
|
|
||||||
- Up to 50 **2-minute** interval check on free plan (Up to unlimited 1-min check on paid Workers plan)
|
- 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**
|
- Monitoring for **HTTP/HTTPS/TCP port**
|
||||||
- Up to 90-day uptime history and uptime percentage
|
- Up to 90-day uptime history and uptime percentage
|
||||||
- Custom request method & headers & body for HTTP(s)
|
- Custom request method & headers & body for HTTP(s)
|
||||||
|
|
@ -36,8 +37,8 @@ Please refer to [Quickstart](https://github.com/lyc8503/UptimeFlare/wiki/Quickst
|
||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
- SSL certificate checks
|
- [ ] SSL certificate checks
|
||||||
- Slack / Telegram webhook example
|
- [ ] Slack / Telegram webhook example
|
||||||
- Incident timeline
|
- [ ] Incident timeline
|
||||||
- Email notification via Cloudflare Email Workers
|
- [ ] Email notification via Cloudflare Email Workers
|
||||||
- Specify region for monitors
|
- [x] Specify region for monitors
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ const config = {
|
||||||
body: 'Hello, world!',
|
body: 'Hello, world!',
|
||||||
// [OPTIONAL] if specified, the response must contains the keyword to be considered as operational.
|
// [OPTIONAL] if specified, the response must contains the keyword to be considered as operational.
|
||||||
responseKeyword: 'success',
|
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
|
// Example TCP Monitor
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ type MonitorTarget = {
|
||||||
method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.)
|
method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.)
|
||||||
target: string // url for http, hostname:port for tcp
|
target: string // url for http, hostname:port for tcp
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
checkLocationWorkerRoute?: string
|
||||||
|
|
||||||
// HTTP Code
|
// HTTP Code
|
||||||
expectedCodes?: number[]
|
expectedCodes?: number[]
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,43 @@
|
||||||
import { connect } from 'cloudflare:sockets'
|
|
||||||
import config from '../../uptime.config'
|
import config from '../../uptime.config'
|
||||||
import { fetchTimeout, getWorkerLocation, withTimeout } from './util'
|
import { getWorkerLocation } from './util'
|
||||||
import { MonitorState, MonitorTarget } from '../../uptime.types'
|
import { MonitorState } from '../../uptime.types'
|
||||||
|
import { getStatus } from './monitor'
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
UPTIMEFLARE_STATE: KVNamespace
|
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 {
|
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> {
|
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
const workerLocation = (await getWorkerLocation()) || 'ERROR'
|
const workerLocation = (await getWorkerLocation()) || 'ERROR'
|
||||||
console.log(`Running scheduled event on ${workerLocation}...`)
|
console.log(`Running scheduled event on ${workerLocation}...`)
|
||||||
|
|
@ -128,12 +60,37 @@ export default {
|
||||||
|
|
||||||
// Check each monitor
|
// Check each monitor
|
||||||
// TODO: callback exception handler
|
// TODO: callback exception handler
|
||||||
// TODO: advanced status check
|
|
||||||
// TODO: concurrent status check
|
// TODO: concurrent status check
|
||||||
for (const monitor of config.monitors) {
|
for (const monitor of config.monitors) {
|
||||||
console.log(`[${workerLocation}] Checking ${monitor.name}...`)
|
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)
|
const currentTimeSecond = Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
// Update counters
|
// Update counters
|
||||||
|
|
@ -198,7 +155,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
loc: workerLocation,
|
loc: checkLocation,
|
||||||
ping: status.ping,
|
ping: status.ping,
|
||||||
time: currentTimeSecond,
|
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