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
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
2ae63653c4
commit
f22fdf8b68
31 files changed
+799
-49
No files matched your search
@@ -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 的页面
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user