Files
blog/blog-admin/src/lib/deployer.ts
T

610 lines
25 KiB
TypeScript
Raw Normal View History

/**
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
*
* 目前两个目标(对应 `certstore.ts` 里的 `DEPLOY_TARGETS`):
* · dogecloud 多吉云 CDN —— 上传证书 + 绑到指定加速域名
* · 1panel 1Panel 面板 —— 上传证书 + 绑到指定网站(走 openresty)
*
* ★ 两家的 API 风格完全相反,各自的坑单独记在下面各自的类里。
*
* ★ 为什么部署要「幂等」(重复调用不出错):
* 续期失败一次就可能连着重试;更要紧的是——**每天都会跑一遍**,
* 如果每次都无脑新建证书,多吉云那边的证书列表会膨胀成几百条,
* 1Panel 那边会不断覆盖同名 SSL。所以这里的每个动作都先查后写。
*/
import type { AccessRecord } from './certstore';
// ==================================================================== 类型
export interface DeployCert {
/** 主域名(1Panel 用来给 SSL 起名字,多吉云用来做备注) */
domain: string;
/** 证书链 PEM(叶 + 中间,多吉云要求含完整链) */
cert: string;
/** 私钥 PEM */
key: string;
}
export interface DeployResult {
/** 目标标签,日志里用 */
target: string;
/** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */
details: string[];
}
export interface Deployer {
readonly kind: string;
/** 把证书推到这个目标的所有配置对象上 */
deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult>;
}
export interface DeployOptions {
/** 多吉云:要绑的加速域名列表(空则只上传不绑定) */
dogecloudDomains?: string[];
/** 1Panel:要绑的网站(域名或 id)列表(空则只上传不绑定) */
onePanelSites?: string[];
/** 追加日志 */
log?: (msg: string) => void;
}
// ==================================================================== 工具
const enc = (s: string) => new TextEncoder().encode(s);
// ==================================================================== 多吉云 CDN
/** hex 输出(多吉云签名用) */
function bufToHex(buf: ArrayBuffer | Uint8Array): string {
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
/**
* 多吉云(api.dogecloud.com)。
*
* ★ 签名方式(老派但有性格):
* stringToSign = <path[+?query]> + "\n" + <body原始字符串>
* signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex**
* Authorization: `TOKEN <accessKey>:<signature>`
* 注意:**不含时间戳**(所以要靠 HTTPS 防重放);HMAC 用的是 **SHA1** 不是 SHA256;
* 输出是 **hex** 不是 base64。这三点任一搞错都只会得到 `401 签名错误`。
*
* ★ body 必须**原样**参与签名:先序列化成字符串再一起发出去,
* 不能签名 JSON.stringify(a) 却发送 JSON.stringify(b)。
* 这里统一「先定 body 字符串 → 签名 → 发送同一个字符串」。
*
* ★ 端点与参数(2026-10-06 用真凭据逐个实测确认,别照抄网上的旧文档):
* POST /cdn/domain/list.json {} → { domains: [{id,name,cname,…}] }
* POST /cdn/cert/list.json {} → { certs: [{id,note,name,domains,…}] }
* POST /cdn/cert/upload.json { note, cert, private } → { id }
* POST /cdn/cert/bind.json { id, domain } → {}
* POST /cdn/cert/delete.json { id } → {}
* 几个容易写错的地方:
* · 列域名是 `/cdn/domain/**list**.json`;`/cdn/domain.json` 会回
* `400 domain 格式错误`(它其实是「查单个域名」的接口,要传 domain)。
* · 上传的私钥字段叫 **`private`**(不是 pri/key/privateKey —— 那三个都会回
* `400 私钥格式错误`)。
* · 绑定的证书 id 字段是 **`id`**(官方文档如此)。★ 已实测一锤定音:
* 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视),
* 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
*
* ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书
* 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日,
* 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。
*/
export class DogeCloudDeployer implements Deployer {
readonly kind = 'dogecloud';
private static readonly HOST = 'https://api.dogecloud.com';
constructor(
private readonly accessKey: string,
private readonly secretKey: string,
) {}
private async call<T>(path: string, body: Record<string, unknown> | null): Promise<T> {
// ★ body 字符串只算一次,签名和发送用同一个
const bodyStr = body === null ? '' : JSON.stringify(body);
const stringToSign = `${path}\n${bodyStr}`;
const key = await crypto.subtle.importKey('raw', enc(this.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, [
'sign',
]);
const sig = bufToHex(await crypto.subtle.sign('HMAC', key, enc(stringToSign)));
const r = await fetch(DogeCloudDeployer.HOST + path, {
method: 'POST',
headers: {
Authorization: `TOKEN ${this.accessKey}:${sig}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: bodyStr || undefined,
});
const text = await r.text();
let d: { code?: number; msg?: string; data?: T };
try {
d = JSON.parse(text);
} catch {
throw new Error(`多吉云返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
}
// code === 200 是成功;0 也有接口用(历史遗留),一并认
if (d.code !== 200 && d.code !== 0) {
throw new Error(`多吉云 ${path} 失败:code=${d.code} ${d.msg || ''}`);
}
return d.data as T;
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
// ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
details.push(`证书 #${certId}`);
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
if (!domains.length) {
log('多吉云:没有配置要绑定的域名,只上传不绑定');
return { target: 'dogecloud', details };
}
for (const domain of domains) {
log(`多吉云:绑定 ${domain}…`);
await this.call('/cdn/cert/bind.json', { id: certId, domain });
details.push(`绑定 ${domain}`);
}
return { target: 'dogecloud', details };
}
/**
* 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。
*
* ★ 复用判据只看**域名集合**,不比对证书内容:
* 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是
* 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。
*/
private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise<number> {
const need = new Set(
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
);
try {
const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>(
'/cdn/cert/list.json',
{},
);
const hit = (list?.certs || []).find((c) => {
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
if (have.size !== need.size) return false;
for (const d of need) if (!have.has(d)) return false;
return true;
});
if (hit?.id) {
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`);
return hit.id;
}
} catch (e) {
// 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好
log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`);
}
log('多吉云:上传证书…');
const up = await this.call<{ id?: number | string }>('/cdn/cert/upload.json', {
note: `${cert.domain} (${new Date().toISOString().slice(0, 10)})`,
cert: cert.cert,
// ★ 私钥字段名是 `private` —— 实测 pri/key/privateKey 都会回「私钥格式错误」
private: cert.key,
});
const id = up?.id;
if (id === undefined || id === null || id === '') {
throw new Error('多吉云上传成功但没返回证书 id(接口可能改了)');
}
return Number(id);
}
}
// ==================================================================== 1Panel
/**
* 1Panel(自建面板)。
*
* ★ 签名:`1Panel-Token = md5("1panel" + apiKey + timestamp)`(hex),
* 同时带 `1Panel-Timestamp`(unix 秒)。**没有别的材料**,
* 与多吉云那种「body 进签名」完全无关 —— 它就是防重放 + 持有密钥即通过。
*
* ★ 版本:国内机(119.29.215.187:3721)**必须用 v2**。
* 2026-10-06 逐条实测的结论(网上和早期笔记里的说法都不准,以实测为准):
* · `/api/v2/dashboard/base/os` → `code:200`,真实数据 ✓
* · `/api/v1/dashboard/base/os` → **HTTP 200 但正文是 HTML 提示页**
* (`Access Temporarily Unavailable`)—— 看起来像限流,其实是 v1 已停用,
* 面板把「路径不对」统一渲染成了那个页面。这一点极易误判成「被限流了」。
*
* ★ v2 的请求体要求和 v1 不同,实测踩过的点:
* · `POST /websites/search` 的 `orderBy` / `order` 是 **required**
* (漏了会回 `400 参数错误: Key: 'WebsiteSearch.OrderBy' … required`)。
* → 固定传 `{orderBy:'created_at', order:'descending'}`。
* · `/websites/list`、`GET /websites` 在 v2 下都是 404,别用。
* · `GET /websites/:id` 是 404(不是 `GET /websites?id=`)。
* · 站点里的 `ssl` 字段在列表里是空的 —— 要拿 https 配置得单独
* `GET /websites/:id/https`。
*
* ★ 部署流程(**必须**先读后写):
* ① `POST /websites/ssl/search` 找同名 SSL;有就 `POST /websites/ssl/update` 覆盖,
* 没有就 `POST /websites/ssl/upload` 新建 → 得到 SSL id
* ② `GET /websites/:id/https` 读现状,若 `enable && ssl.id === 目标` → **跳过**(幂等)
* ③ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
*
* ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。
*/
/** 1Panel 的搜索分页包装 */
interface PageResult<T> {
items?: T[];
total?: number;
}
/** 1Panel v2 的网站对象(只列我们关心的字段) */
interface WebSite {
id: number;
primaryDomain?: string;
/** 别名,多个用逗号分隔 */
alias?: string;
type?: string;
}
export class OnePanelDeployer implements Deployer {
readonly kind = '1panel';
constructor(
private readonly serverUrl: string,
private readonly apiKey: string,
// ★ 默认 v2 —— 实测国内机只有 v2 能用(v1 会返回一个假的「限流」HTML 页)
private readonly apiVersion: 'v1' | 'v2' = 'v2',
) {}
private get base(): string {
return `${this.serverUrl.replace(/\/+$/, '')}/api/${this.apiVersion}`;
}
private async call<T>(path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
const timestamp = String(Math.floor(Date.now() / 1000));
const md5 = await md5Hex(`1panel${this.apiKey}${timestamp}`);
const r = await fetch(this.base + path, {
method,
headers: {
'1Panel-Token': md5,
'1Panel-Timestamp': timestamp,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let d: { code?: number; message?: string; data?: T };
try {
d = JSON.parse(text);
} catch {
// ★ 2026-10-06 实测:1Panel 在「不方便直接回错」时会**返回 HTTP 200
// 但内容是 HTML**。两种成因,处置完全不同,所以必须分开报:
// ① 开了「安全登录」→ 面板挪到随机入口路径,根路径只回提示页
// (正文含 `secure login access` / `1pctl user-info`)
// ② 短时间调用过密被限流 → 正文标题 `Access Temporarily Unavailable`
// 只按「非 JSON 就抛错」处理的话,两种都会被抓成一句看不懂的
// 「返回非 JSON」,排查成本极高。
if (/secure login access|1pctl user-info/i.test(text)) {
throw new Error(
`1Panel 启用了「安全登录」,面板不在根路径下(serverUrl 少了入口路径)。` +
`SSH 上机执行 \`1pctl user-info\` 拿到入口,再把 serverUrl 改成 ` +
`http://<ip>:<port>/<入口路径>`,
);
}
if (/Access Temporarily Unavailable|<!DOCTYPE/i.test(text)) {
throw new Error(
`1Panel 拒绝了本次请求(返回「Access Temporarily Unavailable」页面)。` +
`通常是短时间调用过于频繁触发限流 —— 等一会儿再试。`,
);
}
throw new Error(`1Panel 返回非 JSON(HTTP ${r.status},${r.headers.get('content-type') || '无 CT'}):${text.slice(0, 200)}`);
}
// 1Panel 统一信封:code === 200 才是成功
if (d.code !== 200) {
throw new Error(`1Panel ${path} 失败:code=${d.code} ${d.message || `HTTP ${r.status}`}`);
}
return d.data as T;
}
/**
* 连通性 + 可用性探测(只读)。给「环境自检」用。
*
* ★ 为什么要单独有个 ping 而不是直接 try 某个业务接口:
* `GET /dashboard/base/os` 是最轻的只读接口,用它做「面板是否可用」的
* 探针最合适 —— 业务接口(网站/证书列表)失败时你分不清是「签名错」
* 还是「没数据」,而这个接口必然有数据可回。
*/
async ping(): Promise<{ ok: boolean; error?: string; hint?: string }> {
try {
await this.call<unknown>('/dashboard/base/os', 'GET');
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
ok: false,
error: msg,
hint: msg.includes('Access Temporarily Unavailable')
? '1Panel 启用了「安全登录」:面板被挪到了随机入口路径下,根路径只回提示页。' +
'需要 SSH 上机执行 `1pctl user-info` 拿到入口,再把 serverUrl 改成 ' +
'`http://<ip>:<port>/<入口路径>`'
: msg.includes('401')
? 'API Key 不对(1Panel → 设置 → API 接口 里重新生成)'
: undefined,
};
}
}
/** 域名 or 数字 id → 网站对象 */
async findWebsite(key: string): Promise<{ id: number; primaryDomain?: string; alias?: string }> {
// ★ v2 下 `/websites/:id` 是 404,所以「按 id 找」也得走 search:
// 拉一页(orderBy/order 必填)再在前端按 id 过滤。
// 顺带这一步就拿到了全量网站,后面按域名找也不用再请求一次。
const all = await this.listWebsites();
const key_l = key.toLowerCase();
const match = (w: WebSite) => {
if (String(w.id) === key) return true;
const names = [w.primaryDomain || '', ...(w.alias || '').split(',')].map((s) => s.trim().toLowerCase());
return names.includes(key_l);
};
const hit = all.find(match);
if (hit) return hit;
throw new Error(
`1Panel 里找不到网站「${key}」(可用主域名或网站别名匹配;目前面板上有 ${all.length} 个网站)`,
);
}
/** 拉全量网站列表(v2 的 search 按 name 过滤不可靠,统一拉回来自己筛) */
private async listWebsites(): Promise<WebSite[]> {
const page = await this.call<PageResult<WebSite>>('/websites/search', 'POST', {
page: 1,
pageSize: 200,
// ★ 这两个字段 v2 是 required,漏了直接 400
orderBy: 'created_at',
order: 'descending',
});
return page?.items || [];
}
/** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<number> {
// ① 查同名(v2 的 ssl/search 同样需要 orderBy/order)
const page = await this.call<PageResult<{ id: number; primaryDomain?: string }>>('/websites/ssl/search', 'POST', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending',
});
const existing = (page?.items || []).find((s) => s.primaryDomain === cert.domain);
if (existing?.id) {
// ② 同名存在 → 用 update 覆盖内容(保持 id 不变,网站那边的引用就不会断)
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
await this.call('/websites/ssl/update', 'POST', {
id: existing.id,
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
return existing.id;
}
log('1Panel:上传新证书…');
const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', {
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
const id = typeof up === 'number' ? up : up?.id;
if (!id) throw new Error('1Panel 上传成功但没拿到 SSL id');
return id;
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
const sslId = await this.uploadSsl(cert, log);
details.push(`证书 SSL #${sslId}`);
const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean);
if (!sites.length) {
log('1Panel:没有配置要绑定的网站,只上传不绑定');
return { target: '1panel', details };
}
for (const key of sites) {
const site = await this.findWebsite(key);
// ★ 先读现状 —— 幂等的关键。已经在用同一张证书就什么都别做。
//
// ★★ 字段名是 **`SSL`(全大写)**,不是 `ssl`。写成小写会让
// `cur.ssl?.id === sslId` 永远为 false → 每次续期都重绑一遍。
// 危害不止是多余请求:重绑会 brief 地重载该站点的 nginx 配置。
//
// ★ 这个 GET 的响应里**带明文私钥**(`data.SSL.privateKey`)——
// 绝不能把它写进日志、日志记录或 HTTP 响应。这里只取需要的几个
// 标量字段,然后让整个对象尽快离开作用域。
const resp = await this.call<{
enable?: boolean;
SSL?: { id?: number; primaryDomain?: string };
httpConfig?: string;
SSLProtocol?: string[];
algorithm?: string;
hsts?: boolean;
}>(`/websites/${site.id}/https`, 'GET');
const cur = {
enable: resp?.enable,
sslId: resp?.SSL?.id,
httpConfig: resp?.httpConfig,
SSLProtocol: resp?.SSLProtocol,
algorithm: resp?.algorithm,
hsts: resp?.hsts,
};
if (cur.enable && cur.sslId === sslId) {
log(`1Panel:网站 ${key} 已经在用这张证书,跳过`);
details.push(`跳过 ${key}(已生效)`);
continue;
}
// ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书
const body: Record<string, unknown> = {
websiteId: site.id,
type: 'existed',
sslId,
enable: true,
httpConfig: cur.httpConfig || 'HTTPToHTTPS',
SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'],
algorithm: cur.algorithm || 'RSA',
hsts: cur.hsts ?? false,
};
log(`1Panel:给网站 ${key}(#${site.id})绑定证书…`);
await this.call(`/websites/${site.id}/https`, 'POST', body);
details.push(`绑定 ${key}`);
}
return { target: '1panel', details };
}
/**
* 读某个网站当前的 SSL 绑定情况(只回标量,**绝不回私钥**)。
* 给「环境自检」用 —— 让管理员能在续期之前就看出「站点绑的是不是我们要的那张」。
*/
async inspectSite(
key: string,
): Promise<{ id: number; primaryDomain: string; enable: boolean; sslId?: number; certCN?: string }> {
const site = await this.findWebsite(key);
const resp = await this.call<{ enable?: boolean; SSL?: { id?: number; primaryDomain?: string } }>(
`/websites/${site.id}/https`,
'GET',
);
return {
id: site.id,
primaryDomain: site.primaryDomain || '',
enable: !!resp?.enable,
sslId: resp?.SSL?.id,
certCN: resp?.SSL?.primaryDomain,
};
}
}
// ==================================================================== 工厂
/**
* 按凭据记录造部署器。
* ★ 只认 dogecloud / 1panel;其余抛错而非静默 —— 理由同 makeDnsProvider。
*/
export function makeDeployer(rec: AccessRecord): Deployer {
const t = String(rec.type || '');
if (t === 'dogecloud') {
const ak = String(rec.accessKey || '');
const sk = String(rec.secretKey || '');
if (!ak || !sk) throw new Error('多吉云凭据缺少 accessKey / secretKey');
return new DogeCloudDeployer(ak, sk);
}
if (t === '1panel') {
const url = String(rec.serverUrl || '');
const key = String(rec.apiKey || '');
if (!url || !key) throw new Error('1Panel 凭据缺少 serverUrl / apiKey');
const ver = String(rec.apiVersion || 'v2') === 'v1' ? 'v1' : 'v2';
return new OnePanelDeployer(url, key, ver);
}
throw new Error(`部署目标不支持凭据类型「${t}」(目前只支持 dogecloud / 1panel)`);
}
// ==================================================================== md5
/**
* 1Panel 要的 md5(hex)。
*
* ★ 用 `crypto.subtle` 没有 MD5(它是过时算法,WebCrypto 故意不提供),
* 所以自己写一份。这里只需要处理 ASCII("1panel" + apiKey + 时间戳),
* 但为了将来可能复用它算别的,还是按 UTF-8 字节做了正确处理。
* 实现照 RFC 1321;输出小写 hex。
*/
export function md5Hex(input: string): Promise<string> {
return Promise.resolve(md5(enc(input)));
}
function md5(bytes: Uint8Array): string {
const S = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6,
10, 15, 21,
];
const K = new Uint32Array(64);
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
// 补位:0x80 + 0x00... 到 56 mod 64,再附 64 位长度(小端)
const len = bytes.length;
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
withPad.set(bytes);
withPad[len] = 0x80;
const bitLen = len * 8;
// 低 32 位 + 高 32 位(JS 里用除法拆,避免 >> 32 的坑)
const lo = bitLen >>> 0;
const hi = Math.floor(bitLen / 4294967296) >>> 0;
const dv = new DataView(withPad.buffer);
dv.setUint32(withPad.length - 8, lo, true);
dv.setUint32(withPad.length - 4, hi, true);
let a0 = 0x67452301;
let b0 = 0xefcdab89;
let c0 = 0x98badcfe;
let d0 = 0x10325476;
const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0;
for (let off = 0; off < withPad.length; off += 64) {
const M = new Uint32Array(16);
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
let A = a0;
let B = b0;
let C = c0;
let D = d0;
for (let i = 0; i < 64; i++) {
let F: number;
let g: number;
if (i < 16) {
F = (B & C) | (~B & D);
g = i;
} else if (i < 32) {
F = (D & B) | (~D & C);
g = (5 * i + 1) % 16;
} else if (i < 48) {
F = B ^ C ^ D;
g = (3 * i + 5) % 16;
} else {
F = C ^ (B | ~D);
g = (7 * i) % 16;
}
F = (F + A + K[i] + M[g]) >>> 0;
A = D;
D = C;
C = B;
B = (B + rotl(F, S[i])) >>> 0;
}
a0 = (a0 + A) >>> 0;
b0 = (b0 + B) >>> 0;
c0 = (c0 + C) >>> 0;
d0 = (d0 + D) >>> 0;
}
return [a0, b0, c0, d0].map((x) => {
// 每个字按小端输出
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, x, true);
return [...b].map((v) => v.toString(16).padStart(2, '0')).join('');
}).join('');
}