feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-08 06:47:10 +08:00
1 parent 2ae63653c4
commit f22fdf8b68
31 files changed
+799 -49

No files matched your search

+3 -1
View File
@@ -8,6 +8,7 @@ import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } f
import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice'
import { PasskeyNotice } from './PasskeyNotice'
import { Setup } from './Setup'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -232,7 +233,7 @@ const NAV = [
]
export function App() {
const { me, ready, logout } = useAuth()
const { me, ready, logout, setupActive } = useAuth()
if (!ready) {
return (
<div className="login">
@@ -240,6 +241,7 @@ export function App() {
</div>
)
}
if (setupActive) return <Setup />
if (!me) return <Login />
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
+1 -1
View File
@@ -54,7 +54,7 @@ export function PasskeyNotice() {
<div className="row wrap">
<KeyRound size={16} />
<span>
<b>建议添加 Passkey</b>:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。
<b>还没有 Passkey</b>:不注册 Passkey、不建立信任根就无法添加 Agent 和 Probe,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。
</span>
<span className="right row">
<button type="button" className="btn small text" onClick={later}>
+125
View File
@@ -0,0 +1,125 @@
import { Check, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { ApiError } from '@shared/api/client'
import { passkeySupported } from '@shared/api/webauthn'
import { describeError, Field } from '@shared/ui'
import { useAuth } from './auth'
import { Trust } from './pages/Trust'
import { PasskeyAddForm } from './pages/PasskeyAdd'
const STEPS = [
{ icon: UserPlus, label: '认领面板' },
{ icon: KeyRound, label: '注册 Passkey' },
{ icon: ShieldCheck, label: '建立信任根' },
]
/**
* 认领向导:面板里还没有任何用户时出现。没有默认账户,管理员的用户名和密码由部署的人自己填;
* 谁先打开页面谁就能当管理员,所以第一步要后端日志里的认领码(只有部署的人拿得到)。
* 后两步是引导,不是门槛:之后随时可以在「用户 → 我的 Passkey」和「系统 → 信任」里补,没有 passkey 也能用密码登录管理。
*/
export function Setup() {
const { me, completeSetup, finishSetup } = useAuth()
const [step, setStep] = useState(me ? 1 : 0)
const [trusted, setTrusted] = useState(false)
return (
<div className="login">
<div className="card stack" style={{ maxWidth: 640, width: '100%' }}>
<div>
<h1>MoShiro</h1>
<p className="muted">第一次打开:先认领这个面板——建好管理员、Passkey 和信任根。</p>
</div>
<ol className="row" style={{ gap: 16, listStyle: 'none', padding: 0, margin: 0, flexWrap: 'wrap' }}>
{STEPS.map((s, i) => (
<li key={s.label} className="row" style={{ gap: 6, opacity: i === step ? 1 : 0.55, fontWeight: i === step ? 600 : 400 }}>
{i < step ? <Check size={16} /> : <s.icon size={16} />}
{i + 1}. {s.label}
</li>
))}
</ol>
{step === 0 ? <Account onDone={() => setStep(1)} complete={completeSetup} /> : null}
{step === 1 ? (
<div className="stack">
<p className="muted">
Passkey 是整个系统的根:它给「操作员集合」签名,节点只认从它出发的信任根,以后 web ssh 等敏感操作也只认它。
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent 和 Probe,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
{passkeySupported() ? '' : ' 这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。'}
</p>
<PasskeyAddForm compact onAdded={() => setStep(2)} />
<div>
<button type="button" className="btn text" onClick={finishSetup}>
跳过,只进入面板做基础管理
</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="stack">
<Trust onInitialized={() => setTrusted(true)} />
<div>
<button type="button" className="btn primary" onClick={finishSetup}>
{trusted ? '完成,进入面板' : '跳过,只进入面板做基础管理(之后到「系统 → 信任」建立)'}
</button>
</div>
</div>
) : null}
</div>
</div>
)
}
function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise<void>; onDone: () => void }) {
const [code, setCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
if (password !== confirm) {
setError('两次输入的密码不一样')
return
}
setBusy(true)
setError('')
try {
await complete(code, username, password)
onDone()
} catch (err) {
if (err instanceof ApiError && err.status === 401) setError('认领码不对:它写在后端的日志里(journalctl -u moshiro-backend,或 docker logs)')
else if (err instanceof ApiError && err.status === 409) setError('这个面板已经被认领过了,请直接登录(刷新页面)')
else setError(describeError(err))
} finally {
setBusy(false)
}
}
return (
<form className="stack" onSubmit={submit}>
<Field label="认领码" hint="用来证明你是部署这个面板的人(防止别人抢先认领它)。首次启动时写在后端日志里(journalctl -u moshiro-backend 或 docker logs),或者部署时用 MoShiro__ClaimCode 指定。只有部署的人拿得到。">
<input className="input mono" value={code} onChange={(e) => setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required />
</Field>
<Field label="管理员用户名" hint="3–32 个字符:字母、数字、. _ -">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</Field>
<Field label="密码" hint="至少 12 个字符">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</Field>
<Field label="再输一遍密码">
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
</Field>
{error ? <div className="notice bad">{error}</div> : null}
<div>
<button className="btn primary" type="submit" disabled={busy}>
认领面板
</button>
</div>
</form>
)
}
+31 -4
View File
@@ -1,7 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
import type { LoginResponse, MeDto } from '@shared/api/types'
import type { LoginResponse, MeDto, SetupStatusDto } from '@shared/api/types'
import { signInWithPasskey } from '@shared/api/webauthn'
interface Auth {
@@ -12,6 +12,12 @@ interface Auth {
/** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */
loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise<void>
logout: () => Promise<void>
/** 面板里还没有任何用户:显示认领向导(创建管理员 → 注册 passkey → 建立信任根)。向导走完(或离开)之前一直为真。 */
setupActive: boolean
/** 向导第一步:用后端日志里的认领码创建第一个管理员,并马上以他登录。 */
completeSetup: (claimCode: string, username: string, password: string) => Promise<void>
/** 离开向导,进入面板。 */
finishSetup: () => void
}
const AuthContext = createContext<Auth | null>(null)
@@ -19,6 +25,7 @@ const AuthContext = createContext<Auth | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<MeDto | null>(null)
const [ready, setReady] = useState(false)
const [setupActive, setSetupActive] = useState(false)
useEffect(() => {
api
@@ -27,8 +34,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setCsrf(m.csrf_token ?? null)
setMe(m)
})
.catch((e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e)
.catch(async (e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) {
console.error(e)
return
}
// 没登录:如果面板里连一个用户都没有,就是第一次打开,进认领向导(只对页面所在的这个后端)
if (!getBase()) {
try {
const status = await api.get<SetupStatusDto>('/api/admin/auth/setup')
if (status.needs_setup) setSetupActive(true)
} catch {
// 取不到就当作普通登录页
}
}
})
.finally(() => setReady(true))
}, [])
@@ -61,6 +80,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const completeSetup = useCallback(async (claimCode: string, username: string, password: string) => {
const r = await api.post<LoginResponse>('/api/admin/auth/setup', { claim_code: claimCode, username, password })
setCsrf(r.csrf_token)
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const finishSetup = useCallback(() => setSetupActive(false), [])
const logout = useCallback(async () => {
try {
await api.post('/api/admin/auth/logout')
@@ -73,7 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [])
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout }}>{children}</AuthContext.Provider>
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout, setupActive, completeSetup, finishSetup }}>{children}</AuthContext.Provider>
}
export function useAuth(): Auth {
+1 -1
View File
@@ -92,7 +92,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
+5 -5
View File
@@ -21,21 +21,21 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
<div className="stack">
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
<Field
label={`在要加入的机器上运行(${kind})`}
label={`容器安装(推荐):在要加入的机器上运行(${kind})`}
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
>
<CommandTabs
storageKey="moshiro.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: commands.linux },
{ id: 'macos', label: 'macOS', command: commands.macos },
{ id: 'windows', label: 'Windows', command: commands.windows },
{ id: 'linux', label: 'Linux · 容器', command: commands.linux },
{ id: 'macos', label: 'macOS · 容器', command: commands.macos },
{ id: 'windows', label: 'Windows · 容器', command: commands.windows },
]}
/>
</Field>
<p className="muted">
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
容器方式是首选:镜像按 CI 签名的清单固定到 digest,宿主机更新器能自己更新它,属主和信任根在注册时固定。{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
+13 -9
View File
@@ -1,15 +1,14 @@
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
import type { TrustStateDto } from '@shared/api/types'
/**
* 选这个节点的属主(必选,至少一把):允许授权它 extension 会话(web ssh 等,以后)、解密它加密内容的 passkey。只是选,不需要触碰 Passkey:
* 选这个节点的属主(必选,至少一把):节点绑定在哪几个账户的哪几把 passkey 上,它的 extension 会话(web ssh 等,以后)和加密内容只认它们。只是选,不需要触碰 Passkey:
* 选定的凭证 id 会写进安装命令,节点装上时固定在本地,之后面板改不了。默认是操作员集合里的全部。
* 还没有建立信任根时不显示选项,只提示去建立。
*/
export function OwnerPicker({ value, onChange }: { value: string[] | null; onChange: (next: string[]) => void }) {
const [trust, setTrust] = useState<TrustStateDto>()
const [own, setOwn] = useState<PasskeyDto[]>([])
useEffect(() => {
let alive = true
@@ -21,10 +20,6 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
if (value === null) onChange(t.operators ?? [])
})
.catch(() => undefined)
api
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
.then((list) => alive && setOwn(list.items))
.catch(() => undefined)
return () => {
alive = false
}
@@ -33,11 +28,20 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
if (!trust) return null
if (!trust.initialized) {
return <div className="notice warn">还没有建立信任根:每个节点都必须有属主(passkey),先到「系统 → 信任」用 Passkey 建立信任根,才能添加节点。</div>
return (
<div className="notice warn">
还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。
</div>
)
}
const chosen = new Set(value ?? [])
const name = (id: string) => own.find((p) => p.id === id)?.name ?? `其他账号的 Passkey(${id.slice(0, 8)}…)`
const details = new Map((trust.operator_details ?? []).map((d) => [d.credential_id, d] as const))
// 绑定到哪个账户的哪把 Passkey:「Passkey 名称 · 账户」;对不上任何已注册的 Passkey 时标明
const name = (id: string) => {
const d = details.get(id)
return d?.passkey ? `${d.passkey}${d.account ? ` · ${d.account}` : ''}` : `未知 Passkey(${id.slice(0, 8)}…)`
}
return (
<div className="stack" style={{ gap: 4 }}>
{(trust.operators ?? []).map((id) => (
+1 -1
View File
@@ -147,7 +147,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
+7 -2
View File
@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { api } from '@shared/api/client'
import type { TrustStateDto } from '@shared/api/types'
import { signStatement } from '@shared/api/webauthn'
@@ -49,11 +50,15 @@ function Changes({ proposal }: { proposal: Proposal }) {
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
* 操作员集合,面板自己改不了,也签不出新的版本。
*/
export function Trust() {
export function Trust({ onInitialized }: { onInitialized?: () => void } = {}) {
const trust = useQuery(() => api.get<TrustStateDto>('/api/admin/trust'), [])
const { busy, run } = useAction()
const toast = useToast()
const state = trust.data
useEffect(() => {
if (state?.initialized) onInitialized?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.initialized])
const sign = async () => {
if (!state?.proposal) return
@@ -80,7 +85,7 @@ export function Trust() {
<b>还没有建立信任根</b>
</div>
<p className="muted">
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
<b>不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。</b>建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。
</p>
{state.proposal ? (