feat: trusted release keys setting; drop the duplicate release registration route [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
The panel now lets the deployer decide which release keys it trusts. The official CI key is built in and always trusted; an own P-256 public key can be added (off by default, the private key is rejected) so manifests signed with it are accepted too. Removing it drops the manifests only it signed. Changes are audited and need update.confirm. Admin UI: System > Trust > "Trusted release keys" with a collapsible guide on generating a key pair and signing a manifest with openssl. Registration now has one path, POST /api/release/offer (no token, signature verified). The admin POST /releases route, the release.publish capability and the ci-release preset are removed.
This commit is contained in:
1 parent
904ea2b0ae
commit
0f8f3f9a54
24 files changed
+914
-325
No files matched your search
@@ -19,7 +19,6 @@ const NAMES: Record<string, [string, string]> = {
|
||||
'settings.write': ['修改设置', '站点设置、保留策略、重启计划'],
|
||||
'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'],
|
||||
'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'],
|
||||
'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'],
|
||||
'keys.read': ['查看 API 令牌', ''],
|
||||
'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'],
|
||||
'accounts.manage': ['管理账户', '面板用户、密码'],
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { ReleaseKeysDto } from '@shared/api/types'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, CopyButton, ErrorNotice, Field, useAction } from '@shared/ui'
|
||||
|
||||
const KEYGEN = `openssl ecparam -name prime256v1 -genkey -noout -out release.key
|
||||
openssl ec -in release.key -pubout -out release.pub.pem`
|
||||
|
||||
const SIGN = `# manifest.json 是一行扁平的 JSON(字段见 docs/deployment.md),对它的原始字节签名:
|
||||
openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json
|
||||
base64 -w0 manifest.sig.bin > manifest.sig`
|
||||
|
||||
/**
|
||||
* 可信资产来源:面板信任哪些发布密钥。官方 CI 密钥始终信任;自有发布公钥默认关闭,需要时再导入。
|
||||
* 只收公钥(私钥留在自己的机器上),改动都会写进审计。
|
||||
*/
|
||||
export function ReleaseKeys() {
|
||||
const keys = useQuery(() => api.get<ReleaseKeysDto>('/api/admin/release-key'), [])
|
||||
const { busy, run } = useAction()
|
||||
const [pem, setPem] = useState('')
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const data = keys.data
|
||||
const looksPrivate = /PRIVATE/i.test(pem)
|
||||
|
||||
return (
|
||||
<div className="card stack" style={{ marginTop: 16 }}>
|
||||
<div>
|
||||
<b>可信资产来源</b>
|
||||
<p className="muted">
|
||||
发布清单(镜像 digest)必须带有效的发布签名才会被面板登记和分发;节点宿主机的更新器之后还会用它本机的公钥再验一次。这里决定面板认哪些发布密钥。
|
||||
</p>
|
||||
</div>
|
||||
<ErrorNotice error={keys.error} />
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<Field label="官方 CI 发布密钥" hint="内嵌在程序里,始终信任,不能关闭。指纹可以和 packaging/update/release-key.pub.pem 核对。">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Chip tone="ok">始终信任</Chip>
|
||||
<span className="mono" style={{ wordBreak: 'break-all' }}>{data.official.fingerprint}</span>
|
||||
<CopyButton text={data.official.fingerprint} />
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="自有发布密钥" hint="默认关闭。开启后,用你自己的私钥签名的清单也会被接受,适合自己构建、自己发布的部署。">
|
||||
{data.custom ? (
|
||||
<div className="stack">
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Chip tone="accent">已开启</Chip>
|
||||
<span className="mono" style={{ wordBreak: 'break-all' }}>{data.custom.fingerprint}</span>
|
||||
<CopyButton text={data.custom.fingerprint} />
|
||||
<button type="button" className="btn small danger" disabled={busy} onClick={() => setRemoving(true)}>
|
||||
停用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="stack"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
await api.put('/api/admin/release-key', { public_key: pem })
|
||||
setPem('')
|
||||
keys.reload()
|
||||
}, '已开启自有发布密钥')
|
||||
}}
|
||||
>
|
||||
<textarea className="input mono" rows={5} value={pem} onChange={(e) => setPem(e.target.value)} placeholder={'-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----'} spellCheck={false} />
|
||||
{looksPrivate ? <div className="notice bad">这看起来是私钥。私钥不要上传,留在你自己的机器上;这里只要公钥(BEGIN PUBLIC KEY)。</div> : null}
|
||||
<div className="notice warn">
|
||||
开启之后,持有这把私钥的人签出的清单都会被本面板接受:私钥泄露就等于有人能给你的节点推更新;私钥丢了就签不出新的构建。官方密钥不受影响。
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" className="btn primary" disabled={busy || !pem.trim() || looksPrivate}>
|
||||
开启并信任这把公钥
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<details>
|
||||
<summary>怎么用自己的密钥给资产签名</summary>
|
||||
<div className="stack" style={{ marginTop: 8 }}>
|
||||
<p className="muted">① 生成密钥对(ECDSA P-256)。<b>私钥 release.key 留在你自己的机器或 CI 密钥库里,不要上传到这里</b>;把 release.pub.pem 的内容粘贴到上面。</p>
|
||||
<pre className="code">{KEYGEN}</pre>
|
||||
<CopyButton text={KEYGEN} />
|
||||
<p className="muted">② 给你自己构建的发布清单签名(DER 编码的 ECDSA P-256 / SHA-256,和官方同一种格式;签名对象是清单文件的原始字节)。</p>
|
||||
<pre className="code" style={{ whiteSpace: 'pre-wrap' }}>{SIGN}</pre>
|
||||
<CopyButton text={SIGN} />
|
||||
<p className="muted">③ 把清单和签名交给面板:POST /api/release/offer(不需要令牌,面板用上面开启的公钥验签),或者像官方那样放进你镜像仓库里的清单镜像。</p>
|
||||
<p className="muted">
|
||||
④ <b>节点宿主机的更新器另外要放你的公钥</b>(它只认本机文件 release-key.pub.pem,面板改不了),并且清单里的 image 必须等于它本机配置的镜像仓库;
|
||||
否则这些节点不会自动更新到你签的版本。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{removing ? (
|
||||
<Confirm
|
||||
title="停用自有发布密钥"
|
||||
danger
|
||||
confirmText="停用"
|
||||
onClose={() => setRemoving(false)}
|
||||
onConfirm={async () => {
|
||||
await run(() => api.del('/api/admin/release-key'), '已停用')
|
||||
keys.reload()
|
||||
}}
|
||||
>
|
||||
停用之后,只有这把密钥签过的清单不再被接受,面板里已经登记的这类清单也会被丢掉。官方密钥不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented
|
||||
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
|
||||
import { PasskeyAddForm } from './PasskeyAdd'
|
||||
import { Trust } from './Trust'
|
||||
import { ReleaseKeys } from './ReleaseKeys'
|
||||
|
||||
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | 'events' | 'raw' | 'analysis'
|
||||
|
||||
@@ -866,7 +867,12 @@ export function System() {
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? <Trust /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? (
|
||||
<>
|
||||
<Trust />
|
||||
<ReleaseKeys />
|
||||
</>
|
||||
) : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user