From 8c37db15773cde2aa5226045b5f3dd18fd71c026 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 06:04:04 +0800 Subject: [PATCH 1/3] feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI] --- .../EnrollmentClient.cs | 6 + backend/fleet/tools/fleet-enroll/Program.cs | 6 + .../Admin/AgentEndpoints.cs | 6 +- .../Admin/OwnerPolicy.cs | 32 +++++ .../BackendOptions.cs | 6 + .../MoShiro.Backend.Tests/BackendFixture.cs | 1 + .../tests/MoShiro.Backend.Tests/OwnerTests.cs | 126 ++++++++++++++++++ docs/access.md | 8 +- packaging/container/entrypoint.sh | 7 + packaging/install/install.ps1 | 5 + packaging/install/install.sh | 4 + web/admin/src/pages/Agents.tsx | 4 +- web/admin/src/pages/OwnerPicker.tsx | 4 +- web/admin/src/pages/Probes.tsx | 4 +- 14 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/OwnerTests.cs diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 35cb796..0260cd1 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -46,6 +46,12 @@ public static class EnrollmentClient TrustBundle? bundle = null; if (genesis is not null) { + if (controllers is not { Count: > 0 }) + { + // 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作 + return Result.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)"); + } + Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct); if (!trusted.TryGet(out bundle, out FleetError? trustError)) { diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3e7ee3b..e563fb2 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -33,6 +33,12 @@ if (Arg("--meta") is { } metaText) string? genesis = Arg("--genesis"); string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +if (genesis is not null && controllers.Length == 0) +{ + Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)"); + return 2; +} + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 8fe3026..d0530a6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -137,10 +137,10 @@ public static class AgentEndpoints // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把 OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; - string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]]; - if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c))) + Result owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers); + if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError)) { - return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set")); + return Problems.From(http, ownerError); } var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); diff --git a/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs new file mode 100644 index 0000000..34c2bbe --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs @@ -0,0 +1,32 @@ +using Fleet.Core; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// +/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。 +/// 属主必须是操作员集合里的凭证; 打开时(默认)必须已经有信任根,并且至少选一把—— +/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。 +/// +public static class OwnerPolicy +{ + public static Result Resolve(bool required, OperatorSet? set, IReadOnlyList? requested) + { + if (set is null) + { + return required + ? ValidationError.Fail("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey") + : Result.Ok([]); + } + + string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)]; + if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c))) + { + return ValidationError.Fail("controllers", "must be passkeys of the operator set"); + } + + return required && chosen.Length == 0 + ? ValidationError.Fail("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to") + : Result.Ok(chosen); + } +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 99d564c..12937ba 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,12 @@ public sealed class BackendOptions /// public string BinaryPolicy { get; set; } = "enforce"; + /// + /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 + /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 + /// + public bool RequireOwner { get; set; } = true; + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index c36ce48..b7f5cd9 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -259,6 +259,7 @@ public class BackendFixture : IAsyncLifetime options.InitialAdminUser = AdminUser; options.InitialAdminPassword = AdminPassword; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; + options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests) options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务 options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }); diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs new file mode 100644 index 0000000..ce0c71f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Identity; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// +/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。 +/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。 +/// +public sealed class OwnerPolicyTests +{ + private static OperatorSet Set(params string[] ids) => + new(1, OperatorTrust.Genesis, "example.test", ["https://example.test"], [.. ids.Select(id => new OperatorKey(id, [1, 2, 3]))]); + + [Fact] + public void WithoutATrustRoot_AnOwnerCannotBeChosen_WhenOwnersAreRequired() + { + Assert.False(OwnerPolicy.Resolve(required: true, null, null).IsSuccess); + Assert.False(OwnerPolicy.Resolve(required: true, null, ["a"]).IsSuccess); + Assert.Empty(OwnerPolicy.Resolve(required: false, null, null).Value); // 没有信任根的老部署:不固定属主 + } + + [Fact] + public void ByDefaultEveryOperatorIsAnOwner_AndAnExplicitChoiceMustBeNonEmptyAndKnown() + { + OperatorSet set = Set("a", "b"); + + Assert.Equal(["a", "b"], OwnerPolicy.Resolve(true, set, null).Value); + Assert.Equal(["b"], OwnerPolicy.Resolve(true, set, ["b", "b"]).Value); + Assert.False(OwnerPolicy.Resolve(true, set, []).IsSuccess); // 一把都不选:不行 + Assert.False(OwnerPolicy.Resolve(true, set, ["a", "stranger"]).IsSuccess); // 不是操作员集合里的凭证 + Assert.False(OwnerPolicy.Resolve(false, set, ["stranger"]).IsSuccess); + } +} + +[Collection("probe-e2e")] +public sealed class OwnerEndToEndTests : IDisposable +{ + private readonly BackendFixture _backend; + private readonly BackendOptions _options; + private readonly bool _before; + + public OwnerEndToEndTests(BackendFixture backend) + { + _backend = backend; + _options = backend.App.Services.GetRequiredService(); + _before = _options.RequireOwner; + _options.RequireOwner = true; + } + + public void Dispose() => _options.RequireOwner = _before; + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// 建立信任根:一个有 passkey 的操作员给操作员集合签名。 + private async Task<(HttpClient Session, SoftwarePasskey Passkey)> EstablishTrustAsync() + { + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("owner"); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + if (!state["initialized"]!.GetValue()) + { + string statement = state["proposal"]!["statement"]!.GetValue(); + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + var body = new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + using HttpResponseMessage committed = await session.PostAsync("/api/admin/trust", BackendFixture.Json(body)); + Assert.Equal(HttpStatusCode.Created, committed.StatusCode); + } + + return (session, passkey); + } + + private async Task OperatorsAsync(HttpClient session) => + JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!["operators"]!; + + [Fact] + public async Task ANodeCannotBeAddedWithoutAnOwner() + { + (HttpClient session, SoftwarePasskey passkey) = await EstablishTrustAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; + string known = (await OperatorsAsync(session))[0]!.GetValue(); + + using HttpResponseMessage none = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray() })); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + Assert.Contains("owner", await none.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["controllers"] = new JsonArray("not-an-operator") })); + Assert.Equal(HttpStatusCode.BadRequest, stranger.StatusCode); + + // 明确选了一把,或者不指定(默认是全部操作员):可以,命令里带着创世指纹和属主 + using HttpResponseMessage chosen = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray(known) })); + Assert.Equal(HttpStatusCode.Created, chosen.StatusCode); + string linux = JsonNode.Parse(await chosen.Content.ReadAsStringAsync())!["install_commands"]!["linux"]!.GetValue(); + Assert.Contains("MOSHIRO_GENESIS=", linux, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_CONTROLLERS=", linux, StringComparison.Ordinal); + Assert.Contains(known, linux, StringComparison.Ordinal); + + using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe" })); + Assert.Equal(HttpStatusCode.Created, defaults.StatusCode); + } + + [Fact] + public async Task TheInstallScripts_RefuseANodeWithoutAnOwner() + { + // 脚本是静态的,所以可以直接检查:缺了信任根指纹或属主就不装,只有明确设置 MOSHIRO_ALLOW_NO_OWNER=1(没有信任根的老面板)才放行 + using HttpClient anonymous = _backend.NewClient(); + foreach (string path in new[] { "/install", "/install.ps1" }) + { + string script = await anonymous.GetStringAsync(path); + Assert.Contains("MOSHIRO_ALLOW_NO_OWNER", script, StringComparison.Ordinal); + Assert.Contains("this node has no owner", script, StringComparison.Ordinal); + } + } +} diff --git a/docs/access.md b/docs/access.md index d87f67b..2ed7332 100644 --- a/docs/access.md +++ b/docs/access.md @@ -284,12 +284,14 @@ ### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 - **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 -- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。**每个 Agent 和 Probe 都必须有属主,至少一把**:属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 ### 8.1 添加节点时选属主 -- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。 -- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- API:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。**还没有建立信任根、或者一把属主都没有,都不能创建加入码**(400,字段 `controllers`)。`MoShiro__RequireOwner=false` 可以关掉(只给没有信任根的老部署)。 +- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表,**必选**(一把都不选时创建按钮不可用);只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`);`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。 +- 设计意图(还没做):属主的 passkey 是敏感内容的钥匙。节点和 Probe 这一侧要在宿主侧拿到属主 passkey 的证明,才可以解密、才可以开 extension 会话;Backend 在这里只是搬运,见第 8.2、8.4 节。 - 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。 ### 8.2 谁来强制:节点本地,不是 Backend diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index 6fbc417..a583150 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -13,6 +13,13 @@ if [ ! -f "$identity/node.json" ]; then echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 exit 1 fi + # 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册 + if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + if [ "${MOSHIRO_ALLOW_NO_OWNER:-}" != 1 ]; then + echo "this node has no owner: set MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS (the panel's command has them; set up the trust root first)" >&2 + exit 1 + fi + fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then # 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地 /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \ diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index ba945c2..05b2803 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -113,6 +113,11 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name") + # 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) + if ((-not $env:MOSHIRO_GENESIS -or -not $env:MOSHIRO_CONTROLLERS) -and $env:MOSHIRO_ALLOW_NO_OWNER -ne '1') { + Write-Error 'this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)' + return + } # 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地 if ($env:MOSHIRO_GENESIS) { if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return } diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 2a5659b..26584fb 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -109,6 +109,10 @@ elif [ "$ROLE" = agent ]; then set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp fi set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" +# 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) +if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + [ "${MOSHIRO_ALLOW_NO_OWNER:-}" = 1 ] || die "this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)" +fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then case "$MOSHIRO_GENESIS${MOSHIRO_CONTROLLERS:-}" in *[!A-Za-z0-9_,-]*) die "MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters" ;; esac set -- "$@" -e "MOSHIRO_GENESIS=$MOSHIRO_GENESIS" -e "MOSHIRO_CONTROLLERS=${MOSHIRO_CONTROLLERS:-}" diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 001e880..3942c23 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -50,7 +50,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated + + + ) : null} + + {step === 2 ? ( +
+ setTrusted(true)} /> +
+ +
+
+ ) : null} + + + ) +} + +function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise; 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 ( +
+ + setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required /> + + + setUsername(e.target.value)} autoComplete="username" required /> + + + setPassword(e.target.value)} autoComplete="new-password" required /> + + + setConfirm(e.target.value)} autoComplete="new-password" required /> + + {error ?
{error}
: null} +
+ +
+
+ ) +} diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index ba53ec9..a5b7015 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -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 logout: () => Promise + /** 面板里还没有任何用户:显示认领向导(创建管理员 → 注册 passkey → 建立信任根)。向导走完(或离开)之前一直为真。 */ + setupActive: boolean + /** 向导第一步:用后端日志里的认领码创建第一个管理员,并马上以他登录。 */ + completeSetup: (claimCode: string, username: string, password: string) => Promise + /** 离开向导,进入面板。 */ + finishSetup: () => void } const AuthContext = createContext(null) @@ -19,6 +25,7 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [me, setMe] = useState(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('/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('/api/admin/auth/me')) }, []) + const completeSetup = useCallback(async (claimCode: string, username: string, password: string) => { + const r = await api.post('/api/admin/auth/setup', { claim_code: claimCode, username, password }) + setCsrf(r.csrf_token) + setMe(await api.get('/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 {children} + return {children} } export function useAuth(): Auth { diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 3942c23..c35e465 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -92,7 +92,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated setHours(Number(e.target.value))} />
- +
diff --git a/web/admin/src/pages/JoinTokenCreated.tsx b/web/admin/src/pages/JoinTokenCreated.tsx index 48a05ae..0c18a61 100644 --- a/web/admin/src/pages/JoinTokenCreated.tsx +++ b/web/admin/src/pages/JoinTokenCreated.tsx @@ -21,21 +21,21 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。

- {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)} 过期。

diff --git a/web/admin/src/pages/OwnerPicker.tsx b/web/admin/src/pages/OwnerPicker.tsx index fec5f94..737f5fd 100644 --- a/web/admin/src/pages/OwnerPicker.tsx +++ b/web/admin/src/pages/OwnerPicker.tsx @@ -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() - const [own, setOwn] = useState([]) 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>('/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
还没有建立信任根:每个节点都必须有属主(passkey),先到「系统 → 信任」用 Passkey 建立信任根,才能添加节点。
+ return ( +
+ 还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。 +
+ ) } 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 (
{(trust.operators ?? []).map((id) => ( diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 813e871..c64bcc6 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -147,7 +147,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated setHours(Number(e.target.value))} />
- +
diff --git a/web/admin/src/pages/Trust.tsx b/web/admin/src/pages/Trust.tsx index 687fa07..9582d66 100644 --- a/web/admin/src/pages/Trust.tsx +++ b/web/admin/src/pages/Trust.tsx @@ -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('/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() { 还没有建立信任根

- 建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 + 不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。

{state.proposal ? ( diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 98e9fac..4102822 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -327,6 +327,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Whether the panel has no users yet and still has to be claimed */ + get: operations["setup_status"]; + put?: never; + /** Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists) */ + post: operations["setup_complete"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/passkey/options": { parameters: { query?: never; @@ -2073,6 +2091,15 @@ export interface components { default: string; description: string; }; + SetupRequest: { + claim_code: null | string; + username: null | string; + password: null | string; + return_session: null | boolean; + }; + SetupStatusDto: { + needs_setup: boolean; + }; SignedStatementDto: { statement?: string; credential_id?: string; @@ -2194,6 +2221,7 @@ export interface components { server_key: string; /** @default false */ server_key_listed: boolean; + operator_details?: null | components["schemas"]["NodeOwnerDto"][]; }; UserDto: { id: string; @@ -3076,6 +3104,86 @@ export interface operations { }; }; }; + setup_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetupStatusDto"]; + }; + }; + }; + }; + setup_complete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetupRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @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 Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; auth_passkey_options: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 721db0f..618df03 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -56,6 +56,7 @@ export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow export type LoginResponse = Narrow +export type SetupStatusDto = Narrow export interface Page { items: T[]