#!/usr/bin/env node 'use strict'; /** * CVIN 设备代理 · cvin_attest_agent.js v1.0 * --------------------------------------------------------------------------- * 一条命令,把一台真实的 GPU 机器变成「设备自证」的 L2 身份,并可顺手认领 * 它在市场上的主机号(认领后市场公开报价会并入这台设备的证据集)。 * * 用法: * node cvin_attest_agent.js 只上报签名遥测(L2) * node cvin_attest_agent.js --link vast.ai:513108 同时认领主机号(设备级跨源) * node cvin_attest_agent.js --list 只列出本机 GPU 与将生成的 CVIN * node cvin_attest_agent.js --dry-run 不上报,只打印将要发送的内容 * node cvin_attest_agent.js --watch 60 每 60 分钟上报一次(长期维护 L2) * node cvin_attest_agent.js --gpu 0 --operator "机房名" * * 它证明什么,不证明什么(请如实转述给你的客户/资金方): * 证明:① 这台机器的 GPU 序列号哈希与你声明的 CVIN 一致(序列号原文不出机器); * ② 此后每条遥测都由同一把设备私钥签名,服务端验签通过才落库; * ③ 如果你认领了主机号,服务端会用台账里该主机的公开报价做机型核对。 * 不证明:机器没换过卡(换卡必须换 CVIN)、运营方没说谎(只能证明「同一把钥匙持续发言」)、 * 也不证明你是这台机器的合法所有人(我们没有平台账户权限,这一点不假装有)。 * * 依赖:Node.js 16+,无需 npm install。私钥只写在本机,永不上传。 */ const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); const { execFileSync } = require('child_process'); const VERSION = '1.0.0'; const DEFAULT_API = 'https://cvin.org'; // ---------- 参数 ---------- const argv = process.argv.slice(2); function arg(name, def) { const i = argv.indexOf('--' + name); if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1]; return def; } function flag(name) { return argv.includes('--' + name); } const API = String(arg('api', process.env.CVIN_API || DEFAULT_API)).replace(/\/+$/, ''); const LINK = arg('link', ''); const WATCH = Number(arg('watch', 0)) || 0; const DRY = flag('dry-run'); const LIST = flag('list'); const JSON_OUT = flag('json'); const OPERATOR = arg('operator', os.hostname()); const ONLY_GPU = arg('gpu', null); const KEYDIR = path.resolve(arg('keydir', path.join(os.homedir(), '.cvin'))); const HOURS = arg('hours', null); // ---------- CVIN 编码(与 lib/cvin.js 逐字节一致,改动必须两边同步) ---------- const VOCAB_MODEL = { h100: 'H100', h200: 'H200', h800: 'H800', b200: 'B200', b100: 'B100', gb200: 'GB200', a100: 'A100', a800: 'A800', l40s: 'L40S', l4: 'L4', a40: 'A40', v100: 'V100', rtx4090: 'RTX 4090', rtx5090: 'RTX 5090', mi300x: 'MI300X', mi250x: 'MI250X', mi300a: 'MI300A', gaudi2: 'Gaudi 2', gaudi3: 'Gaudi 3', a10: 'A10', a6000: 'RTX A6000', h20: 'H20', l20: 'L20', l40: 'L40', rtx3090: 'RTX 3090', rtx4090d: 'RTX 4090D', rtx6000: 'RTX 6000', rtx6000ada: 'RTX 6000 Ada', rtxpro6000: 'RTX PRO 6000', rtx4000ada: 'RTX 4000 Ada', t4: 'T4', b300: 'B300', gh200: 'GH200', a30: 'A30', a16: 'A16', a4000: 'RTX A4000', a5000: 'RTX A5000' }; function normalizeSerial(raw) { return String(raw == null ? '' : raw).trim().toLowerCase() .replace(/[\s_\-./:#]+/g, '') .replace(/^(sn|s\/n|serial|serialno)/, ''); } function serialHash(serial, len = 16) { const hex = crypto.createHash('sha256').update(normalizeSerial(serial), 'utf8').digest('hex'); return BigInt('0x' + hex).toString(36).slice(0, len).padEnd(len, '0'); } function encodeCvin({ mfr, model, form, mem, serial }) { const t = [mfr, model, form, mem].map(v => String(v || '').trim().toLowerCase().replace(/[\s_]+/g, '')); for (const [i, v] of t.entries()) { if (!v) throw new Error(['mfr', 'model', 'form', 'mem'][i] + ' 为空'); if (!/^[a-z0-9]+$/.test(v)) throw new Error(['mfr', 'model', 'form', 'mem'][i] + ' 含非法字符:' + v); } return `cvin:${t[0]}.${t[1]}.${t[2]}.${t[3]}:${serialHash(serial)}`; } // 型号名 → 受控词表 token。表里没有的按 slug 化处理(服务端仍可解析,只是 known=false)。 function modelToken(rawName) { const raw = String(rawName || ''); const s = raw.toLowerCase(); // 先匹配长串,避免 "RTX 4090 D" 被 "rtx4090" 抢走 const table = [ [/rtx\s*pro\s*6000/, 'rtxpro6000'], [/rtx\s*6000\s*ada/, 'rtx6000ada'], [/rtx\s*6000/, 'rtx6000'], [/rtx\s*4090\s*d/, 'rtx4090d'], [/rtx\s*4090/, 'rtx4090'], [/rtx\s*5090/, 'rtx5090'], [/rtx\s*3090/, 'rtx3090'], [/rtx\s*a?6000/, 'a6000'], [/rtx\s*a?5000/, 'a5000'], [/rtx\s*a?4000/, 'a4000'], [/rtx\s*4000\s*ada/, 'rtx4000ada'], [/gb200/, 'gb200'], [/gh200/, 'gh200'], [/b300/, 'b300'], [/b200/, 'b200'], [/b100/, 'b100'], [/h200/, 'h200'], [/h100/, 'h100'], [/h800/, 'h800'], [/h20/, 'h20'], [/l40s/, 'l40s'], [/l40/, 'l40'], [/l20/, 'l20'], [/l4\b/, 'l4'], [/a100/, 'a100'], [/a800/, 'a800'], [/a40\b/, 'a40'], [/a30\b/, 'a30'], [/a16\b/, 'a16'], [/a10\b/, 'a10'], [/v100/, 'v100'], [/t4\b/, 't4'], [/mi300x/, 'mi300x'], [/mi250x/, 'mi250x'], [/mi300a/, 'mi300a'], [/gaudi\s*3/, 'gaudi3'], [/gaudi\s*2/, 'gaudi2'] ]; for (const [re, tok] of table) if (re.test(s)) return tok; return s.replace(/[^a-z0-9]+/g, '').slice(0, 16) || 'unknown'; } function formToken(rawName) { const s = String(rawName || '').toLowerCase(); if (/sxm/.test(s)) return 'sxm'; if (/hgx/.test(s)) return 'hgx'; if (/oam/.test(s)) return 'oam'; if (/nvl/.test(s)) return 'sxm'; // H100 NVL 属于 SXM 形态家族,不新造 token if (/pcie/.test(s)) return 'pcie'; if (/geforce|rtx\s*\d|tesla\s*t4|quadro/.test(s)) return 'pcie'; // 显存类型在型号名里出现时,基本是 SXM/NVL 板卡(H100 80GB HBM3 / A100 80GB HBM2e)。 // 必须放在 pcie 判断之后:H100 PCIe 也写 HBM2e,先判 pcie 才不会把 PCIe 卡认成 SXM。 // 这一条决定 CVIN 里的形态 token,写错了整台设备的编码就和规范示例对不上。 if (/hbm\s*\d/.test(s)) return 'sxm'; return 'na'; } function memToken(mib) { const gb = Math.round(Number(mib) / 1024); if (!gb || gb <= 0) return 'na'; return gb + 'g'; } // ---------- nvidia-smi ---------- function nvidiaSmiBin() { const cands = []; if (process.env.NVIDIA_SMI) cands.push(process.env.NVIDIA_SMI); cands.push('nvidia-smi'); if (process.platform === 'win32') { cands.push('C:\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe'); cands.push('C:\\Windows\\System32\\nvidia-smi.exe'); } return cands; } function runSmi(args) { for (const bin of nvidiaSmiBin()) { try { // Windows 上 nvidia-smi 可能是 .exe(正常)也可能是 .cmd 包装;shell 模式只影响查找, // 参数是常量字符串、不含用户输入,不会被注入。 return execFileSync(bin, args, { encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }); } catch (e) { /* 换下一个路径 */ } } return null; } function listGpus() { const base = runSmi(['--query-gpu=index,uuid,name,memory.total', '--format=csv,noheader,nounits']); if (!base) { return null; } // 扩展字段单独取:GeForce 上 serial / ecc 常为 [N/A] 或直接报错,不能拖垮主查询 const ext = runSmi(['--query-gpu=index,serial,temperature.gpu,power.draw,utilization.gpu,ecc.errors.uncorrected.volatile.total,driver_version', '--format=csv,noheader,nounits']) || ''; return parseSmiOutput(base, ext); } /** 纯函数:解析 nvidia-smi 的 CSV 输出(单独导出,便于跨平台自测,不依赖真的 GPU) */ function parseSmiOutput(baseText, extText) { const extRows = {}; for (const line of String(extText || '').split(/\r?\n/)) { const p = line.split(',').map(x => x.trim()); if (p.length >= 2) extRows[p[0]] = p; } const out = []; for (const line of String(baseText || '').split(/\r?\n/)) { const p = line.split(',').map(x => x.trim()); if (p.length < 4) continue; const idx = p[0], uuid = p[1], name = p.slice(2, p.length - 1).join(', '), memMib = p[p.length - 1]; const e = extRows[idx] || []; const num = (v) => { if (v == null) return null; const s = String(v); if (!s || /\[?N\/A\]?/i.test(s)) return null; const n = Number(s); if (Number.isFinite(n)) return n; // 容错:部分驱动/旧版本即使加了 nounits 仍会带单位("81559 MiB"、"302 W"、"41 °C")。 // 只认不出这一种写法就会把显存解析成 null → mem token 变 na → CVIN 与规范示例对不上, // 而机主看到的只是一份「格式怪怪的」编码,没人会去查为什么。宁可多剥一次单位。 const m = String(s).replace(/[^\d.\-]/g, ''); const n2 = Number(m); return Number.isFinite(n2) ? n2 : null; }; const serialRaw = e[1] && !/\[?N\/A\]?/i.test(e[1]) ? e[1] : null; out.push({ index: Number(idx), uuid, name, mem_total_mb: num(memMib), serial: serialRaw, temp_c: num(e[2]), power_w: num(e[3]), utilization_pct: num(e[4]), ecc_uncorrectable: num(e[5]), driver_version: e[6] && !/\[?N\/A\]?/i.test(e[6]) ? e[6] : null }); } return out; } // ---------- 设备密钥 ---------- function keyPathFor(fingerprint) { return path.join(KEYDIR, 'device-' + fingerprint + '.pem'); } function fingerprintOf(pem) { const hex = crypto.createHash('sha256').update(String(pem), 'utf8').digest('hex'); return BigInt('0x' + hex).toString(36).slice(0, 16).padEnd(16, '0'); } function loadOrCreateKey() { fs.mkdirSync(KEYDIR, { recursive: true, mode: 0o700 }); // 复用已存在的密钥:轮换会让服务端判定「设备密钥不匹配」(防密钥替换攻击)。 try { const files = fs.readdirSync(KEYDIR).filter(f => /^device-.*\.pem$/.test(f)); for (const f of files) { const pem = fs.readFileSync(path.join(KEYDIR, f), 'utf8'); if (/BEGIN PRIVATE KEY/.test(pem)) { return { private_pem: pem, public_pem: crypto.createPublicKey(pem).export({ type: 'spki', format: 'pem' }), fingerprint: fingerprintOf(crypto.createPublicKey(pem).export({ type: 'spki', format: 'pem' })), reused: true }; } } } catch (e) { /* 目录空或不可读 → 新建 */ } const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }); const fp = fingerprintOf(publicKey); const p = keyPathFor(fp); fs.writeFileSync(p, privateKey, { mode: 0o600 }); try { fs.chmodSync(p, 0o600); } catch (e) {} return { private_pem: privateKey, public_pem: publicKey, fingerprint: fp, reused: false, path: p }; } // ---------- 规范化哈希(与服务端 chain.canon 逐字节一致) ---------- function canon(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']'; const keys = Object.keys(v).sort(); return '{' + keys.map(k => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}'; } function sha256(s) { return crypto.createHash('sha256').update(String(s), 'utf8').digest('hex'); } // ---------- HTTP ---------- function post(url, body) { return new Promise((resolve, reject) => { const data = Buffer.from(JSON.stringify(body)); const u = new URL(url); const mod = u.protocol === 'http:' ? http : https; const req = mod.request({ hostname: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, method: 'POST', timeout: 30000, headers: { 'content-type': 'application/json', 'content-length': data.length, 'user-agent': 'cvin-agent/' + VERSION } }, (res) => { let buf = ''; res.setEncoding('utf8'); res.on('data', d => buf += d); res.on('end', () => { let j = null; try { j = JSON.parse(buf); } catch (e) {} resolve({ status: res.statusCode, json: j, body: buf }); }); }); req.on('timeout', () => { req.destroy(new Error('请求超时(30s)')); }); req.on('error', reject); req.write(data); req.end(); }); } // ---------- 遥测(只上报能核验的字段) ---------- function telemetryOf(g) { const t = {}; const put = (k, v) => { if (v !== null && v !== undefined && Number.isFinite(Number(v))) t[k] = Number(v); }; put('temp_c', g.temp_c); put('power_w', g.power_w); put('utilization_pct', g.utilization_pct); put('ecc_uncorrectable', g.ecc_uncorrectable); put('mem_total_mb', g.mem_total_mb); if (HOURS) put('hours_power_on', Number(HOURS)); if (g.driver_version) t.driver_version = String(g.driver_version).slice(0, 24); // 注意:不要在这里塞服务端白名单以外的字段。签名必须在「服务端存下来的那份遥测」上成立, // 否则第三方没法从台账复算签名——那等于把可复算性换成了方便。字段表见 /v1/spec。 return t; } function log(...a) { if (!JSON_OUT) console.log(...a); } const out = (o) => { if (JSON_OUT) process.stdout.write(JSON.stringify(o) + '\n'); }; async function main() { const gpus = listGpus(); if (!gpus) { console.error('找不到 nvidia-smi。这台机器看起来没有 NVIDIA GPU(或驱动没装)。'); console.error('CVIN 是给「真实算力资产」做身份的:没有 GPU 的机器不该登记成 GPU 资产。'); console.error('如果你用的是 AMD / 昇腾,请把型号与序列号发到 hi@cvin.org,我们按同一规范给你编码。'); process.exit(2); } if (!gpus.length) { console.error('nvidia-smi 存在但没枚举到 GPU。'); process.exit(2); } const key = loadOrCreateKey(); log('CVIN 设备代理 v' + VERSION + ' · ' + API); log('设备密钥指纹 ' + key.fingerprint + '(' + (key.reused ? '复用已有私钥' : '本次新生成,已存 ' + (key.path || keyPathFor(key.fingerprint))) + ')'); log('检测到 ' + gpus.length + ' 张 GPU\n'); const results = []; for (const g of gpus) { if (ONLY_GPU != null && String(g.index) !== String(ONLY_GPU)) continue; const model = modelToken(g.name); const form = formToken(g.name); const mem = memToken(g.mem_total_mb); let cvin; try { cvin = encodeCvin({ mfr: 'nv', model, form, mem, serial: (g.serial || g.uuid) }); } catch (e) { log(' GPU ' + g.index + ' ' + g.name + ' → 编码失败:' + e.message); continue; } const telemetry = telemetryOf(g); const observed_at = new Date().toISOString(); const signature = crypto.sign(null, Buffer.from(sha256(canon({ cvin, observed_at, telemetry })), 'utf8'), key.private_pem).toString('base64'); const payload = { cvin, observed_at, telemetry, device_pubkey: key.public_pem, signature, operator: OPERATOR }; log('─ GPU ' + g.index + ' · ' + g.name + ' · ' + (g.mem_total_mb / 1024).toFixed(0) + 'GB'); log(' CVIN ' + cvin); log(' 遥测 ' + JSON.stringify(telemetry) + (Object.keys(telemetry).length ? '' : '(空:这张卡没暴露可核验字段)')); log(' 序列依据 ' + (g.serial ? '卡序列号(原文不出机器,只上传哈希)' : 'GPU UUID(消费者卡通常不给序列号)')); let att = null; if (!DRY && !LIST) { try { att = await post(API + '/v1/attest', payload); } catch (e) { log(' ✗ 上报失败:' + e.message); results.push({ cvin, ok: false, error: e.message }); continue; } if (att.status === 201) { log(' ✓ 已验签并入链(L2)· 设备密钥指纹 ' + (att.json.device_key_fp || '') + (att.json.first_binding ? ' · 首次绑定' : '')); log(' ' + API + '/r/' + encodeURIComponent(cvin)); } else { const err = (att.json && (att.json.detail || att.json.error)) || att.body.slice(0, 160); log(' ✗ 被拒绝(HTTP ' + att.status + '):' + err); results.push({ cvin, ok: false, status: att.status, error: err }); if (att.json && att.json.error === 'device_key_mismatch') { log(' 这台设备此前绑过另一把密钥。防密钥替换是设计如此:换卡/换钥匙必须换 CVIN。'); } continue; } if (LINK) { const m = /^([a-z0-9.\-]+):(.+)$/i.exec(LINK.trim()); if (!m) { log(' ! --link 格式应为 platform:host_id,例如 vast.ai:513108'); } else { const platform = m[1].toLowerCase(), host_id = m[2].trim(); const bmsg = sha256(canon({ purpose: 'cvin-bind', cvin, platform, host_id, observed_at })); const bsig = crypto.sign(null, Buffer.from(bmsg, 'utf8'), key.private_pem).toString('base64'); try { const b = await post(API + '/v1/bind', { cvin, platform, host_id, observed_at, device_pubkey: key.public_pem, signature: bsig, operator: OPERATOR }); if (b.status === 201) { log(' ✓ 已认领主机 ' + platform + ':' + host_id + ' → 市场侧 ' + ((b.json.matched || {}).host_listings || 0) + ' 条公开报价并入本设备证据'); } else { const err = (b.json && (b.json.detail || b.json.error)) || b.body.slice(0, 160); log(' ! 认领未通过(HTTP ' + b.status + '):' + err); } } catch (e) { log(' ! 认领请求失败:' + e.message); } } } } else { log(' (' + (LIST ? '--list' : '--dry-run') + ':未上报)'); } results.push({ cvin, ok: true, gpu: { index: g.index, name: g.name, mem_gb: Math.round(g.mem_total_mb / 1024) } }); } out({ ok: results.every(r => r.ok), api: API, fingerprint: key.fingerprint, results }); if (!LIST && !DRY) { log('\n下一步(都是免费的):'); log(' · 查看履历:' + API + '/r/<上面的 CVIN>'); log(' · 认领主机号(有市场挂牌才有):' + API + '/claim'); log(' · 长期维护 L2:加 --watch 60,或用 cron 每小时跑一次'); log(' · 长期不跑(>30 天)身份会退回 stale——这是设计,不是故障'); } if (WATCH > 0) { log('\n进入守护模式,每 ' + WATCH + ' 分钟上报一次(Ctrl+C 退出)…'); setInterval(() => { main().catch(e => console.error('上报异常:', e.message)); }, WATCH * 60000); } } if (require.main === module) { main().catch(e => { console.error('agent 异常:', e && e.message || e); process.exit(1); }); } module.exports = { encodeCvin, serialHash, modelToken, formToken, memToken, canon, sha256, parseSmiOutput, telemetryOf, fingerprintOf, bindMessageFor: (o) => sha256(canon(o)) };