feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI]
This commit is contained in:
1 parent
0b384a58f5
commit
8c37db1577
14 files changed
+207
-12
No files matched your search
@@ -46,6 +46,12 @@ public static class EnrollmentClient
|
||||
TrustBundle? bundle = null;
|
||||
if (genesis is not null)
|
||||
{
|
||||
if (controllers is not { Count: > 0 })
|
||||
{
|
||||
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
|
||||
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
|
||||
}
|
||||
|
||||
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
||||
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||
{
|
||||
|
||||
@@ -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<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||
|
||||
@@ -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<string[]> 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));
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。
|
||||
/// 属主必须是操作员集合里的凭证;<see cref="BackendOptions.RequireOwner"/> 打开时(默认)必须已经有信任根,并且至少选一把——
|
||||
/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。
|
||||
/// </summary>
|
||||
public static class OwnerPolicy
|
||||
{
|
||||
public static Result<string[]> Resolve(bool required, OperatorSet? set, IReadOnlyList<string>? requested)
|
||||
{
|
||||
if (set is null)
|
||||
{
|
||||
return required
|
||||
? ValidationError.Fail<string[]>("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey")
|
||||
: Result<string[]>.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<string[]>("controllers", "must be passkeys of the operator set");
|
||||
}
|
||||
|
||||
return required && chosen.Length == 0
|
||||
? ValidationError.Fail<string[]>("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to")
|
||||
: Result<string[]>.Ok(chosen);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ public sealed class BackendOptions
|
||||
/// </summary>
|
||||
public string BinaryPolicy { get; set; } = "enforce";
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
|
||||
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
|
||||
/// </summary>
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
|
||||
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
|
||||
@@ -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 秒
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。
|
||||
/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。
|
||||
/// </summary>
|
||||
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<BackendOptions>();
|
||||
_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)));
|
||||
|
||||
/// <summary>建立信任根:一个有 passkey 的操作员给操作员集合签名。</summary>
|
||||
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<bool>())
|
||||
{
|
||||
string statement = state["proposal"]!["statement"]!.GetValue<string>();
|
||||
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<JsonNode> 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<string>();
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -50,7 +50,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={busy}
|
||||
disabled={busy || !controllers?.length}
|
||||
onClick={async () => {
|
||||
await run(async () => {
|
||||
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
|
||||
@@ -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 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from '@shared/api/client'
|
||||
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
|
||||
|
||||
/**
|
||||
* 选这个节点的属主:允许授权它 extension 会话(web ssh 等,以后)的 passkey。只是选,不需要触碰 Passkey:
|
||||
* 选这个节点的属主(必选,至少一把):允许授权它 extension 会话(web ssh 等,以后)、解密它加密内容的 passkey。只是选,不需要触碰 Passkey:
|
||||
* 选定的凭证 id 会写进安装命令,节点装上时固定在本地,之后面板改不了。默认是操作员集合里的全部。
|
||||
* 还没有建立信任根时不显示选项,只提示去建立。
|
||||
*/
|
||||
@@ -33,7 +33,7 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
|
||||
|
||||
if (!trust) return null
|
||||
if (!trust.initialized) {
|
||||
return <div className="notice warn">还没有建立信任根:这个节点装上后不会固定属主,也不会校验面板的操作员集合。先到「系统 → 信任」用 Passkey 建立。</div>
|
||||
return <div className="notice warn">还没有建立信任根:每个节点都必须有属主(passkey),先到「系统 → 信任」用 Passkey 建立信任根,才能添加节点。</div>
|
||||
}
|
||||
|
||||
const chosen = new Set(value ?? [])
|
||||
|
||||
@@ -113,7 +113,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={busy}
|
||||
disabled={busy || !controllers?.length}
|
||||
onClick={async () => {
|
||||
await run(async () => {
|
||||
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
|
||||
@@ -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 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user