webhooks.js
8.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'node:crypto'
import { lookup } from 'node:dns/promises'
import { BlockList, isIP } from 'node:net'
const DELIVERIES = 'vk_webhook_deliveries'
const BATCH = 20
const TIMEOUT_MS = 10_000
const LEASE_MS = 60_000
/** 首次失败后的重试间隔:1 分钟、5 分钟、30 分钟、2 小时;之后只留在 /events 里。 */
const RETRY_AFTER_MS = Object.freeze([60_000, 300_000, 1_800_000, 7_200_000])
// 回环、内网、链路本地(含云元数据 169.254.169.254)、运营商 NAT、组播与保留段一律不投递。
const NON_PUBLIC = new BlockList()
for (const [net, prefix] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4]]) NON_PUBLIC.addSubnet(net, prefix, 'ipv4')
for (const [net, prefix] of [['::', 127], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96]]) NON_PUBLIC.addSubnet(net, prefix, 'ipv6')
/**
* @param {string} address IPv4 或 IPv6 字面量
* @returns {boolean}
*/
export function isPublicAddress(address) {
const family = isIP(address)
if (family === 0) throw new Error(`isPublicAddress: not an IP address: ${address}`)
return !NON_PUBLIC.check(address, family === 4 ? 'ipv4' : 'ipv6')
}
/**
* `X-VOKO-Signature` 的值:sha256=HMAC(secret, timestamp + "." + body)。
* @param {string} secret
* @param {string} timestamp
* @param {string} body
* @returns {string}
*/
export function signWebhook(secret, timestamp, body) {
return `sha256=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`
}
/**
* Webhook:Agent 登记 HTTPS 地址后,事件写入时生成投递记录,由 worker 签名投递并按退避重试。
* Webhook 只是「提醒你来拉」,漏收的事件都能从 /home 或 /events 补到。
*
* @param {{ db: import('knex').Knex, secret_key: string }} deps `secret_key` 用于加密存储各 Agent 的签名 secret
*/
export function createWebhooks({ db, secret_key }) {
if (typeof db !== 'function') throw new Error('createWebhooks: db is required')
if (typeof secret_key !== 'string' || secret_key === '') throw new Error('createWebhooks: secret_key is required')
const key = createHash('sha256').update(secret_key).digest()
/** @param {string} secret */
function encrypt(secret) {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const data = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()])
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), data.toString('base64url')].join('.')
}
/** @param {string} stored */
function decrypt(stored) {
const [version, iv, tag, data] = stored.split('.')
if (version !== 'v1') throw new Error(`webhook secret has unknown version: ${version}`)
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
decipher.setAuthTag(Buffer.from(tag, 'base64url'))
return Buffer.concat([decipher.update(Buffer.from(data, 'base64url')), decipher.final()]).toString('utf8')
}
/**
* 只允许 HTTPS,且域名解析出的全部地址都必须是公网地址(防 SSRF)。
* ponytail: 校验与实际请求各解析一次 DNS,存在重绑定窗口;要堵死就给 fetch 换成固定到已校验 IP 的 dispatcher。
* @param {string} webhook_url
* @returns {Promise<string|null>} 不合规时返回原因
*/
async function urlProblem(webhook_url) {
const url = new URL(webhook_url)
if (url.protocol !== 'https:') return 'webhook_url must use https.'
if (url.username !== '' || url.password !== '') return 'webhook_url must not contain credentials.'
const host = url.hostname.replace(/^\[|\]$/g, '')
// 外部 IO 的最小包裹:解析不了的域名是调用方的输入错误,不是程序 bug。
let addresses
try {
addresses = isIP(host) === 0 ? (await lookup(host, { all: true })).map((item) => item.address) : [host]
} catch {
return 'webhook_url host cannot be resolved.'
}
return addresses.length > 0 && addresses.every(isPublicAddress) ? null : 'webhook_url must resolve to public addresses only.'
}
/**
* 登记或取消(传 null)Webhook。登记成功返回签名 secret,仅此一次。
* @param {object} _ctx
* @param {{ agent_id: string, webhook_url: string|null }} input
* @returns {Promise<{ ok: true, webhook_secret: string|null } | { ok: false, hint: string }>}
*/
async function register(_ctx, { agent_id, webhook_url }) {
if (webhook_url === null) {
await db('vk_agents').where({ id: agent_id }).update({ webhook_url: null, webhook_secret_enc: null, updated_at: new Date() })
return { ok: true, webhook_secret: null }
}
const problem = await urlProblem(webhook_url)
if (problem !== null) return { ok: false, hint: problem }
const webhook_secret = randomBytes(32).toString('hex')
await db('vk_agents').where({ id: agent_id }).update({ webhook_url, webhook_secret_enc: encrypt(webhook_secret), updated_at: new Date() })
return { ok: true, webhook_secret }
}
/**
* worker:领取并投递一批到期的 Webhook;返回本轮处理的条数。
* @param {object} _ctx
* @returns {Promise<number>}
*/
async function deliverDue(_ctx) {
const now = new Date()
const due = await db.transaction(async (trx) => {
const rows = await trx(DELIVERIES).where({ status: 'pending' }).where('next_attempt_at', '<=', now).orderBy('next_attempt_at', 'asc').limit(BATCH).forUpdate().skipLocked()
// 把下次尝试时间推后当作租约,避免另一个 worker 同时投递同一条。
if (rows.length > 0) await trx(DELIVERIES).whereIn('id', rows.map((row) => row.id)).update({ next_attempt_at: new Date(now.getTime() + LEASE_MS) })
return rows
})
for (const delivery of due) {
const failure = await attempt(delivery)
if (failure === null) {
await db(DELIVERIES).where({ id: delivery.id }).update({ status: 'delivered', attempts: delivery.attempts + 1, next_attempt_at: null, delivered_at: new Date() })
continue
}
const retryAfter = RETRY_AFTER_MS[delivery.attempts]
await db(DELIVERIES).where({ id: delivery.id }).update(retryAfter === undefined
? { status: 'failed', attempts: delivery.attempts + 1, next_attempt_at: null }
: { attempts: delivery.attempts + 1, next_attempt_at: new Date(Date.now() + retryAfter) })
}
return due.length
}
/**
* 投递一次。成功返回 null,失败返回原因。
* @param {{ event_id: number, agent_id: string }} delivery
* @returns {Promise<string|null>}
*/
async function attempt(delivery) {
const [agent, event] = await Promise.all([
db('vk_agents').where({ id: delivery.agent_id }).first('webhook_url', 'webhook_secret_enc'),
db('vk_events').where({ id: delivery.event_id }).first('id', 'type', 'task_id', 'created_at'),
])
if (agent === undefined || event === undefined) throw new Error(`vk_webhook_deliveries refers to a missing agent or event, event: ${delivery.event_id}, agent: ${delivery.agent_id}`)
if (agent.webhook_url === null) return 'webhook was unregistered'
const problem = await urlProblem(agent.webhook_url)
if (problem !== null) return problem
const body = JSON.stringify({ id: String(event.id), type: event.type, created_at: event.created_at.toISOString(), data: { task_id: event.task_id } })
const timestamp = String(Math.floor(Date.now() / 1000))
// 对外部 IO 的最小包裹:对方不可达、超时、非 2xx 都是预期失败,按退避重试。
try {
const response = await fetch(agent.webhook_url, {
method: 'POST',
redirect: 'manual',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: {
'content-type': 'application/json',
'x-voko-event': event.type,
'x-voko-delivery': String(event.id),
'x-voko-timestamp': timestamp,
'x-voko-signature': signWebhook(decrypt(agent.webhook_secret_enc), timestamp, body),
},
body,
})
return response.status >= 200 && response.status < 300 ? null : `receiver returned ${response.status}`
} catch (err) {
return err instanceof Error ? err.message : String(err)
}
}
return { register, deliverDue }
}