Global ping as check proxy support (#147)
* WIP: http * improve error handling * tcp monitoring looks good * fix globalping bugs and improve logs * add globalping param * looks good, add real call to globalping, todo fallback * stupid JS, forgot bind this * feat: add globalping city * run prettier * fix no magic * fix missing country in TCP * update config templatepull/153/head
parent
20e7f47b5e
commit
e757e1b970
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import 'chartjs-adapter-moment'
|
import 'chartjs-adapter-moment'
|
||||||
import { MonitorState, MonitorTarget } from '@/types/config'
|
import { MonitorState, MonitorTarget } from '@/types/config'
|
||||||
import { iataToCountry } from '@/util/iata'
|
import { codeToCountry } from '@/util/iata'
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|
@ -66,7 +66,7 @@ export default function DetailChart({
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (item: any) => {
|
label: (item: any) => {
|
||||||
if (item.parsed.y) {
|
if (item.parsed.y) {
|
||||||
return `${item.parsed.y}ms (${iataToCountry(item.raw.loc)})`
|
return `${item.parsed.y}ms (${codeToCountry(item.raw.loc)})`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ const workerConfig: WorkerConfig = {
|
||||||
responseForbiddenKeyword: 'bad gateway',
|
responseForbiddenKeyword: 'bad gateway',
|
||||||
// [OPTIONAL] if specified, will call the check proxy to check the monitor, mainly for geo-specific checks
|
// [OPTIONAL] if specified, will call the check proxy to check the monitor, mainly for geo-specific checks
|
||||||
// refer to docs https://github.com/lyc8503/UptimeFlare/wiki/Check-proxy-setup before setting this value
|
// refer to docs https://github.com/lyc8503/UptimeFlare/wiki/Check-proxy-setup before setting this value
|
||||||
// currently supports `worker://` and `http(s)://` proxies
|
// currently supports `worker://`, `globalping://` and `http(s)://` proxies
|
||||||
checkProxy: 'https://xxx.example.com OR worker://weur',
|
checkProxy: 'https://xxx.example.com OR worker://weur',
|
||||||
// [OPTIONAL] if true, the check will fallback to local if the specified proxy is down
|
// [OPTIONAL] if true, the check will fallback to local if the specified proxy is down
|
||||||
checkProxyFallback: true,
|
checkProxyFallback: true,
|
||||||
|
|
|
||||||
18
util/iata.ts
18
util/iata.ts
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
import { MonitorState, MonitorTarget } from '../../types/config'
|
import { MonitorState, MonitorTarget } from '../../types/config'
|
||||||
import { maintenances, workerConfig } from '../../uptime.config'
|
import { maintenances, workerConfig } from '../../uptime.config'
|
||||||
import { getStatus } from './monitor'
|
import { getStatus, getStatusWithGlobalPing } from './monitor'
|
||||||
import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util'
|
import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util'
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
|
|
@ -107,6 +107,8 @@ const Worker = {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// An error here is expected, ignore it
|
// An error here is expected, ignore it
|
||||||
}
|
}
|
||||||
|
} else if (monitor.checkProxy.startsWith('globalping://')) {
|
||||||
|
resp = await getStatusWithGlobalPing(monitor)
|
||||||
} else {
|
} else {
|
||||||
resp = await (
|
resp = await (
|
||||||
await fetch(monitor.checkProxy, {
|
await fetch(monitor.checkProxy, {
|
||||||
|
|
@ -124,7 +126,7 @@ const Worker = {
|
||||||
console.log('Falling back to local check...')
|
console.log('Falling back to local check...')
|
||||||
status = await getStatus(monitor)
|
status = await getStatus(monitor)
|
||||||
} else {
|
} else {
|
||||||
status = { ping: 0, up: false, err: 'Error initiating check from remote worker' }
|
status = { ping: 0, up: false, err: 'Unknown check proxy error' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,244 @@
|
||||||
import { MonitorTarget } from '../../types/config'
|
import { MonitorTarget } from '../../types/config'
|
||||||
import { withTimeout, fetchTimeout } from './util'
|
import { withTimeout, fetchTimeout } from './util'
|
||||||
|
|
||||||
|
async function httpResponseBasicCheck(
|
||||||
|
monitor: MonitorTarget,
|
||||||
|
code: number,
|
||||||
|
bodyReader: () => Promise<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (monitor.expectedCodes) {
|
||||||
|
if (!monitor.expectedCodes.includes(code)) {
|
||||||
|
return `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${code}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (code < 200 || code > 299) {
|
||||||
|
return `Expected codes: 2xx, Got: ${code}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.responseKeyword || monitor.responseForbiddenKeyword) {
|
||||||
|
// Only read response body if we have a keyword to check
|
||||||
|
const responseBody = await bodyReader()
|
||||||
|
|
||||||
|
// MUST contain responseKeyword
|
||||||
|
if (monitor.responseKeyword && !responseBody.includes(monitor.responseKeyword)) {
|
||||||
|
console.log(
|
||||||
|
`${monitor.name} expected keyword ${
|
||||||
|
monitor.responseKeyword
|
||||||
|
}, not found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
|
||||||
|
)
|
||||||
|
return "HTTP response doesn't contain the configured keyword"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MUST NOT contain responseForbiddenKeyword
|
||||||
|
if (
|
||||||
|
monitor.responseForbiddenKeyword &&
|
||||||
|
responseBody.includes(monitor.responseForbiddenKeyword)
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`${monitor.name} forbidden keyword ${
|
||||||
|
monitor.responseForbiddenKeyword
|
||||||
|
}, found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
|
||||||
|
)
|
||||||
|
return 'HTTP response contains the configured forbidden keyword'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatusWithGlobalPing(
|
||||||
|
monitor: MonitorTarget
|
||||||
|
): Promise<{ location: string; status: { ping: number; up: boolean; err: string } }> {
|
||||||
|
// TODO: should throw when there's error with globalping API
|
||||||
|
try {
|
||||||
|
if (monitor.checkProxy === undefined) {
|
||||||
|
throw "empty check proxy for globalping, shouldn't call this method"
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpUrl = new URL(monitor.checkProxy)
|
||||||
|
if (gpUrl.protocol !== 'globalping:') {
|
||||||
|
throw 'incorrect check proxy protocol for globalping, got: ' + gpUrl.protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = gpUrl.hostname
|
||||||
|
let globalPingRequest = {}
|
||||||
|
|
||||||
|
if (monitor.method === 'TCP_PING') {
|
||||||
|
const targetUrl = new URL('https://' + monitor.target) // dummy https:// to parse hostname & port
|
||||||
|
globalPingRequest = {
|
||||||
|
type: 'ping',
|
||||||
|
target: targetUrl.hostname,
|
||||||
|
locations:
|
||||||
|
gpUrl.searchParams.get('magic') !== null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
magic: gpUrl.searchParams.get('magic'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
measurementOptions: {
|
||||||
|
port: targetUrl.port,
|
||||||
|
packets: 1,
|
||||||
|
protocol: 'tcp', // TODO: icmp?
|
||||||
|
ipVersion: Number(gpUrl.searchParams.get('ipVersion') || 4),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetUrl = new URL(monitor.target)
|
||||||
|
if (monitor.body !== undefined) {
|
||||||
|
throw 'custom body not supported'
|
||||||
|
}
|
||||||
|
if (monitor.method && !['GET', 'HEAD', 'OPTIONS'].includes(monitor.method.toUpperCase())) {
|
||||||
|
throw 'only GET, HEAD, OPTIONS methods are supported'
|
||||||
|
}
|
||||||
|
globalPingRequest = {
|
||||||
|
type: 'http',
|
||||||
|
target: targetUrl.hostname,
|
||||||
|
locations:
|
||||||
|
gpUrl.searchParams.get('magic') !== null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
magic: gpUrl.searchParams.get('magic'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
measurementOptions: {
|
||||||
|
request: {
|
||||||
|
method: monitor.method,
|
||||||
|
path: targetUrl.pathname,
|
||||||
|
query: targetUrl.search === '' ? undefined : targetUrl.search,
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
Object.entries(monitor.headers ?? {}).map(([key, value]) => [key, String(value)])
|
||||||
|
), // TODO: host header?
|
||||||
|
},
|
||||||
|
port:
|
||||||
|
targetUrl.port === ''
|
||||||
|
? targetUrl.protocol === 'http:'
|
||||||
|
? 80
|
||||||
|
: 443
|
||||||
|
: Number(targetUrl.port),
|
||||||
|
protocol: targetUrl.protocol.replace(':', ''),
|
||||||
|
ipVersion: Number(gpUrl.searchParams.get('ipVersion') || 4),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
console.log(`Requesting the Global Ping API, payload: ${JSON.stringify(globalPingRequest)}`)
|
||||||
|
const measurement = await fetchTimeout('https://api.globalping.io/v1/measurements', 5000, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(globalPingRequest),
|
||||||
|
})
|
||||||
|
const measurementResponse = (await measurement.json()) as any
|
||||||
|
|
||||||
|
if (measurement.status !== 202) {
|
||||||
|
throw measurementResponse.error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurementId = measurementResponse.id
|
||||||
|
console.log(
|
||||||
|
`Measurement created successfully, id: ${measurementId}, time elapsed: ${
|
||||||
|
Date.now() - startTime
|
||||||
|
}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
const pollStart = Date.now()
|
||||||
|
let measurementResult: any
|
||||||
|
while (true) {
|
||||||
|
if (Date.now() - pollStart > (monitor.timeout ?? 10000) + 2000) {
|
||||||
|
// 2s extra buffer
|
||||||
|
throw 'api polling timeout'
|
||||||
|
}
|
||||||
|
|
||||||
|
measurementResult = (await (
|
||||||
|
await fetchTimeout(`https://api.globalping.io/v1/measurements/${measurementId}`, 5000)
|
||||||
|
).json()) as any
|
||||||
|
if (measurementResult.status !== 'in-progress') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Measurement ${measurementId} finished with response: ${JSON.stringify(
|
||||||
|
measurementResult
|
||||||
|
)}, time elapsed: ${Date.now() - pollStart}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
measurementResult.status !== 'finished' ||
|
||||||
|
measurementResult.results[0].result.status !== 'finished'
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`measurement failed with status: ${measurementResult.status}, result status: ${measurementResult.results[0].result.status}`
|
||||||
|
)
|
||||||
|
// Truncate raw output to avoid huge error messages
|
||||||
|
throw `status [${measurementResult.status}|${
|
||||||
|
measurementResult.results[0].result.status
|
||||||
|
}]: ${measurementResult.results?.[0].result?.rawOutput?.slice(0, 64)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const country = measurementResult.results[0].probe.country
|
||||||
|
const city = measurementResult.results[0].probe.city
|
||||||
|
|
||||||
|
if (monitor.method === 'TCP_PING') {
|
||||||
|
const time = Math.round(measurementResult.results[0].result.stats.avg)
|
||||||
|
return {
|
||||||
|
location: country + '/' + city,
|
||||||
|
status: {
|
||||||
|
ping: time,
|
||||||
|
up: true,
|
||||||
|
err: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const time = measurementResult.results[0].result.timings.total
|
||||||
|
const code = measurementResult.results[0].result.statusCode
|
||||||
|
const body = measurementResult.results[0].result.rawBody
|
||||||
|
|
||||||
|
let err = await httpResponseBasicCheck(monitor, code, () => body)
|
||||||
|
if (err !== null) {
|
||||||
|
console.log(`${monitor.name} didn't pass response check: ${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
monitor.target.toLowerCase().startsWith('https') &&
|
||||||
|
!measurementResult.results[0].result.tls.authorized
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`${monitor.name} TLS certificate not trusted: ${measurementResult.results[0].result.tls.error}`
|
||||||
|
)
|
||||||
|
err = 'TLS certificate not trusted: ' + measurementResult.results[0].result.tls.error
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
location: country + '/' + city,
|
||||||
|
status: {
|
||||||
|
ping: time,
|
||||||
|
up: err === null,
|
||||||
|
err: err ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Globalping ${monitor.name} errored with ${e}`)
|
||||||
|
return {
|
||||||
|
location: 'ERROR',
|
||||||
|
status: {
|
||||||
|
ping: e.toString().toLowerCase().includes('timeout') ? monitor.timeout ?? 10000 : 0,
|
||||||
|
up: false,
|
||||||
|
err: 'Globalping error: ' + e.toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStatus(
|
export async function getStatus(
|
||||||
monitor: MonitorTarget
|
monitor: MonitorTarget
|
||||||
): Promise<{ ping: number; up: boolean; err: string }> {
|
): Promise<{ ping: number; up: boolean; err: string }> {
|
||||||
|
|
@ -56,58 +294,16 @@ export async function getStatus(
|
||||||
console.log(`${monitor.name} responded with ${response.status}`)
|
console.log(`${monitor.name} responded with ${response.status}`)
|
||||||
status.ping = Date.now() - startTime
|
status.ping = Date.now() - startTime
|
||||||
|
|
||||||
if (monitor.expectedCodes) {
|
const err = await httpResponseBasicCheck(
|
||||||
if (!monitor.expectedCodes.includes(response.status)) {
|
monitor,
|
||||||
console.log(`${monitor.name} expected ${monitor.expectedCodes}, got ${response.status}`)
|
response.status,
|
||||||
status.up = false
|
response.text.bind(response)
|
||||||
status.err = `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${
|
)
|
||||||
response.status
|
if (err !== null) {
|
||||||
}`
|
console.log(`${monitor.name} didn't pass response check: ${err}`)
|
||||||
return status
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (response.status < 200 || response.status > 299) {
|
|
||||||
console.log(`${monitor.name} expected 2xx, got ${response.status}`)
|
|
||||||
status.up = false
|
|
||||||
status.err = `Expected codes: 2xx, Got: ${response.status}`
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
status.up = err === null
|
||||||
if (monitor.responseKeyword || monitor.responseForbiddenKeyword) {
|
status.err = err ?? ''
|
||||||
// Only read response body if we have a keyword to check
|
|
||||||
const responseBody = await response.text()
|
|
||||||
|
|
||||||
// MUST contain responseKeyword
|
|
||||||
if (monitor.responseKeyword && !responseBody.includes(monitor.responseKeyword)) {
|
|
||||||
console.log(
|
|
||||||
`${monitor.name} expected keyword ${
|
|
||||||
monitor.responseKeyword
|
|
||||||
}, not found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
|
|
||||||
)
|
|
||||||
status.up = false
|
|
||||||
status.err = "HTTP response doesn't contain the configured keyword"
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// MUST NOT contain responseForbiddenKeyword
|
|
||||||
if (
|
|
||||||
monitor.responseForbiddenKeyword &&
|
|
||||||
responseBody.includes(monitor.responseForbiddenKeyword)
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`${monitor.name} forbidden keyword ${
|
|
||||||
monitor.responseForbiddenKeyword
|
|
||||||
}, found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
|
|
||||||
)
|
|
||||||
status.up = false
|
|
||||||
status.err = 'HTTP response contains the configured forbidden keyword'
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status.up = true
|
|
||||||
status.err = ''
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
|
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ function formatStatusChangeNotification(
|
||||||
|
|
||||||
function templateWebhookPlayload(payload: any, message: string) {
|
function templateWebhookPlayload(payload: any, message: string) {
|
||||||
for (const key in payload) {
|
for (const key in payload) {
|
||||||
if (Object.prototype.hasOwnProperty.call(payload, key)) {
|
if (Object.prototype.hasOwnProperty.call(payload, key)) {
|
||||||
if (payload[key] === '$MSG') {
|
if (payload[key] === '$MSG') {
|
||||||
payload[key] = message
|
payload[key] = message
|
||||||
} else if (typeof payload[key] === 'object' && payload[key] !== null) {
|
} else if (typeof payload[key] === 'object' && payload[key] !== null) {
|
||||||
|
|
@ -86,7 +86,9 @@ async function webhookNotify(webhook: WebhookConfig, message: string) {
|
||||||
let url = webhook.url
|
let url = webhook.url
|
||||||
let method = webhook.method
|
let method = webhook.method
|
||||||
let headers = new Headers(webhook.headers as any)
|
let headers = new Headers(webhook.headers as any)
|
||||||
let payloadTemplated: { [key: string]: string | number } = JSON.parse(JSON.stringify(webhook.payload))
|
let payloadTemplated: { [key: string]: string | number } = JSON.parse(
|
||||||
|
JSON.stringify(webhook.payload)
|
||||||
|
)
|
||||||
templateWebhookPlayload(payloadTemplated, message)
|
templateWebhookPlayload(payloadTemplated, message)
|
||||||
let body = undefined
|
let body = undefined
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue