feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed

This commit is contained in:
justme committed 2026-10-08 05:39:13 +08:00
1 parent 117d34a2af
commit 4841f7b15b
40 files changed
+1011 -21

No files matched your search

+26 -1
View File
@@ -1,7 +1,7 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
@@ -191,6 +191,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
<td className="muted">{platform || dash}</td>
<td>{update ?? dash}</td>
<td>{dash}</td>
</tr>
)
}
@@ -205,6 +206,26 @@ const UPDATE_STATE: Record<string, { text: string; tone: '' | 'ok' | 'warn' | 'b
invalid: { text: '清单无效', tone: 'bad' },
}
/**
* 节点二进制的绑定:第一次握手成功时绑定它正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里就拒绝连接。
* 「已签名」表示哈希出现在一份 CI 签名的清单里;否则只是首次握手时记下的。不符时可以确认当前的二进制是自己要的(比如手工升级了)。
*/
function BinaryCell({ binary, busy, onAccept }: { binary?: NodeBinaryDto | null; busy: boolean; onAccept: () => void }) {
if (!binary) return <span className="faint">未绑定</span>
const short = binary.sha256.slice(0, 10)
return (
<span className="row" style={{ gap: 8 }} title={binary.violation ?? binary.sha256}>
{binary.violation ? <Chip tone="bad">不符</Chip> : <Chip tone={binary.signed ? 'ok' : ''}>{binary.signed ? '已签名' : '首次绑定'}</Chip>}
<span className="mono muted">{short}</span>
{binary.violation ? (
<button type="button" className="btn small" disabled={busy} onClick={onAccept} title="清除绑定,节点下一次连接时重新绑定当前的二进制">
接受当前二进制
</button>
) : null}
</span>
)
}
function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) {
if (!update) return <span className="faint">—</span>
const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const }
@@ -271,6 +292,7 @@ function Versions() {
<th>构建时间</th>
<th>系统</th>
<th>更新</th>
<th>二进制</th>
</tr>
</thead>
<tbody>
@@ -292,6 +314,9 @@ function Versions() {
<td>
<UpdateCell update={n.update} busy={busy} onUpdate={() => requestUpdate([n.id])} />
</td>
<td>
<BinaryCell binary={n.binary} busy={busy} onAccept={() => void run(async () => { await api.del(`/api/admin/nodes/${n.id}/binary`); info.reload() }, '已清除绑定,节点下一次连接时重新绑定')} />
</td>
</tr>
))}
</tbody>
+69
View File
@@ -988,6 +988,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/nodes/{id}/binary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Accept the node's current binary: clear its binding so the next handshake binds it again */
delete: operations["nodes_binary_clear"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backend/update": {
parameters: {
query?: never;
@@ -1680,6 +1697,17 @@ export interface components {
version: number | string;
needs: string[];
};
NodeBinaryDto: {
sha256: string;
signed: boolean;
/** Format: int64 */
seq: number | string;
/** Format: date-time */
bound_at: unknown;
violation: null | string;
/** Format: date-time */
violation_at: unknown;
};
NodeBuildDto: {
id: string;
kind: string;
@@ -1691,6 +1719,7 @@ export interface components {
commit: null | string;
built_at: null | string;
update?: null | components["schemas"]["NodeUpdateDto"];
binary?: null | components["schemas"]["NodeBinaryDto"];
};
NodeInfoDto: {
id: string;
@@ -5593,6 +5622,46 @@ export interface operations {
};
};
};
nodes_binary_clear: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
backend_update_get: {
parameters: {
query?: never;
+1
View File
@@ -19,6 +19,7 @@ export type BuildInfoDto = Narrow<S['BuildInfoDto']>
export type BackendUpdateDto = Narrow<S['BackendUpdateDto']>
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
export type NodeBinaryDto = Narrow<S['NodeBinaryDto']>
export type ReleaseDto = Narrow<S['ReleaseDto']>
export type LinkDto = Narrow<S['LinkDto']>
export type LinkMatrixDto = Narrow<S['LinkMatrixDto']>