feat!: probes generate their own keys and enroll with a join token; node and peer keys are separate [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
1ff847e45a
commit
a9f562441f
51 files changed
+505
-749
No files matched your search
@@ -1,13 +1,13 @@
|
||||
import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LinkDto, LinkMatrixDto, Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
|
||||
import type { CreatedJoinTokenDto, LinkDto, LinkMatrixDto, Page, ProbeDto } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { IPV6_STATES } from '@shared/ipVersions'
|
||||
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
||||
import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, guessPlatform, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
/**
|
||||
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
|
||||
@@ -86,39 +86,96 @@ function LinkMatrix() {
|
||||
)
|
||||
}
|
||||
|
||||
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
|
||||
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) {
|
||||
const json = JSON.stringify(identity, null, 2)
|
||||
/**
|
||||
* 创建 Probe 和创建 Agent 一样:生成一个一次性的加入令牌,在家宽机器上运行命令。
|
||||
* 密钥由 Probe 自己生成,面板从头到尾不接触私钥;想换密钥就吊销之后重新注册。
|
||||
*/
|
||||
function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const { busy, run } = useAction()
|
||||
const [note, setNote] = useState('')
|
||||
const [hours, setHours] = useState(24)
|
||||
const [created, setCreated] = useState<CreatedJoinTokenDto>()
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<Dialog
|
||||
title="加入令牌已创建"
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<button type="button" className="btn primary" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="stack">
|
||||
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
|
||||
<Field label="在家宽机器上运行" hint="把 <node-name> 换成这台 Probe 的名字,例如 home。Probe 会自己生成密钥并注册;Agent 列表和更新都由面板下发。">
|
||||
<CommandTabs
|
||||
storageKey="moshiro.install_tab"
|
||||
initial={guessPlatform()}
|
||||
tabs={[
|
||||
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
|
||||
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
|
||||
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="令牌">
|
||||
<pre className="code">{created.plaintext}</pre>
|
||||
<div>
|
||||
<CopyButton text={created.plaintext} label="复制令牌" />
|
||||
</div>
|
||||
</Field>
|
||||
<p className="muted">一次性令牌,{fmtTime(created.token.expires_at)} 过期。</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={title}
|
||||
title="添加 Probe"
|
||||
onClose={onClose}
|
||||
actions={
|
||||
<button type="button" className="btn primary" onClick={onClose}>
|
||||
我已保存
|
||||
</button>
|
||||
<>
|
||||
<button type="button" className="btn text" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
await run(async () => {
|
||||
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
|
||||
role: 'probe',
|
||||
note: note || null,
|
||||
auto_approve: true,
|
||||
max_uses: 1,
|
||||
expires_in_hours: hours,
|
||||
})
|
||||
setCreated(token)
|
||||
onCreated()
|
||||
})
|
||||
}}
|
||||
>
|
||||
创建加入令牌
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="stack">
|
||||
<div className="notice warn">私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。</div>
|
||||
<div className="row wrap">
|
||||
<button type="button" className="btn primary" onClick={() => downloadText('probe.json', json)}>
|
||||
<Download size={16} />
|
||||
下载 probe.json
|
||||
</button>
|
||||
<CopyButton text={json} label="复制内容" />
|
||||
<p className="muted" style={{ marginBottom: 16 }}>
|
||||
添加 Probe 就是创建一个一次性的加入令牌:在家宽机器上运行命令,Probe 会用它注册并出现在列表里。
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<div className="wide">
|
||||
<Field label="备注" hint="例如这台机器的位置或运营商">
|
||||
<input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="上海电信家宽" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="在家宽机器上" hint="保存为 /etc/moshiro/probe.json,然后启动 Probe;它会连接 probe.json 里的面板地址,Agent 列表和更新都由面板下发。">
|
||||
<pre className="code">{`moshiro-probe --config /etc/moshiro/probe.json`}</pre>
|
||||
<Field label="有效期(小时)">
|
||||
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
||||
</Field>
|
||||
<details>
|
||||
<summary className="muted" style={{ cursor: 'pointer' }}>
|
||||
查看内容
|
||||
</summary>
|
||||
<pre className="code" style={{ marginTop: 8 }}>
|
||||
{json}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -159,7 +216,7 @@ function NameDialog({ title, initial, onClose, onSubmit }: { title: string; init
|
||||
)
|
||||
}
|
||||
|
||||
type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
|
||||
type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto }
|
||||
|
||||
export function Probes() {
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
|
||||
@@ -168,13 +225,11 @@ export function Probes() {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [renaming, setRenaming] = useState<ProbeDto>()
|
||||
const [pending, setPending] = useState<Pending>()
|
||||
const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>()
|
||||
|
||||
const list = probes.data?.items ?? []
|
||||
|
||||
const confirmText = {
|
||||
rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' },
|
||||
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' },
|
||||
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复;要换密钥就吊销之后重新添加一个 Probe。' },
|
||||
delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' },
|
||||
}
|
||||
|
||||
@@ -186,7 +241,7 @@ export function Probes() {
|
||||
{probes.loading && !probes.data ? <Spinner /> : null}
|
||||
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} />
|
||||
创建 Probe
|
||||
添加 Probe
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
@@ -197,7 +252,7 @@ export function Probes() {
|
||||
<LinkMatrix />
|
||||
{probes.data && list.length === 0 ? (
|
||||
<div className="card">
|
||||
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
|
||||
<Empty>还没有 Probe。点击右上角「添加 Probe」,在家宽机器上运行命令即可注册。</Empty>
|
||||
</div>
|
||||
) : null}
|
||||
{list.map((p) => (
|
||||
@@ -221,16 +276,10 @@ export function Probes() {
|
||||
改名
|
||||
</button>
|
||||
{!p.revoked ? (
|
||||
<>
|
||||
<button type="button" className="btn small" onClick={() => setPending({ kind: 'rotate', probe: p })}>
|
||||
<KeyRound size={14} />
|
||||
轮换密钥
|
||||
</button>
|
||||
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
|
||||
<ShieldOff size={14} />
|
||||
吊销
|
||||
</button>
|
||||
</>
|
||||
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
|
||||
<ShieldOff size={14} />
|
||||
吊销
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}>
|
||||
<Trash2 size={14} />
|
||||
@@ -275,24 +324,7 @@ export function Probes() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{creating ? (
|
||||
<NameDialog
|
||||
title="创建 Probe"
|
||||
initial=""
|
||||
onClose={() => setCreating(false)}
|
||||
onSubmit={async (name) => {
|
||||
let created: ProbeIdentityDto | undefined
|
||||
const ok = await run(async () => {
|
||||
created = await api.post<ProbeIdentityDto>('/api/admin/probes', { name })
|
||||
})
|
||||
if (ok && created) {
|
||||
setIdentity({ title: `Probe ${name} 已创建`, value: created })
|
||||
probes.reload()
|
||||
}
|
||||
return ok
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{creating ? <AddProbeDialog onClose={() => setCreating(false)} onCreated={probes.reload} /> : null}
|
||||
{renaming ? (
|
||||
<NameDialog
|
||||
title="修改名称"
|
||||
@@ -313,13 +345,7 @@ export function Probes() {
|
||||
onClose={() => setPending(undefined)}
|
||||
onConfirm={async () => {
|
||||
const { kind, probe } = pending
|
||||
if (kind === 'rotate') {
|
||||
let rotated: ProbeIdentityDto | undefined
|
||||
const ok = await run(async () => {
|
||||
rotated = await api.post<ProbeIdentityDto>(`/api/admin/probes/${probe.id}/rotate`)
|
||||
})
|
||||
if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated })
|
||||
} else if (kind === 'revoke') {
|
||||
if (kind === 'revoke') {
|
||||
await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销')
|
||||
} else {
|
||||
await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除')
|
||||
@@ -330,7 +356,6 @@ export function Probes() {
|
||||
{confirmText[pending.kind].text}
|
||||
</Confirm>
|
||||
) : null}
|
||||
{identity ? <IdentityDialog title={identity.title} identity={identity.value} onClose={() => setIdentity(undefined)} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+5
-153
@@ -493,7 +493,7 @@ export interface paths {
|
||||
/** Join tokens */
|
||||
get: operations["join_tokens_list"];
|
||||
put?: never;
|
||||
/** Create a join token; the plaintext is returned only once */
|
||||
/** Create a join token for an agent or a probe (role); the plaintext is returned only once */
|
||||
post: operations["join_tokens_create"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -528,8 +528,7 @@ export interface paths {
|
||||
/** All probes, with the agents each one is connected to */
|
||||
get: operations["probes_list"];
|
||||
put?: never;
|
||||
/** Create a probe identity; the private key is returned only once */
|
||||
post: operations["probes_create"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -555,23 +554,6 @@ export interface paths {
|
||||
patch: operations["probes_update"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/probes/{id}/rotate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once */
|
||||
post: operations["probes_rotate"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/probes/{id}/revoke": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1168,9 +1150,7 @@ export interface components {
|
||||
max_uses: null | number | string;
|
||||
/** Format: int32 */
|
||||
expires_in_hours: null | number | string;
|
||||
};
|
||||
CreateProbeRequest: {
|
||||
name: null | string;
|
||||
role?: null | string;
|
||||
};
|
||||
CreateTaskRequest: {
|
||||
scope: string;
|
||||
@@ -1353,6 +1333,8 @@ export interface components {
|
||||
revoked_at: unknown;
|
||||
/** Format: date-time */
|
||||
created_at: unknown;
|
||||
/** @default agent */
|
||||
role: string;
|
||||
};
|
||||
JsonElement: unknown;
|
||||
KindDto: {
|
||||
@@ -1644,18 +1626,6 @@ export interface components {
|
||||
connections: components["schemas"]["ProbeConnectionDto"][];
|
||||
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
|
||||
};
|
||||
ProbeIdentityDto: {
|
||||
probe_id: string;
|
||||
private_key: string;
|
||||
server: string;
|
||||
limits: components["schemas"]["ProbeLimitsDto"];
|
||||
};
|
||||
ProbeLimitsDto: {
|
||||
/** Format: double */
|
||||
max_test_mbps: number | string;
|
||||
/** Format: int64 */
|
||||
max_total_pps: number | string;
|
||||
};
|
||||
ProblemDto: {
|
||||
type: string;
|
||||
title: string;
|
||||
@@ -3485,66 +3455,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
probes_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": null | components["schemas"]["CreateProbeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProbeIdentityDto"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
probes_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3705,64 +3615,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
probes_rotate: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProbeIdentityDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
probes_revoke: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -11,7 +11,6 @@ export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
|
||||
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
|
||||
export type ProbeDto = Narrow<S['ProbeDto']>
|
||||
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
|
||||
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
|
||||
export type LineDto = Narrow<S['LineDto']>
|
||||
export type NodeInfoDto = Narrow<S['NodeInfoDto']>
|
||||
export type AboutDto = Narrow<S['AboutDto']>
|
||||
|
||||
Reference in new issue
Block a user