aaden-cli 1.6.109 → 1.6.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AadenDeploy/lightUpdate.sh +17 -5
- package/AadenDeploy/upgrade.sh +55 -5
- package/dist/index.js +144 -8
- package/index.js +3 -1
- package/modules/tse-cert.js +89 -0
- package/modules/tse-info.js +44 -6
- package/package.json +1 -1
|
@@ -93,16 +93,28 @@ echo "产物版本门通过:${LOCAL_VER:-未知} -> $ART_VER"
|
|
|
93
93
|
CONF=/var/www/html/Resource/config/config.ini
|
|
94
94
|
DBCREDS=$(sudoS "php -r '\$c=@parse_ini_file(\"$CONF\",true);\$d=\$c[\"DB\"]??[];echo (\$d[\"host\"]??\"localhost\").\"|\".(\$d[\"user\"]??\"aaden\").\"|\".(\$d[\"password\"]??\"\");'" 2>/dev/null)
|
|
95
95
|
DB_HOST="${DBCREDS%%|*}"; _r="${DBCREDS#*|}"; DB_USER="${_r%%|*}"; DB_PASS="${_r#*|}"
|
|
96
|
+
sqlq(){ MYSQL_PWD="$DB_PASS" mysql -u"$DB_USER" -h"${DB_HOST:-localhost}" -N -e "$1"; }
|
|
97
|
+
DUE=""; TSE_ERR=""; TSE_NOTE=""
|
|
96
98
|
if [ -n "$DB_USER" ] && [ -n "$DB_PASS" ]; then
|
|
97
|
-
DUE=$(
|
|
98
|
-
|
|
99
|
+
DUE=$(sqlq "SELECT COUNT(*) FROM aaden.TseExecuteQueue WHERE finished=0 AND nextExecuteTime<=NOW() AND createdAt>=NOW()-INTERVAL 5 MINUTE;" 2>"$STAGE/tse.err")
|
|
100
|
+
TSE_ERR=$(head -c 120 "$STAGE/tse.err" 2>/dev/null | tr '\n' ' ')
|
|
101
|
+
if [ -z "$DUE" ]; then
|
|
102
|
+
# 查询失败要分清两种:TseExecuteQueue 是 1.9.1(2025-03 的 migration)才有的,更老的后端没有这张表,
|
|
103
|
+
# 签名在结账请求里同步做、没有队列可查 —— 这种按空闲处理,否则永远「保守跳过」、永远升不上来
|
|
104
|
+
# (2026-10 的 1407 停在 1.7.913,每天 SKIP_TSE_QUERY)。连不上库之类的其他失败照旧跳过。
|
|
105
|
+
HAS_QUEUE=$(sqlq "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='aaden' AND table_name='TseExecuteQueue';" 2>/dev/null)
|
|
106
|
+
if [ "$HAS_QUEUE" = "0" ]; then
|
|
107
|
+
echo "本机后端没有 TseExecuteQueue 表(1.9.1 之前的版本),没有 TSE 队列可查,按空闲处理"
|
|
108
|
+
DUE=0; TSE_NOTE=" TSE_NO_QUEUE"
|
|
109
|
+
fi
|
|
110
|
+
fi
|
|
99
111
|
else
|
|
100
|
-
|
|
112
|
+
TSE_ERR="config.ini 里读不到 [DB] 用户名/密码"
|
|
101
113
|
fi
|
|
102
114
|
if [ -z "$DUE" ]; then
|
|
103
115
|
# 查不到 = 无法确认 TSE 状态 → 对财政数据保守跳过(不部署也不冒险打断签名)。
|
|
104
116
|
# 非破坏性:此刻还没动任何文件。版本停滞会被 fleet-rollout-status 监控发现。
|
|
105
|
-
echo "TSE
|
|
117
|
+
echo "TSE 队列查询失败,保守跳过本轮(未改动任何文件): $TSE_ERR"; finish "SKIP_TSE_QUERY${TSE_ERR:+ ($TSE_ERR)}" 0
|
|
106
118
|
fi
|
|
107
119
|
if [ "$DUE" -gt 0 ]; then
|
|
108
120
|
echo "有进行中的交易/TSE 签名 (due=$DUE),跳过本轮,等下个周期"; finish "SKIP_TSE_BUSY (due=$DUE)" 0
|
|
@@ -141,4 +153,4 @@ if [ "$HEALTHY" != "1" ]; then
|
|
|
141
153
|
sudoS "rm -rf $STAGE"; finish "ROLLED_BACK (回到 ${LOCAL_VER:-未知})" 1
|
|
142
154
|
fi
|
|
143
155
|
sudoS "rm -rf $STAGE"
|
|
144
|
-
finish "DEPLOY_OK $(pkgVer /var/www/html/package.json)" 0
|
|
156
|
+
finish "DEPLOY_OK $(pkgVer /var/www/html/package.json)${TSE_NOTE}" 0
|
package/AadenDeploy/upgrade.sh
CHANGED
|
@@ -14,8 +14,9 @@ export PATH="/usr/local/bin:$PATH"
|
|
|
14
14
|
# 同样的命令 SSH 进去跑却正常,而 /var/log/aaden-auto-upgrade.log 远程读不到。
|
|
15
15
|
# 输出照常打到 stdout(cron 重定向进日志);单独跑这个脚本、没设变量时写 /dev/null。
|
|
16
16
|
STEPS="${AADEN_UPGRADE_STEPS:-/dev/null}"
|
|
17
|
+
# 失败时摘要优先取 npm 的 code/syscall/path 行:npm 输出的最后两行永远是「完整日志在 …debug-0.log」,看不出原因。
|
|
17
18
|
step() {
|
|
18
|
-
local name=$1 start=$SECONDS out code
|
|
19
|
+
local name=$1 start=$SECONDS out code ex
|
|
19
20
|
shift
|
|
20
21
|
out=$("$@" 2>&1)
|
|
21
22
|
code=$?
|
|
@@ -23,8 +24,11 @@ step() {
|
|
|
23
24
|
if [ "$code" = 0 ]; then
|
|
24
25
|
echo "$name=0/$((SECONDS - start))s" >>"$STEPS"
|
|
25
26
|
else
|
|
26
|
-
|
|
27
|
+
ex=$(printf '%s\n' "$out" | grep -E 'npm (ERR!|error) (code|errno|syscall|path|dest) ' | head -n 4)
|
|
28
|
+
[ -n "$ex" ] || ex=$(printf '%s' "$out" | tail -n 2)
|
|
29
|
+
echo "$name=$code/$((SECONDS - start))s($(printf '%s' "$ex" | tr '\n' ' ' | cut -c1-160))" >>"$STEPS"
|
|
27
30
|
fi
|
|
31
|
+
return "$code"
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
step config sh -c 'mkdir -p /etc/aaden/config && chmod -R 777 /etc/aaden/config'
|
|
@@ -34,10 +38,56 @@ step config sh -c 'mkdir -p /etc/aaden/config && chmod -R 777 /etc/aaden/config'
|
|
|
34
38
|
# root 跑(cron)时 sudo 不要密码,多喂的一行没人读,无害。
|
|
35
39
|
sudoS() { echo "Innerken322." | sudo -S "$@"; }
|
|
36
40
|
|
|
41
|
+
# 别的 aaden-cli npm 安装还在跑时,不能动手:
|
|
42
|
+
# crontab 里 04:55 和开机 sleep 30 各有一条单独的 `npm install aaden-cli -g`,慢的设备上要跑好几分钟,
|
|
43
|
+
# 正好撞上 04:59 / 开机 sleep 60 的这里。两个 npm 同时重装同一个包 —— 我们这个 1 秒内 ENOTEMPTY(退出码 217),
|
|
44
|
+
# 那一刻 aaden-cli 已被对方挪走,后面 patch/light-update/appcenter 全部 command not found;
|
|
45
|
+
# 运气差的设备两边都失败,aaden-cli 整个没了,do-log 从此停报(2026-10-07:1333、1541)。
|
|
46
|
+
# SSH 进去手动跑不会撞上,所以以前一直以为是「cron 环境问题」。
|
|
47
|
+
# 认进程看命令行:npm 自己的进程标题不一定带参数,但 cron 起的 sh -c / sudo 外壳一直活到 npm 结束。
|
|
48
|
+
# 本脚本的祖先不算 —— 远程 selfUpgrade 的 bash -lc 命令行里就有 "npm i -g aaden-cli",它在等我们跑完。
|
|
49
|
+
otherNpm() {
|
|
50
|
+
local anc=" " p=$$ q
|
|
51
|
+
while [ -n "$p" ] && [ "$p" -gt 1 ] 2>/dev/null; do
|
|
52
|
+
anc="$anc$p "
|
|
53
|
+
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
|
|
54
|
+
done
|
|
55
|
+
for q in $(pgrep -f 'npm (i|install) (-g )?aaden-cli' 2>/dev/null); do
|
|
56
|
+
case "$anc" in *" $q "*) ;; *) echo "$q" ;; esac
|
|
57
|
+
done
|
|
58
|
+
}
|
|
59
|
+
waitOtherNpm() {
|
|
60
|
+
local waited=0
|
|
61
|
+
while [ -n "$(otherNpm)" ]; do
|
|
62
|
+
if [ "$waited" -ge 900 ]; then
|
|
63
|
+
echo "等了 ${waited}s 仍有别的 npm 在装 aaden-cli: $(ps -o pid=,etime=,args= -p "$(otherNpm | tr '\n' ',' | sed 's/,$//')" 2>/dev/null | tr '\n' ';')"
|
|
64
|
+
return 1
|
|
65
|
+
fi
|
|
66
|
+
sleep 5
|
|
67
|
+
waited=$((waited + 5))
|
|
68
|
+
done
|
|
69
|
+
echo "waited=${waited}s"
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
# npm 全局重装会先把旧目录改名成 .aaden-cli-<随机串>;上次失败留下的这种目录会让之后每次都 ENOTEMPTY 失败,
|
|
38
|
-
# CLI 永远停在老版本(10-03 车队里有几台卡在 1.6.88~1.6.98
|
|
39
|
-
|
|
40
|
-
|
|
73
|
+
# CLI 永远停在老版本(10-03 车队里有几台卡在 1.6.88~1.6.98)。全局目录不一定是 /usr/local(node 走 apt 装的是 /usr),
|
|
74
|
+
# 两处都清。必须在 waitOtherNpm 之后:正在装的 npm 的 .aaden-cli-* 是它回滚用的,删了它就真回不去了。
|
|
75
|
+
cleanResidue() {
|
|
76
|
+
local root
|
|
77
|
+
root=$(sudoS npm root -g 2>/dev/null | tail -n 1)
|
|
78
|
+
# 只认绝对路径的 node_modules,免得把一行 npm 警告当路径拼进 rm -rf
|
|
79
|
+
case "$root" in /*/node_modules) ;; *) root= ;; esac
|
|
80
|
+
sudoS sh -c "rm -rf /usr/local/lib/node_modules/.aaden-cli-* ${root:+$root/.aaden-cli-*}"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
step wait waitOtherNpm
|
|
84
|
+
step clean cleanResidue
|
|
85
|
+
if ! step npm sudoS npm i aaden-cli -g || ! aaden-cli --version >/dev/null 2>&1; then
|
|
86
|
+
# 失败、或装完 aaden-cli 不见了:等一等、清一遍再装一次。不补救的话后面几步全是 command not found
|
|
87
|
+
step wait2 waitOtherNpm
|
|
88
|
+
step clean2 cleanResidue
|
|
89
|
+
step npm2 sudoS npm i aaden-cli -g
|
|
90
|
+
fi
|
|
41
91
|
|
|
42
92
|
step patch aaden-cli patch
|
|
43
93
|
|
package/dist/index.js
CHANGED
|
@@ -250,6 +250,102 @@ function collectHostInfo() {
|
|
|
250
250
|
module.exports = {collectHostInfo}
|
|
251
251
|
|
|
252
252
|
|
|
253
|
+
/***/ }),
|
|
254
|
+
|
|
255
|
+
/***/ 8226:
|
|
256
|
+
/***/ ((module) => {
|
|
257
|
+
|
|
258
|
+
// TSE 证书到期日:第一代 Swissbit TSE(BSI-DSZ-CC-1121)的证书只有 5.5 年,
|
|
259
|
+
// 到期当天起 TSE 拒绝对时,之后每笔交易都报 WORM_ERROR_NO_TIME_SET,整店静默停签。
|
|
260
|
+
// 门店上的 Node 是 14,没有 crypto.X509Certificate,这里只解析需要的几个字段:
|
|
261
|
+
// 有效期(notBefore / notAfter)和 subject 里的 dnQualifier(区分第一代 / 第二代)。
|
|
262
|
+
|
|
263
|
+
const OID_DN_QUALIFIER = '2.5.4.46'
|
|
264
|
+
|
|
265
|
+
// 读一个 DER TLV,返回 tag、内容起止位置
|
|
266
|
+
function readTlv(buf, pos) {
|
|
267
|
+
const tag = buf[pos]
|
|
268
|
+
let len = buf[pos + 1]
|
|
269
|
+
let start = pos + 2
|
|
270
|
+
if (len & 0x80) {
|
|
271
|
+
const n = len & 0x7f
|
|
272
|
+
len = 0
|
|
273
|
+
for (let i = 0; i < n; i++) len = len * 256 + buf[pos + 2 + i]
|
|
274
|
+
start += n
|
|
275
|
+
}
|
|
276
|
+
return {tag, start, end: start + len}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function children(buf, tlv) {
|
|
280
|
+
const list = []
|
|
281
|
+
let pos = tlv.start
|
|
282
|
+
while (pos < tlv.end) {
|
|
283
|
+
const child = readTlv(buf, pos)
|
|
284
|
+
list.push(child)
|
|
285
|
+
pos = child.end
|
|
286
|
+
}
|
|
287
|
+
return list
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeOid(bytes) {
|
|
291
|
+
const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40]
|
|
292
|
+
let value = 0
|
|
293
|
+
for (let i = 1; i < bytes.length; i++) {
|
|
294
|
+
value = value * 128 + (bytes[i] & 0x7f)
|
|
295
|
+
if (!(bytes[i] & 0x80)) {
|
|
296
|
+
parts.push(value)
|
|
297
|
+
value = 0
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return parts.join('.')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// UTCTime(0x17,两位年份)或 GeneralizedTime(0x18)
|
|
304
|
+
function decodeTime(buf, tlv) {
|
|
305
|
+
const s = buf.slice(tlv.start, tlv.end).toString('ascii')
|
|
306
|
+
const full = tlv.tag === 0x17 ? (parseInt(s.slice(0, 2), 10) < 50 ? '20' : '19') + s : s
|
|
307
|
+
const m = full.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/)
|
|
308
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z` : null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function parseCertificateDer(der) {
|
|
312
|
+
const cert = readTlv(der, 0)
|
|
313
|
+
const tbs = children(der, cert)[0]
|
|
314
|
+
const fields = children(der, tbs)
|
|
315
|
+
// 有显式 version([0])时整体后移一位:serialNumber、signature、issuer、validity、subject
|
|
316
|
+
const offset = fields[0].tag === 0xa0 ? 1 : 0
|
|
317
|
+
const [notBefore, notAfter] = children(der, fields[offset + 3])
|
|
318
|
+
let profile = null
|
|
319
|
+
for (const rdn of children(der, fields[offset + 4])) {
|
|
320
|
+
for (const attr of children(der, rdn)) {
|
|
321
|
+
const [oid, value] = children(der, attr)
|
|
322
|
+
if (decodeOid(der.slice(oid.start, oid.end)) === OID_DN_QUALIFIER) {
|
|
323
|
+
profile = der.slice(value.start, value.end).toString('utf-8')
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
tseCertNotBefore: decodeTime(der, notBefore),
|
|
329
|
+
tseCertNotAfter: decodeTime(der, notAfter),
|
|
330
|
+
tseCertProfile: profile,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* TseHost 的 /certificate 和门店库 RealTseInfo.certificate 都是「证书链 PEM 整体再 base64」,
|
|
336
|
+
* 第一张是 TSE 自己的证书,后面是 CA。也兼容直接给 PEM 或 DER 的情况。
|
|
337
|
+
*/
|
|
338
|
+
function parseTseCertificate(encoded) {
|
|
339
|
+
const raw = String(encoded).trim()
|
|
340
|
+
const pem = raw.includes('-----BEGIN') ? raw : Buffer.from(raw, 'base64').toString('utf-8')
|
|
341
|
+
const block = pem.match(/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/)
|
|
342
|
+
const der = block ? Buffer.from(block[1].replace(/\s+/g, ''), 'base64') : Buffer.from(raw, 'base64')
|
|
343
|
+
return parseCertificateDer(der)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {parseTseCertificate}
|
|
347
|
+
|
|
348
|
+
|
|
253
349
|
/***/ }),
|
|
254
350
|
|
|
255
351
|
/***/ 6677:
|
|
@@ -259,6 +355,8 @@ const fs = __nccwpck_require__(7147)
|
|
|
259
355
|
const os = __nccwpck_require__(2037)
|
|
260
356
|
const http = __nccwpck_require__(3685)
|
|
261
357
|
const https = __nccwpck_require__(5687)
|
|
358
|
+
const {execFileSync} = __nccwpck_require__(2081)
|
|
359
|
+
const {parseTseCertificate} = __nccwpck_require__(8226)
|
|
262
360
|
|
|
263
361
|
// do-log 顺带上报的 TSE 适配服务(TseHost)信息,用来在云端看车队的 TSE 版本和服务状态。
|
|
264
362
|
// WSL 门店的 TseHost 跑在 Windows 侧,网站目录里的 jar 不一定是实际在跑的那个,
|
|
@@ -286,18 +384,18 @@ async function safe(fn, fallback = null) {
|
|
|
286
384
|
}
|
|
287
385
|
}
|
|
288
386
|
|
|
289
|
-
function
|
|
387
|
+
function readIniSection(name) {
|
|
290
388
|
const section = {}
|
|
291
|
-
let
|
|
389
|
+
let inSection = false
|
|
292
390
|
for (const raw of fs.readFileSync(CONFIG_INI, 'utf-8').split(/\r?\n/)) {
|
|
293
391
|
const line = raw.trim()
|
|
294
392
|
if (!line || line.startsWith(';') || line.startsWith('#')) continue
|
|
295
393
|
const header = line.match(/^\[(.+)]$/)
|
|
296
394
|
if (header) {
|
|
297
|
-
|
|
395
|
+
inSection = header[1].trim() === name
|
|
298
396
|
continue
|
|
299
397
|
}
|
|
300
|
-
if (!
|
|
398
|
+
if (!inSection) continue
|
|
301
399
|
const eq = line.indexOf('=')
|
|
302
400
|
if (eq < 0) continue
|
|
303
401
|
section[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"(.*)"$/, '$1')
|
|
@@ -358,8 +456,42 @@ function servingJarPath() {
|
|
|
358
456
|
return match ? match[1] : LINUX_DEFAULT_JAR
|
|
359
457
|
}
|
|
360
458
|
|
|
459
|
+
// 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
|
|
460
|
+
function readCertFromDb() {
|
|
461
|
+
const db = readIniSection('DB')
|
|
462
|
+
const out = execFileSync('mysql', [
|
|
463
|
+
'-h', db.host || 'localhost', '-u', db.user || 'aaden', '-N', '-B', '--raw', db.name || 'aaden',
|
|
464
|
+
'-e', 'select serialNumber, certificate from RealTseInfo order by id desc limit 1',
|
|
465
|
+
], {
|
|
466
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
467
|
+
// 密码走环境变量,不出现在进程列表里
|
|
468
|
+
env: Object.assign({}, process.env, {MYSQL_PWD: db.password || ''}),
|
|
469
|
+
}).trim()
|
|
470
|
+
if (!out) return null
|
|
471
|
+
const [serial, certificate] = out.split('\t')
|
|
472
|
+
return {serial, certificate: certificate && certificate !== 'NULL' ? certificate : null}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// TSE 自己的序列号、证书有效期和型号(dnQualifier:BSI-DSZ-CC-1121 是第一代,-V2 是第二代)
|
|
476
|
+
async function collectCert(tseHost, serviceUp) {
|
|
477
|
+
const fromDb = await safe(readCertFromDb)
|
|
478
|
+
let certificate = fromDb && fromDb.certificate
|
|
479
|
+
// 库里还没存证书(新装、或从没取到过)时再问服务;服务连不上就算了
|
|
480
|
+
if (!certificate && serviceUp) {
|
|
481
|
+
const res = await httpGet(`${tseHost}/certificate`)
|
|
482
|
+
if (res.status === 200) certificate = await safe(() => JSON.parse(res.body).certificate)
|
|
483
|
+
}
|
|
484
|
+
const parsed = certificate ? await safe(() => parseTseCertificate(certificate), {}) : {}
|
|
485
|
+
return {
|
|
486
|
+
tseSerial: (fromDb && fromDb.serial) || null,
|
|
487
|
+
tseCertNotBefore: parsed.tseCertNotBefore || null,
|
|
488
|
+
tseCertNotAfter: parsed.tseCertNotAfter || null,
|
|
489
|
+
tseCertProfile: parsed.tseCertProfile || null,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
361
493
|
async function probeTse() {
|
|
362
|
-
const section = await safe(
|
|
494
|
+
const section = await safe(() => readIniSection('TSE'), {})
|
|
363
495
|
const tseEnabled = parseEnabled(section.TseEnabled)
|
|
364
496
|
const tseHost = (section.TseHost || DEFAULT_TSE_HOST).replace(/\/+$/, '')
|
|
365
497
|
// 不同版本的 jar 大小不同,没有 /adapterVersion 的老版本只能靠它区分
|
|
@@ -390,7 +522,9 @@ async function probeTse() {
|
|
|
390
522
|
}
|
|
391
523
|
}
|
|
392
524
|
|
|
393
|
-
|
|
525
|
+
const cert = await collectCert(tseHost, ping.status !== 0)
|
|
526
|
+
|
|
527
|
+
return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert}
|
|
394
528
|
}
|
|
395
529
|
|
|
396
530
|
// 整体出错就什么都不报,云端保留上次的值
|
|
@@ -18729,7 +18863,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
|
|
|
18729
18863
|
/***/ ((module) => {
|
|
18730
18864
|
|
|
18731
18865
|
"use strict";
|
|
18732
|
-
module.exports = {"i8":"1.6.
|
|
18866
|
+
module.exports = {"i8":"1.6.111"};
|
|
18733
18867
|
|
|
18734
18868
|
/***/ })
|
|
18735
18869
|
|
|
@@ -19594,8 +19728,10 @@ async function upgrade() {
|
|
|
19594
19728
|
fs.unlinkSync(stepsFile)
|
|
19595
19729
|
} catch (_) {}
|
|
19596
19730
|
const failed = code !== 0 || !steps || /=[1-9]/.test(steps)
|
|
19597
|
-
|
|
19731
|
+
const summary = ' [' + (steps || 'upgrade.sh 没跑起来') + (code ? ' exit=' + code : '') + ']'
|
|
19598
19732
|
+ (failed ? ' ' + upgradeEnvInfo() : '')
|
|
19733
|
+
// 云端 device_event_log.content 是 varchar(1024),超长整条事件会丢;npm 重试时步骤变多,截一下
|
|
19734
|
+
return summary.length > 900 ? summary.slice(0, 897) + '...' : summary
|
|
19599
19735
|
}
|
|
19600
19736
|
|
|
19601
19737
|
function upgradeEnvInfo() {
|
package/index.js
CHANGED
|
@@ -791,8 +791,10 @@ async function upgrade() {
|
|
|
791
791
|
fs.unlinkSync(stepsFile)
|
|
792
792
|
} catch (_) {}
|
|
793
793
|
const failed = code !== 0 || !steps || /=[1-9]/.test(steps)
|
|
794
|
-
|
|
794
|
+
const summary = ' [' + (steps || 'upgrade.sh 没跑起来') + (code ? ' exit=' + code : '') + ']'
|
|
795
795
|
+ (failed ? ' ' + upgradeEnvInfo() : '')
|
|
796
|
+
// 云端 device_event_log.content 是 varchar(1024),超长整条事件会丢;npm 重试时步骤变多,截一下
|
|
797
|
+
return summary.length > 900 ? summary.slice(0, 897) + '...' : summary
|
|
796
798
|
}
|
|
797
799
|
|
|
798
800
|
function upgradeEnvInfo() {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// TSE 证书到期日:第一代 Swissbit TSE(BSI-DSZ-CC-1121)的证书只有 5.5 年,
|
|
2
|
+
// 到期当天起 TSE 拒绝对时,之后每笔交易都报 WORM_ERROR_NO_TIME_SET,整店静默停签。
|
|
3
|
+
// 门店上的 Node 是 14,没有 crypto.X509Certificate,这里只解析需要的几个字段:
|
|
4
|
+
// 有效期(notBefore / notAfter)和 subject 里的 dnQualifier(区分第一代 / 第二代)。
|
|
5
|
+
|
|
6
|
+
const OID_DN_QUALIFIER = '2.5.4.46'
|
|
7
|
+
|
|
8
|
+
// 读一个 DER TLV,返回 tag、内容起止位置
|
|
9
|
+
function readTlv(buf, pos) {
|
|
10
|
+
const tag = buf[pos]
|
|
11
|
+
let len = buf[pos + 1]
|
|
12
|
+
let start = pos + 2
|
|
13
|
+
if (len & 0x80) {
|
|
14
|
+
const n = len & 0x7f
|
|
15
|
+
len = 0
|
|
16
|
+
for (let i = 0; i < n; i++) len = len * 256 + buf[pos + 2 + i]
|
|
17
|
+
start += n
|
|
18
|
+
}
|
|
19
|
+
return {tag, start, end: start + len}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function children(buf, tlv) {
|
|
23
|
+
const list = []
|
|
24
|
+
let pos = tlv.start
|
|
25
|
+
while (pos < tlv.end) {
|
|
26
|
+
const child = readTlv(buf, pos)
|
|
27
|
+
list.push(child)
|
|
28
|
+
pos = child.end
|
|
29
|
+
}
|
|
30
|
+
return list
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decodeOid(bytes) {
|
|
34
|
+
const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40]
|
|
35
|
+
let value = 0
|
|
36
|
+
for (let i = 1; i < bytes.length; i++) {
|
|
37
|
+
value = value * 128 + (bytes[i] & 0x7f)
|
|
38
|
+
if (!(bytes[i] & 0x80)) {
|
|
39
|
+
parts.push(value)
|
|
40
|
+
value = 0
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return parts.join('.')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// UTCTime(0x17,两位年份)或 GeneralizedTime(0x18)
|
|
47
|
+
function decodeTime(buf, tlv) {
|
|
48
|
+
const s = buf.slice(tlv.start, tlv.end).toString('ascii')
|
|
49
|
+
const full = tlv.tag === 0x17 ? (parseInt(s.slice(0, 2), 10) < 50 ? '20' : '19') + s : s
|
|
50
|
+
const m = full.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/)
|
|
51
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z` : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseCertificateDer(der) {
|
|
55
|
+
const cert = readTlv(der, 0)
|
|
56
|
+
const tbs = children(der, cert)[0]
|
|
57
|
+
const fields = children(der, tbs)
|
|
58
|
+
// 有显式 version([0])时整体后移一位:serialNumber、signature、issuer、validity、subject
|
|
59
|
+
const offset = fields[0].tag === 0xa0 ? 1 : 0
|
|
60
|
+
const [notBefore, notAfter] = children(der, fields[offset + 3])
|
|
61
|
+
let profile = null
|
|
62
|
+
for (const rdn of children(der, fields[offset + 4])) {
|
|
63
|
+
for (const attr of children(der, rdn)) {
|
|
64
|
+
const [oid, value] = children(der, attr)
|
|
65
|
+
if (decodeOid(der.slice(oid.start, oid.end)) === OID_DN_QUALIFIER) {
|
|
66
|
+
profile = der.slice(value.start, value.end).toString('utf-8')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
tseCertNotBefore: decodeTime(der, notBefore),
|
|
72
|
+
tseCertNotAfter: decodeTime(der, notAfter),
|
|
73
|
+
tseCertProfile: profile,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* TseHost 的 /certificate 和门店库 RealTseInfo.certificate 都是「证书链 PEM 整体再 base64」,
|
|
79
|
+
* 第一张是 TSE 自己的证书,后面是 CA。也兼容直接给 PEM 或 DER 的情况。
|
|
80
|
+
*/
|
|
81
|
+
function parseTseCertificate(encoded) {
|
|
82
|
+
const raw = String(encoded).trim()
|
|
83
|
+
const pem = raw.includes('-----BEGIN') ? raw : Buffer.from(raw, 'base64').toString('utf-8')
|
|
84
|
+
const block = pem.match(/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/)
|
|
85
|
+
const der = block ? Buffer.from(block[1].replace(/\s+/g, ''), 'base64') : Buffer.from(raw, 'base64')
|
|
86
|
+
return parseCertificateDer(der)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {parseTseCertificate}
|
package/modules/tse-info.js
CHANGED
|
@@ -2,6 +2,8 @@ const fs = require('fs')
|
|
|
2
2
|
const os = require('os')
|
|
3
3
|
const http = require('http')
|
|
4
4
|
const https = require('https')
|
|
5
|
+
const {execFileSync} = require('child_process')
|
|
6
|
+
const {parseTseCertificate} = require('./tse-cert')
|
|
5
7
|
|
|
6
8
|
// do-log 顺带上报的 TSE 适配服务(TseHost)信息,用来在云端看车队的 TSE 版本和服务状态。
|
|
7
9
|
// WSL 门店的 TseHost 跑在 Windows 侧,网站目录里的 jar 不一定是实际在跑的那个,
|
|
@@ -29,18 +31,18 @@ async function safe(fn, fallback = null) {
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
function
|
|
34
|
+
function readIniSection(name) {
|
|
33
35
|
const section = {}
|
|
34
|
-
let
|
|
36
|
+
let inSection = false
|
|
35
37
|
for (const raw of fs.readFileSync(CONFIG_INI, 'utf-8').split(/\r?\n/)) {
|
|
36
38
|
const line = raw.trim()
|
|
37
39
|
if (!line || line.startsWith(';') || line.startsWith('#')) continue
|
|
38
40
|
const header = line.match(/^\[(.+)]$/)
|
|
39
41
|
if (header) {
|
|
40
|
-
|
|
42
|
+
inSection = header[1].trim() === name
|
|
41
43
|
continue
|
|
42
44
|
}
|
|
43
|
-
if (!
|
|
45
|
+
if (!inSection) continue
|
|
44
46
|
const eq = line.indexOf('=')
|
|
45
47
|
if (eq < 0) continue
|
|
46
48
|
section[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^"(.*)"$/, '$1')
|
|
@@ -101,8 +103,42 @@ function servingJarPath() {
|
|
|
101
103
|
return match ? match[1] : LINUX_DEFAULT_JAR
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
// 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
|
|
107
|
+
function readCertFromDb() {
|
|
108
|
+
const db = readIniSection('DB')
|
|
109
|
+
const out = execFileSync('mysql', [
|
|
110
|
+
'-h', db.host || 'localhost', '-u', db.user || 'aaden', '-N', '-B', '--raw', db.name || 'aaden',
|
|
111
|
+
'-e', 'select serialNumber, certificate from RealTseInfo order by id desc limit 1',
|
|
112
|
+
], {
|
|
113
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
114
|
+
// 密码走环境变量,不出现在进程列表里
|
|
115
|
+
env: Object.assign({}, process.env, {MYSQL_PWD: db.password || ''}),
|
|
116
|
+
}).trim()
|
|
117
|
+
if (!out) return null
|
|
118
|
+
const [serial, certificate] = out.split('\t')
|
|
119
|
+
return {serial, certificate: certificate && certificate !== 'NULL' ? certificate : null}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// TSE 自己的序列号、证书有效期和型号(dnQualifier:BSI-DSZ-CC-1121 是第一代,-V2 是第二代)
|
|
123
|
+
async function collectCert(tseHost, serviceUp) {
|
|
124
|
+
const fromDb = await safe(readCertFromDb)
|
|
125
|
+
let certificate = fromDb && fromDb.certificate
|
|
126
|
+
// 库里还没存证书(新装、或从没取到过)时再问服务;服务连不上就算了
|
|
127
|
+
if (!certificate && serviceUp) {
|
|
128
|
+
const res = await httpGet(`${tseHost}/certificate`)
|
|
129
|
+
if (res.status === 200) certificate = await safe(() => JSON.parse(res.body).certificate)
|
|
130
|
+
}
|
|
131
|
+
const parsed = certificate ? await safe(() => parseTseCertificate(certificate), {}) : {}
|
|
132
|
+
return {
|
|
133
|
+
tseSerial: (fromDb && fromDb.serial) || null,
|
|
134
|
+
tseCertNotBefore: parsed.tseCertNotBefore || null,
|
|
135
|
+
tseCertNotAfter: parsed.tseCertNotAfter || null,
|
|
136
|
+
tseCertProfile: parsed.tseCertProfile || null,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
104
140
|
async function probeTse() {
|
|
105
|
-
const section = await safe(
|
|
141
|
+
const section = await safe(() => readIniSection('TSE'), {})
|
|
106
142
|
const tseEnabled = parseEnabled(section.TseEnabled)
|
|
107
143
|
const tseHost = (section.TseHost || DEFAULT_TSE_HOST).replace(/\/+$/, '')
|
|
108
144
|
// 不同版本的 jar 大小不同,没有 /adapterVersion 的老版本只能靠它区分
|
|
@@ -133,7 +169,9 @@ async function probeTse() {
|
|
|
133
169
|
}
|
|
134
170
|
}
|
|
135
171
|
|
|
136
|
-
|
|
172
|
+
const cert = await collectCert(tseHost, ping.status !== 0)
|
|
173
|
+
|
|
174
|
+
return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert}
|
|
137
175
|
}
|
|
138
176
|
|
|
139
177
|
// 整体出错就什么都不报,云端保留上次的值
|