@@ -0,0 +1,746 @@
#!/usr/bin/env node
/**
* shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18)
*
* 模式:
* 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板
* --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复)
* --demo 生成一份演示状态数据后退出(用于静态部署的首版占位)
* --out=<path> 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照)
* --config=<path> / --local-config=<path> 指定公开配置与私有覆盖的路径(多实例、测试用)
*
* 私有配置:若存在 local.monitors.json(已在 .gitignore 中),其 monitors 按 id 覆盖公开配置,
* 用于把真实地址/端口从会公开的 public/monitors.json 中拆出去。
*/
import http from 'node:http' ;
import fs from 'node:fs' ;
import path from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { probeHttp } from './lib/http-probe.js' ;
import { probeTcp } from './lib/tcp-probe.js' ;
import { probeBanner } from './lib/banner-probe.js' ;
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js' ;
const _ _dirname = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const ROOT = path . resolve ( _ _dirname , '../..' ) ;
const PUBLIC _DIR = path . join ( ROOT , 'public' ) ;
const CONFIG _PATH = path . join ( PUBLIC _DIR , 'monitors.json' ) ;
// 私有配置(不入库):用于把真实地址/端口从公开的 monitors.json 中拆出去
const LOCAL _CONFIG _PATH = path . join ( ROOT , 'local.monitors.json' ) ;
const STATUS _PATH = path . join ( PUBLIC _DIR , 'status.json' ) ;
const HISTORY _LIMIT = 48 ;
const UPTIME _DAYS = 90 ;
const CONCURRENCY = 8 ;
const WRITE _DEBOUNCE _MS = 300 ;
const DEFAULT _INTERVAL _SEC = 60 ;
const PORT = Number ( process . env . PORT || 8787 ) ;
const VERSION = '0.6.0' ;
const UR _DEFAULT _POLL _SEC = 300 ;
const UR _MIN _REFRESH _GAP _MS = 60000 ;
const argv = process . argv . slice ( 2 ) ;
const ONCE = argv . includes ( '--once' ) ;
const DEMO = argv . includes ( '--demo' ) ;
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
function argValue ( name ) {
const inline = argv . find ( ( a ) => a . startsWith ( ` ${ name } = ` ) ) ;
if ( inline ) return inline . slice ( name . length + 1 ) ;
const idx = argv . indexOf ( name ) ;
return idx >= 0 && argv [ idx + 1 ] && ! argv [ idx + 1 ] . startsWith ( '--' ) ? argv [ idx + 1 ] : null ;
}
const OUT _PATH = path . resolve ( argValue ( '--out' ) || STATUS _PATH ) ;
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
const CONFIG _OVERRIDE = argValue ( '--config' ) ;
const LOCAL _CONFIG _OVERRIDE = argValue ( '--local-config' ) ;
const ACTIVE _CONFIG _PATH = CONFIG _OVERRIDE ? path . resolve ( CONFIG _OVERRIDE ) : CONFIG _PATH ;
const ACTIVE _LOCAL _CONFIG _PATH = LOCAL _CONFIG _OVERRIDE ? path . resolve ( LOCAL _CONFIG _OVERRIDE ) : LOCAL _CONFIG _PATH ;
const MIME = {
'.html' : 'text/html; charset=utf-8' ,
'.css' : 'text/css; charset=utf-8' ,
'.js' : 'text/javascript; charset=utf-8' ,
'.mjs' : 'text/javascript; charset=utf-8' ,
'.json' : 'application/json; charset=utf-8' ,
'.svg' : 'image/svg+xml' ,
'.png' : 'image/png' ,
'.jpg' : 'image/jpeg' ,
'.ico' : 'image/x-icon' ,
'.woff2' : 'font/woff2' ,
} ;
function loadConfig ( ) {
const pub = JSON . parse ( fs . readFileSync ( ACTIVE _CONFIG _PATH , 'utf8' ) ) ;
const local = loadLocalOverlay ( ) ;
if ( ! local ) return pub ;
// 私有配置按 id 覆盖公开条目(前者可只写 url/host/port 等敏感字段),settings 同样按需覆盖
const byId = new Map ( ( local . monitors ? ? [ ] ) . map ( ( m ) => [ m . id , m ] ) ) ;
return {
... pub ,
settings : { ... pub . settings , ... ( local . settings ? ? { } ) } ,
monitors : ( pub . monitors ? ? [ ] ) . map ( ( m ) => ( { ... m , ... ( byId . get ( m . id ) ? ? { } ) } ) ) ,
} ;
}
function loadLocalOverlay ( ) {
try {
return JSON . parse ( fs . readFileSync ( ACTIVE _LOCAL _CONFIG _PATH , 'utf8' ) ) ;
} catch {
return null ;
}
}
function loadPersistedStatus ( ) {
try {
return JSON . parse ( fs . readFileSync ( STATUS _PATH , 'utf8' ) ) ;
} catch {
return null ;
}
}
function dayKey ( d = new Date ( ) ) {
const p = ( n ) => String ( n ) . padStart ( 2 , '0' ) ;
return ` ${ d . getFullYear ( ) } - ${ p ( d . getMonth ( ) + 1 ) } - ${ p ( d . getDate ( ) ) } ` ;
}
// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序
function pruneDaily ( daily ) {
const keys = Object . keys ( daily ) . sort ( ) ;
while ( keys . length > UPTIME _DAYS ) delete daily [ keys . shift ( ) ] ;
}
function recordDaily ( daily , ok ) {
const key = dayKey ( ) ;
const bucket = daily [ key ] ? ? ( daily [ key ] = { total : 0 , up : 0 } ) ;
bucket . total ++ ;
if ( ok ) bucket . up ++ ;
pruneDaily ( daily ) ;
}
function intervalMs ( monitor ) {
const sec = Number ( monitor . interval ) ;
// 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度
return Math . max ( 5 , Number . isFinite ( sec ) && sec > 0 ? sec : DEFAULT _INTERVAL _SEC ) * 1000 ;
}
/* ---------- UptimeRobot 只读数据源(异地探测视角) ---------- */
// API Key 建议放 local.monitors.json(不入库)或环境变量 UPTIMEROBOT_API_KEY;两者都无则视为未启用
function uptimeRobotConfig ( config ) {
const ur = config . settings ? . uptimeRobot ? ? { } ;
const apiKey = String ( ur . apiKey || process . env . UPTIMEROBOT _API _KEY || '' ) . trim ( ) ;
const ratios = Array . isArray ( ur . ratios ) && ur . ratios . length ? ur . ratios . map ( Number ) . filter ( Number . isFinite ) : [ 1 , 7 , 30 , 90 ] ;
return {
enabled : ur . enabled === true && Boolean ( apiKey ) ,
apiKey ,
pollIntervalSec : Math . max ( 60 , Number ( ur . pollIntervalSec ) || UR _DEFAULT _POLL _SEC ) ,
group : ur . group || 'UptimeRobot' ,
includeTypes : Array . isArray ( ur . includeTypes ) && ur . includeTypes . length ? ur . includeTypes . map ( Number ) : null ,
ratios : ratios . length ? ratios : [ 1 , 7 , 30 , 90 ] ,
} ;
}
// UR 侧不探测,只做「拉取 + 映射」;失败时保留上一次数据,绝不因为读不到 UR 就把服务判为离线
async function syncUptimeRobot ( runtime , { minGapMs = 0 } = { } ) {
const ur = uptimeRobotConfig ( runtime . config ) ;
runtime . ur . enabled = ur . enabled ;
if ( ! ur . enabled || runtime . ur . syncing ) return ;
// minGapMs 用于「面板手动刷新时顺带同步」的场景,避免被反复点击打爆 UR 接口
if ( minGapMs && Date . now ( ) - runtime . ur . lastSyncMs < minGapMs ) return ;
runtime . ur . lastSyncMs = Date . now ( ) ;
runtime . ur . syncing = true ;
try {
let list = await fetchUptimeRobotMonitors ( { apiKey : ur . apiKey , ratios : ur . ratios } ) ;
if ( ur . includeTypes ) list = list . filter ( ( m ) => ur . includeTypes . includes ( m . sourceType ) ) ;
const seen = new Set ( ) ;
for ( const item of list ) {
seen . add ( item . id ) ;
let s = runtime . state . get ( item . id ) ;
if ( ! s ) {
s = { monitor : item , nextDue : 0 , inFlight : false , lastResult : null , history : [ ] , daily : { } } ;
runtime . state . set ( item . id , s ) ;
}
s . monitor = item ;
s . lastResult = {
status : item . status ,
latency : item . latency ,
code : item . code ,
detail : item . detail ,
checkedAt : item . lastCheckIso ,
} ;
s . history = item . history ;
s . uptimeRatios = item . uptimeRatios ;
s . downSeconds = item . downSeconds ;
if ( item . status !== 'pending' ) recordDaily ( s . daily , item . status === 'up' ) ;
}
// 清理 UR 侧已删除的监控
for ( const [ id , s ] of runtime . state ) {
if ( s . monitor ? . source === 'uptimerobot' && ! seen . has ( id ) ) runtime . state . delete ( id ) ;
}
runtime . ur . ok = true ;
runtime . ur . error = null ;
runtime . ur . count = list . length ;
runtime . ur . lastSyncAt = new Date ( ) . toISOString ( ) ;
markDataUpdated ( runtime ) ;
console . log ( ` [uptimerobot] 同步完成: ${ list . length } 个监控(在线 ${ list . filter ( ( m ) => m . status === 'up' ) . length } ) ` ) ;
} catch ( err ) {
runtime . ur . ok = false ;
runtime . ur . error = err . message ;
console . error ( ` [uptimerobot] 同步失败(保留上次数据): ${ err . message } ` ) ;
} finally {
runtime . ur . syncing = false ;
}
}
// 恢复 lastResult / history / daily:优先复用内存态(配置热重载),否则读持久化的 status.json(重启)
function hydrate ( config , prevState = null ) {
const persisted = prevState ? null : loadPersistedStatus ( ) ;
const state = new Map ( ) ;
for ( const m of config . monitors ) {
const own = prevState ? . get ( m . id ) ;
const saved = own
? {
status : own . lastResult ? . status ,
latency : own . lastResult ? . latency ,
code : own . lastResult ? . code ,
detail : own . lastResult ? . detail ,
lastCheck : own . lastResult ? . checkedAt ,
history : own . history ,
daily : own . daily ,
}
: persisted ? . monitors ? . [ m . id ] ;
state . set ( m . id , {
monitor : m ,
nextDue : 0 ,
inFlight : false ,
lastResult : saved ? . status && saved . status !== 'pending'
? { status : saved . status , latency : saved . latency , code : saved . code , detail : saved . detail , checkedAt : saved . lastCheck }
: null ,
history : Array . isArray ( saved ? . history ) ? saved . history . slice ( - HISTORY _LIMIT ) : [ ] ,
daily : saved ? . daily && typeof saved . daily === 'object' ? { ... saved . daily } : { } ,
// UR 侧的聚合可用率(用于在逐日分桶填满前也能显示官方数字)
uptimeRatios : own ? . uptimeRatios ? ? saved ? . uptimeRatios ? ? null ,
downSeconds : own ? . downSeconds ? ? saved ? . downSeconds ? ? null ,
} ) ;
}
// UptimeRobot 镜像目标不在 config 里,热重载时需整体保留,否则会丢掉已累积的逐日分桶
if ( prevState ) {
for ( const [ id , s ] of prevState ) {
if ( s . monitor ? . source === 'uptimerobot' ) state . set ( id , s ) ;
}
}
return state ;
}
async function probeOne ( monitor ) {
let result ;
if ( monitor . type === 'tcp' ) result = await probeTcp ( monitor ) ;
else if ( monitor . type === 'banner' ) result = await probeBanner ( monitor ) ;
else if ( monitor . type === 'http' ) result = await probeHttp ( monitor ) ;
else return { status : 'down' , latency : 0 , code : null , detail : ` 不支持的探测类型: ${ monitor . type } ` , checkedAt : new Date ( ) . toISOString ( ) } ;
result . checkedAt = new Date ( ) . toISOString ( ) ;
return result ;
}
async function mapLimit ( items , limit , fn ) {
let idx = 0 ;
const workers = Array . from ( { length : Math . min ( limit , items . length ) } , async ( ) => {
while ( idx < items . length ) {
const i = idx ++ ;
await fn ( items [ i ] , i ) ;
}
} ) ;
await Promise . all ( workers ) ;
}
async function probeDue ( runtime , force = false ) {
const { config , state } = runtime ;
const now = Date . now ( ) ;
const due = [ ] ;
for ( const m of config . monitors ) {
const s = state . get ( m . id ) ;
if ( ! s || s . inFlight ) continue ;
// UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果
if ( m . type === 'ur' || m . source === 'uptimerobot' ) continue ;
// 目标可单独退出「刷新即检测」(如有限流的 API),只按自身 interval 探测
if ( force && m . probeOnRefresh === false ) continue ;
if ( ! force && s . nextDue > now ) continue ;
// 先同步占位再探测:否则慢目标(如 10s 超时)尚未返回时会被下一秒的 tick 重复调度,形成探测风暴
s . inFlight = true ;
s . nextDue = Date . now ( ) + intervalMs ( m ) ;
due . push ( m ) ;
}
await mapLimit ( due , CONCURRENCY , async ( m ) => {
const s = state . get ( m . id ) ;
const task = ( async ( ) => {
try {
const result = await probeOne ( m ) ;
s . lastResult = result ;
s . history . push ( { t : result . checkedAt , ok : result . status === 'up' , ms : result . latency ? ? 0 } ) ;
if ( s . history . length > HISTORY _LIMIT ) s . history . shift ( ) ;
recordDaily ( s . daily , result . status === 'up' ) ;
const tag = result . status === 'up' ? '[up] ' : '[down]' ;
console . log ( ` ${ tag } ${ m . id . padEnd ( 20 ) } ${ String ( result . latency ? ? '-' ) . padStart ( 5 ) } ms ${ result . detail } ` ) ;
} catch ( err ) {
console . error ( ` [probe] ${ m . id } 探测异常: ${ err . message } ` ) ;
} finally {
s . inFlight = false ;
s . inFlightPromise = null ;
}
} ) ( ) ;
s . inFlightPromise = task ;
await task ;
} ) ;
if ( due . length ) markDataUpdated ( runtime ) ;
}
// 等待在途探测收尾(强制刷新前调用,避免"正在探测中"的目标被跳过而显示旧数据)
async function settleInflight ( state ) {
const pending = [ ... state . values ( ) ] . map ( ( s ) => s . inFlightPromise ) . filter ( Boolean ) ;
if ( pending . length ) await Promise . allSettled ( pending ) ;
}
// 探针自报内存占用(MB):便于观察这个「监控器」自身的资源开销
function memoryStats ( ) {
const m = process . memoryUsage ( ) ;
const mb = ( v ) => Math . round ( ( v / 1048576 ) * 10 ) / 10 ;
return { rssMB : mb ( m . rss ) , heapUsedMB : mb ( m . heapUsed ) , heapTotalMB : mb ( m . heapTotal ) } ;
}
/* ---------- 数据源过滤 ---------- */
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
function dataFilter ( config ) {
const f = config . settings ? . dataFilter ;
return [ 'both' , 'config' , 'uptimerobot' ] . includes ( f ) ? f : 'both' ;
}
/* ---------- 服务端更新节奏 ---------- */
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
// 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据)
function markDataUpdated ( runtime ) {
runtime . dataUpdatedAt = Date . now ( ) ;
}
// 当前展示集合下,服务端多久产出一次新数据:取各目标 interval 与 UR 拉取间隔的最小值
function updateIntervalSec ( config , filter ) {
const intervals = [ ] ;
if ( filter !== 'uptimerobot' ) {
for ( const m of config . monitors ) {
const n = Number ( m . interval ) ;
if ( Number . isFinite ( n ) && n > 0 ) intervals . push ( n ) ;
}
}
if ( filter !== 'config' ) {
const ur = uptimeRobotConfig ( config ) ;
if ( ur . enabled ) intervals . push ( ur . pollIntervalSec ) ;
}
return Math . max ( 5 , Math . round ( intervals . length ? Math . min ( ... intervals ) : DEFAULT _INTERVAL _SEC ) ) ;
}
function snapshot ( runtime , demo = false ) {
const { config , state } = runtime ;
// redactDetails:公开部署时可只输出「正常/异常」,避免 detail 中的版本、地址等细节被公开读取
const redact = config . settings ? . redactDetails === true ;
const filter = dataFilter ( config ) ;
const hideUrls = config . settings ? . hideTargets === true ;
const includeConfig = filter !== 'uptimerobot' ;
const includeExternal = filter !== 'config' ;
const monitors = { } ;
let up = 0 ;
let down = 0 ;
let pending = 0 ;
let latencySum = 0 ;
let latencyCount = 0 ;
let uptimeUp = 0 ;
let uptimeTotal = 0 ;
// 配置里的探测目标 + UptimeRobot 镜像目标(后者只存在于 state 中,由同步产生)
const entries = [ ] ;
if ( includeConfig ) for ( const m of config . monitors ) entries . push ( [ m . id , m , state . get ( m . id ) ] ) ;
if ( includeExternal ) {
for ( const [ id , s ] of state ) {
if ( s . monitor ? . source === 'uptimerobot' ) entries . push ( [ id , s . monitor , s ] ) ;
}
}
for ( const [ id , m , s ] of entries ) {
const r = s ? . lastResult ;
const isUr = m . source === 'uptimerobot' ;
if ( ! r ) {
pending ++ ;
monitors [ id ] = { status : 'pending' , latency : null , code : null , detail : '等待检测' , lastCheck : null , history : [ ] , daily : s ? . daily ? ? { } } ;
continue ;
}
if ( r . status === 'up' ) {
up ++ ;
// 平均延迟只统计自建探针:UR 的延迟来自其探测节点,与本机视角不可比
if ( ! isUr && r . latency != null ) {
latencySum += r . latency ;
latencyCount ++ ;
}
} else if ( r . status === 'down' ) {
down ++ ;
} else {
pending ++ ;
}
for ( const bucket of Object . values ( s . daily ? ? { } ) ) {
uptimeTotal += bucket . total ? ? 0 ;
uptimeUp += bucket . up ? ? 0 ;
}
const entry = {
status : r . status ,
latency : r . latency ,
code : r . code ,
detail : redact ? ( r . status === 'up' ? '正常' : r . status === 'down' ? '异常' : '等待检测' ) : r . detail ,
lastCheck : r . checkedAt ,
history : s . history ,
daily : s . daily ,
} ;
if ( isUr ) {
// UR 镜像目标不在 config 中,需要把展示元信息随快照下发,面板据此合并渲染;
// hideTargets 开启时不下发地址:面板反正不渲染,公开的 status.json 里也就不该有
entry . meta = {
name : m . name ,
group : m . group ,
type : 'ur' ,
url : hideUrls ? '' : m . url ,
source : 'uptimerobot' ,
sourceLabel : m . sourceLabel ,
interval : m . interval ,
} ;
entry . uptimeRatios = s . uptimeRatios ? ? null ;
entry . downSeconds = s . downSeconds ? ? null ;
}
monitors [ id ] = entry ;
}
return {
prober : ` shiroko-monitor-node/ ${ VERSION } ` ,
demo ,
generatedAt : new Date ( ) . toISOString ( ) ,
// 数据最后变更时刻 + 服务端更新节奏:面板据此把刷新对齐到服务端,而不是每访客重新计时
dataUpdatedAt : new Date ( runtime . dataUpdatedAt ? ? Date . now ( ) ) . toISOString ( ) ,
updateIntervalSec : updateIntervalSec ( config , filter ) ,
// 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致
dataFilter : filter ,
stats : {
total : entries . length ,
up ,
down ,
pending ,
avgLatency : latencyCount ? Math . round ( latencySum / latencyCount ) : null ,
avgUptime90 : uptimeTotal ? Math . round ( ( uptimeUp / uptimeTotal ) * 1000 ) / 10 : null ,
memory : memoryStats ( ) ,
} ,
sources : { uptimerobot : { ... ( runtime . ur ? ? { } ) } } ,
monitors ,
} ;
}
function writeStatus ( data ) {
const payload = ` ${ JSON . stringify ( data , null , 2 ) } \n ` ;
const tmp = ` ${ OUT _PATH } .tmp ` ;
try {
fs . writeFileSync ( tmp , payload ) ;
// 原子替换:避免面板读到写了一半的 JSON
fs . renameSync ( tmp , OUT _PATH ) ;
} catch {
// Windows 下 rename 可能因目标文件被短暂占用而失败(EPERM,常见于杀软扫描),
// 退化为直接写入;监控探针绝不能因为一次写文件抖动而整个进程退出
try {
fs . writeFileSync ( OUT _PATH , payload ) ;
fs . rmSync ( tmp , { force : true } ) ;
} catch ( err ) {
console . error ( ` [write] status.json 写入失败: ${ err . message } ` ) ;
}
}
}
function scheduleWrite ( runtime ) {
if ( scheduleWrite . timer ) return ;
scheduleWrite . timer = setTimeout ( ( ) => {
scheduleWrite . timer = null ;
writeStatus ( snapshot ( runtime ) ) ;
} , WRITE _DEBOUNCE _MS ) ;
}
// 刷新即重新检测:多客户端/多标签页同时刷新时共享同一轮探测,避免对目标重复施压;
// 间隔过近的重复请求直接复用当前快照,只有真正需要时才新起一轮
const FORCED _MIN _GAP _MS = 2000 ;
// 兜底上限:正常情况下轮次在目标超时内完成;若有 socket 卡死,也不让请求无限挂起
const FORCED _MAX _WAIT _MS = 22000 ;
function forceRound ( runtime ) {
if ( runtime . forced ) return runtime . forced ;
if ( runtime . forcedAt && Date . now ( ) - runtime . forcedAt < FORCED _MIN _GAP _MS ) return Promise . resolve ( ) ;
runtime . forced = ( async ( ) => {
try {
// 先等在途探测收尾再统一探测,保证返回时每个目标都是本轮的新结果
await settleInflight ( runtime . state ) ;
await probeDue ( runtime , true ) ;
await settleInflight ( runtime . state ) ;
runtime . forcedAt = Date . now ( ) ;
scheduleWrite ( runtime ) ;
} catch ( err ) {
console . error ( ` [refresh] 强制探测异常: ${ err . message } ` ) ;
} finally {
runtime . forced = null ;
}
} ) ( ) ;
return runtime . forced ;
}
function writeDemo ( config ) {
if ( OUT _PATH === STATUS _PATH && fs . existsSync ( STATUS _PATH ) && ! loadPersistedStatus ( ) ? . demo ) {
console . warn ( '[demo] 目标已是真实快照,将被演示数据覆盖(如不希望覆盖,请用 --out=<path> 指定其他输出路径)' ) ;
}
const state = hydrate ( config ) ;
for ( const m of config . monitors ) {
const s = state . get ( m . id ) ;
// 过期证书目标在演示数据中固定为离线,其余按 id 哈希产生稳定的伪随机结果
const seed = [ ... m . id ] . reduce ( ( a , c ) => a + c . charCodeAt ( 0 ) , 0 ) ;
const alwaysDown = m . id . includes ( 'badssl' ) ;
const history = Array . from ( { length : HISTORY _LIMIT } , ( _ , i ) => {
const ok = alwaysDown ? false : ( seed + i * 7 ) % 23 !== 0 ;
return { t : new Date ( Date . now ( ) - ( HISTORY _LIMIT - i ) * ( m . interval ? ? 60 ) * 1000 ) . toISOString ( ) , ok , ms : ok ? 30 + ( ( seed * ( i + 3 ) ) % 240 ) : 0 } ;
} ) ;
const lastOk = history [ history . length - 1 ] . ok ;
s . lastResult = {
status : lastOk ? 'up' : 'down' ,
latency : lastOk ? history [ history . length - 1 ] . ms : 0 ,
code : m . type === 'tcp' ? null : lastOk ? 200 : 0 ,
detail : m . type === 'tcp'
? ( lastOk ? '端口可连接' : '连接被拒绝(端口关闭)' )
: m . type === 'banner'
? ( lastOk ? ` ${ m . protocol ? ? 'BANNER' } 响应正常 ` : '标识串与期望规则不符' )
: lastOk ? 'HTTP 200' : 'CERT_HAS_EXPIRED' ,
checkedAt : history [ history . length - 1 ] . t ,
} ;
s . history = history ;
// 90 天可用率演示数据:多数日期满分,少量日期按档位抖落,便于观察分级配色
const perDay = Math . max ( 1 , Math . round ( 86400 / ( Number ( m . interval ) > 0 ? Number ( m . interval ) : DEFAULT _INTERVAL _SEC ) ) ) ;
const daily = { } ;
for ( let d = UPTIME _DAYS - 1 ; d >= 0 ; d -- ) {
const key = dayKey ( new Date ( Date . now ( ) - d * 86400000 ) ) ;
const missRatio = alwaysDown ? 1 : ( seed + d * 13 ) % 23 === 0 ? 0.05 : ( seed + d * 7 ) % 11 === 0 ? 0.012 : 0 ;
const miss = Math . round ( perDay * missRatio ) ;
daily [ key ] = { total : perDay , up : perDay - miss } ;
}
s . daily = daily ;
}
writeStatus ( snapshot ( { config , state } , true ) ) ;
console . log ( ` [demo] 演示状态已写入 ${ path . relative ( ROOT , OUT _PATH ) || OUT _PATH } ` ) ;
}
function sendJson ( res , data , { status = 200 , headers = { } } = { } ) {
res . writeHead ( status , { 'content-type' : 'application/json; charset=utf-8' , 'cache-control' : 'no-store' , ... headers } ) ;
res . end ( JSON . stringify ( data ) ) ;
}
/* ---------- /api/refresh 滥用防护 ---------- */
// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false
export function isPrivateAddress ( addr ) {
const ip = String ( addr || '' ) . replace ( /^::ffff:/i , '' ) ;
if ( ip === '::1' || ip . startsWith ( '127.' ) ) return true ;
if ( ip . startsWith ( '10.' ) || ip . startsWith ( '192.168.' ) ) return true ;
if ( /^172\.(1[6-9]|2\d|3[01])\./ . test ( ip ) ) return true ;
if ( /^f[cd][0-9a-f]{2}:/i . test ( ip ) || /^fe80:/i . test ( ip ) ) return true ;
return false ;
}
const refreshHits = new Map ( ) ;
const DEFAULT _COOLDOWN _SEC = 5 ;
function refreshDecision ( config , ip ) {
const s = config . settings ? ? { } ;
if ( s . allowRefresh === false ) return { ok : false , status : 403 , error : 'refresh-disabled' } ;
if ( s . refreshLocalOnly !== false && ! isPrivateAddress ( ip ) ) return { ok : false , status : 403 , error : 'refresh-local-only' } ;
const cooldown = Math . max ( 1 , Number ( s . refreshCooldownSec ) || DEFAULT _COOLDOWN _SEC ) * 1000 ;
const wait = ( refreshHits . get ( ip ) ? ? 0 ) + cooldown - Date . now ( ) ;
if ( wait > 0 ) return { ok : false , status : 429 , error : 'refresh-throttled' , retryAfter : Math . ceil ( wait / 1000 ) } ;
refreshHits . set ( ip , Date . now ( ) ) ;
// 简单清理,避免长期运行下 Map 无界增长
const expire = Date . now ( ) - 10 * 60 * 1000 ;
for ( const [ k , ts ] of refreshHits ) if ( ts < expire ) refreshHits . delete ( k ) ;
return { ok : true } ;
}
/* ---------- UptimeRobot 兼容输出(只读) ---------- */
// 把内部快照映射成 getMonitors 的响应形状,便于既有 UptimeRobot 生态的脚本/看板直接复用本服务数据
function toUptimeRobotResponse ( runtime ) {
const snap = snapshot ( runtime ) ;
const cfgById = new Map ( runtime . config . monitors . map ( ( m ) => [ m . id , m ] ) ) ;
// 字符串 id → 稳定数字 id( UptimeRobot 侧 id 为数字)
const numericId = ( id ) => {
let h = 0 ;
for ( const ch of id ) h = ( h * 31 + ch . charCodeAt ( 0 ) ) % 1e9 ;
return h ;
} ;
const ratio = ( st , days ) => {
const buckets = Object . entries ( st . daily ? ? { } ) . sort ( ) . slice ( - days ) ;
const total = buckets . reduce ( ( a , [ , b ] ) => a + ( b . total ? ? 0 ) , 0 ) ;
const upCount = buckets . reduce ( ( a , [ , b ] ) => a + ( b . up ? ? 0 ) , 0 ) ;
if ( total ) return ( upCount / total ) * 100 ;
// 无逐日数据时回退到数据源给出的聚合比率(UptimeRobot 镜像目标自带)
const provided = st . uptimeRatios ? . [ days ] ;
return Number . isFinite ( provided ) ? provided : 0 ;
} ;
const monitors = Object . entries ( snap . monitors ) . map ( ( [ id , st ] ) => {
const cfg = cfgById . get ( id ) ;
const isUr = ( st . meta ? . source ? ? cfg ? . source ) === 'uptimerobot' ;
const name = st . meta ? . name ? ? cfg ? . name ? ? id ;
const url = cfg ? ( cfg . type === 'http' ? cfg . url : ` ${ cfg . host } : ${ cfg . port } ` ) : st . meta ? . url ? ? '' ;
const portLike = isUr ? st . meta ? . sourceLabel === 'PORT' : cfg ? . type !== 'http' ;
return {
id : numericId ( id ) ,
friendly _name : name ,
url ,
type : portLike ? 4 : 1 ,
status : st . status === 'up' ? 2 : st . status === 'down' ? 9 : 1 ,
interval : cfg ? . interval ? ? st . meta ? . interval ? ? DEFAULT _INTERVAL _SEC ,
custom _uptime _ratio : [ 1 , 7 , 30 , 90 ] . map ( ( d ) => ratio ( st , d ) . toFixed ( 3 ) ) . join ( '-' ) ,
average _response _time : st . latency ? ? 0 ,
response _times : ( st . history ? ? [ ] ) . slice ( - HISTORY _LIMIT ) . map ( ( h ) => ( { datetime : Math . floor ( new Date ( h . t ) . getTime ( ) / 1000 ) , value : h . ms } ) ) ,
} ;
} ) ;
return { stat : 'ok' , pagination : { offset : 0 , limit : 50 , total : monitors . length } , monitors } ;
}
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
function createServer ( runtime ) {
return http . createServer ( async ( req , res ) => {
const url = new URL ( req . url , ` http:// ${ req . headers . host || 'localhost' } ` ) ;
const { config , state } = runtime ;
try {
if ( url . pathname === '/api/status' ) return sendJson ( res , snapshot ( runtime ) ) ;
if ( url . pathname === '/api/monitors' ) return sendJson ( res , config ) ;
if ( url . pathname === '/api/refresh' ) {
const decide = refreshDecision ( config , req . socket . remoteAddress ) ;
if ( ! decide . ok ) {
// 只回错误码,不回显目标地址与内部细节
return sendJson ( res , { error : decide . error } , {
status : decide . status ,
headers : decide . retryAfter ? { 'retry-after' : String ( decide . retryAfter ) } : { } ,
} ) ;
}
// scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用)
const scope = url . searchParams . get ( 'scope' ) ;
if ( scope !== 'external' ) {
// 等本轮探测完成再返回快照,避免面板刷新后读到的仍是旧数据;上限兜底防止个别目标卡死请求
await Promise . race ( [ forceRound ( runtime ) , new Promise ( ( resolve ) => setTimeout ( resolve , FORCED _MAX _WAIT _MS ) ) ] ) ;
}
// 顺带同步 UptimeRobot(至少间隔 60s,尊重其接口限流),让「只看 UR」模式下的刷新同样有意义;
// 再等 6s 上限,避免 UR 接口慢把整体刷新拖过面板超时
await Promise . race ( [
syncUptimeRobot ( runtime , { minGapMs : UR _MIN _REFRESH _GAP _MS } ) ,
new Promise ( ( resolve ) => setTimeout ( resolve , 6000 ) ) ,
] ) ;
// 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应
if ( res . writableEnded || res . destroyed ) return ;
return sendJson ( res , snapshot ( runtime ) ) ;
}
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
if ( url . pathname === '/api/v2/getMonitors' || url . pathname === '/v2/getMonitors' ) {
return sendJson ( res , toUptimeRobotResponse ( runtime ) ) ;
}
let rel = decodeURIComponent ( url . pathname ) ;
if ( rel === '/' ) rel = '/index.html' ;
const file = path . join ( PUBLIC _DIR , path . normalize ( rel ) ) ;
if ( ! file . startsWith ( PUBLIC _DIR ) ) {
res . writeHead ( 403 ) ;
return res . end ( 'Forbidden' ) ;
}
if ( ! fs . existsSync ( file ) || fs . statSync ( file ) . isDirectory ( ) ) {
res . writeHead ( 404 , { 'content-type' : 'text/plain; charset=utf-8' } ) ;
return res . end ( 'Not Found' ) ;
}
const ext = path . extname ( file ) . toLowerCase ( ) ;
const live = rel === '/status.json' || rel === '/monitors.json' ;
res . writeHead ( 200 , {
'content-type' : MIME [ ext ] || 'application/octet-stream' ,
'cache-control' : live ? 'no-store' : 'public, max-age=300' ,
} ) ;
fs . createReadStream ( file ) . pipe ( res ) ;
} catch ( err ) {
try {
res . writeHead ( 500 , { 'content-type' : 'text/plain; charset=utf-8' } ) ;
res . end ( 'Internal Error' ) ;
} catch {
// 连接已被对端关闭,忽略写入失败(避免未捕获异常终止探针进程)
}
}
} ) ;
}
async function main ( ) {
const runtime = {
config : loadConfig ( ) ,
state : null ,
dataUpdatedAt : Date . now ( ) ,
ur : { enabled : false , ok : null , error : null , lastSyncAt : null , count : 0 , syncing : false , lastSyncMs : 0 } ,
} ;
if ( DEMO ) {
writeDemo ( runtime . config ) ;
return ;
}
runtime . state = hydrate ( runtime . config ) ;
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
await syncUptimeRobot ( runtime ) ;
if ( ONCE ) {
await probeDue ( runtime , true ) ;
writeStatus ( snapshot ( runtime ) ) ;
const s = snapshot ( runtime ) . stats ;
console . log ( ` [once] 完成:共 ${ s . total } ,在线 ${ s . up } ,离线 ${ s . down } ` ) ;
return ;
}
let reloadTimer = null ;
fs . watch ( ACTIVE _CONFIG _PATH , ( ) => {
clearTimeout ( reloadTimer ) ;
reloadTimer = setTimeout ( ( ) => {
try {
const next = loadConfig ( ) ;
// 复用当前内存态恢复 history/daily;仅替换持有器属性,不再产生游离的旧引用
runtime . state = hydrate ( next , runtime . state ) ;
runtime . config = next ;
console . log ( '[config] monitors.json 已热重载' ) ;
scheduleWrite ( runtime ) ;
} catch ( err ) {
console . error ( ` [config] 重载失败: ${ err . message } ` ) ;
}
} , 200 ) ;
} ) ;
setInterval ( ( ) => {
probeDue ( runtime )
. then ( ( ) => scheduleWrite ( runtime ) )
. catch ( ( err ) => console . error ( ` [tick] ${ err . stack || err } ` ) ) ;
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
if ( Date . now ( ) - runtime . ur . lastSyncMs >= uptimeRobotConfig ( runtime . config ) . pollIntervalSec * 1000 ) {
syncUptimeRobot ( runtime )
. then ( ( ) => scheduleWrite ( runtime ) )
. catch ( ( ) => { /* syncUptimeRobot 内部已记录错误 */ } ) ;
}
} , 1000 ) ;
createServer ( runtime ) . listen ( PORT , ( ) => {
console . log ( ` shiroko-monitor v ${ VERSION } ` ) ;
console . log ( ` 面板: http://localhost: ${ PORT } / ` ) ;
console . log ( ` 状态 API: http://localhost: ${ PORT } /api/status ` ) ;
console . log ( ` 兼容接口: http://localhost: ${ PORT } /api/v2/getMonitors ( UptimeRobot getMonitors 只读兼容) ` ) ;
console . log ( ` 监控目标: ${ runtime . config . monitors . length } 个自建探测 ${ runtime . ur . enabled ? ` + ${ runtime . ur . count } 个 UptimeRobot 监控 ` : '' } (配置热重载已启用) ` ) ;
} ) ;
}
// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动
if ( process . argv [ 1 ] && path . resolve ( process . argv [ 1 ] ) === fileURLToPath ( import . meta . url ) ) main ( ) ;