411 lines
14 KiB
JavaScript
411 lines
14 KiB
JavaScript
import { createHash } from 'node:crypto'
|
||
import { execFileSync, spawn } from 'node:child_process'
|
||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||
import { hostname, platform, release } from 'node:os'
|
||
import { dirname, resolve } from 'node:path'
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
|
||
export const AGENT_VERSION = '1.0.0'
|
||
const AGENT_DIR = dirname(fileURLToPath(import.meta.url))
|
||
|
||
function requiredText(value, label) {
|
||
if (typeof value !== 'string' || !value.trim()) throw new Error(`缺少 ${label}`)
|
||
return value.trim()
|
||
}
|
||
|
||
export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || resolve(AGENT_DIR, 'config.json')) {
|
||
if (!existsSync(configPath)) {
|
||
throw new Error(`配置文件不存在:${configPath},请复制 config.example.json 为 config.json`)
|
||
}
|
||
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
||
config.agentId = requiredText(config.agentId, 'agentId')
|
||
config.agentToken = requiredText(config.agentToken, 'agentToken')
|
||
config.connectionMode = String(config.connectionMode || 'auto').toLowerCase()
|
||
if (!['intranet', 'internet', 'auto'].includes(config.connectionMode)) {
|
||
throw new Error('connectionMode 必须是 intranet、internet 或 auto')
|
||
}
|
||
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
|
||
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
|
||
config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5))
|
||
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
|
||
return config
|
||
}
|
||
|
||
function normalizeCenterUrl(value) {
|
||
if (typeof value !== 'string' || !value.trim()) return null
|
||
const url = new URL(value.trim())
|
||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`不支持的中心地址协议:${url.protocol}`)
|
||
return url.toString().replace(/\/$/, '')
|
||
}
|
||
|
||
export function resolveCenterEndpoints(config) {
|
||
const intranet = normalizeCenterUrl(config.centerUrls?.intranet)
|
||
const internet = normalizeCenterUrl(config.centerUrls?.internet)
|
||
const endpoints = []
|
||
if (config.connectionMode !== 'internet' && intranet) endpoints.push({ kind: 'INTRANET', url: intranet })
|
||
if (config.connectionMode !== 'intranet' && internet) endpoints.push({ kind: 'INTERNET', url: internet })
|
||
if (endpoints.length === 0) throw new Error('当前连接模式没有可用的 centerUrls 地址')
|
||
return endpoints
|
||
}
|
||
|
||
export async function postToCenter(config, route, payload) {
|
||
let lastError = null
|
||
for (const endpoint of resolveCenterEndpoints(config)) {
|
||
const controller = new AbortController()
|
||
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs)
|
||
try {
|
||
const response = await fetch(`${endpoint.url}${route}`, {
|
||
method: 'POST',
|
||
signal: controller.signal,
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
authorization: `Bearer ${config.agentToken}`,
|
||
'x-script-monitor-agent-id': config.agentId,
|
||
},
|
||
body: JSON.stringify({
|
||
...payload,
|
||
...(route.endsWith('/report')
|
||
? { connectionKind: payload.connectionKind ?? endpoint.kind }
|
||
: {}),
|
||
}),
|
||
})
|
||
if (!response.ok) {
|
||
const message = await response.text().catch(() => '')
|
||
throw new Error(`HTTP ${response.status}${message ? `:${message.slice(0, 240)}` : ''}`)
|
||
}
|
||
return { endpoint, body: await response.json().catch(() => null) }
|
||
} catch (error) {
|
||
lastError = error
|
||
} finally {
|
||
clearTimeout(timeout)
|
||
}
|
||
}
|
||
throw lastError ?? new Error('没有可用的中心地址')
|
||
}
|
||
|
||
function hashKey(value) {
|
||
return createHash('sha256').update(value).digest('hex').slice(0, 20)
|
||
}
|
||
|
||
function redactCommand(command) {
|
||
return String(command || '')
|
||
.replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)([^\s]+)/gi, '$1[REDACTED]')
|
||
.replace(/(https?:\/\/[^:/\s]+:)[^@/\s]+@/gi, '$1[REDACTED]@')
|
||
.slice(0, 2000)
|
||
}
|
||
|
||
function readPm2() {
|
||
try {
|
||
const raw = execFileSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000 })
|
||
return JSON.parse(raw)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function scanPm2() {
|
||
return readPm2().map((processInfo) => {
|
||
const name = String(processInfo.name || '未命名 PM2 进程')
|
||
const status = processInfo.pm2_env?.status === 'online'
|
||
? 'RUNNING'
|
||
: processInfo.pm2_env?.status === 'stopped'
|
||
? 'DISABLED'
|
||
: processInfo.pm2_env?.status === 'errored'
|
||
? 'FAILED'
|
||
: 'UNKNOWN'
|
||
return {
|
||
key: `pm2:${name}`,
|
||
name,
|
||
taskDescription: '',
|
||
jobType: 'PM2',
|
||
schedule: null,
|
||
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
|
||
expectedIntervalSec: null,
|
||
status,
|
||
isEnabled: status !== 'DISABLED',
|
||
canManualRun: false,
|
||
lastStartedAt: processInfo.pm2_env?.pm_uptime
|
||
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
|
||
: null,
|
||
lastResultSummary: status === 'RUNNING'
|
||
? `PM2 在线,PID ${processInfo.pid || '-'}`
|
||
: `PM2 状态:${processInfo.pm2_env?.status || 'unknown'}`,
|
||
}
|
||
})
|
||
}
|
||
|
||
function parseCronLine(line) {
|
||
const value = line.trim()
|
||
if (!value || value.startsWith('#')) return null
|
||
const special = value.match(/^@(reboot|yearly|annually|monthly|weekly|daily|hourly)\s+(.+)$/)
|
||
const regular = value.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/)
|
||
const schedule = special ? `@${special[1]}` : regular?.[1]
|
||
const command = special?.[2] ?? regular?.[2]
|
||
if (!schedule || !command) return null
|
||
const tag = command.match(/#\s*([\w.-]{3,})\s*$/)?.[1]
|
||
const name = tag || command.split(/\s+/).filter(Boolean).slice(-1)[0] || 'cron task'
|
||
return {
|
||
key: `cron:${hashKey(`${schedule}\n${command}`)}`,
|
||
name,
|
||
taskDescription: '',
|
||
jobType: 'CRON',
|
||
schedule,
|
||
commandPreview: redactCommand(command.replace(/\s+#.*$/, '')),
|
||
expectedIntervalSec: null,
|
||
status: 'UNKNOWN',
|
||
isEnabled: true,
|
||
canManualRun: false,
|
||
}
|
||
}
|
||
|
||
function scanCron() {
|
||
if (platform() === 'win32') return []
|
||
try {
|
||
const raw = execFileSync('crontab', ['-l'], { encoding: 'utf8', timeout: 5000 })
|
||
return raw.split(/\r?\n/).map(parseCronLine).filter(Boolean)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function checkProcess(name) {
|
||
try {
|
||
if (platform() === 'win32') {
|
||
const output = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${name}`], { encoding: 'utf8', timeout: 4000 })
|
||
return output.toLowerCase().includes(String(name).toLowerCase())
|
||
}
|
||
execFileSync('pgrep', ['-f', '--', name], { stdio: 'ignore', timeout: 4000 })
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function checkConfiguredScript(script) {
|
||
if (script.enabled === false) return { status: 'DISABLED', summary: '监控已停用' }
|
||
const check = script.resultCheck
|
||
if (!check) return { status: 'UNKNOWN', summary: null }
|
||
|
||
if (check.type === 'file') {
|
||
try {
|
||
const stat = statSync(requiredText(check.path, 'resultCheck.path'))
|
||
const ageSec = Math.max(0, Math.round((Date.now() - stat.mtimeMs) / 1000))
|
||
const fresh = !check.maxAgeSec || ageSec <= Number(check.maxAgeSec)
|
||
const largeEnough = check.minBytes == null || stat.size >= Number(check.minBytes)
|
||
return {
|
||
status: fresh && largeEnough ? 'SUCCESS' : 'FAILED',
|
||
summary: `文件 ${stat.size} B,${ageSec} 秒前更新`,
|
||
}
|
||
} catch (error) {
|
||
return { status: 'FAILED', summary: `文件检查失败:${error.message}` }
|
||
}
|
||
}
|
||
|
||
if (check.type === 'systemd') {
|
||
try {
|
||
const service = requiredText(check.service, 'resultCheck.service')
|
||
const state = execFileSync('systemctl', ['is-active', service], { encoding: 'utf8', timeout: 4000 }).trim()
|
||
return { status: state === 'active' ? 'RUNNING' : 'FAILED', summary: `systemd:${state}` }
|
||
} catch {
|
||
return { status: 'FAILED', summary: 'systemd 服务未运行' }
|
||
}
|
||
}
|
||
|
||
if (check.type === 'process') {
|
||
const running = checkProcess(requiredText(check.name, 'resultCheck.name'))
|
||
return { status: running ? 'RUNNING' : 'FAILED', summary: running ? '进程正在运行' : '未找到目标进程' }
|
||
}
|
||
|
||
return { status: 'UNKNOWN', summary: '未识别的结果检查类型' }
|
||
}
|
||
|
||
function normalizeConfiguredScript(script) {
|
||
const key = requiredText(script.key, 'scripts[].key')
|
||
const result = checkConfiguredScript(script)
|
||
return {
|
||
key,
|
||
name: requiredText(script.name, `scripts[${key}].name`),
|
||
taskDescription: String(script.taskDescription || '').slice(0, 2000),
|
||
jobType: String(script.jobType || 'OTHER').toUpperCase(),
|
||
schedule: script.schedule ? String(script.schedule).slice(0, 240) : null,
|
||
commandPreview: redactCommand(script.commandPreview || script.command || ''),
|
||
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
|
||
status: result.status,
|
||
isEnabled: script.enabled !== false,
|
||
canManualRun: script.allowManualRun === true && Boolean(script.command),
|
||
lastResultSummary: result.summary,
|
||
}
|
||
}
|
||
|
||
export function collectScripts(config) {
|
||
const byKey = new Map()
|
||
if (config.autoDiscover?.cron !== false) {
|
||
for (const script of scanCron()) byKey.set(script.key, script)
|
||
}
|
||
if (config.autoDiscover?.pm2 !== false) {
|
||
for (const script of scanPm2()) byKey.set(script.key, script)
|
||
}
|
||
for (const script of config.scripts) {
|
||
const normalized = normalizeConfiguredScript(script)
|
||
byKey.set(normalized.key, { ...(byKey.get(normalized.key) || {}), ...normalized })
|
||
}
|
||
return [...byKey.values()]
|
||
}
|
||
|
||
export async function reportSnapshot(config) {
|
||
const scripts = collectScripts(config)
|
||
const result = await postToCenter(config, '/api/internal/script-monitor/report', {
|
||
hostname: hostname(),
|
||
os: `${platform()} ${release()}`,
|
||
agentVersion: AGENT_VERSION,
|
||
reportIntervalSec: config.reportIntervalSec,
|
||
scripts,
|
||
})
|
||
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
|
||
}
|
||
|
||
function keepTail(current, chunk, limit = 12000) {
|
||
const next = current + chunk.toString()
|
||
return next.length > limit ? next.slice(-limit) : next
|
||
}
|
||
|
||
async function reportManualRun(config, command, input) {
|
||
return postToCenter(config, '/api/internal/script-monitor/run', {
|
||
commandId: command.id,
|
||
scriptKey: command.scriptKey,
|
||
scriptName: command.scriptName,
|
||
taskDescription: command.taskDescription || '',
|
||
jobType: String(command.jobType || 'OTHER').toUpperCase(),
|
||
...input,
|
||
})
|
||
}
|
||
|
||
export async function executeManualCommand(config, command) {
|
||
const script = config.scripts.find((item) => item.key === command.scriptKey)
|
||
const startedAt = new Date()
|
||
const externalRunId = `manual:${command.id}`
|
||
if (!script?.command || script.allowManualRun !== true) {
|
||
const message = 'Agent 配置未开放该脚本的手动运行权限'
|
||
await reportManualRun(config, command, {
|
||
externalRunId,
|
||
status: 'FAILED',
|
||
trigger: 'manual',
|
||
startedAt: startedAt.toISOString(),
|
||
finishedAt: new Date().toISOString(),
|
||
durationMs: 0,
|
||
exitCode: 1,
|
||
resultSummary: message,
|
||
errorTail: message,
|
||
})
|
||
return
|
||
}
|
||
|
||
await reportManualRun(config, command, {
|
||
externalRunId,
|
||
status: 'RUNNING',
|
||
trigger: 'manual',
|
||
startedAt: startedAt.toISOString(),
|
||
})
|
||
let outputTail = ''
|
||
let errorTail = ''
|
||
try {
|
||
const child = spawn(script.command, {
|
||
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
|
||
env: process.env,
|
||
shell: true,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
})
|
||
child.stdout.on('data', (chunk) => {
|
||
outputTail = keepTail(outputTail, chunk)
|
||
process.stdout.write(chunk)
|
||
})
|
||
child.stderr.on('data', (chunk) => {
|
||
errorTail = keepTail(errorTail, chunk)
|
||
process.stderr.write(chunk)
|
||
})
|
||
const exitCode = await new Promise((resolveExit, reject) => {
|
||
child.once('error', reject)
|
||
child.once('close', (code) => resolveExit(code ?? 1))
|
||
})
|
||
const finishedAt = new Date()
|
||
await reportManualRun(config, command, {
|
||
externalRunId,
|
||
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
|
||
trigger: 'manual',
|
||
startedAt: startedAt.toISOString(),
|
||
finishedAt: finishedAt.toISOString(),
|
||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||
exitCode,
|
||
resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`,
|
||
outputTail,
|
||
errorTail,
|
||
})
|
||
} catch (error) {
|
||
const finishedAt = new Date()
|
||
await reportManualRun(config, command, {
|
||
externalRunId,
|
||
status: 'FAILED',
|
||
trigger: 'manual',
|
||
startedAt: startedAt.toISOString(),
|
||
finishedAt: finishedAt.toISOString(),
|
||
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
||
exitCode: 1,
|
||
resultSummary: `手动运行启动失败:${error.message}`,
|
||
errorTail: error.message,
|
||
})
|
||
}
|
||
}
|
||
|
||
export async function pollManualCommand(config) {
|
||
const result = await postToCenter(
|
||
config,
|
||
'/api/internal/script-monitor/commands/claim',
|
||
{},
|
||
)
|
||
const command = result.body?.data?.command
|
||
if (!command) return false
|
||
await executeManualCommand(config, command)
|
||
return true
|
||
}
|
||
|
||
async function main() {
|
||
const config = loadConfig()
|
||
const run = async () => {
|
||
try {
|
||
await reportSnapshot(config)
|
||
} catch (error) {
|
||
console.error('[script-monitor] 上报失败:', error.message)
|
||
}
|
||
}
|
||
let commandBusy = false
|
||
const poll = async () => {
|
||
if (commandBusy) return
|
||
commandBusy = true
|
||
try {
|
||
await pollManualCommand(config)
|
||
} catch (error) {
|
||
console.error('[script-monitor] 手动任务轮询失败:', error.message)
|
||
} finally {
|
||
commandBusy = false
|
||
}
|
||
}
|
||
await run()
|
||
await poll()
|
||
const timer = setInterval(run, config.reportIntervalSec * 1000)
|
||
const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000)
|
||
const stop = () => {
|
||
clearInterval(timer)
|
||
clearInterval(commandTimer)
|
||
process.exit(0)
|
||
}
|
||
process.on('SIGINT', stop)
|
||
process.on('SIGTERM', stop)
|
||
}
|
||
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||
main().catch((error) => {
|
||
console.error('[script-monitor] 启动失败:', error.message)
|
||
process.exit(1)
|
||
})
|
||
}
|