feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 轮换「首页导航」密钥对,并同步更换首页 token(旧 token 立即作废)
|
||||
*
|
||||
* 为什么需要轮换两把:
|
||||
* 服务端校验首页 token 时会组成一个「信任池」,池中含
|
||||
* · config/nav-private.jwk.json 的公钥部分
|
||||
* · config/nav.jwk.json
|
||||
* 经审计,线上那枚旧 token 实际是由 nav.jwk.json 对应私钥签发的。
|
||||
* 因此只换 nav-private 无效 —— 必须把两者都换成同一把新密钥,
|
||||
* 旧公钥才会彻底离开信任池,旧 token 才真正失效。
|
||||
*
|
||||
* 本脚本动作:
|
||||
* 1) 备份现有两个密钥文件(时间戳目录,可回滚)
|
||||
* 2) 生成新的 RSA-2048 密钥对,写入上述两个文件(同一把新密钥)
|
||||
* 3) 用新私钥签发一枚新的首页 token
|
||||
* 4) 把短链接 PSq6xv 的目标地址替换为新 token(其它字段不动)
|
||||
* 5) 就地做黑盒校验:旧 token 必须 401,新 token 必须 302 并能进首页
|
||||
*
|
||||
* 用法:
|
||||
* node dev_test_scripts/tools/tool_rotate_nav_key.js # 默认有效期 365 天
|
||||
* node dev_test_scripts/tools/tool_rotate_nav_key.js 180 # 指定天数
|
||||
* node dev_test_scripts/tools/tool_rotate_nav_key.js --dry-run # 只看计划,不写入
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const CONFIG_DIR = path.join(process.cwd(), 'config')
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
|
||||
const PRIV_PATH = path.join(CONFIG_DIR, 'nav-private.jwk.json')
|
||||
const PUB_PATH = path.join(CONFIG_DIR, 'nav.jwk.json')
|
||||
const BASE_URL = 'https://traesite.umersoft.com:8975/'
|
||||
const TARGET_CODE = 'PSq6xv'
|
||||
const LOCAL = 'http://localhost:8976'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes('--dry-run')
|
||||
const daysArg = args.find(a => /^\d+$/.test(a))
|
||||
const EXPIRE_DAYS = daysArg ? Math.max(1, Math.min(730, Number(daysArg))) : 365
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
const check = (name, ok, detail) => {
|
||||
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
|
||||
else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) }
|
||||
}
|
||||
|
||||
const fp = jwk => {
|
||||
const n = String((jwk && jwk.n) || '')
|
||||
return n ? crypto.createHash('sha256').update(n).digest('hex').slice(0, 16) : '(空)'
|
||||
}
|
||||
const readJson = p => JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
|
||||
const signJwtRS256 = (payload, jwk) => {
|
||||
const enc = v => Buffer.from(JSON.stringify(v)).toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const data = enc({ alg: 'RS256', typ: 'JWT' }) + '.' + enc(payload)
|
||||
const sig = crypto.sign('RSA-SHA256', Buffer.from(data), crypto.createPrivateKey({ key: jwk, format: 'jwk' }))
|
||||
.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return data + '.' + sig
|
||||
}
|
||||
|
||||
/** 取短链接当前目标里的 token(用于轮换后验证"旧的确实失效了") */
|
||||
const readCurrentTarget = () => {
|
||||
const db = new Database(DB_PATH, { readonly: true })
|
||||
const row = db.prepare('SELECT target_url FROM short_links WHERE code = ?').get(TARGET_CODE)
|
||||
db.close()
|
||||
if (!row) throw new Error(`短链接 ${TARGET_CODE} 不存在`)
|
||||
const m = /[?&]token=([^&\s]+)/.exec(String(row.target_url || ''))
|
||||
return { url: String(row.target_url || ''), token: m ? decodeURIComponent(m[1]) : '' }
|
||||
}
|
||||
|
||||
const updateShortLinkTarget = newUrl => {
|
||||
const db = new Database(DB_PATH)
|
||||
const info = db.prepare('UPDATE short_links SET target_url = ? WHERE code = ?').run(newUrl, TARGET_CODE)
|
||||
db.close()
|
||||
return info.changes
|
||||
}
|
||||
|
||||
const getTokenFromUrl = async url => {
|
||||
// 跟随一次 302,取 Set-Cookie 与状态
|
||||
const res = await fetch(url, { redirect: 'manual' })
|
||||
return { status: res.status, setCookie: res.headers.get('set-cookie') || '', location: res.headers.get('location') || '' }
|
||||
}
|
||||
|
||||
const probeWithToken = async token => {
|
||||
const res = await fetch(`${LOCAL}/?token=${encodeURIComponent(token)}`, { redirect: 'manual' })
|
||||
return { status: res.status, setCookie: res.headers.get('set-cookie') || '' }
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log('=== 首页导航密钥轮换 ===')
|
||||
console.log(`有效期:${EXPIRE_DAYS} 天${dryRun ? '(dry-run,不写入)' : ''}\n`)
|
||||
|
||||
// ---------- 0. 现状 ----------
|
||||
const oldPriv = readJson(PRIV_PATH)
|
||||
const oldPub = readJson(PUB_PATH)
|
||||
const before = readCurrentTarget()
|
||||
console.log('[0] 现状')
|
||||
console.log(` nav-private.jwk.json 指纹 = ${fp(oldPriv)}(含私钥=${oldPriv.d ? '是' : '否'})`)
|
||||
console.log(` nav.jwk.json 指纹 = ${fp(oldPub)}(含私钥=${oldPub.d ? '是' : '否'})`)
|
||||
console.log(` 短链接 ${TARGET_CODE} 目标 = ${before.url.slice(0, 55)}...`)
|
||||
console.log(` 旧 token 长度 = ${before.token.length}`)
|
||||
check('旧 token 在轮换前是有效的(基线)', (await probeWithToken(before.token)).status === 302,
|
||||
'旧 token 当前不可用,请先确认现状')
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n[dry-run] 将执行:备份密钥 → 生成新密钥对 → 覆写两个文件 → 签发新 token → 更新短链接 → 校验')
|
||||
console.log(` 新 token 过期时间:${new Date((Math.floor(Date.now() / 1000) + EXPIRE_DAYS * 86400) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ---------- 1. 备份 ----------
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const backupDir = path.join(CONFIG_DIR, `_nav-key-backup-${stamp}`)
|
||||
fs.mkdirSync(backupDir, { recursive: true })
|
||||
fs.copyFileSync(PRIV_PATH, path.join(backupDir, 'nav-private.jwk.json'))
|
||||
fs.copyFileSync(PUB_PATH, path.join(backupDir, 'nav.jwk.json'))
|
||||
// 一并备份短链接库,便于回滚目标地址
|
||||
try { fs.copyFileSync(DB_PATH, path.join(backupDir, 'short_link.db')) } catch (_) { /* 非致命 */ }
|
||||
console.log(`\n[1] 已备份到 config/${path.basename(backupDir)}/`)
|
||||
check('备份目录创建成功', fs.existsSync(path.join(backupDir, 'nav-private.jwk.json')))
|
||||
|
||||
// ---------- 2. 生成新密钥对并写入 ----------
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
const newPriv = privateKey.export({ format: 'jwk' })
|
||||
const newPub = publicKey.export({ format: 'jwk' })
|
||||
// 公钥文件只保留公开字段,避免私钥混进 nav.jwk.json(历史上它就是纯公钥文件)
|
||||
const newPubOnly = { kty: newPub.kty, n: newPub.n, e: newPub.e }
|
||||
|
||||
fs.writeFileSync(PRIV_PATH, JSON.stringify(newPriv, null, 2))
|
||||
fs.writeFileSync(PUB_PATH, JSON.stringify(newPubOnly, null, 2))
|
||||
console.log('\n[2] 已写入新密钥对')
|
||||
console.log(` nav-private.jwk.json 新指纹 = ${fp(newPriv)}(含私钥=${newPriv.d ? '是' : '否'})`)
|
||||
console.log(` nav.jwk.json 新指纹 = ${fp(newPubOnly)}`)
|
||||
check('两个文件的公钥一致(同一把新密钥)', fp(newPriv) === fp(newPubOnly), `${fp(newPriv)} vs ${fp(newPubOnly)}`)
|
||||
check('nav.jwk.json 未混入私钥字段', !newPubOnly.d)
|
||||
check('新密钥已替换旧公钥(旧公钥离开信任池)', fp(newPubOnly) !== fp(oldPriv) && fp(newPubOnly) !== fp(oldPub))
|
||||
|
||||
// ---------- 3. 签发新 token ----------
|
||||
let iss = 'TRAE-NAV'
|
||||
try { iss = String(readJson(path.join(CONFIG_DIR, 'flags.json')).navAuth.iss || iss) } catch (_) { /* 用默认 */ }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = { iss, iat: now, exp: now + EXPIRE_DAYS * 86400, jti: crypto.randomUUID() }
|
||||
const newToken = signJwtRS256(payload, newPriv)
|
||||
const newUrl = `${BASE_URL}?token=${encodeURIComponent(newToken)}`
|
||||
console.log('\n[3] 已用新私钥签发新 token')
|
||||
console.log(` iss=${payload.iss} jti=${payload.jti}`)
|
||||
console.log(` 过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
|
||||
|
||||
// ---------- 4. 更新短链接目标 ----------
|
||||
const changed = updateShortLinkTarget(newUrl)
|
||||
console.log(`\n[4] 短链接 ${TARGET_CODE} 目标已更新(影响行数 ${changed})`)
|
||||
check('短链接目标已成功更新', changed === 1, `影响 ${changed} 行`)
|
||||
|
||||
// ---------- 5. 黑盒校验 ----------
|
||||
console.log('\n[5] 黑盒校验(服务端按请求实时读密钥文件,无需重启)')
|
||||
const oldProbe = await probeWithToken(before.token)
|
||||
check('旧 token 已失效(401)', oldProbe.status === 401, `实际 ${oldProbe.status}`)
|
||||
const newProbe = await probeWithToken(newToken)
|
||||
check('新 token 可用(302,进入握手)', newProbe.status === 302, `实际 ${newProbe.status}`)
|
||||
check('新 token 能种下 nav_gate cookie', newProbe.setCookie.indexOf('nav_gate=1') >= 0, newProbe.setCookie.slice(0, 60))
|
||||
|
||||
// 带 cookie 取首页,确认真的能进
|
||||
const homeRes = await fetch(`${LOCAL}/`, { headers: { Cookie: 'nav_gate=1' } })
|
||||
const homeText = await homeRes.text()
|
||||
check('带 cookie 可打开九宫格首页(200)', homeRes.status === 200, `实际 ${homeRes.status}`)
|
||||
check('首页内容正确', homeText.indexOf("Yang's Toolbox") >= 0)
|
||||
|
||||
// 公网链路:新 token 从公网进也应 302
|
||||
try {
|
||||
const pubProbe = await getTokenFromUrl(`${BASE_URL}?token=${encodeURIComponent(newToken)}`)
|
||||
check('公网地址可用(302)', pubProbe.status === 302, `实际 ${pubProbe.status}`)
|
||||
} catch (e) {
|
||||
check('公网地址可用(302)', false, String(e.message || e))
|
||||
}
|
||||
|
||||
// ---------- 汇总 ----------
|
||||
console.log('\n--- 结果 ---')
|
||||
console.log(` 新短链长链接:${BASE_URL}?token=<新token>(共 ${newUrl.length} 字符)`)
|
||||
console.log(` 短链接:https://maise.pro/to/${TARGET_CODE}`)
|
||||
console.log(` 备份目录:config/${path.basename(backupDir)}/`)
|
||||
console.log(' 回滚方法:把备份目录里的两个 jwk 文件覆盖回 config/,并从 short_link.db 备份恢复目标地址。')
|
||||
|
||||
if (failed > 0) {
|
||||
console.log(`\n⚠️ 有 ${failed} 项未通过,请按上面提示检查;密钥与数据都已备份,可回滚。`)
|
||||
}
|
||||
console.log(`\n=== 校验:通过 ${passed} 项,失败 ${failed} 项 ===`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
})().catch(e => {
|
||||
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
|
||||
console.log('密钥与数据在写入前已备份,可在 config/_nav-key-backup-*/ 回滚。')
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user