Files
Toolbox/dev_test_scripts/tools/tool_rotate_nav_key.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

195 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 轮换「首页导航」密钥对,并同步更换首页 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)
})