feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
This commit is contained in:
1 parent
9bb6841dce
commit
a2f88a344b
89 files changed
+2101
-1151
No files matched your search
@@ -6,3 +6,4 @@ packaging/update/*.sh text eol=lf
|
|||||||
packaging/container/*.sh text eol=lf
|
packaging/container/*.sh text eol=lf
|
||||||
ci/release-node-images.sh text eol=lf
|
ci/release-node-images.sh text eol=lf
|
||||||
packaging/install/*.sh text eol=lf
|
packaging/install/*.sh text eol=lf
|
||||||
|
packaging/install/*.ps1 text eol=lf
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
<Version>0.1.1</Version>
|
<Version>0.2.0</Version>
|
||||||
<NuGetAudit>false</NuGetAudit>
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
private readonly AgentConfig _config;
|
private readonly AgentConfig _config;
|
||||||
private readonly bool _ipv6Supported;
|
private readonly bool _ipv6Supported;
|
||||||
private readonly KnockGate _knock;
|
private readonly KnockGate _knock;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private readonly RestartController _restart;
|
private readonly RestartController _restart;
|
||||||
private readonly RestartController? _ownedRestart;
|
private readonly RestartController? _ownedRestart;
|
||||||
private readonly LoadedNodeIdentity _identity;
|
private readonly LoadedNodeIdentity _identity;
|
||||||
@@ -63,6 +64,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
_admission = new AgentAdmission(_streams);
|
_admission = new AgentAdmission(_streams);
|
||||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||||
|
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
};
|
};
|
||||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, TrustAnchor.Load(config.Fleet.IdentityDir, log));
|
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||||
|
|
||||||
@@ -123,6 +125,9 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
public int ControlPort => _peers.Port;
|
public int ControlPort => _peers.Port;
|
||||||
|
|
||||||
|
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
|
||||||
|
public int TrustVersion => _trust.Version;
|
||||||
|
|
||||||
public AgentCounters Counters => _counters;
|
public AgentCounters Counters => _counters;
|
||||||
|
|
||||||
public FleetLink Link => _link;
|
public FleetLink Link => _link;
|
||||||
@@ -270,6 +275,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
{
|
{
|
||||||
Server = new Uri(_config.Fleet.Server),
|
Server = new Uri(_config.Fleet.Server),
|
||||||
Identity = _identity.Identity,
|
Identity = _identity.Identity,
|
||||||
|
ServerVerifier = _trust,
|
||||||
NodeId = _identity.NodeId,
|
NodeId = _identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fleet.Hosting;
|
using Fleet.Hosting;
|
||||||
using Fleet.Identity;
|
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
using MoShiro.Measurement;
|
using MoShiro.Measurement;
|
||||||
@@ -102,22 +101,14 @@ public sealed class TaskManager
|
|||||||
_ = ct;
|
_ = ct;
|
||||||
var result = new StateSyncResult { AppliedVersion = version };
|
var result = new StateSyncResult { AppliedVersion = version };
|
||||||
|
|
||||||
// 固定了操作员创世的节点:先验面板带来的操作员集合链(从固定的创世出发),再要求每个 Probe 带着操作员批准的证明。
|
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||||||
// 面板自己改名册、塞进自己的 Probe 公钥是过不了这一关的:它签不出操作员的委托。
|
|
||||||
_trust.Update(set.TrustChain);
|
_trust.Update(set.TrustChain);
|
||||||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||||
foreach (PeerSpec peer in set.Peers)
|
foreach (PeerSpec peer in set.Peers)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
byte[] key = Convert.FromBase64String(peer.PublicKey);
|
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
|
||||||
if (_trust.VerifyPeer(peer.Proof, key, "probe") != TrustFailure.None)
|
|
||||||
{
|
|
||||||
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.PolicyDenied });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
peers[peer.ProbeId.Value] = key;
|
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Fleet.Core;
|
namespace Fleet.Core;
|
||||||
|
|
||||||
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
|
||||||
public sealed class SignedStatementDto
|
public sealed class SignedStatementDto
|
||||||
{
|
{
|
||||||
[JsonPropertyName("statement")]
|
[JsonPropertyName("statement")]
|
||||||
@@ -21,25 +21,9 @@ public sealed class SignedStatementDto
|
|||||||
public string Signature { get; set; } = "";
|
public string Signature { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。</summary>
|
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
|
||||||
public sealed class MemberProofDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("grant")]
|
|
||||||
public SignedStatementDto Grant { get; set; } = new();
|
|
||||||
|
|
||||||
[JsonPropertyName("binding")]
|
|
||||||
public string Binding { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("binding_signature")]
|
|
||||||
public string BindingSignature { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。</summary>
|
|
||||||
public sealed class TrustBundle
|
public sealed class TrustBundle
|
||||||
{
|
{
|
||||||
[JsonPropertyName("chain")]
|
[JsonPropertyName("chain")]
|
||||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("grant")]
|
|
||||||
public SignedStatementDto? Grant { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class EnrollmentClient
|
|||||||
{
|
{
|
||||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
||||||
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
|
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
|
||||||
string? seed = null, string? genesis = null, string role = "agent")
|
string? genesis = null, IReadOnlyList<string>? controllers = null)
|
||||||
{
|
{
|
||||||
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||||
if (File.Exists(nodeFile))
|
if (File.Exists(nodeFile))
|
||||||
@@ -41,25 +41,16 @@ public static class EnrollmentClient
|
|||||||
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的,
|
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
|
||||||
// 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。
|
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
|
||||||
Binding? binding = null;
|
|
||||||
string? bindingStatement = null;
|
|
||||||
string? bindingSignature = null;
|
|
||||||
TrustBundle? bundle = null;
|
TrustBundle? bundle = null;
|
||||||
if (seed is not null || genesis is not null)
|
if (genesis is not null)
|
||||||
{
|
{
|
||||||
Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted =
|
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
|
||||||
await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct);
|
if (!trusted.TryGet(out bundle, out FleetError? trustError))
|
||||||
if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError))
|
|
||||||
{
|
{
|
||||||
return Result<EnrollmentResult>.Fail(trustError);
|
return Result<EnrollmentResult>.Fail(trustError);
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle = value.Bundle;
|
|
||||||
binding = value.Binding;
|
|
||||||
bindingStatement = value.Statement;
|
|
||||||
bindingSignature = value.Signature;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||||
@@ -74,8 +65,6 @@ public static class EnrollmentClient
|
|||||||
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
||||||
["node_version"] = nodeVersion,
|
["node_version"] = nodeVersion,
|
||||||
["meta"] = productMeta,
|
["meta"] = productMeta,
|
||||||
["binding"] = bindingStatement,
|
|
||||||
["binding_signature"] = bindingSignature,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||||
@@ -104,9 +93,9 @@ public static class EnrollmentClient
|
|||||||
var store = new ConfigStore(identityDirectory);
|
var store = new ConfigStore(identityDirectory);
|
||||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||||
if (bundle is not null && binding is not null)
|
if (bundle is not null)
|
||||||
{
|
{
|
||||||
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! });
|
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// node.json 最后写:它存在就表示注册完整完成
|
// node.json 最后写:它存在就表示注册完整完成
|
||||||
@@ -116,37 +105,21 @@ public static class EnrollmentClient
|
|||||||
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
|
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)>> EstablishTrustAsync(
|
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
|
||||||
HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
|
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
|
||||||
|
|
||||||
if (seedText is null || genesis is null)
|
|
||||||
{
|
|
||||||
return Refuse("--seed and --genesis go together (the panel's install command has both)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32)
|
|
||||||
{
|
|
||||||
return Refuse("the code seed is malformed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OperatorTrust.AuthToken(seed) != joinToken)
|
|
||||||
{
|
|
||||||
return Refuse("the join token is not derived from the code seed");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
|
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
|
||||||
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
|
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
|
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
|
||||||
}
|
}
|
||||||
|
|
||||||
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
|
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
|
||||||
if (bundle?.Grant is null || bundle.Chain.Count == 0)
|
if (bundle is null || bundle.Chain.Count == 0)
|
||||||
{
|
{
|
||||||
return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator");
|
return Refuse("the panel has no operator trust root set up");
|
||||||
}
|
}
|
||||||
|
|
||||||
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
|
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
|
||||||
@@ -155,26 +128,11 @@ public static class EnrollmentClient
|
|||||||
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
|
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
|
||||||
}
|
}
|
||||||
|
|
||||||
Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement);
|
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
|
||||||
long now = time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current))
|
|
||||||
{
|
{
|
||||||
return Refuse("the grant is not signed by your operator passkey");
|
return Refuse("an owner passkey in the install command is not in the operator set");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs)
|
return Result<TrustBundle>.Ok(bundle);
|
||||||
{
|
|
||||||
return Refuse("the grant is not for this code, this role, or this time");
|
|
||||||
}
|
|
||||||
|
|
||||||
using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
|
|
||||||
if (codeKey is null)
|
|
||||||
{
|
|
||||||
return Refuse("the code seed does not match the grant's code key");
|
|
||||||
}
|
|
||||||
|
|
||||||
var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now);
|
|
||||||
string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement);
|
|
||||||
return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ using Fleet.Identity;
|
|||||||
|
|
||||||
namespace Fleet.Hosting;
|
namespace Fleet.Hosting;
|
||||||
|
|
||||||
/// <summary><c>trust.json</c>:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。</summary>
|
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
|
||||||
public sealed class TrustFile
|
public sealed class TrustFile
|
||||||
{
|
{
|
||||||
[JsonPropertyName("genesis")]
|
[JsonPropertyName("genesis")]
|
||||||
@@ -14,19 +14,20 @@ public sealed class TrustFile
|
|||||||
[JsonPropertyName("chain")]
|
[JsonPropertyName("chain")]
|
||||||
public List<SignedStatementDto> Chain { get; set; } = [];
|
public List<SignedStatementDto> Chain { get; set; } = [];
|
||||||
|
|
||||||
[JsonPropertyName("proof")]
|
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
|
||||||
public MemberProofDto? Proof { get; set; }
|
[JsonPropertyName("controllers")]
|
||||||
|
public List<string> Controllers { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
[JsonSerializable(typeof(TrustFile))]
|
[JsonSerializable(typeof(TrustFile))]
|
||||||
public sealed partial class TrustFileContext : JsonSerializerContext;
|
public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式——
|
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||||
/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 <c>trust.json</c>(旧节点、没有指纹的安装)就不强制。
|
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||||
/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TrustAnchor
|
public sealed class TrustAnchor : IServerVerifier
|
||||||
{
|
{
|
||||||
public const string FileName = "trust.json";
|
public const string FileName = "trust.json";
|
||||||
|
|
||||||
@@ -44,14 +45,68 @@ public sealed class TrustAnchor
|
|||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>不强制(没有固定的创世)。</summary>
|
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
|
||||||
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
|
||||||
|
|
||||||
public bool Enforcing => _file is not null;
|
public bool Pinned => _file is not null;
|
||||||
|
|
||||||
public string? Genesis => _file?.Genesis;
|
public string? Genesis => _file?.Genesis;
|
||||||
|
|
||||||
public MemberProofDto? OwnProof => _file?.Proof;
|
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
|
||||||
|
public int Version
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _current?.Version ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
|
||||||
|
public bool Required
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _current?.Servers is { Count: > 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
|
||||||
|
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||||
|
{
|
||||||
|
IReadOnlyList<byte[]> servers;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
servers = _current?.Servers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (byte[] server in servers)
|
||||||
|
{
|
||||||
|
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
|
||||||
|
public IReadOnlyList<string> Controllers
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
public static TrustAnchor Load(string identityDirectory, INodeLog log)
|
||||||
{
|
{
|
||||||
@@ -65,18 +120,18 @@ public sealed class TrustAnchor
|
|||||||
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
|
||||||
if (current is null)
|
if (current is null)
|
||||||
{
|
{
|
||||||
// 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好
|
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
|
||||||
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>注册时写下:固定的创世、验证过的链、本节点的证明。</summary>
|
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
|
||||||
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, MemberProofDto proof) =>
|
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
|
||||||
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile));
|
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
|
||||||
|
|
||||||
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。</summary>
|
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
|
||||||
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
public int Update(IReadOnlyList<SignedStatementDto>? chain)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -112,21 +167,4 @@ public sealed class TrustAnchor
|
|||||||
return _current.Version;
|
return _current.Version;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。</summary>
|
|
||||||
public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan<byte> peerPublicKey, string role)
|
|
||||||
{
|
|
||||||
OperatorSet? current;
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_file is null)
|
|
||||||
{
|
|
||||||
return TrustFailure.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = _current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
|
|||||||
public static class SignaturePurpose
|
public static class SignaturePurpose
|
||||||
{
|
{
|
||||||
public const string Auth = "auth";
|
public const string Auth = "auth";
|
||||||
|
|
||||||
|
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
|
||||||
|
public const string AuthSecure = "auth2";
|
||||||
public const string Peer = "peer";
|
public const string Peer = "peer";
|
||||||
public const string Enroll = "enroll";
|
public const string Enroll = "enroll";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,45 +15,21 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
|||||||
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
|
||||||
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys);
|
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
|
||||||
|
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
|
||||||
/// <summary>委托:操作员授权了一个加入码。<c>CodeKey</c> 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。</summary>
|
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||||
public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses);
|
|
||||||
|
|
||||||
/// <summary>节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。<c>AtMs</c> 必须落在委托的有效期内。</summary>
|
|
||||||
public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs);
|
|
||||||
|
|
||||||
/// <summary>一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。</summary>
|
|
||||||
public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature);
|
|
||||||
|
|
||||||
public enum TrustFailure
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
Malformed,
|
|
||||||
BrokenChain,
|
|
||||||
GrantNotSigned,
|
|
||||||
GrantExpired,
|
|
||||||
BindingInvalid,
|
|
||||||
KeyMismatch,
|
|
||||||
RoleMismatch,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。
|
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
|
||||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。
|
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||||
/// 语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。
|
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||||
|
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class OperatorTrust
|
public static class OperatorTrust
|
||||||
{
|
{
|
||||||
public const string SetHeader = "moshiro-opset-v1";
|
public const string SetHeader = "moshiro-opset-v1";
|
||||||
public const string GrantHeader = "moshiro-grant-v1";
|
|
||||||
public const string BindingHeader = "moshiro-binding-v1";
|
|
||||||
public const string Genesis = "0";
|
public const string Genesis = "0";
|
||||||
|
|
||||||
private const string AuthLabel = "moshiro-join-auth-v1";
|
|
||||||
|
|
||||||
// ───────────────────────── 编码 ─────────────────────────
|
|
||||||
|
|
||||||
public static string Encode(OperatorSet set)
|
public static string Encode(OperatorSet set)
|
||||||
{
|
{
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
@@ -67,21 +43,15 @@ public static class OperatorTrust
|
|||||||
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (byte[] server in set.Servers ?? [])
|
||||||
|
{
|
||||||
|
text.Append("\nserver=").Append(Convert.ToBase64String(server));
|
||||||
|
}
|
||||||
|
|
||||||
return text.ToString();
|
return text.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Encode(Grant grant) =>
|
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
|
||||||
string.Join(
|
|
||||||
'\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey),
|
|
||||||
"nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
public static string Encode(Binding binding) =>
|
|
||||||
string.Join(
|
|
||||||
'\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey),
|
|
||||||
"peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
// ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ─────────────────────────
|
|
||||||
|
|
||||||
public static OperatorSet? ParseSet(string text)
|
public static OperatorSet? ParseSet(string text)
|
||||||
{
|
{
|
||||||
string[] lines = text.Split('\n');
|
string[] lines = text.Split('\n');
|
||||||
@@ -92,46 +62,26 @@ public static class OperatorTrust
|
|||||||
}
|
}
|
||||||
|
|
||||||
var keys = new List<OperatorKey>();
|
var keys = new List<OperatorKey>();
|
||||||
|
var servers = new List<byte[]>();
|
||||||
foreach (string line in lines.Skip(5))
|
foreach (string line in lines.Skip(5))
|
||||||
{
|
{
|
||||||
if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki))
|
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
|
||||||
{
|
{
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
keys.Add(new OperatorKey(value[..split], spki));
|
keys.Add(new OperatorKey(value[..split], spki));
|
||||||
}
|
}
|
||||||
|
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
|
||||||
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Grant? ParseGrant(string text)
|
|
||||||
{
|
{
|
||||||
string[] lines = text.Split('\n');
|
servers.Add(serverKey);
|
||||||
if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey)
|
}
|
||||||
|| !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count)
|
else
|
||||||
|| role.Length == 0 || auth.Length != 64)
|
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Grant(role, auth, codeKey, notBefore, notAfter, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Binding? ParseBinding(string text)
|
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
|
||||||
{
|
|
||||||
string[] lines = text.Split('\n');
|
|
||||||
if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node)
|
|
||||||
|| !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Binding(name, role, node, peerKey, auth, at);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 指纹与验证 ─────────────────────────
|
|
||||||
|
|
||||||
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
|
||||||
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
|
||||||
|
|
||||||
@@ -187,111 +137,6 @@ public static class OperatorTrust
|
|||||||
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证一个对端:和 <see cref="VerifyMember"/> 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥),
|
|
||||||
/// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。
|
|
||||||
/// </summary>
|
|
||||||
public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> peerPublicKey, string role)
|
|
||||||
{
|
|
||||||
Binding? binding = ParseBinding(proof.BindingStatement);
|
|
||||||
return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签,
|
|
||||||
/// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。
|
|
||||||
/// </summary>
|
|
||||||
public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan<byte> nodePublicKey, ReadOnlySpan<byte> peerPublicKey, string role)
|
|
||||||
{
|
|
||||||
Grant? grant = ParseGrant(proof.Grant.Statement);
|
|
||||||
Binding? binding = ParseBinding(proof.BindingStatement);
|
|
||||||
if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature))
|
|
||||||
{
|
|
||||||
return TrustFailure.Malformed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!VerifyOperator(proof.Grant, current))
|
|
||||||
{
|
|
||||||
return TrustFailure.GrantNotSigned;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs)
|
|
||||||
{
|
|
||||||
return TrustFailure.GrantExpired;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role)
|
|
||||||
{
|
|
||||||
return TrustFailure.KeyMismatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey))
|
|
||||||
{
|
|
||||||
return TrustFailure.KeyMismatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grant.Role != role)
|
|
||||||
{
|
|
||||||
return TrustFailure.RoleMismatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using ECDsa codeKey = ECDsa.Create();
|
|
||||||
codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _);
|
|
||||||
return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
|
|
||||||
? TrustFailure.None
|
|
||||||
: TrustFailure.BindingInvalid;
|
|
||||||
}
|
|
||||||
catch (CryptographicException)
|
|
||||||
{
|
|
||||||
return TrustFailure.BindingInvalid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 加入码的种子 ─────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子,
|
|
||||||
/// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。
|
|
||||||
/// </summary>
|
|
||||||
public static string AuthToken(ReadOnlySpan<byte> seed) =>
|
|
||||||
"ja_" + Base64Url.EncodeToString(Derive(seed));
|
|
||||||
|
|
||||||
private static byte[] Derive(ReadOnlySpan<byte> seed)
|
|
||||||
{
|
|
||||||
byte[] output = new byte[32];
|
|
||||||
HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan<byte>.Empty, Encoding.UTF8.GetBytes(AuthLabel));
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken)));
|
|
||||||
|
|
||||||
/// <summary>用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。</summary>
|
|
||||||
public static ECDsa? CodeSigningKey(ReadOnlySpan<byte> seed, ReadOnlySpan<byte> codeKeySpki)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using ECDsa publicOnly = ECDsa.Create();
|
|
||||||
publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _);
|
|
||||||
ECParameters parameters = publicOnly.ExportParameters(false);
|
|
||||||
parameters.D = seed.ToArray();
|
|
||||||
return ECDsa.Create(parameters);
|
|
||||||
}
|
|
||||||
catch (CryptographicException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string SignBinding(ECDsa codeKey, Binding binding, out string statement)
|
|
||||||
{
|
|
||||||
statement = Encode(binding);
|
|
||||||
return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── 小工具 ─────────────────────────
|
|
||||||
|
|
||||||
private static bool TryValue(string line, string key, out string value)
|
private static bool TryValue(string line, string key, out string value)
|
||||||
{
|
{
|
||||||
value = "";
|
value = "";
|
||||||
@@ -304,12 +149,6 @@ public static class OperatorTrust
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryLong(string line, string key, out long value)
|
|
||||||
{
|
|
||||||
value = 0;
|
|
||||||
return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryBase64(string text, out byte[] data)
|
private static bool TryBase64(string text, out byte[] data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Fleet.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
|
||||||
|
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
|
||||||
|
/// </summary>
|
||||||
|
public interface IServerVerifier
|
||||||
|
{
|
||||||
|
bool Required { get; }
|
||||||
|
|
||||||
|
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
|
||||||
|
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
|
||||||
|
public static class ServerHello
|
||||||
|
{
|
||||||
|
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
|
||||||
|
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||||
|
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
|
||||||
|
|
||||||
|
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
|
||||||
|
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
|
||||||
|
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using ECDsa key = ECDsa.Create();
|
||||||
|
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
|
||||||
|
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
|
||||||
|
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
|
||||||
|
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LinkCipher : IDisposable
|
||||||
|
{
|
||||||
|
public const int Overhead = 8 + 16;
|
||||||
|
|
||||||
|
private const byte NodeToServer = 0;
|
||||||
|
private const byte ServerToNode = 1;
|
||||||
|
|
||||||
|
private readonly AesGcm _send;
|
||||||
|
private readonly AesGcm _receive;
|
||||||
|
private readonly byte _sendDirection;
|
||||||
|
private readonly byte _receiveDirection;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private long _sendCounter;
|
||||||
|
private long _receiveCounter;
|
||||||
|
|
||||||
|
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
|
||||||
|
{
|
||||||
|
_send = new AesGcm(sendKey, 16);
|
||||||
|
_receive = new AesGcm(receiveKey, 16);
|
||||||
|
_sendDirection = sendDirection;
|
||||||
|
_receiveDirection = receiveDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
|
||||||
|
{
|
||||||
|
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
|
||||||
|
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
|
||||||
|
{
|
||||||
|
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
|
||||||
|
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
|
||||||
|
{
|
||||||
|
byte[] secret = own.AgreeRaw(remote);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
|
||||||
|
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
|
||||||
|
return (material[..32], material[32..]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>加密一帧。</summary>
|
||||||
|
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
long counter;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
counter = _sendCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] output = new byte[Overhead + 1 + data.Length];
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(output, counter);
|
||||||
|
byte[] plaintext = new byte[1 + data.Length];
|
||||||
|
plaintext[0] = kind;
|
||||||
|
data.CopyTo(plaintext.AsSpan(1));
|
||||||
|
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
|
||||||
|
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
|
||||||
|
{
|
||||||
|
kind = 0;
|
||||||
|
data = [];
|
||||||
|
if (frame.Length < Overhead + 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (counter != _receiveCounter)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = frame.Length - Overhead;
|
||||||
|
byte[] plaintext = new byte[length];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_receiveCounter++;
|
||||||
|
kind = plaintext[0];
|
||||||
|
data = plaintext[1..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Nonce(long counter)
|
||||||
|
{
|
||||||
|
byte[] nonce = new byte[12];
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Aad(byte direction, long counter)
|
||||||
|
{
|
||||||
|
byte[] aad = new byte[9];
|
||||||
|
aad[0] = direction;
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
|
||||||
|
return aad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_send.Dispose();
|
||||||
|
_receive.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
|
|||||||
|
|
||||||
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
|
||||||
|
|
||||||
|
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
|
||||||
|
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
|
||||||
|
{
|
||||||
|
using ECDiffieHellman remote = ECDiffieHellman.Create();
|
||||||
|
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
|
||||||
|
return _key.DeriveRawSecretAgreement(remote.PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
||||||
|
|||||||
@@ -8,8 +8,4 @@ public static class TrustMapping
|
|||||||
|
|
||||||
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
public static SignedStatementDto ToDto(this SignedStatement signed) =>
|
||||||
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
|
||||||
|
|
||||||
public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature);
|
|
||||||
|
|
||||||
public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature };
|
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
|
|||||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||||
|
|
||||||
public Backoff Backoff { get; init; } = new();
|
public Backoff Backoff { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
|
||||||
|
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||||
|
/// </summary>
|
||||||
|
public IServerVerifier? ServerVerifier { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -77,6 +83,7 @@ public sealed class FleetLink
|
|||||||
private const string RpcPath = "/api/agent/v1/rpc";
|
private const string RpcPath = "/api/agent/v1/rpc";
|
||||||
|
|
||||||
private readonly FleetLinkOptions _options;
|
private readonly FleetLinkOptions _options;
|
||||||
|
private volatile bool _secure;
|
||||||
private readonly RpcRouter<LinkContext> _router;
|
private readonly RpcRouter<LinkContext> _router;
|
||||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
@@ -153,6 +160,9 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||||
|
public bool IsSecure => _secure && IsConnected;
|
||||||
|
|
||||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var socket = new ClientWebSocket();
|
using var socket = new ClientWebSocket();
|
||||||
@@ -160,7 +170,7 @@ public sealed class FleetLink
|
|||||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||||
await socket.ConnectAsync(builder.Uri, ct);
|
await socket.ConnectAsync(builder.Uri, ct);
|
||||||
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
|
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
|
||||||
|
|
||||||
string? failure = await AuthenticateAsync(transport, ct);
|
string? failure = await AuthenticateAsync(transport, ct);
|
||||||
if (failure is not null)
|
if (failure is not null)
|
||||||
@@ -168,6 +178,8 @@ public sealed class FleetLink
|
|||||||
return failure;
|
return failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_secure = transport.Secure;
|
||||||
|
|
||||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
Task run = session.RunAsync(sessionCts.Token);
|
Task run = session.RunAsync(sessionCts.Token);
|
||||||
@@ -211,7 +223,7 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
@@ -222,9 +234,44 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
||||||
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
|
|
||||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
|
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
|
||||||
|
EphemeralKey? ephemeral = null;
|
||||||
|
AuthParams authParams;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_options.ServerVerifier is { Required: true } verifier)
|
||||||
|
{
|
||||||
|
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|
||||||
|
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
|
||||||
|
{
|
||||||
|
return "the server could not prove its identity (it is not the server your operator set authorizes)";
|
||||||
|
}
|
||||||
|
|
||||||
|
ephemeral = new EphemeralKey();
|
||||||
|
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
|
||||||
|
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
|
||||||
|
authParams = new AuthParams
|
||||||
|
{
|
||||||
|
NodeId = _options.NodeId.Value,
|
||||||
|
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||||
|
NodeEphemeral = nodeEphemeralText,
|
||||||
|
};
|
||||||
|
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||||
|
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||||
|
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||||
|
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ephemeral?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -259,7 +306,26 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
|
private static bool TryDecodeBase64(string? text, out byte[] data)
|
||||||
|
{
|
||||||
|
data = [];
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = Convert.FromBase64String(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
|
||||||
{
|
{
|
||||||
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Fleet.Identity;
|
||||||
|
using Fleet.Rpc;
|
||||||
|
|
||||||
|
namespace Fleet.Link;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
|
||||||
|
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SecureTransport : IRpcTransport
|
||||||
|
{
|
||||||
|
private readonly IRpcTransport _inner;
|
||||||
|
private LinkCipher? _cipher;
|
||||||
|
private bool _allowPlaintext = true;
|
||||||
|
|
||||||
|
public SecureTransport(IRpcTransport inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Secure => _cipher is not null;
|
||||||
|
|
||||||
|
public void Enable(LinkCipher cipher) => _cipher = cipher;
|
||||||
|
|
||||||
|
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
|
||||||
|
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
|
||||||
|
|
||||||
|
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
RpcFrame? frame = await _inner.ReceiveAsync(ct);
|
||||||
|
if (frame is not { } received || _cipher is null)
|
||||||
|
{
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
|
||||||
|
{
|
||||||
|
_allowPlaintext = false;
|
||||||
|
return new RpcFrame((FrameKind)kind, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_allowPlaintext && received.Kind == FrameKind.Text)
|
||||||
|
{
|
||||||
|
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException("a frame on the secure link failed to authenticate");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_cipher?.Dispose();
|
||||||
|
await _inner.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,14 @@ public sealed class ChallengeParams
|
|||||||
|
|
||||||
[JsonPropertyName("expires_at_ms")]
|
[JsonPropertyName("expires_at_ms")]
|
||||||
public long ExpiresAtMs { get; set; }
|
public long ExpiresAtMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
|
||||||
|
[JsonPropertyName("server_eph")]
|
||||||
|
public string? ServerEphemeral { get; set; }
|
||||||
|
|
||||||
|
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
|
||||||
|
[JsonPropertyName("server_sig")]
|
||||||
|
public string? ServerSignature { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthParams
|
public sealed class AuthParams
|
||||||
@@ -63,6 +71,10 @@ public sealed class AuthParams
|
|||||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||||
[JsonPropertyName("signature")]
|
[JsonPropertyName("signature")]
|
||||||
public string Signature { get; set; } = "";
|
public string Signature { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||||
|
[JsonPropertyName("node_eph")]
|
||||||
|
public string? NodeEphemeral { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthResult
|
public sealed class AuthResult
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ public sealed record NewNode(
|
|||||||
long NowMs,
|
long NowMs,
|
||||||
string Kind = "",
|
string Kind = "",
|
||||||
byte[]? TlsPublicKey = null,
|
byte[]? TlsPublicKey = null,
|
||||||
string? AddedBy = null,
|
string? AddedBy = null);
|
||||||
MemberProofDto? Proof = null);
|
|
||||||
|
|
||||||
public sealed record JoinTokenRecord(
|
public sealed record JoinTokenRecord(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -49,7 +48,6 @@ public sealed record JoinTokenRecord(
|
|||||||
long? RevokedAtMs,
|
long? RevokedAtMs,
|
||||||
long CreatedAtMs,
|
long CreatedAtMs,
|
||||||
string Kind = "",
|
string Kind = "",
|
||||||
string? CreatedBy = null,
|
string? CreatedBy = null);
|
||||||
SignedStatementDto? Grant = null);
|
|
||||||
|
|
||||||
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||||
@@ -6,6 +6,18 @@ namespace Fleet.Server;
|
|||||||
|
|
||||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
|
||||||
|
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
|
||||||
|
/// </summary>
|
||||||
|
public interface IServerIdentity
|
||||||
|
{
|
||||||
|
byte[] PublicKeySpki { get; }
|
||||||
|
|
||||||
|
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
|
||||||
|
byte[] Sign(ReadOnlySpan<byte> payload);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||||
public interface IOperatorTrustSource
|
public interface IOperatorTrustSource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ public sealed class EnrollRequest
|
|||||||
[JsonPropertyName("node_version")]
|
[JsonPropertyName("node_version")]
|
||||||
public string NodeVersion { get; set; } = "";
|
public string NodeVersion { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。</summary>
|
|
||||||
[JsonPropertyName("binding")]
|
|
||||||
public string? Binding { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("binding_signature")]
|
|
||||||
public string? BindingSignature { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("meta")]
|
[JsonPropertyName("meta")]
|
||||||
public JsonElement? Meta { get; set; }
|
public JsonElement? Meta { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,17 +61,6 @@ public sealed partial class FleetEnrollmentService
|
|||||||
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
|
return Result<EnrollResponse>.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收
|
|
||||||
MemberProofDto? memberProof = null;
|
|
||||||
if (token.Grant is not null)
|
|
||||||
{
|
|
||||||
Result<MemberProofDto> verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct);
|
|
||||||
if (!verified.TryGet(out memberProof, out FleetError? bindingError))
|
|
||||||
{
|
|
||||||
return Result<EnrollResponse>.Fail(bindingError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
|
if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct))
|
||||||
{
|
{
|
||||||
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
return Result<EnrollResponse>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
|
||||||
@@ -94,8 +83,7 @@ public sealed partial class FleetEnrollmentService
|
|||||||
nowMs,
|
nowMs,
|
||||||
token.Kind,
|
token.Kind,
|
||||||
tlsPublicKey,
|
tlsPublicKey,
|
||||||
token.CreatedBy,
|
token.CreatedBy);
|
||||||
memberProof);
|
|
||||||
|
|
||||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
||||||
@@ -116,7 +104,7 @@ public sealed partial class FleetEnrollmentService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。</summary>
|
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
|
||||||
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
|
||||||
{
|
{
|
||||||
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
|
||||||
@@ -131,7 +119,7 @@ public sealed partial class FleetEnrollmentService
|
|||||||
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
var bundle = new TrustBundle { Grant = token.Grant };
|
var bundle = new TrustBundle();
|
||||||
foreach (IOperatorTrustSource source in _trust)
|
foreach (IOperatorTrustSource source in _trust)
|
||||||
{
|
{
|
||||||
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
bundle.Chain.AddRange(await source.ChainAsync(ct));
|
||||||
@@ -140,26 +128,6 @@ public sealed partial class FleetEnrollmentService
|
|||||||
return Result<TrustBundle>.Ok(bundle);
|
return Result<TrustBundle>.Ok(bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<Result<MemberProofDto>> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct)
|
|
||||||
{
|
|
||||||
IReadOnlyList<SignedStatementDto> chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : [];
|
|
||||||
OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]);
|
|
||||||
if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature))
|
|
||||||
{
|
|
||||||
return Result<MemberProofDto>.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key");
|
|
||||||
}
|
|
||||||
|
|
||||||
string role = token.Kind.Length == 0 ? "agent" : token.Kind;
|
|
||||||
var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature };
|
|
||||||
TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role);
|
|
||||||
if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name)
|
|
||||||
{
|
|
||||||
return Result<MemberProofDto>.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result<MemberProofDto>.Ok(proof);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryDecode(string text, out byte[] data)
|
private static bool TryDecode(string text, out byte[] data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
IEnumerable<IStateProvider> stateProviders,
|
IEnumerable<IStateProvider> stateProviders,
|
||||||
IEnumerable<IInbox> inboxes,
|
IEnumerable<IInbox> inboxes,
|
||||||
IEnumerable<INodeEventSink> sinks,
|
IEnumerable<INodeEventSink> sinks,
|
||||||
FleetRouter router)
|
FleetRouter router,
|
||||||
|
IServerIdentity? serverIdentity = null)
|
||||||
{
|
{
|
||||||
|
ServerIdentity = serverIdentity;
|
||||||
Store = store;
|
Store = store;
|
||||||
Options = options;
|
Options = options;
|
||||||
Time = time;
|
Time = time;
|
||||||
@@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
|
|
||||||
internal IFleetStore Store { get; }
|
internal IFleetStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||||
|
internal IServerIdentity? ServerIdentity { get; }
|
||||||
|
|
||||||
internal FleetServerOptions Options { get; }
|
internal FleetServerOptions Options { get; }
|
||||||
|
|
||||||
internal TimeProvider Time { get; }
|
internal TimeProvider Time { get; }
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ public static class FleetServerExtensions
|
|||||||
provider.GetServices<IStateProvider>(),
|
provider.GetServices<IStateProvider>(),
|
||||||
provider.GetServices<IInbox>(),
|
provider.GetServices<IInbox>(),
|
||||||
provider.GetServices<INodeEventSink>(),
|
provider.GetServices<INodeEventSink>(),
|
||||||
provider.GetRequiredService<FleetRouter>()));
|
provider.GetRequiredService<FleetRouter>(),
|
||||||
|
provider.GetService<IServerIdentity>()));
|
||||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ internal sealed partial class NodeConnection
|
|||||||
{
|
{
|
||||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||||
await SendAsync(
|
|
||||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
|
||||||
ct);
|
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
|
||||||
|
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
|
||||||
|
string? serverEphemeralText = null;
|
||||||
|
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
|
||||||
|
{
|
||||||
|
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
|
||||||
|
challenge.ServerEphemeral = serverEphemeralText;
|
||||||
|
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
|
||||||
|
|
||||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
@@ -48,10 +58,21 @@ internal sealed partial class NodeConnection
|
|||||||
}
|
}
|
||||||
|
|
||||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||||
|
|
||||||
|
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
|
||||||
|
byte[] nodeEphemeral = [];
|
||||||
|
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
|
||||||
bool signatureOk = node is not null
|
bool signatureOk = node is not null
|
||||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
&& (secure
|
||||||
|
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
|
||||||
|
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
|
||||||
|
if (signatureOk && secure)
|
||||||
|
{
|
||||||
|
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
if (!signatureOk)
|
if (!signatureOk)
|
||||||
{
|
{
|
||||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||||
|
|||||||
@@ -75,7 +75,15 @@ internal sealed partial class NodeConnection
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_cipher is { } cipher)
|
||||||
|
{
|
||||||
|
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (WebSocketException)
|
catch (WebSocketException)
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ internal sealed partial class NodeConnection
|
|||||||
|
|
||||||
if (result.EndOfMessage)
|
if (result.EndOfMessage)
|
||||||
{
|
{
|
||||||
|
if (_cipher is { } cipher)
|
||||||
|
{
|
||||||
|
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
|
||||||
|
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
|
||||||
|
{
|
||||||
|
_closeReason ??= "frame failed authentication";
|
||||||
|
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Frame(kind == 0, plain);
|
||||||
|
}
|
||||||
|
|
||||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
|
|
||||||
namespace Fleet.Server;
|
namespace Fleet.Server;
|
||||||
@@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable
|
|||||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||||
private readonly CancellationTokenSource _lifetime = new();
|
private readonly CancellationTokenSource _lifetime = new();
|
||||||
|
private LinkCipher? _cipher;
|
||||||
private long _nextRequestId;
|
private long _nextRequestId;
|
||||||
private long _lastPresenceTicks;
|
private long _lastPresenceTicks;
|
||||||
private NodeRecord? _node;
|
private NodeRecord? _node;
|
||||||
@@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_cipher?.Dispose();
|
||||||
_lifetime.Dispose();
|
_lifetime.Dispose();
|
||||||
_sendLock.Dispose();
|
_sendLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,85 +111,13 @@ public sealed class OperatorTrustTests : IDisposable
|
|||||||
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth);
|
|
||||||
|
|
||||||
private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null)
|
|
||||||
{
|
|
||||||
OperatorSet set = Genesis(_alice);
|
|
||||||
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
||||||
byte[] seed = code.ExportParameters(true).D!;
|
|
||||||
string auth = OperatorTrust.AuthToken(seed);
|
|
||||||
var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1);
|
|
||||||
SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant));
|
|
||||||
|
|
||||||
byte[] nodeKey = RandomNumberGenerator.GetBytes(91);
|
|
||||||
byte[] peerKey = RandomNumberGenerator.GetBytes(91);
|
|
||||||
using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey);
|
|
||||||
Assert.NotNull(signing);
|
|
||||||
string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement);
|
|
||||||
return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AMember_VerifiesThroughTheWholeChain()
|
|
||||||
{
|
|
||||||
Enrolled e = Enroll();
|
|
||||||
Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TheSeed_IsNotRecoverableFromWhatTheBackendSees()
|
|
||||||
{
|
|
||||||
Enrolled e = Enroll();
|
|
||||||
Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal);
|
|
||||||
Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused()
|
|
||||||
{
|
|
||||||
Enrolled e = Enroll();
|
|
||||||
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上
|
|
||||||
Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe"));
|
|
||||||
Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent
|
|
||||||
|
|
||||||
Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定
|
|
||||||
Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused()
|
|
||||||
{
|
|
||||||
using var stranger = new TestAuthenticator();
|
|
||||||
Enrolled forged = Enroll(signer: stranger);
|
|
||||||
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe"));
|
|
||||||
|
|
||||||
// Backend 拿到了委托但没有种子:用自己的密钥签绑定
|
|
||||||
Enrolled e = Enroll();
|
|
||||||
using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
||||||
Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!;
|
|
||||||
string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement);
|
|
||||||
Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned()
|
|
||||||
{
|
|
||||||
Enrolled e = Enroll();
|
|
||||||
OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) };
|
|
||||||
Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parsing_IsStrict()
|
public void Parsing_IsStrict()
|
||||||
{
|
{
|
||||||
Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe"));
|
|
||||||
Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x"));
|
|
||||||
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
|
||||||
OperatorSet set = Genesis(_alice);
|
OperatorSet set = Genesis(_alice);
|
||||||
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
|
||||||
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Fleet.Identity;
|
||||||
|
|
||||||
|
namespace Fleet.Tests;
|
||||||
|
|
||||||
|
public sealed class SecureChannelTests
|
||||||
|
{
|
||||||
|
private const string Nonce = "n0nce-from-the-server";
|
||||||
|
|
||||||
|
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
|
||||||
|
{
|
||||||
|
using var node = new EphemeralKey();
|
||||||
|
using var server = new EphemeralKey();
|
||||||
|
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
|
||||||
|
{
|
||||||
|
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using var eph = new EphemeralKey();
|
||||||
|
using var otherEph = new EphemeralKey();
|
||||||
|
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||||
|
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
|
||||||
|
|
||||||
|
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
|
||||||
|
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
|
||||||
|
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
|
||||||
|
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
|
||||||
|
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
|
||||||
|
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair();
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
|
||||||
|
byte[] toServer = n.Seal(0, "hello server"u8);
|
||||||
|
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
|
||||||
|
Assert.Equal(0, kind);
|
||||||
|
Assert.Equal("hello server"u8.ToArray(), data);
|
||||||
|
|
||||||
|
byte[] big = RandomNumberGenerator.GetBytes(300_000);
|
||||||
|
byte[] toNode = s.Seal(1, big);
|
||||||
|
Assert.True(n.TryOpen(toNode, out kind, out data));
|
||||||
|
Assert.Equal(1, kind);
|
||||||
|
Assert.Equal(big, data);
|
||||||
|
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair();
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
|
||||||
|
byte[] first = n.Seal(0, "one"u8);
|
||||||
|
byte[] second = n.Seal(0, "two"u8);
|
||||||
|
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
|
||||||
|
Assert.True(s.TryOpen(first, out _, out _));
|
||||||
|
Assert.False(s.TryOpen(first, out _, out _)); // 重放
|
||||||
|
Assert.True(s.TryOpen(second, out _, out _));
|
||||||
|
|
||||||
|
byte[] tampered = n.Seal(0, "three"u8);
|
||||||
|
tampered[12] ^= 0x01;
|
||||||
|
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
|
||||||
|
|
||||||
|
byte[] fromNode = n.Seal(0, "four"u8);
|
||||||
|
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
|
||||||
|
using LinkCipher on = otherNode;
|
||||||
|
using LinkCipher os = otherServer;
|
||||||
|
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
|
||||||
|
|
||||||
|
byte[] fromServer = s.Seal(0, "reflected"u8);
|
||||||
|
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
|
||||||
|
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADifferentNonce_GivesDifferentKeys()
|
||||||
|
{
|
||||||
|
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
|
||||||
|
using LinkCipher n = node;
|
||||||
|
using LinkCipher s = server;
|
||||||
|
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
|
||||||
|
{
|
||||||
|
using var alice = new TestAuthenticator();
|
||||||
|
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
|
||||||
|
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
|
||||||
|
string text = OperatorTrust.Encode(set);
|
||||||
|
|
||||||
|
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
|
||||||
|
Assert.Equal(spki, Assert.Single(parsed.Servers!));
|
||||||
|
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
|
||||||
|
|
||||||
|
SignedStatement genesis = alice.Sign(text);
|
||||||
|
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
|
||||||
|
Assert.Equal(spki, Assert.Single(verified.Servers!));
|
||||||
|
|
||||||
|
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
|
||||||
|
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ string? name = Arg("--name");
|
|||||||
string? directory = Arg("--identity-dir");
|
string? directory = Arg("--identity-dir");
|
||||||
if (server is null || join is null || name is null || directory is null)
|
if (server is null || join is null || name is null || directory is null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--role agent|probe --seed <code seed> --genesis <operator fingerprint>]");
|
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +30,12 @@ if (Arg("--meta") is { } metaText)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string? seed = Arg("--seed");
|
|
||||||
string? genesis = Arg("--genesis");
|
string? genesis = Arg("--genesis");
|
||||||
string role = Arg("--role") ?? "agent";
|
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
||||||
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role);
|
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
|
||||||
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||||
|
|||||||
+44
-143
@@ -3999,78 +3999,6 @@
|
|||||||
"x-moshiro-capability": "accounts.manage"
|
"x-moshiro-capability": "accounts.manage"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/admin/join-tokens/signed": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"admin-trust"
|
|
||||||
],
|
|
||||||
"summary": "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees",
|
|
||||||
"operationId": "join_tokens_create_signed",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CreateSignedJoinTokenRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"201": {
|
|
||||||
"description": "Created",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/JoinTokenDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"description": "Conflict",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"x-moshiro-capability": "fleet.enroll"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/admin/api-tokens": {
|
"/api/admin/api-tokens": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5003,30 +4931,11 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"ops"
|
"ops"
|
||||||
],
|
],
|
||||||
"summary": "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)",
|
"summary": "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)",
|
||||||
"operationId": "install_sh",
|
"operationId": "install_sh",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "code",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK"
|
"description": "OK"
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5102,30 +5011,11 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"ops"
|
"ops"
|
||||||
],
|
],
|
||||||
"summary": "One-line installer for Windows nodes (agent or probe, decided by the code)",
|
"summary": "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)",
|
||||||
"operationId": "install_ps1",
|
"operationId": "install_ps1",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "code",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK"
|
"description": "OK"
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6015,28 +5905,8 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"CreateSignedJoinTokenRequest": {
|
"controllers": {
|
||||||
"required": [
|
|
||||||
"grant",
|
|
||||||
"note",
|
|
||||||
"labels",
|
|
||||||
"auto_approve"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"grant": {
|
|
||||||
"$ref": "#/components/schemas/SignedStatementDto"
|
|
||||||
},
|
|
||||||
"note": {
|
|
||||||
"type": [
|
|
||||||
"null",
|
|
||||||
"string"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"labels": {
|
|
||||||
"type": [
|
"type": [
|
||||||
"null",
|
"null",
|
||||||
"array"
|
"array"
|
||||||
@@ -6044,12 +5914,6 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"auto_approve": {
|
|
||||||
"type": [
|
|
||||||
"null",
|
|
||||||
"boolean"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6914,10 +6778,6 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"signed": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7223,6 +7083,12 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"auth_method": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9404,6 +9270,41 @@
|
|||||||
"$ref": "#/components/schemas/TrustProposalDto"
|
"$ref": "#/components/schemas/TrustProposalDto"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"install_url": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"operators": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"array"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"install_sh_sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"install_ps1_sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"install_matches_release": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"boolean"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"server_key": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"server_key_listed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Identity;
|
|
||||||
using Fleet.Hosting;
|
using Fleet.Hosting;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
@@ -67,20 +66,6 @@ public sealed class AgentDirectory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryKey(string text, out byte[] key)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
key = Convert.FromBase64String(text);
|
|
||||||
return key.Length > 0;
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
key = [];
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
|
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
|
||||||
public void Apply(ProbeAgentList result)
|
public void Apply(ProbeAgentList result)
|
||||||
{
|
{
|
||||||
@@ -88,13 +73,6 @@ public sealed class AgentDirectory
|
|||||||
_trust.Update(result.TrustChain);
|
_trust.Update(result.TrustChain);
|
||||||
foreach (AgentEndpoint agent in result.Agents)
|
foreach (AgentEndpoint agent in result.Agents)
|
||||||
{
|
{
|
||||||
// 固定了操作员创世的 Probe 只连操作员批准过的 Agent(证明验不过的,面板塞进来的也不连)
|
|
||||||
if (!TryKey(agent.PublicKey, out byte[] key) || _trust.VerifyPeer(agent.Proof, key, "agent") != TrustFailure.None)
|
|
||||||
{
|
|
||||||
_log.Warn("an agent from the panel carries no valid operator proof; not connecting", ("agent", agent.AgentId.Value));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||||
{
|
{
|
||||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
|
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public sealed class ProbeApp : IDisposable
|
|||||||
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||||
{
|
{
|
||||||
_log = log;
|
_log = log;
|
||||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, TrustAnchor.Load(file.Fleet.IdentityDir, log));
|
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||||
|
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
||||||
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
||||||
directory.Changed += () => _changed.Release();
|
directory.Changed += () => _changed.Release();
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ public sealed class ProbeApp : IDisposable
|
|||||||
{
|
{
|
||||||
Server = new Uri(file.Fleet.Server),
|
Server = new Uri(file.Fleet.Server),
|
||||||
Identity = identity.Identity,
|
Identity = identity.Identity,
|
||||||
|
ServerVerifier = trust,
|
||||||
NodeId = identity.NodeId,
|
NodeId = identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base],
|
Capabilities = [MoShiroCapabilities.Base],
|
||||||
|
|||||||
@@ -152,10 +152,6 @@ public sealed class AgentEndpoint
|
|||||||
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
||||||
[JsonPropertyName("public_key")]
|
[JsonPropertyName("public_key")]
|
||||||
public string PublicKey { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>操作员批准这个 Agent 的证明;固定了创世的 Probe 没有它就不连。</summary>
|
|
||||||
[JsonPropertyName("proof")]
|
|
||||||
public MemberProofDto? Proof { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ public sealed class PeerSpec
|
|||||||
/// <summary>Probe 的身份公钥(SPKI,base64)。</summary>
|
/// <summary>Probe 的身份公钥(SPKI,base64)。</summary>
|
||||||
[JsonPropertyName("pubkey")]
|
[JsonPropertyName("pubkey")]
|
||||||
public string PublicKey { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>操作员批准这个 Probe 的证明(passkey 签的委托加码的私钥签的绑定)。固定了创世的 Agent 没有它就不接纳。</summary>
|
|
||||||
[JsonPropertyName("proof")]
|
|
||||||
public MemberProofDto? Proof { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>期望状态文档(Fleet state type = <c>task_set</c>):Backend 为每个 Agent 计算的有效任务集。</summary>
|
/// <summary>期望状态文档(Fleet state type = <c>task_set</c>):Backend 为每个 Agent 计算的有效任务集。</summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
@@ -129,12 +130,23 @@ public static class AgentEndpoints
|
|||||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||||
|
|
||||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
|
||||||
{
|
{
|
||||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
|
|
||||||
|
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 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)))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||||
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted);
|
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
Principal principal = http.GetPrincipal();
|
Principal principal = http.GetPrincipal();
|
||||||
return TypedResults.Json(
|
return TypedResults.Json(
|
||||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken),
|
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
|
||||||
ApiJsonContext.Default.MeDto);
|
ApiJsonContext.Default.MeDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,18 +26,35 @@ public static class TrustEndpoints
|
|||||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
.Requires(Capability.AccountsManage)
|
.Requires(Capability.AccountsManage)
|
||||||
.ElevationExempt();
|
.ElevationExempt();
|
||||||
guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync)
|
|
||||||
.Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag)
|
|
||||||
.Returns<JoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
|
||||||
.Requires(Capability.FleetEnroll)
|
|
||||||
.ElevationExempt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, BackendOptions options)
|
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||||
{
|
{
|
||||||
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
|
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto);
|
string sh = InstallScripts.Sha256(InstallScripts.Sh);
|
||||||
|
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
|
||||||
|
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
||||||
|
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
||||||
|
{
|
||||||
|
if (await releases.GetAsync(component, ct) is not { } offer)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
|
||||||
|
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
|
||||||
|
{
|
||||||
|
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||||
@@ -59,17 +76,4 @@ public static class TrustEndpoints
|
|||||||
http.SetDetail($"operator set v{state.Version}");
|
http.SetDetail($"operator set v{state.Version}");
|
||||||
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
|
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens)
|
|
||||||
{
|
|
||||||
Result<JoinTokenInfo> result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted);
|
|
||||||
if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error))
|
|
||||||
{
|
|
||||||
return Problems.From(http, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}");
|
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
|
||||||
return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -154,9 +154,9 @@ public static class Mappers
|
|||||||
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
||||||
|
|
||||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null);
|
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||||
|
|
||||||
public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge));
|
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||||
|
|
||||||
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
|||||||
|
|
||||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||||
|
|
||||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken);
|
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
||||||
|
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||||
|
|
||||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||||
|
|
||||||
@@ -74,22 +75,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
|
|||||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||||
|
|
||||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false);
|
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
|
||||||
|
|
||||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
|
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
|
||||||
|
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||||
|
|
||||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
/// <param name="Grant">操作员用 passkey 对这个码签的委托(语句加断言)。</param>
|
|
||||||
public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove);
|
|
||||||
|
|
||||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||||
|
|
||||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal);
|
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||||
|
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||||
|
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||||
|
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||||
|
|
||||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||||
[JsonSerializable(typeof(CreateSignedJoinTokenRequest))]
|
|
||||||
[JsonSerializable(typeof(TrustStateDto))]
|
[JsonSerializable(typeof(TrustStateDto))]
|
||||||
[JsonSerializable(typeof(ElevationRequest))]
|
[JsonSerializable(typeof(ElevationRequest))]
|
||||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using MoShiro.Backend.Application;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,
|
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||||
/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。
|
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
|
||||||
|
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class InstallScripts
|
public static class InstallScripts
|
||||||
{
|
{
|
||||||
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本用它验发布清单的签名。</summary>
|
public const string Sh = "install.sh";
|
||||||
public static string ReleaseKey()
|
public const string Ps1 = "install.ps1";
|
||||||
{
|
|
||||||
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem")
|
|
||||||
?? throw new InvalidOperationException("the embedded release key is missing");
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
return reader.ReadToEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Render(string resource, InstallTarget target)
|
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
|
||||||
|
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
|
||||||
|
|
||||||
|
public static string Script(string resource) => Read(resource);
|
||||||
|
|
||||||
|
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||||
|
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||||
|
|
||||||
|
private static string Read(string resource)
|
||||||
{
|
{
|
||||||
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
||||||
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
return reader.ReadToEnd()
|
return reader.ReadToEnd();
|
||||||
.Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal)
|
|
||||||
.Replace("@@ROLE@@", target.Role, StringComparison.Ordinal)
|
|
||||||
.Replace("@@SERVER@@", target.Server, StringComparison.Ordinal)
|
|
||||||
.Replace("@@CODE@@", target.Code, StringComparison.Ordinal)
|
|
||||||
.Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,12 @@ public static class OpsEndpoints
|
|||||||
app.MapGet("/metrics", MetricsAsync)
|
app.MapGet("/metrics", MetricsAsync)
|
||||||
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||||
// 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。
|
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||||
// Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。
|
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||||
app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
|
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
.Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag)
|
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404);
|
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||||
app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript"))
|
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
.ExcludeFromDescription();
|
.ExcludeFromDescription();
|
||||||
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
||||||
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
||||||
@@ -33,9 +33,9 @@ public static class OpsEndpoints
|
|||||||
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
||||||
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||||
app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain"))
|
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||||||
.Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag)
|
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
||||||
@@ -52,18 +52,10 @@ public static class OpsEndpoints
|
|||||||
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType)
|
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
|
||||||
{
|
{
|
||||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
|
||||||
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
|
||||||
if (target is null)
|
|
||||||
{
|
|
||||||
return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used");
|
|
||||||
}
|
|
||||||
|
|
||||||
string script = InstallScripts.Render(resource, target);
|
|
||||||
http.Response.Headers.CacheControl = "no-store";
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
return TypedResults.Text(script, contentType + "; charset=utf-8");
|
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>?
|
|||||||
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||||
|
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
|
||||||
|
|
||||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||||
|
|
||||||
/// <summary>安装脚本里要填的四个值。</summary>
|
/// <summary>安装脚本里要填的四个值。</summary>
|
||||||
@@ -27,19 +33,17 @@ public sealed class JoinTokenService
|
|||||||
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
|
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
|
||||||
|
|
||||||
private readonly IJoinTokenStore _store;
|
private readonly IJoinTokenStore _store;
|
||||||
private readonly TrustService _trust;
|
|
||||||
private readonly BackendOptions _options;
|
private readonly BackendOptions _options;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
|
|
||||||
public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time)
|
public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_trust = trust;
|
|
||||||
_options = options;
|
_options = options;
|
||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var errors = new Dictionary<string, string>();
|
var errors = new Dictionary<string, string>();
|
||||||
if (request.Role is not (AgentRole or ProbeRole))
|
if (request.Role is not (AgentRole or ProbeRole))
|
||||||
@@ -47,9 +51,9 @@ public sealed class JoinTokenService
|
|||||||
errors["role"] = "must be agent or probe";
|
errors["role"] = "must be agent or probe";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.MaxUses is < 1 or > 1000)
|
if (request.MaxUses is < 1 or > 100_000)
|
||||||
{
|
{
|
||||||
errors["max_uses"] = "must be between 1 and 1000";
|
errors["max_uses"] = "must be between 1 and 100000";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||||
@@ -76,79 +80,25 @@ public sealed class JoinTokenService
|
|||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
JoinTokenInfo info = await _store.CreateAsync(
|
JoinTokenInfo info = await _store.CreateAsync(
|
||||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
||||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct);
|
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
|
||||||
|
|
||||||
string baseUrl = BaseUrl(requestBaseUrl);
|
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||||
string query = $"{baseUrl}/install?code={plaintext}";
|
|
||||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(
|
|
||||||
$"curl -fsSL '{query}' | bash",
|
|
||||||
$"curl -fsSL '{query}' | zsh",
|
|
||||||
$"irm '{baseUrl}/install.ps1?code={plaintext}' | iex")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子,
|
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
|
||||||
/// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。
|
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask<Result<JoinTokenInfo>> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList<string>? labels, bool autoApprove, string? createdBy, CancellationToken ct)
|
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
|
||||||
{
|
{
|
||||||
OperatorSet? set = await _trust.CurrentAsync(ct);
|
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
|
||||||
if (set is null)
|
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
|
||||||
{
|
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
|
||||||
return Result<JoinTokenInfo>.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)");
|
return new InstallCommands(
|
||||||
}
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
|
||||||
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
|
||||||
Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement);
|
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
|
||||||
if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set))
|
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||||
{
|
|
||||||
return Result<JoinTokenInfo>.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey");
|
|
||||||
}
|
|
||||||
|
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
||||||
var errors = new Dictionary<string, string>();
|
|
||||||
if (grant.Role is not (AgentRole or ProbeRole))
|
|
||||||
{
|
|
||||||
errors["role"] = "must be agent or probe";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grant.Uses is < 1 or > 1000)
|
|
||||||
{
|
|
||||||
errors["uses"] = "must be between 1 and 1000";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000)
|
|
||||||
{
|
|
||||||
errors["validity"] = "the grant must be valid now and for at most one year";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$"))
|
|
||||||
{
|
|
||||||
errors["auth"] = "must be a lowercase sha-256 hex";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32)))
|
|
||||||
{
|
|
||||||
errors["labels"] = "at most 16 labels of 1-32 characters";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note is { Length: > 200 })
|
|
||||||
{
|
|
||||||
errors["note"] = "must be at most 200 characters";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.Count > 0)
|
|
||||||
{
|
|
||||||
return ValidationError.Fail<JoinTokenInfo>(errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] hash = Convert.FromHexString(grant.AuthHash);
|
|
||||||
if (await _store.FindAsync(hash, ct) is not null)
|
|
||||||
{
|
|
||||||
return Result<JoinTokenInfo>.Fail(MoShiroErrors.NameConflict, "this code already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result<JoinTokenInfo>.Ok(await _store.CreateAsync(
|
|
||||||
Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ public sealed partial class AgentService
|
|||||||
TcpPort = agent.ProbeTcpPort,
|
TcpPort = agent.ProbeTcpPort,
|
||||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||||
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
||||||
Proof = agent.Proof,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddSingleton<AuthService>();
|
services.AddSingleton<AuthService>();
|
||||||
services.AddSingleton<PasskeyService>();
|
services.AddSingleton<PasskeyService>();
|
||||||
services.AddSingleton<AuditService>();
|
services.AddSingleton<AuditService>();
|
||||||
|
services.AddSingleton<ServerIdentityService>();
|
||||||
|
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||||
services.AddSingleton<TrustService>();
|
services.AddSingleton<TrustService>();
|
||||||
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||||
services.AddSingleton<ElevationService>();
|
services.AddSingleton<ElevationService>();
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed partial class AuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
|
||||||
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public static class CapabilityCatalog
|
|||||||
|
|
||||||
new(Capability.FleetRead, "fleet"),
|
new(Capability.FleetRead, "fleet"),
|
||||||
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
|
new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead),
|
||||||
new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead),
|
new(Capability.FleetEnroll, "fleet", false, false, Capability.FleetRead),
|
||||||
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
|
new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead),
|
||||||
|
|
||||||
new(Capability.TasksRead, "tasks"),
|
new(Capability.TasksRead, "tasks"),
|
||||||
@@ -134,4 +134,10 @@ public static class CapabilityCatalog
|
|||||||
|
|
||||||
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
|
/// <summary>登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口(<see cref="ElevationService"/>)。</summary>
|
||||||
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
public static IReadOnlyList<string> ForSession() => [.. All.Select(c => c.Id)];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按登录方式给会话能力:passkey 登录的拿全部;<b>密码登录的什么能力都没有</b>,只能做「已登录」就能做的事——
|
||||||
|
/// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : [];
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,6 @@ public static class Elevation
|
|||||||
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
/// <summary>端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。</summary>
|
||||||
public static ElevationClass? For(string capability) => capability switch
|
public static ElevationClass? For(string capability) => capability switch
|
||||||
{
|
{
|
||||||
Capability.FleetEnroll => ElevationClass.Enroll,
|
|
||||||
Capability.FleetRevoke => ElevationClass.Revoke,
|
Capability.FleetRevoke => ElevationClass.Revoke,
|
||||||
Capability.AccountsManage => ElevationClass.Credential,
|
Capability.AccountsManage => ElevationClass.Credential,
|
||||||
_ => null,
|
_ => null,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using Fleet.Server;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务器的身份密钥(P-256):操作员用 passkey 把公钥签进操作员集合,之后节点只认带这把密钥签名的服务器,
|
||||||
|
/// 并在这条链路上用应用层加密(TLS 在 Cloudflare 或被冒充的证书那里终止时,中间人拿不出这把密钥)。
|
||||||
|
/// 私钥存在 Backend 的设置里:它保护的是「面板被冒充」,不是「面板被攻破」;面板被攻破时它也会泄露,那时要由 passkey 签一个换掉它的新集合版本。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerIdentityService : IServerIdentity, IDisposable
|
||||||
|
{
|
||||||
|
private const string Key = "server.identity_key";
|
||||||
|
|
||||||
|
private readonly ISettingsStore _settings;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private ECDsa? _key;
|
||||||
|
private byte[]? _spki;
|
||||||
|
|
||||||
|
public ServerIdentityService(ISettingsStore settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] PublicKeySpki
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _spki!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>公钥的指纹(SHA-256,小写十六进制):页面上给人核对。</summary>
|
||||||
|
public string Fingerprint => Convert.ToHexStringLower(SHA256.HashData(PublicKeySpki));
|
||||||
|
|
||||||
|
public byte[] Sign(ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _key!.SignData(payload, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动时调用:读出或生成密钥。节点可能在启动完成之前就连上来,所以 <see cref="Sign"/> 也会按需加载。</summary>
|
||||||
|
public ValueTask EnsureAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
EnsureLoaded(ct);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_key is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_key is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? stored = _settings.GetAsync(Key, ct).AsTask().GetAwaiter().GetResult();
|
||||||
|
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
if (stored is not null)
|
||||||
|
{
|
||||||
|
key.ImportPkcs8PrivateKey(Convert.FromBase64String(stored), out _);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_settings.SetAsync(Key, Convert.ToBase64String(key.ExportPkcs8PrivateKey()), ct).AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
_spki = key.ExportSubjectPublicKeyInfo();
|
||||||
|
_key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _key?.Dispose();
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ namespace MoShiro.Backend.Application;
|
|||||||
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
public sealed record TrustProposal(string Statement, int Version, string Challenge);
|
||||||
|
|
||||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal);
|
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||||
|
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||||
@@ -22,11 +23,13 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IOperatorSetStore _store;
|
private readonly IOperatorSetStore _store;
|
||||||
private readonly IAuthStore _auth;
|
private readonly IAuthStore _auth;
|
||||||
|
private readonly ServerIdentityService _server;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time)
|
public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time)
|
||||||
{
|
{
|
||||||
|
_server = server;
|
||||||
_store = store;
|
_store = store;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_time = time;
|
_time = time;
|
||||||
@@ -50,7 +53,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||||
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||||
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal);
|
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||||
@@ -66,14 +69,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement),
|
chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement),
|
||||||
context.RpId,
|
context.RpId,
|
||||||
[.. context.Origins.Order(StringComparer.Ordinal)],
|
[.. context.Origins.Order(StringComparer.Ordinal)],
|
||||||
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))]);
|
[.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))],
|
||||||
|
[_server.PublicKeySpki]);
|
||||||
string statement = OperatorTrust.Encode(next);
|
string statement = OperatorTrust.Encode(next);
|
||||||
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
|
return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool Same(OperatorSet a, OperatorSet b) =>
|
private static bool Same(OperatorSet a, OperatorSet b) =>
|
||||||
a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal))
|
a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal))
|
||||||
&& a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal));
|
&& a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal))
|
||||||
|
&& (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
/// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。
|
||||||
@@ -102,6 +107,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
|||||||
return ValidationError.Fail<TrustState>("statement", "the relying party or origins do not match this panel");
|
return ValidationError.Fail<TrustState>("statement", "the relying party or origins do not match this panel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)))
|
||||||
|
{
|
||||||
|
// 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了
|
||||||
|
return ValidationError.Fail<TrustState>("statement", "must list this panel's server key");
|
||||||
|
}
|
||||||
|
|
||||||
IReadOnlyList<PasskeyRecord> registered = await _auth.ListAllPasskeysAsync(ct);
|
IReadOnlyList<PasskeyRecord> registered = await _auth.ListAllPasskeysAsync(ct);
|
||||||
if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki))))
|
if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki))))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ public sealed record AgentRecord(
|
|||||||
bool Ipv6HostManual = false,
|
bool Ipv6HostManual = false,
|
||||||
IReadOnlyList<string>? ReportedAddresses = null,
|
IReadOnlyList<string>? ReportedAddresses = null,
|
||||||
byte[]? TlsPublicKey = null,
|
byte[]? TlsPublicKey = null,
|
||||||
string? AddedBy = null,
|
string? AddedBy = null);
|
||||||
MemberProofDto? Proof = null);
|
|
||||||
|
|
||||||
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
/// <summary>管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。</summary>
|
||||||
public sealed class AgentPatch
|
public sealed class AgentPatch
|
||||||
@@ -79,11 +78,10 @@ public sealed record JoinTokenInfo(
|
|||||||
long? RevokedAtMs,
|
long? RevokedAtMs,
|
||||||
long CreatedAtMs,
|
long CreatedAtMs,
|
||||||
string Kind = "",
|
string Kind = "",
|
||||||
string? CreatedBy = null,
|
string? CreatedBy = null);
|
||||||
SignedStatementDto? Grant = null);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
||||||
/// Agent 的 <c>peers</c> 里放的是后者。
|
/// Agent 的 <c>peers</c> 里放的是后者。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null);
|
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null);
|
||||||
@@ -54,7 +54,7 @@ public interface IGeoLookup
|
|||||||
|
|
||||||
public interface IJoinTokenStore
|
public interface IJoinTokenStore
|
||||||
{
|
{
|
||||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct);
|
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct);
|
||||||
|
|
||||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ public sealed class StartupTasks : IHostedService
|
|||||||
private readonly IRawStore _raw;
|
private readonly IRawStore _raw;
|
||||||
private readonly AuthService _auth;
|
private readonly AuthService _auth;
|
||||||
private readonly TaskService _tasks;
|
private readonly TaskService _tasks;
|
||||||
|
private readonly ServerIdentityService _identity;
|
||||||
|
|
||||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks)
|
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
|
||||||
{
|
{
|
||||||
|
_identity = identity;
|
||||||
_raw = raw;
|
_raw = raw;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
@@ -24,6 +26,7 @@ public sealed class StartupTasks : IHostedService
|
|||||||
await _raw.RecoverAsync(cancellationToken);
|
await _raw.RecoverAsync(cancellationToken);
|
||||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||||
|
await _identity.EnsureAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public sealed class TaskPlanner : IDisposable
|
|||||||
|
|
||||||
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
|
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value)))
|
||||||
{
|
{
|
||||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!), Proof = probe.Proof });
|
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
|
||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
||||||
|
|||||||
@@ -218,9 +218,6 @@ internal static class Schema
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT");
|
|
||||||
connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT");
|
|
||||||
connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT");
|
|
||||||
connection.Exec("PRAGMA user_version = 13");
|
connection.Exec("PRAGMA user_version = 13");
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
|||||||
{
|
{
|
||||||
private const string AgentColumns =
|
private const string AgentColumns =
|
||||||
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
|
"id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " +
|
||||||
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust";
|
"task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by";
|
||||||
|
|
||||||
private readonly SqliteDatabase _db;
|
private readonly SqliteDatabase _db;
|
||||||
|
|
||||||
@@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
|||||||
byte[] hash = tokenSha256.ToArray();
|
byte[] hash = tokenSha256.ToArray();
|
||||||
return _db.ReadAsync(
|
return _db.ReadAsync(
|
||||||
c => c.QueryOne(
|
c => c.QueryOne(
|
||||||
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
|
"SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
|
||||||
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))),
|
r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)),
|
||||||
("@h", hash)),
|
("@h", hash)),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
@@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Exec(
|
c.Exec(
|
||||||
"INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)",
|
"INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)",
|
||||||
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki),
|
("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki),
|
||||||
("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
|
("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy));
|
||||||
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
|
return Result<NodeRecord>.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson));
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Exec(
|
c.Exec(
|
||||||
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)",
|
"INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)",
|
||||||
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof)));
|
("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy));
|
||||||
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
|
return Result<NodeRecord>.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,8 +268,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore
|
|||||||
r.GetInt64(23) != 0,
|
r.GetInt64(23) != 0,
|
||||||
StorageJson.Strings(r.GetString(24)),
|
StorageJson.Strings(r.GetString(24)),
|
||||||
r.NullableBytes(25),
|
r.NullableBytes(25),
|
||||||
r.NullableString(26),
|
r.NullableString(26));
|
||||||
StorageJson.Proof(r.NullableString(27)));
|
|
||||||
|
|
||||||
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
|
private static NodeRecord ToNode(AgentRecord a, string? metaJson) =>
|
||||||
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
|
new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs);
|
||||||
|
|||||||
@@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore
|
|||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) =>
|
public ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) =>
|
||||||
_db.WriteAsync(c =>
|
_db.WriteAsync(c =>
|
||||||
{
|
{
|
||||||
c.Exec(
|
c.Exec(
|
||||||
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)",
|
"INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)",
|
||||||
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
|
("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind),
|
||||||
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant)));
|
("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy));
|
||||||
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant);
|
return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy);
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
|
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) =>
|
||||||
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
|
_db.ReadAsync<IReadOnlyList<JoinTokenInfo>>(
|
||||||
c => c.Query(
|
c => c.Query(
|
||||||
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id",
|
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id",
|
||||||
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))),
|
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
public ValueTask<JoinTokenInfo?> FindAsync(byte[] tokenSha256, CancellationToken ct) =>
|
public ValueTask<JoinTokenInfo?> FindAsync(byte[] tokenSha256, CancellationToken ct) =>
|
||||||
_db.ReadAsync(
|
_db.ReadAsync(
|
||||||
c => c.QueryOne(
|
c => c.QueryOne(
|
||||||
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h",
|
"SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h",
|
||||||
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))),
|
r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)),
|
||||||
("@h", tokenSha256)),
|
("@h", tokenSha256)),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
@@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore
|
|||||||
|
|
||||||
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
|
public ValueTask<IReadOnlyList<ProbeRecord>> ListAsync(CancellationToken ct) =>
|
||||||
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
|
_db.ReadAsync<IReadOnlyList<ProbeRecord>>(
|
||||||
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))),
|
c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
|
public ValueTask<ProbeRecord?> GetAsync(string id, CancellationToken ct) =>
|
||||||
_db.ReadAsync(
|
_db.ReadAsync(
|
||||||
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)),
|
c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
|
public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fleet.Core;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Storage.Sqlite;
|
namespace MoShiro.Backend.Storage.Sqlite;
|
||||||
|
|
||||||
[JsonSerializable(typeof(List<string>))]
|
[JsonSerializable(typeof(List<string>))]
|
||||||
[JsonSerializable(typeof(SignedStatementDto))]
|
|
||||||
[JsonSerializable(typeof(MemberProofDto))]
|
|
||||||
[JsonSerializable(typeof(List<int>))]
|
[JsonSerializable(typeof(List<int>))]
|
||||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
|
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
|
||||||
internal sealed partial class StorageJson : JsonSerializerContext
|
internal sealed partial class StorageJson : JsonSerializerContext
|
||||||
@@ -18,14 +15,6 @@ internal sealed partial class StorageJson : JsonSerializerContext
|
|||||||
public static List<string> Strings(string? json) =>
|
public static List<string> Strings(string? json) =>
|
||||||
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? [];
|
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? [];
|
||||||
|
|
||||||
public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto);
|
|
||||||
|
|
||||||
public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto);
|
|
||||||
|
|
||||||
public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto);
|
|
||||||
|
|
||||||
public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto);
|
|
||||||
|
|
||||||
public static List<int> Ints(string? json) =>
|
public static List<int> Ints(string? json) =>
|
||||||
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? [];
|
string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? [];
|
||||||
}
|
}
|
||||||
@@ -41,14 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable
|
|||||||
|
|
||||||
public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||||
|
|
||||||
/// <param name="signed">操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。</param>
|
/// <param name="pin">带信任根固定的加入码(码、创世指纹、属主):节点按固定了创世和属主的方式注册。</param>
|
||||||
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null)
|
public static async Task<AgentHarness> StartAsync(BackendFixture backend, Action<AgentConfig>? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null)
|
||||||
{
|
{
|
||||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N"));
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N"));
|
||||||
string token = signed?.Auth ?? await backend.CreateJoinTokenAsync();
|
string token = pin?.Code ?? await backend.CreateJoinTokenAsync();
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(
|
Result<EnrollmentResult> enrolled = await EnrollmentClient.EnrollAsync(
|
||||||
http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent");
|
http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, pin?.Genesis, pin?.Controllers);
|
||||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||||
|
|
||||||
// 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖)
|
// 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖)
|
||||||
|
|||||||
@@ -61,8 +61,11 @@ public sealed class ApiBasicsTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded()
|
||||||
{
|
{
|
||||||
|
// 密码登录的会话没有任何能力(只能注册 passkey);有能力的会话来自 passkey 登录
|
||||||
|
(string username, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
using HttpClient client = _backend.NewClient();
|
using HttpClient client = _backend.NewClient();
|
||||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true }));
|
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey);
|
||||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||||
Assert.False(login.Headers.Contains("Set-Cookie"));
|
Assert.False(login.Headers.Contains("Set-Cookie"));
|
||||||
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>();
|
||||||
@@ -73,7 +76,7 @@ public sealed class ApiBasicsTests
|
|||||||
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||||
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
using HttpResponseMessage meResponse = await client.SendAsync(me);
|
||||||
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||||
Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
Assert.Equal(username, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue<string>());
|
||||||
|
|
||||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
|
using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) };
|
||||||
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session);
|
||||||
@@ -94,8 +97,10 @@ public sealed class ApiBasicsTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods()
|
||||||
{
|
{
|
||||||
|
(_, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync();
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
using HttpClient client = _backend.NewClient();
|
using HttpClient client = _backend.NewClient();
|
||||||
using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword }));
|
using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey, returnSession: false);
|
||||||
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
|
string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue<string>();
|
||||||
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0];
|
||||||
|
|
||||||
@@ -220,15 +225,6 @@ public sealed class ApiBasicsTests
|
|||||||
Assert.Contains("moshiro_agents_online", text);
|
Assert.Contains("moshiro_agents_online", text);
|
||||||
Assert.Contains("moshiro_analysis_queue_length", text);
|
Assert.Contains("moshiro_analysis_queue_length", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InstallScripts_RequireAValidCode()
|
|
||||||
{
|
|
||||||
// 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests)
|
|
||||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh");
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[CollectionDefinition("backend")]
|
[CollectionDefinition("backend")]
|
||||||
|
|||||||
@@ -135,6 +135,46 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 <see cref="PasskeyLoginAsync"/> 登录才有能力。</summary>
|
||||||
|
public async Task<(string Username, SoftwarePasskey Passkey)> CreateUserWithPasskeyAsync(string prefix = "user")
|
||||||
|
{
|
||||||
|
string name = UniqueName(prefix);
|
||||||
|
using HttpResponseMessage created = await Admin.PostAsync("/api/admin/users", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||||||
|
created.EnsureSuccessStatusCode();
|
||||||
|
using HttpClient session = NewClient();
|
||||||
|
using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||||
|
login.EnsureSuccessStatusCode();
|
||||||
|
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
var passkey = new SoftwarePasskey(BaseAddress.Host, BaseAddress.GetLeftPart(UriPartial.Authority));
|
||||||
|
using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||||
|
options.EnsureSuccessStatusCode();
|
||||||
|
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||||
|
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge)));
|
||||||
|
registered.EnsureSuccessStatusCode();
|
||||||
|
return (name, passkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用 passkey 登录(可发现凭证,不用用户名);<paramref name="returnSession"/> 为真时不设 Cookie、把会话令牌放在响应里。</summary>
|
||||||
|
public async Task<HttpResponseMessage> PasskeyLoginAsync(HttpClient client, SoftwarePasskey passkey, bool returnSession = true)
|
||||||
|
{
|
||||||
|
using HttpResponseMessage options = await client.PostAsync("/api/admin/auth/passkey/options", null);
|
||||||
|
options.EnsureSuccessStatusCode();
|
||||||
|
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||||
|
assertion["return_session"] = returnSession;
|
||||||
|
return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。</summary>
|
||||||
|
public async Task<(HttpClient Client, SoftwarePasskey Passkey, string Username)> CreateStrongSessionAsync(string prefix = "strong")
|
||||||
|
{
|
||||||
|
(string name, SoftwarePasskey passkey) = await CreateUserWithPasskeyAsync(prefix);
|
||||||
|
HttpClient client = NewClient();
|
||||||
|
using HttpResponseMessage login = await PasskeyLoginAsync(client, passkey);
|
||||||
|
login.EnsureSuccessStatusCode();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
return (client, passkey, name);
|
||||||
|
}
|
||||||
|
|
||||||
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
|
public HttpClient NewClient() => new() { BaseAddress = BaseAddress };
|
||||||
|
|
||||||
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
|
public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}";
|
||||||
@@ -233,45 +273,49 @@ public class BackendFixture : IAsyncLifetime
|
|||||||
await App.DisposeAsync();
|
await App.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey;
|
||||||
|
/// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。
|
||||||
|
/// </summary>
|
||||||
private async Task<string> CreateAdminTokenAsync()
|
private async Task<string> CreateAdminTokenAsync()
|
||||||
{
|
{
|
||||||
using HttpClient session = NewClient();
|
using HttpClient session = NewClient();
|
||||||
using HttpResponseMessage login = await session.PostAsync(
|
using HttpResponseMessage login = await session.PostAsync(
|
||||||
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword }));
|
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
|
||||||
login.EnsureSuccessStatusCode();
|
login.EnsureSuccessStatusCode();
|
||||||
JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!;
|
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0];
|
|
||||||
|
|
||||||
// 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建
|
|
||||||
string csrf = body["csrf_token"]!.GetValue<string>();
|
|
||||||
async Task<HttpResponseMessage> SendAsync(string path, JsonObject? content)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) };
|
|
||||||
request.Headers.Add("Cookie", cookie);
|
|
||||||
request.Headers.Add("X-CSRF-Token", csrf);
|
|
||||||
return await session.SendAsync(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||||
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
|
using var passkey = new SoftwarePasskey(BaseAddress.Host, origin);
|
||||||
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null))
|
using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
|
||||||
{
|
{
|
||||||
options.EnsureSuccessStatusCode();
|
options.EnsureSuccessStatusCode();
|
||||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
||||||
using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge));
|
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("fixture", challenge)));
|
||||||
registered.EnsureSuccessStatusCode();
|
registered.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" }))
|
// passkey 登录:拿到有能力的会话(令牌走 Bearer)
|
||||||
|
using HttpClient strong = NewClient();
|
||||||
|
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/passkey/options", null))
|
||||||
|
{
|
||||||
|
options.EnsureSuccessStatusCode();
|
||||||
|
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||||
|
using HttpResponseMessage signedIn = await strong.PostAsync("/api/admin/auth/passkey/login", Json(assertion));
|
||||||
|
signedIn.EnsureSuccessStatusCode();
|
||||||
|
strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" })))
|
||||||
{
|
{
|
||||||
options.EnsureSuccessStatusCode();
|
options.EnsureSuccessStatusCode();
|
||||||
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>());
|
||||||
assertion["class"] = "credential";
|
assertion["class"] = "credential";
|
||||||
using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion);
|
using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion));
|
||||||
elevated.EnsureSuccessStatusCode();
|
elevated.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() });
|
using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }));
|
||||||
created.EnsureSuccessStatusCode();
|
created.EnsureSuccessStatusCode();
|
||||||
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ public sealed class CapabilityTests
|
|||||||
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue<string>())];
|
string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue<string>())];
|
||||||
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
|
Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray());
|
||||||
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue<string>() == "all");
|
||||||
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetEnroll && c["trust"]!.GetValue<bool>());
|
Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue<string>() == Capability.FleetRevoke && c["trust"]!.GetValue<bool>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public sealed class ElevationTests
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(HttpClient, SoftwarePasskey)> StrongAsync()
|
||||||
|
{
|
||||||
|
(HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev");
|
||||||
|
return (client, passkey);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
|
private async Task<SoftwarePasskey> RegisterAsync(HttpClient session, string name = "key")
|
||||||
{
|
{
|
||||||
var passkey = new SoftwarePasskey(RpId, Origin);
|
var passkey = new SoftwarePasskey(RpId, Origin);
|
||||||
@@ -57,57 +63,61 @@ public sealed class ElevationTests
|
|||||||
|
|
||||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
|
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue<string>();
|
||||||
|
|
||||||
private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" });
|
/// <summary>需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。</summary>
|
||||||
|
private static Task<HttpResponseMessage> Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
|
public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens()
|
||||||
{
|
{
|
||||||
using HttpClient session = await NewSessionAsync();
|
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||||
using SoftwarePasskey passkey = await RegisterAsync(session);
|
using HttpClient sessionScope = session;
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
|
|
||||||
using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode());
|
using HttpResponseMessage denied = await Gated(session);
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
Assert.Equal("elevation_required", await ProblemCodeAsync(denied));
|
||||||
Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||||
|
|
||||||
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll");
|
using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke");
|
||||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的)
|
||||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰
|
Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰
|
||||||
|
|
||||||
// 窗口只管它那一种
|
// 窗口只管它那一种
|
||||||
using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody");
|
using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password }));
|
||||||
Assert.Equal("elevation_required", await ProblemCodeAsync(revoke));
|
Assert.Equal("elevation_required", await ProblemCodeAsync(credential));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
|
public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow()
|
||||||
{
|
{
|
||||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode());
|
using HttpResponseMessage allowed = await Gated(_backend.Admin);
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheWindowCanBeListed_AndEndedEarly()
|
public async Task TheWindowCanBeListed_AndEndedEarly()
|
||||||
{
|
{
|
||||||
using HttpClient session = await NewSessionAsync();
|
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||||
using SoftwarePasskey passkey = await RegisterAsync(session);
|
using HttpClient sessionScope = session;
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||||
|
|
||||||
(await ElevateAsync(session, passkey, "enroll")).Dispose();
|
(await ElevateAsync(session, passkey, "revoke")).Dispose();
|
||||||
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
|
JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray();
|
||||||
Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue<string>());
|
Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue<string>());
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode);
|
||||||
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray());
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ACredentialWindow_IsSingleUse()
|
public async Task ACredentialWindow_IsSingleUse()
|
||||||
{
|
{
|
||||||
using HttpClient session = await NewSessionAsync();
|
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||||
using SoftwarePasskey passkey = await RegisterAsync(session);
|
using HttpClient sessionScope = session;
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
|
|
||||||
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
(await ElevateAsync(session, passkey, "credential")).Dispose();
|
||||||
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password }));
|
||||||
@@ -119,10 +129,8 @@ public sealed class ElevationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
|
public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow()
|
||||||
{
|
{
|
||||||
using HttpClient mine = await NewSessionAsync();
|
(HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync();
|
||||||
using SoftwarePasskey myPasskey = await RegisterAsync(mine);
|
(HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync();
|
||||||
using HttpClient theirs = await NewSessionAsync();
|
|
||||||
using SoftwarePasskey theirPasskey = await RegisterAsync(theirs);
|
|
||||||
|
|
||||||
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
|
// 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里)
|
||||||
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" }));
|
||||||
@@ -144,7 +152,7 @@ public sealed class ElevationTests
|
|||||||
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode);
|
||||||
|
|
||||||
// 另一个用户的会话没有因此提权
|
// 另一个用户的会话没有因此提权
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -169,9 +177,10 @@ public sealed class ElevationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
|
public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate()
|
||||||
{
|
{
|
||||||
using HttpClient session = await NewSessionAsync();
|
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||||
using SoftwarePasskey passkey = await RegisterAsync(session);
|
using HttpClient sessionScope = session;
|
||||||
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") };
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
|
var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") };
|
||||||
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
|
var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") };
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
|
Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode);
|
||||||
@@ -184,7 +193,9 @@ public sealed class ElevationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListingUsers_NeedsOnlyTheReadCapability()
|
public async Task ListingUsers_NeedsOnlyTheReadCapability()
|
||||||
{
|
{
|
||||||
using HttpClient session = await NewSessionAsync();
|
(HttpClient session, SoftwarePasskey passkey) = await StrongAsync();
|
||||||
|
using HttpClient sessionScope = session;
|
||||||
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
|
Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,10 @@ using System.Text.Json.Nodes;
|
|||||||
|
|
||||||
namespace MoShiro.Backend.Tests;
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
/// <summary>一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。</summary>
|
/// <summary>
|
||||||
|
/// 一行安装:Agent 和 Probe 共用同一份<b>静态</b>脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行),
|
||||||
|
/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。
|
||||||
|
/// </summary>
|
||||||
[Collection("probe-e2e")]
|
[Collection("probe-e2e")]
|
||||||
public sealed class InstallTests
|
public sealed class InstallTests
|
||||||
{
|
{
|
||||||
@@ -23,7 +26,7 @@ public sealed class InstallTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("agent")]
|
[InlineData("agent")]
|
||||||
[InlineData("probe")]
|
[InlineData("probe")]
|
||||||
public async Task Commands_AreOneLiners_WithoutAPlaceholder_AndTheScriptIsFilledInForTheRole(string role)
|
public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role)
|
||||||
{
|
{
|
||||||
JsonNode created = await CreateAsync(role);
|
JsonNode created = await CreateAsync(role);
|
||||||
string code = created["plaintext"]!.GetValue<string>();
|
string code = created["plaintext"]!.GetValue<string>();
|
||||||
@@ -31,33 +34,55 @@ public sealed class InstallTests
|
|||||||
string linux = commands["linux"]!.GetValue<string>();
|
string linux = commands["linux"]!.GetValue<string>();
|
||||||
string macos = commands["macos"]!.GetValue<string>();
|
string macos = commands["macos"]!.GetValue<string>();
|
||||||
string windows = commands["windows"]!.GetValue<string>();
|
string windows = commands["windows"]!.GetValue<string>();
|
||||||
Assert.StartsWith("curl -fsSL '", linux, StringComparison.Ordinal);
|
|
||||||
Assert.EndsWith("/install?code=" + code + "' | bash", linux, StringComparison.Ordinal);
|
Assert.StartsWith("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal);
|
||||||
Assert.EndsWith("/install?code=" + code + "' | zsh", macos, StringComparison.Ordinal);
|
Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal);
|
||||||
Assert.StartsWith("irm '", windows, StringComparison.Ordinal);
|
Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal);
|
||||||
Assert.EndsWith("/install.ps1?code=" + code + "' | iex", windows, StringComparison.Ordinal);
|
Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
|
||||||
|
Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal);
|
||||||
|
Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal);
|
||||||
|
Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
||||||
|
|
||||||
// 脚本不需要登录(拿代码就是授权),不带缓存
|
|
||||||
using HttpResponseMessage sh = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
|
||||||
string script = await sh.Content.ReadAsStringAsync();
|
|
||||||
Assert.Equal(HttpStatusCode.OK, sh.StatusCode);
|
|
||||||
Assert.Equal("no-store", sh.Headers.CacheControl?.ToString());
|
|
||||||
Assert.Contains($"ROLE='{role}'", script, StringComparison.Ordinal);
|
|
||||||
Assert.Contains($"CODE='{code}'", script, StringComparison.Ordinal);
|
|
||||||
Assert.Contains($"/moshiro-{role}:latest'", script, StringComparison.Ordinal);
|
|
||||||
Assert.Contains("SERVER='http", script, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
|
|
||||||
|
|
||||||
using HttpResponseMessage ps = await _backend.Anonymous.GetAsync("/install.ps1?code=" + code);
|
|
||||||
string powershell = await ps.Content.ReadAsStringAsync();
|
|
||||||
Assert.Equal(HttpStatusCode.OK, ps.StatusCode);
|
|
||||||
Assert.Contains($"$role = '{role}'", powershell, StringComparison.Ordinal);
|
|
||||||
Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode()
|
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
||||||
|
{
|
||||||
|
foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" })
|
||||||
|
{
|
||||||
|
using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path);
|
||||||
|
using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
Assert.Equal("no-store", first.Headers.CacheControl?.ToString());
|
||||||
|
string body = await first.Content.ReadAsStringAsync();
|
||||||
|
Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样
|
||||||
|
Assert.DoesNotContain("@@", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一份字节:sh 的两个路径一致
|
||||||
|
Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips()
|
||||||
|
{
|
||||||
|
string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey();
|
||||||
|
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal);
|
||||||
|
foreach (string path in new[] { "/install", "/install.ps1" })
|
||||||
|
{
|
||||||
|
string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||||
|
Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte()
|
||||||
{
|
{
|
||||||
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}""";
|
||||||
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
|
using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" }));
|
||||||
@@ -67,24 +92,33 @@ public sealed class InstallTests
|
|||||||
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||||
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
||||||
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
|
Assert.Equal("no-store", served.Headers.CacheControl?.ToString());
|
||||||
string body = await served.Content.ReadAsStringAsync();
|
Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
||||||
Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改
|
|
||||||
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
|
using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code);
|
||||||
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, signature.StatusCode);
|
||||||
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
|
Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync()));
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest()
|
public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes()
|
||||||
{
|
{
|
||||||
string code = (await CreateAsync("agent"))["plaintext"]!.GetValue<string>();
|
foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" })
|
||||||
string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code);
|
{
|
||||||
Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal);
|
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
||||||
Assert.DoesNotContain("@@", script, StringComparison.Ordinal);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal);
|
}
|
||||||
|
|
||||||
|
JsonNode revoked = await CreateAsync("probe");
|
||||||
|
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
|
||||||
|
Assert.True(delete.IsSuccessStatusCode);
|
||||||
|
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue<string>());
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||||
|
|
||||||
|
// 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示
|
||||||
|
JsonNode used = await CreateAsync("probe");
|
||||||
|
string code = used["plaintext"]!.GetValue<string>();
|
||||||
|
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
||||||
|
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -93,29 +127,4 @@ public sealed class InstallTests
|
|||||||
JsonNode created = await CreateAsync("agent");
|
JsonNode created = await CreateAsync("agent");
|
||||||
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
|
Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue<string>(), StringComparison.Ordinal); // Admin 客户端是 API key
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes()
|
|
||||||
{
|
|
||||||
foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" })
|
|
||||||
{
|
|
||||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode revoked = await CreateAsync("agent");
|
|
||||||
using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue<string>());
|
|
||||||
Assert.True(delete.IsSuccessStatusCode);
|
|
||||||
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue<string>());
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
|
||||||
|
|
||||||
// 用完了(一次性令牌已经注册过一个节点)
|
|
||||||
JsonNode used = await CreateAsync("probe");
|
|
||||||
string code = used["plaintext"]!.GetValue<string>();
|
|
||||||
using HttpResponseMessage before = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
|
||||||
Assert.Equal(HttpStatusCode.OK, before.StatusCode);
|
|
||||||
await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { }
|
|
||||||
using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install?code=" + code);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ using Fleet.Core;
|
|||||||
namespace MoShiro.Backend.Tests;
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
/// <summary>软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。</summary>
|
/// <summary>软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。</summary>
|
||||||
internal sealed class SoftwarePasskey : IDisposable
|
public sealed class SoftwarePasskey : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
private uint _counter;
|
private uint _counter;
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable
|
|||||||
var tokens = new SqliteJoinTokenStore(_db);
|
var tokens = new SqliteJoinTokenStore(_db);
|
||||||
var fleet = new SqliteAgentStore(_db);
|
var fleet = new SqliteAgentStore(_db);
|
||||||
byte[] hash = TokenHash.Sha256("jt_race");
|
byte[] hash = TokenHash.Sha256("jt_race");
|
||||||
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default);
|
JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default);
|
||||||
Assert.Equal(0, created.Uses);
|
Assert.Equal(0, created.Uses);
|
||||||
|
|
||||||
bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default))));
|
bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default))));
|
||||||
@@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable
|
|||||||
Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
|
Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses);
|
||||||
|
|
||||||
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default));
|
Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default));
|
||||||
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default);
|
JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default);
|
||||||
Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
|
Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary");
|
||||||
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
||||||
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default));
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ using Fleet.Core;
|
|||||||
using Fleet.Enrollment.Client;
|
using Fleet.Enrollment.Client;
|
||||||
using Fleet.Hosting;
|
using Fleet.Hosting;
|
||||||
using Fleet.Identity;
|
using Fleet.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MoShiro.Backend.Application;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Tests;
|
namespace MoShiro.Backend.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。
|
/// 信任根:操作员(passkey)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。
|
||||||
/// 设计见 docs/access.md 第 7 节。
|
/// 设计见 docs/access.md 第 7 节。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("probe-e2e")]
|
[Collection("probe-e2e")]
|
||||||
public sealed class TrustTests
|
public sealed class TrustTests
|
||||||
{
|
{
|
||||||
private const string Password = "correct horse battery";
|
|
||||||
private static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
|
private static readonly ConditionalWeakTable<BackendFixture, Operator> s_operators = [];
|
||||||
private static readonly SemaphoreSlim s_gate = new(1, 1);
|
private static readonly SemaphoreSlim s_gate = new(1, 1);
|
||||||
|
|
||||||
@@ -32,8 +33,6 @@ public sealed class TrustTests
|
|||||||
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
|
/// <summary>一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。</summary>
|
||||||
private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
|
private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis);
|
||||||
|
|
||||||
private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority);
|
|
||||||
|
|
||||||
private async Task<Operator> OperatorAsync()
|
private async Task<Operator> OperatorAsync()
|
||||||
{
|
{
|
||||||
await s_gate.WaitAsync();
|
await s_gate.WaitAsync();
|
||||||
@@ -44,20 +43,8 @@ public sealed class TrustTests
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
string name = _backend.UniqueName("operator");
|
// 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有)
|
||||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password }));
|
(HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator");
|
||||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
||||||
var session = _backend.NewClient();
|
|
||||||
using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true }));
|
|
||||||
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
|
||||||
|
|
||||||
var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin);
|
|
||||||
using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null))
|
|
||||||
{
|
|
||||||
string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue<string>();
|
|
||||||
using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge)));
|
|
||||||
Assert.Equal(HttpStatusCode.Created, registered.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
// 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身
|
||||||
await ElevateAsync(session, passkey);
|
await ElevateAsync(session, passkey);
|
||||||
@@ -105,98 +92,127 @@ public sealed class TrustTests
|
|||||||
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, elevated.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。</summary>
|
/// <summary>创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。</summary>
|
||||||
private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null)
|
private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null)
|
||||||
{
|
{
|
||||||
using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
var body = new JsonObject { ["role"] = role, ["note"] = "trust test" };
|
||||||
byte[] seed = code.ExportParameters(true).D!;
|
if (controllers is not null)
|
||||||
string auth = OperatorTrust.AuthToken(seed);
|
{
|
||||||
long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
body["controllers"] = new JsonArray([.. controllers.Select(c => (JsonNode?)c)]);
|
||||||
string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1));
|
|
||||||
using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" }));
|
|
||||||
return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result<EnrollmentResult>> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null)
|
using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(body));
|
||||||
|
JsonNode result = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
|
||||||
|
return response.StatusCode == HttpStatusCode.Created ? (result["plaintext"]!.GetValue<string>(), result["install_commands"]!, response.StatusCode) : ("", result, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Result<EnrollmentResult>> EnrollAsync(string code, string? genesis, string[]? controllers, string? directory = null)
|
||||||
{
|
{
|
||||||
directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role);
|
return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, code, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, genesis, controllers);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof()
|
public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
Operator op = await OperatorAsync();
|
||||||
(string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op);
|
(string code, JsonNode commands, HttpStatusCode status) = await CreateCodeAsync();
|
||||||
Assert.Equal(HttpStatusCode.Created, status);
|
Assert.Equal(HttpStatusCode.Created, status);
|
||||||
|
|
||||||
|
string linux = commands["linux"]!.GetValue<string>();
|
||||||
|
string sha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install"))));
|
||||||
|
Assert.Contains($"echo \"{sha} $f\" | sha256sum -c -", linux, StringComparison.Ordinal); // 先核对哈希,一致才执行
|
||||||
|
Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", linux, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(op.Passkey.Id, linux, StringComparison.Ordinal); // 默认属主:操作员集合里的全部 passkey
|
||||||
|
Assert.DoesNotContain("SEED", linux + commands["macos"] + commands["windows"], StringComparison.Ordinal); // 没有任何私钥在命令里
|
||||||
|
Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||||
|
string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1"))));
|
||||||
|
Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue<string>(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ANode_PinsTheGenesisAndItsOwners_AtEnrollment()
|
||||||
|
{
|
||||||
|
Operator op = await OperatorAsync();
|
||||||
|
(string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]);
|
||||||
|
|
||||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||||
Result<EnrollmentResult> enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory);
|
Result<EnrollmentResult> enrolled = await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory);
|
||||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
||||||
Assert.True(enrolled.Value.Pinned);
|
Assert.True(enrolled.Value.Pinned);
|
||||||
|
|
||||||
// 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明
|
|
||||||
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
|
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
|
||||||
Assert.True(anchor.Enforcing);
|
Assert.True(anchor.Pinned);
|
||||||
Assert.Equal(op.Genesis, anchor.Genesis);
|
Assert.Equal(op.Genesis, anchor.Genesis);
|
||||||
using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
|
Assert.Equal([op.Passkey.Id], anchor.Controllers);
|
||||||
Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe"));
|
Assert.True(anchor.Version >= 1);
|
||||||
Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe"));
|
|
||||||
Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe"));
|
|
||||||
|
|
||||||
// 面板上这个码标记为已签名
|
|
||||||
JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!;
|
|
||||||
Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue<bool>() && t["note"]?.GetValue<string>() == "trust test");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
|
public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
Operator op = await OperatorAsync();
|
||||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
|
(string code, _, _) = await CreateCodeAsync();
|
||||||
|
|
||||||
Result<EnrollmentResult> wrong = await EnrollAsync(auth, seed, new string('0', 64));
|
Result<EnrollmentResult> wrong = await EnrollAsync(code, new string('0', 64), [op.Passkey.Id]);
|
||||||
Assert.False(wrong.IsSuccess);
|
Assert.False(wrong.IsSuccess);
|
||||||
Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal);
|
Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal);
|
||||||
|
|
||||||
Result<EnrollmentResult> right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗
|
Result<EnrollmentResult> unknownOwner = await EnrollAsync(code, op.Genesis, ["not-a-credential-of-the-set"]);
|
||||||
Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString());
|
Assert.False(unknownOwner.IsSuccess);
|
||||||
|
Assert.Contains("owner", unknownOwner.Error.Message, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id])).IsSuccess); // 前两次都在交出密钥之前被拒绝,码没有被消耗
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn()
|
public async Task APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
(string code, _, _) = await CreateCodeAsync();
|
||||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op);
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Result<EnrollmentResult> enrolled = await EnrollAsync(code, null, null, directory);
|
||||||
// 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定
|
Assert.True(enrolled.IsSuccess);
|
||||||
Result<EnrollmentResult> withoutSeed = await EnrollAsync(auth, null, null);
|
Assert.False(enrolled.Value.Pinned);
|
||||||
Assert.False(withoutSeed.IsSuccess);
|
Assert.False(TrustAnchor.Load(directory, NullNodeLog.Instance).Pinned);
|
||||||
|
|
||||||
// 种子不是这个码的:节点自己就不干
|
|
||||||
(string otherSeed, _, _) = await CreateSignedCodeAsync(op);
|
|
||||||
Result<EnrollmentResult> mismatched = await EnrollAsync(auth, otherSeed, op.Genesis);
|
|
||||||
Assert.False(mismatched.IsSuccess);
|
|
||||||
|
|
||||||
Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused()
|
public async Task TheOwnersOfACode_MustBePasskeysOfTheOperatorSet()
|
||||||
|
{
|
||||||
|
await OperatorAsync();
|
||||||
|
(_, JsonNode problem, HttpStatusCode status) = await CreateCodeAsync(controllers: ["not-in-the-set"]);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||||
|
Assert.Contains("controllers", problem.ToJsonString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APinnedAgent_FollowsTheOperatorSet_WhenThePasskeysChange_AndOnlyWhenTheOperatorSigns()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
Operator op = await OperatorAsync();
|
||||||
|
(string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]);
|
||||||
|
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id]));
|
||||||
|
Assert.True(agent.App.Link.IsSecure, "a pinned agent must be on the encrypted link (the server proved itself with the key in the operator set)");
|
||||||
|
int before = agent.App.TrustVersion;
|
||||||
|
Assert.True(before >= 1);
|
||||||
|
|
||||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
// 账号下多了一把 passkey:集合和 passkey 不一致,面板给出下一版要签的语句
|
||||||
Result<EnrollmentResult> asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的
|
(_, SoftwarePasskey added) = await _backend.CreateUserWithPasskeyAsync("colleague");
|
||||||
Assert.False(asProbe.IsSuccess);
|
using SoftwarePasskey addedScope = added;
|
||||||
|
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||||
|
Assert.False(state["in_sync"]!.GetValue<bool>());
|
||||||
|
string next = state["proposal"]!["statement"]!.GetValue<string>();
|
||||||
|
|
||||||
(_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000);
|
// 不是操作员签的(新来的 passkey 还不在上一版里):不收
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, expired);
|
using HttpResponseMessage forged = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(added, next)));
|
||||||
|
Assert.True(forged.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.BadRequest, await forged.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(before, agent.App.TrustVersion);
|
||||||
|
|
||||||
using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托
|
// 现有操作员签:收下,随名册下发给节点,节点从固定的创世验过之后采用
|
||||||
(_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger);
|
using HttpResponseMessage committed = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, next)));
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, foreign);
|
Assert.Equal(HttpStatusCode.Created, committed.StatusCode);
|
||||||
|
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.TrustVersion > before), because: "the pinned agent should adopt the operator-signed set");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -207,63 +223,86 @@ public sealed class TrustTests
|
|||||||
Assert.True(state["initialized"]!.GetValue<bool>());
|
Assert.True(state["initialized"]!.GetValue<bool>());
|
||||||
Assert.Equal(op.Genesis, state["genesis"]!.GetValue<string>());
|
Assert.Equal(op.Genesis, state["genesis"]!.GetValue<string>());
|
||||||
|
|
||||||
// 没有操作员签名的语句:不收
|
// 语句被换掉,签名对不上:不收
|
||||||
JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA");
|
JsonObject tampered = Sign(op.Passkey, "moshiro-opset-v1\nversion=99\nprev=x\nrp=x\norigins=x\nkey=a:AAAA");
|
||||||
unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上
|
tampered["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + _backend.BaseAddress.GetLeftPart(UriPartial.Authority) + "\nkey=a:AAAA";
|
||||||
using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
|
using HttpResponseMessage rejected = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
|
||||||
Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict);
|
Assert.True(rejected.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict);
|
||||||
|
|
||||||
// 匿名和只有 API key 的调用也没有可能立根
|
// 匿名没有可能立根或改根
|
||||||
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned));
|
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered));
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers()
|
public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
// 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey
|
||||||
(string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
string name = _backend.UniqueName("pwonly");
|
||||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis));
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
using HttpClient password = _backend.NewClient();
|
||||||
|
using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true }));
|
||||||
|
password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
|
||||||
|
JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!;
|
||||||
|
Assert.Empty(me["capabilities"]!.AsArray());
|
||||||
|
Assert.Equal("password", me["auth_method"]!.GetValue<string>());
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode);
|
||||||
|
using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, options.StatusCode);
|
||||||
|
|
||||||
// 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的
|
// passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里)
|
||||||
using EnrolledProbe plain = await _backend.CreateProbeAsync();
|
(HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator");
|
||||||
(string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe");
|
using HttpClient strongScope = strong;
|
||||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
using SoftwarePasskey passkeyScope = passkey;
|
||||||
Result<EnrollmentResult> enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory);
|
using HttpResponseMessage code = await strong.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }));
|
||||||
Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString());
|
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
|
||||||
using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory);
|
|
||||||
|
|
||||||
await BackendFixture.EventuallyAsync(
|
|
||||||
() => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))),
|
|
||||||
because: "the agent should accept the probe the operator approved");
|
|
||||||
Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved()
|
public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck()
|
||||||
{
|
{
|
||||||
Operator op = await OperatorAsync();
|
Operator op = await OperatorAsync();
|
||||||
(string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe");
|
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||||
string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
Assert.True(state["server_key_listed"]!.GetValue<bool>());
|
||||||
Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess);
|
Assert.Equal(64, state["server_key"]!.GetValue<string>().Length);
|
||||||
|
|
||||||
(string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent");
|
(string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]);
|
||||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis));
|
string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N"));
|
||||||
MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof;
|
Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory)).IsSuccess);
|
||||||
string key = Convert.ToBase64String(agent.TlsPublicKey);
|
TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance);
|
||||||
|
Assert.True(anchor.Required); // 集合里登记了服务器密钥:节点要求服务器证明自己
|
||||||
|
|
||||||
string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N"));
|
// 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了
|
||||||
var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance));
|
ServerIdentityService server = _backend.Services.GetRequiredService<ServerIdentityService>();
|
||||||
directory.Apply(new MoShiro.Protocol.ProbeAgentList
|
using var ephemeral = new EphemeralKey();
|
||||||
|
byte[] proof = server.Sign(ServerHello.Payload("nonce-1", ephemeral.PublicKeySpki));
|
||||||
|
Assert.True(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, proof));
|
||||||
|
using ECDsa impostor = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
Assert.False(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, ServerHello.Sign(impostor, "nonce-1", ephemeral.PublicKeySpki)));
|
||||||
|
using var otherEphemeral = new EphemeralKey();
|
||||||
|
Assert.False(anchor.Verify("nonce-1", otherEphemeral.PublicKeySpki, proof));
|
||||||
|
Assert.False(anchor.Verify("nonce-2", ephemeral.PublicKeySpki, proof));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnOperatorSetWithoutThisPanelsServerKey_IsNotAccepted()
|
||||||
{
|
{
|
||||||
Version = 1,
|
Operator op = await OperatorAsync();
|
||||||
Agents =
|
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||||
[
|
string current = state["proposal"]?["statement"]?.GetValue<string>() ?? "";
|
||||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof },
|
if (current.Length == 0)
|
||||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明
|
{
|
||||||
new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥
|
// 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版
|
||||||
],
|
(_, SoftwarePasskey extra) = await _backend.CreateUserWithPasskeyAsync("bystander");
|
||||||
});
|
using SoftwarePasskey extraScope = extra;
|
||||||
Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId));
|
state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
|
||||||
|
current = state["proposal"]!["statement"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
string withoutServer = string.Join('\n', current.Split('\n').Where(l => !l.StartsWith("server=", StringComparison.Ordinal)));
|
||||||
|
using HttpResponseMessage refused = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, withoutServer)));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); // 签下去会让固定了它的节点认不出这个面板
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,10 @@ printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$
|
|||||||
chmod 600 "$netrc"
|
chmod 600 "$netrc"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
release_body="Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized."
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | cut -d" " -f1)"
|
||||||
|
install_ps1="$(sha256sum "$root_dir/packaging/install/install.ps1" | cut -d" " -f1)"
|
||||||
|
release_body="Install script SHA-256 (compare with the panel before you run an install command): install.sh $install_sh, install.ps1 $install_ps1. Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized."
|
||||||
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \
|
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \
|
||||||
"$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")"
|
"$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")"
|
||||||
release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
|
release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 生成并签名一个镜像的发布清单:ci/release-manifest.sh <组件> <镜像仓库> <digest> <tag> <通道> <输出目录>
|
# 生成并签名一个镜像的发布清单:ci/release-manifest.sh <组件> <镜像仓库> <digest> <tag> <通道> <输出目录>
|
||||||
# 签名私钥从环境变量 RELEASE_SIGNING_KEY(PEM 内容)读取,只写进一个 600 权限的临时文件,用完即删;私钥只放在 CI 密钥里,不进仓库、不上传面板。
|
# 签名私钥从环境变量 RELEASE_SIGNING_KEY(PEM 内容)读取,只写进一个 600 权限的临时文件,用完即删;私钥只放在 CI 密钥里,不进仓库、不上传面板。
|
||||||
|
# 换发布密钥:把新公钥(PEM)放进环境变量 RELEASE_NEXT_KEY,清单里就会带 "next_key"(SPKI DER 的 base64);这份清单仍由旧密钥签名,
|
||||||
|
# 宿主机更新器验过之后改认新密钥。下一个版本起把 RELEASE_SIGNING_KEY 换成新私钥、去掉 RELEASE_NEXT_KEY,并把安装脚本和 release-key.pub.pem 里的公钥换成新的。
|
||||||
# 输出:<输出目录>/manifest.json(扁平 JSON,签名对象是它的原始字节)和 manifest.sig(base64 的 DER ECDSA 签名)。
|
# 输出:<输出目录>/manifest.json(扁平 JSON,签名对象是它的原始字节)和 manifest.sig(base64 的 DER ECDSA 签名)。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -16,10 +18,19 @@ version="$(sed -n 's#.*<Version>\(.*\)</Version>.*#\1#p' "$root/backend/Director
|
|||||||
commit="$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)"
|
commit="$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)"
|
||||||
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
seq="$(date +%s)"
|
seq="$(date +%s)"
|
||||||
|
# 静态安装脚本的 SHA-256:面板给的命令会先核对它再执行;记在签名的清单里,面板和用户都能拿它和这份构建对照
|
||||||
|
install_sh="$(sha256sum "$root/packaging/install/install.sh" | cut -d" " -f1)"
|
||||||
|
install_ps1="$(sha256sum "$root/packaging/install/install.ps1" | cut -d" " -f1)"
|
||||||
|
|
||||||
|
next_key=""
|
||||||
|
if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then
|
||||||
|
next_key="$(printf '%s\n' "$RELEASE_NEXT_KEY" | openssl pkey -pubin -inform PEM -outform DER | base64 -w0 2>/dev/null || true)"
|
||||||
|
[[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s}' \
|
printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s}' \
|
||||||
"$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" > "$outdir/manifest.json"
|
"$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" > "$outdir/manifest.json"
|
||||||
|
|
||||||
key="$(mktemp)"
|
key="$(mktemp)"
|
||||||
trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT
|
trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT
|
||||||
|
|||||||
+96
-25
@@ -1,9 +1,38 @@
|
|||||||
# 访问控制与入网信任链
|
# 访问控制与入网信任链
|
||||||
|
|
||||||
这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:设计已定,未实现**(实施顺序见第 12 节)。
|
这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:部分实现**(当前实现见第 0 节,完整设计的实施顺序见第 12 节)。
|
||||||
|
|
||||||
一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。
|
一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。
|
||||||
|
|
||||||
|
## 0. 当前实现(以这一节为准)
|
||||||
|
|
||||||
|
下面的第 1~16 节是完整设计,其中「成员证明」那一整套(Grant、Ticket、Binding、每个节点的入网链,第 7.1~7.3 节)**当前没有采用**:评估之后,被攻破的 Backend 往名册里塞节点拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰、一套证明格式和一套自动化委托。现在实现的是它的精简版:
|
||||||
|
|
||||||
|
```text
|
||||||
|
两个根,各管一摊
|
||||||
|
根 A:CI(代码) 发布公钥写在安装脚本和节点二进制里
|
||||||
|
└─ CI 签名的发布清单 → 镜像 digest → 节点跑的二进制;安装脚本的 SHA-256 也记在清单里,并公布在发布页
|
||||||
|
假设:CI(Woodpecker、Gitea、镜像仓库、发布密钥)可信;CI 被攻破等于能给所有节点签恶意更新
|
||||||
|
根 B:你的 passkey(运维)
|
||||||
|
└─ 操作员集合(passkey 签,版本递增;创世指纹在安装时固定到节点)
|
||||||
|
├─ 操作员的 passkey 公钥
|
||||||
|
├─ 服务器密钥:Backend 的身份密钥(节点据此认服务器,链路用应用层加密)
|
||||||
|
└─ 域名(rp、origins)
|
||||||
|
|
||||||
|
添加节点:创建加入码不需要 passkey、不需要触碰,一个码可以装任意多台。命令里只有公开的值:
|
||||||
|
面板地址、角色、一次性码、创世指纹、属主(passkey 凭证 id)、安装脚本的哈希(命令先核对哈希再执行)
|
||||||
|
节点注册:向面板取操作员集合链,从命令里的创世指纹验起,属主必须是集合里的凭证;任何一步不对就不注册、不交出密钥;
|
||||||
|
通过后写 trust.json(创世指纹、链、属主),之后只认从这个创世出发的新版本
|
||||||
|
节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥,
|
||||||
|
认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。
|
||||||
|
所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路
|
||||||
|
密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰)
|
||||||
|
```
|
||||||
|
|
||||||
|
**发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。
|
||||||
|
|
||||||
|
**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。
|
||||||
|
|
||||||
## 1. 名词
|
## 1. 名词
|
||||||
|
|
||||||
| 名词 | 含义 |
|
| 名词 | 含义 |
|
||||||
@@ -105,24 +134,11 @@
|
|||||||
- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。
|
- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。
|
||||||
- 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。
|
- 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。
|
||||||
|
|
||||||
### 5.2 `enroll` key 怎么工作
|
### 5.2 `enroll` key:自动化添加节点
|
||||||
|
|
||||||
```text
|
**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。
|
||||||
创建(浏览器 + passkey 一次触控)
|
|
||||||
浏览器生成种子 S,只显示一次(形如 mok_…)
|
|
||||||
认证值 = HKDF(S, "auth") → Backend 只存哈希,用来识别这把 key
|
|
||||||
签名密钥 = HKDF(S, "sign") → 公钥 P 写进委托;私钥只有拿着 S 的人算得出
|
|
||||||
passkey 对委托签名:{P, scope=enroll, not_after, max_ticket_ttl, 数量上限, 签发凭证 id}
|
|
||||||
Backend 存委托和认证值哈希,不存 S
|
|
||||||
|
|
||||||
使用(和普通令牌一样的 HTTP 调用)
|
早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。
|
||||||
POST /api/admin/join-tokens Authorization: Bearer mok_S
|
|
||||||
Backend 用请求带来的 S 现算签名密钥,签出入网券和 K_code,返回一行安装命令;用完即忘
|
|
||||||
```
|
|
||||||
|
|
||||||
- 数据库被偷走拿不到任何签名密钥。被攻破的 Backend 最多在 key 被使用的那一刻拿到 S,权力受这把 key 的有效期和单券 TTL 限制,key 过期或被撤销就没用了。
|
|
||||||
- 数量上限由 Backend 执行,**节点强制不了**;节点只强制时间(见第 7.3 节)。
|
|
||||||
- CI 和测试:夹具自己生成 key 种子,把公钥写进测试的操作员集合,不需要真 passkey。
|
|
||||||
|
|
||||||
### 5.3 创建界面
|
### 5.3 创建界面
|
||||||
|
|
||||||
@@ -154,7 +170,9 @@
|
|||||||
- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。
|
- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。
|
||||||
- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。
|
- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。
|
||||||
|
|
||||||
## 7. 入网信任链
|
## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节)
|
||||||
|
|
||||||
|
> 第 7.1~7.3 节描述的 Grant、Ticket、Binding、成员证明和节点验证在代码里**不存在**。要不要叠加回来,取决于你要不要防「被攻破的 Backend 往名册里塞节点」;操作员集合和创世固定已经在,叠加时不用重装节点。
|
||||||
|
|
||||||
### 7.1 文档
|
### 7.1 文档
|
||||||
|
|
||||||
@@ -221,6 +239,18 @@
|
|||||||
|
|
||||||
不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。
|
不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。
|
||||||
|
|
||||||
|
### 7.6 当前实现要点
|
||||||
|
|
||||||
|
- 语句是 `key=value` 文本,第一行是带版本的标题,签名覆盖整段文本;WebAuthn 的挑战是语句的 SHA-256。库在 `Fleet.Identity` 的 `OperatorTrust`(操作员集合的编码、解析、链验证)和 `SecureChannel`(服务器证明和链路加密),都是只用 BCL 的纯函数,节点和 Backend 共用。
|
||||||
|
- **创世**:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 `MOSHIRO_GENESIS`。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。
|
||||||
|
- **操作员集合版本**随期望状态(`trust_chain`)下发,节点从固定的创世验起,版本比现有的新才采用并保存。
|
||||||
|
- **节点注册**:`fleet-enroll --genesis … --controllers …`:先 `POST /api/agent/v1/enroll/trust` 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 `trust.json`。没有命令里的指纹(`--genesis`)就不固定,节点按旧方式工作。
|
||||||
|
- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。
|
||||||
|
- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。
|
||||||
|
- **安装脚本是静态的**:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。
|
||||||
|
- **密码登录的会话没有任何能力**,只能注册 passkey;创建加入码需要 `fleet.enroll`,不需要提权窗口。
|
||||||
|
- **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。
|
||||||
|
|
||||||
## 8. 节点属主与 extension 授权(预留)
|
## 8. 节点属主与 extension 授权(预留)
|
||||||
|
|
||||||
后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。
|
后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。
|
||||||
@@ -249,21 +279,46 @@
|
|||||||
- **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。
|
- **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。
|
||||||
- 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。
|
- 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。
|
||||||
|
|
||||||
### 8.1 添加节点时选属主(接口现在就留)
|
### 8.0 范围:这一节先固定协议和要求,extension 本身先不做
|
||||||
|
|
||||||
- API:`POST /api/admin/join-tokens` 和 `enroll` key 的签发请求都带可选的 `controllers: [credential_id…]`;缺省 = 签发账户的全部 passkey。校验:每个 id 必须是已注册的 passkey(任何账户的都可以,比如把同事的一把加进来),数量不超过上限。
|
- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。
|
||||||
- 界面:「添加 Agent / Probe」对话框里一个「属主 passkey」多选列表,按账户分组,带**全选**、组内全选和反选;默认勾选自己的全部;至少选一把;提示「只有这些 passkey 能授权这个节点上的 web ssh 等敏感能力,管理员也不能」。
|
- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。
|
||||||
- 节点详情页显示属主(凭证 id 和所属账户),以及「由谁绑定」;改属主走 `ControllerUpdate`,需要现有属主签名,管理员做不了。
|
|
||||||
- 没有 extension 的现阶段,这组字段只是被签名、被节点持久化,不起别的作用。
|
|
||||||
|
|
||||||
### 8.2 数据用属主的私钥解密:Backend 解不开,也改不了
|
### 8.1 添加节点时选属主
|
||||||
|
|
||||||
|
- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。
|
||||||
|
- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。
|
||||||
|
- 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。
|
||||||
|
|
||||||
|
### 8.2 谁来强制:节点本地,不是 Backend
|
||||||
|
|
||||||
|
web ssh 这类会话要不要加密、要不要验证,**由 Agent(和 Probe)自己要求**,不是 Backend。Backend 是被防的一方:让它决定,被攻破的 Backend 一句「这次不用加密」或自己充当会话的一端就绕过去了。所以要求落在节点本地,Backend 只能转发:
|
||||||
|
|
||||||
|
| 节点本地强制的要求 | 为什么不能交给 Backend |
|
||||||
|
|---|---|
|
||||||
|
| 没有操作员的现场证明就不启动会话 | Backend 说「已经授权过了」不算数 |
|
||||||
|
| 会话密钥只和证明里的那把密钥协商,不和 Backend 转来的任何公钥协商 | 否则 Backend 换一把自己的公钥就成了中间人 |
|
||||||
|
| 加密字段缺失就拒绝,没有明文降级 | 防降级 |
|
||||||
|
| 能开哪些 extension、哪些 passkey 能授权,只看本节点属主(passkey 签的 `controllers`) | Backend 改不了属主,账户角色也不赋予任何权力 |
|
||||||
|
| 这些要求写在节点本地(编进二进制或本地 `trust.json`),Backend 下发的配置、任务、更新清单都覆盖不了 | 配置是 Backend 能碰的东西 |
|
||||||
|
|
||||||
|
**要求证明的方向是反的:节点向请求者要证明,不信任 Backend 的转述。** 一次会话由节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),浏览器把这个 nonce 和自己的临时公钥一起放进 passkey 断言的挑战里(见 8.3 的流程);节点验:断言来自固定的操作员集合里的凭证、要求用户验证、挑战里的节点 id、extension、过期时间都对得上、这把凭证在本节点的 `controllers` 里、nonce 是它自己刚出的且没用过。任何一条不满足,节点直接拒绝,不看 Backend 的任何说法。
|
||||||
|
|
||||||
|
**Backend 在这件事里仍然管**:谁有资格发起请求(一项 `extensions.use` 能力)、审计、限流、转发密文。这些只挡「不该问的人」;真正的放行始终是节点本地验过证明之后。
|
||||||
|
|
||||||
|
**交互式会话和静态数据用不同的密钥**:
|
||||||
|
- 交互式会话(终端、ssh):现场签名的临时密钥协商,**不依赖 PRF**,passkey 只用来签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密。
|
||||||
|
- 静态数据(抓包文件、会话录像):需要事后解密,才用接收者名单里的公钥加密(`recipients`,来自 WebAuthn PRF 或浏览器里的不可导出密钥,见 8.3)。
|
||||||
|
|
||||||
|
### 8.3 数据用属主的私钥解密:Backend 解不开,也改不了
|
||||||
|
|
||||||
目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),**只有属主 passkey 对应的私钥能解开**。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。
|
目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),**只有属主 passkey 对应的私钥能解开**。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
浏览器(属主) Backend(只搬运) 节点上的 extension
|
浏览器(属主) Backend(只搬运) 节点上的 extension
|
||||||
|
⓪ 节点先出题:nonce 和它的临时公钥 n,签名后经 Backend 转给浏览器(见 8.2:要求证明的是节点)
|
||||||
① 生成会话临时 ECDH 密钥 e
|
① 生成会话临时 ECDH 密钥 e
|
||||||
② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ nonce ‖ not_after」做 WebAuthn 断言(能力令牌)
|
② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ 节点出的 nonce ‖ not_after」做 WebAuthn 断言(能力令牌)
|
||||||
──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主
|
──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主
|
||||||
+ 令牌新鲜、nonce 未用
|
+ 令牌新鲜、nonce 未用
|
||||||
◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回
|
◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回
|
||||||
@@ -281,6 +336,20 @@
|
|||||||
属主记录里没有 `enc_pubkey` 的节点只支持实时会话,不能存加密的静态数据。
|
属主记录里没有 `enc_pubkey` 的节点只支持实时会话,不能存加密的静态数据。
|
||||||
- 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。
|
- 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。
|
||||||
|
|
||||||
|
### 8.4 数据边界:哪些明文,哪些加密,谁能解
|
||||||
|
|
||||||
|
| 数据 | 形态 | 谁能读 |
|
||||||
|
|---|---|---|
|
||||||
|
| **监测数据**(延迟、丢包、吞吐曲线、事件、主机资源汇总) | **明文**:Backend 要做分析、聚合和展示页 | 有 `data.read` 等能力的人和 key |
|
||||||
|
| **extension 会话**(web ssh、文件、抓包等,以后)、原始 payload | **端到端加密**:节点加密给接收者名单里的公钥,Backend 只存和转发密文 | 只有接收者私钥的持有者 |
|
||||||
|
|
||||||
|
- **读取权不是能力,是私钥**:Backend 不持有任何接收者私钥,所以 `data.read`、`data.export` 对加密流只返回密文。API key 解不开,除非你登记过读者密钥。
|
||||||
|
- **接收者名单由操作员固定**(做加密时和 `controllers` 一样,写进安装命令并固定在节点本地;现在还没有加密功能):
|
||||||
|
- 默认只有操作员自己的 passkey(经 WebAuthn PRF 派生的公钥)。
|
||||||
|
- 接入密钥签出来的码,名单必须和操作员签的接入委托完全一致:它能让节点加入,不能让节点把数据加密给自己。
|
||||||
|
- **读者密钥**(预留):自动化想读明文,要操作员用 passkey 显式登记一把读者公钥(范围、有效期),私钥由读者自己保管;吊销只管之后的数据,已经加密给它的历史数据它仍然能解。
|
||||||
|
- 管和读互不相通:passkey 或接入密钥能让节点加入和批准,但不能解密;解密靠私钥,Backend 被攻破也拿不到。
|
||||||
|
|
||||||
## 9. 流程
|
## 9. 流程
|
||||||
|
|
||||||
| 流程 | 步骤 |
|
| 流程 | 步骤 |
|
||||||
@@ -317,6 +386,8 @@
|
|||||||
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。
|
||||||
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。
|
||||||
|
|
||||||
|
实现状态:能力模型、passkey 登录、提权窗口、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。
|
||||||
|
|
||||||
## 13. 局限
|
## 13. 局限
|
||||||
|
|
||||||
- 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。
|
- 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。
|
||||||
|
|||||||
+8
-1
@@ -307,7 +307,14 @@ sh packaging/update/install-updater.sh agent --user \
|
|||||||
|
|
||||||
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
|
||||||
|
|
||||||
一行安装会验证镜像:脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`),签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 才会按标签安装。脚本本身的完整性(哈希核对)见 [access.md](access.md) 第 15 节。
|
**换发布密钥**(泄露或定期轮换,不用重装节点):
|
||||||
|
|
||||||
|
1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。
|
||||||
|
2. 宿主机更新器验过这份清单后,把新公钥存到自己的状态目录(`<组件>.key.pem`),之后只认新密钥,旧密钥签的清单一律拒绝。清单里的 `next_key` 不是合法公钥就整份拒绝。
|
||||||
|
3. 下一个版本起把 `release_signing_key` 换成新私钥、去掉 `RELEASE_NEXT_KEY`,并把 `packaging/update/release-key.pub.pem`、`install.sh`、`install.ps1` 里的公钥换成新的(安装脚本哈希随清单变化)。
|
||||||
|
4. 还没收到过渡清单的节点(离线很久)会因为签名对不上而停在旧版本,需要手动换 `release-key.pub.pem`;旧密钥泄露时,这些节点是唯一的暴露面。
|
||||||
|
|
||||||
|
一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。
|
||||||
|
|
||||||
## Backend 签名更新
|
## Backend 签名更新
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -305,7 +305,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec
|
|||||||
1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。
|
1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。
|
||||||
2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。
|
2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。
|
||||||
3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。
|
3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。
|
||||||
4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 节);剩下的是静态数据私钥来源和 AEAD 的选择。
|
4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 和 8.3 节);剩下的是静态数据私钥来源和 AEAD 的选择。
|
||||||
5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。
|
5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。
|
||||||
6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。
|
6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。
|
||||||
7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。
|
7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ if [ ! -f "$identity/node.json" ]; then
|
|||||||
echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2
|
echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2
|
||||||
exit 1
|
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" \
|
||||||
|
--genesis "$MOSHIRO_GENESIS" --controllers "${MOSHIRO_CONTROLLERS:-}"
|
||||||
|
else
|
||||||
/usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity"
|
/usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -f "$config" ]; then
|
if [ ! -f "$config" ]; then
|
||||||
if [ -z "${MOSHIRO_SERVER:-}" ]; then
|
if [ -z "${MOSHIRO_SERVER:-}" ]; then
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好:
|
# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。
|
||||||
# irm 'https://panel/install.ps1?code=jt_…' | iex
|
# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行。
|
||||||
# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。面板还没有签名的清单时,设 $env:MOSHIRO_ALLOW_UNVERIFIED='1' 才会按标签安装。
|
# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。
|
||||||
# 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。
|
# 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。
|
||||||
# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。
|
# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:信任根指纹和这个节点的属主 passkey 凭证 id,都是公开值)、
|
||||||
|
# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。
|
||||||
|
# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。
|
||||||
function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) {
|
function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) {
|
||||||
# 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s
|
# 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s
|
||||||
$b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join ''
|
$b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join ''
|
||||||
@@ -29,14 +31,22 @@ function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pe
|
|||||||
|
|
||||||
function Install-MoShiro {
|
function Install-MoShiro {
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$role = '@@ROLE@@'
|
$role = $env:MOSHIRO_ROLE
|
||||||
$server = '@@SERVER@@'
|
$server = $env:MOSHIRO_SERVER
|
||||||
$code = '@@CODE@@'
|
$code = $env:MOSHIRO_CODE
|
||||||
$image = '@@IMAGE@@'
|
$image = $env:MOSHIRO_IMAGE
|
||||||
$releaseKey = @'
|
$releaseKey = @'
|
||||||
@@RELEASE_KEY@@
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q
|
||||||
|
YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
'@
|
'@
|
||||||
|
|
||||||
|
# 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里
|
||||||
|
if ($role -notin @('agent', 'probe')) { Write-Error 'MOSHIRO_ROLE must be agent or probe'; return }
|
||||||
|
if ($server -notmatch '^https?://[A-Za-z0-9._:/@~%+\[\]-]+$') { Write-Error 'MOSHIRO_SERVER must be the panel address (http:// or https://)'; return }
|
||||||
|
if ($code -notmatch '^[A-Za-z0-9._~%+-]+$') { Write-Error 'MOSHIRO_CODE is required (copy the whole command from the panel)'; return }
|
||||||
|
|
||||||
$name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME }
|
$name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME }
|
||||||
$name = $name -replace '[^A-Za-z0-9._-]', '-'
|
$name = $name -replace '[^A-Za-z0-9._-]', '-'
|
||||||
if ($name.Length -gt 64) { $name = $name.Substring(0, 64) }
|
if ($name.Length -gt 64) { $name = $name.Substring(0, 64) }
|
||||||
@@ -72,8 +82,8 @@ function Install-MoShiro {
|
|||||||
$manifest = $null
|
$manifest = $null
|
||||||
}
|
}
|
||||||
if ($null -eq $manifest) {
|
if ($null -eq $manifest) {
|
||||||
if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1') {
|
if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1' -or -not $image) {
|
||||||
Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. Run again with `$env:MOSHIRO_ALLOW_UNVERIFIED='1' to install $image without verification"
|
Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. To install anyway set `$env:MOSHIRO_ALLOW_UNVERIFIED='1' and `$env:MOSHIRO_IMAGE"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Write-Host "warning: installing $image without signature verification"
|
Write-Host "warning: installing $image without signature verification"
|
||||||
@@ -102,7 +112,13 @@ function Install-MoShiro {
|
|||||||
'-v', "${volume}:/etc/moshiro")
|
'-v', "${volume}:/etc/moshiro")
|
||||||
# Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
# Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
||||||
if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') }
|
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", $image)
|
$arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name")
|
||||||
|
# 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地
|
||||||
|
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 }
|
||||||
|
$arguments += @('-e', "MOSHIRO_GENESIS=$($env:MOSHIRO_GENESIS)", '-e', "MOSHIRO_CONTROLLERS=$($env:MOSHIRO_CONTROLLERS)")
|
||||||
|
}
|
||||||
|
$arguments += $image
|
||||||
|
|
||||||
Write-Host "starting $container as '$name'"
|
Write-Host "starting $container as '$name'"
|
||||||
docker @arguments | Out-Null
|
docker @arguments | Out-Null
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好:
|
# MoShiro 节点一键安装(Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。
|
||||||
# curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh)
|
# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行:
|
||||||
|
# f=$(mktemp) && curl -fsSL 'https://panel/install' -o "$f" && echo "<sha256> $f" | sha256sum -c - && MOSHIRO_SERVER=… MOSHIRO_ROLE=… MOSHIRO_CODE=… sh "$f"
|
||||||
|
# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。
|
||||||
# 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。
|
# 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。
|
||||||
# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、
|
# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、
|
||||||
# MOSHIRO_ALLOW_UNVERIFIED=1(面板还没有已签名的发布清单时,不验签、按标签安装;默认拒绝)。
|
# MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:你的信任根指纹和这个节点的属主 passkey 凭证 id,节点在注册时验证面板的操作员集合并把它们固定在本地;都是公开值)、
|
||||||
|
# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。
|
||||||
# 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。
|
# 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROLE='@@ROLE@@'
|
SERVER="${MOSHIRO_SERVER:-}"
|
||||||
SERVER='@@SERVER@@'
|
ROLE="${MOSHIRO_ROLE:-}"
|
||||||
CODE='@@CODE@@'
|
CODE="${MOSHIRO_CODE:-}"
|
||||||
IMAGE='@@IMAGE@@'
|
IMAGE="${MOSHIRO_IMAGE:-}"
|
||||||
RELEASE_KEY='@@RELEASE_KEY@@'
|
RELEASE_KEY='-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q
|
||||||
|
YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA==
|
||||||
|
-----END PUBLIC KEY-----'
|
||||||
|
|
||||||
say() { printf '%s\n' "$*"; }
|
say() { printf '%s\n' "$*"; }
|
||||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里
|
||||||
|
case "$ROLE" in agent|probe) ;; *) die "MOSHIRO_ROLE must be agent or probe" ;; esac
|
||||||
|
case "$SERVER" in http://*|https://*) ;; *) die "MOSHIRO_SERVER must be the panel address (http:// or https://)" ;; esac
|
||||||
|
case "$SERVER$CODE" in *[!A-Za-z0-9._:/@~%+\[\]-]*|'') die "MOSHIRO_SERVER or MOSHIRO_CODE has unexpected characters" ;; esac
|
||||||
|
[ -n "$CODE" ] || die "MOSHIRO_CODE is required (copy the whole command from the panel)"
|
||||||
|
|
||||||
name="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}"
|
name="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}"
|
||||||
name="$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)"
|
name="$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)"
|
||||||
[ -n "$name" ] || name=node
|
[ -n "$name" ] || name=node
|
||||||
@@ -51,14 +63,14 @@ fi
|
|||||||
|
|
||||||
# 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装
|
# 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装
|
||||||
resolve_image() {
|
resolve_image() {
|
||||||
if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then return 0; fi
|
if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then IMAGE="${IMAGE:-<the image of the signed release>}"; return 0; fi
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
have_manifest=1
|
have_manifest=1
|
||||||
curl -fsS "$SERVER/install/manifest?code=$CODE" -o "$tmp/manifest" 2>/dev/null </dev/null || have_manifest=0
|
curl -fsS "$SERVER/install/manifest?code=$CODE" -o "$tmp/manifest" 2>/dev/null </dev/null || have_manifest=0
|
||||||
curl -fsS "$SERVER/install/manifest.sig?code=$CODE" -o "$tmp/sig.b64" 2>/dev/null </dev/null || have_manifest=0
|
curl -fsS "$SERVER/install/manifest.sig?code=$CODE" -o "$tmp/sig.b64" 2>/dev/null </dev/null || have_manifest=0
|
||||||
if [ "$have_manifest" -eq 0 ]; then
|
if [ "$have_manifest" -eq 0 ]; then
|
||||||
[ "${MOSHIRO_ALLOW_UNVERIFIED:-}" = 1 ] || die "the panel has no signed release for $ROLE yet, so the image cannot be verified. Run again with MOSHIRO_ALLOW_UNVERIFIED=1 to install $IMAGE without verification"
|
[ "${MOSHIRO_ALLOW_UNVERIFIED:-}" = 1 ] && [ -n "$IMAGE" ] || die "the panel has no signed release for $ROLE yet, so the image cannot be verified. To install anyway set MOSHIRO_ALLOW_UNVERIFIED=1 and MOSHIRO_IMAGE=<image>"
|
||||||
say "warning: installing $IMAGE without signature verification"
|
say "warning: installing $IMAGE without signature verification"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -96,7 +108,12 @@ elif [ "$ROLE" = agent ]; then
|
|||||||
# macOS 上的 Docker 没有宿主网络:把 Agent 的端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
# macOS 上的 Docker 没有宿主网络:把 Agent 的端口发布出来(面板里的地址需要指向这台机器的公网地址)
|
||||||
set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp
|
set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp
|
||||||
fi
|
fi
|
||||||
set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" "$IMAGE"
|
set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name"
|
||||||
|
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:-}"
|
||||||
|
fi
|
||||||
|
set -- "$@" "$IMAGE"
|
||||||
|
|
||||||
if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then
|
if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then
|
||||||
say "$engine $*"
|
say "$engine $*"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ else
|
|||||||
chmod 755 "$STATUS_DIR"
|
chmod 755 "$STATUS_DIR"
|
||||||
fi
|
fi
|
||||||
INSTALLED="$STATE_DIR/$COMPONENT.installed" # 两行:seq、digest
|
INSTALLED="$STATE_DIR/$COMPONENT.installed" # 两行:seq、digest
|
||||||
|
ADOPTED="$STATE_DIR/$COMPONENT.key.pem" # 发布密钥换新之后采用的公钥;有它就只认它,配置里的旧公钥不再用
|
||||||
mkdir -p "$STATE_DIR"
|
mkdir -p "$STATE_DIR"
|
||||||
# Serialize check/apply path events for this component.
|
# Serialize check/apply path events for this component.
|
||||||
exec 9>"$STATE_DIR/$COMPONENT.lock"
|
exec 9>"$STATE_DIR/$COMPONENT.lock"
|
||||||
@@ -169,7 +170,18 @@ verify_offer() {
|
|||||||
[ "$(wc -c < "$OFFER_MANIFEST")" -le 4096 ] || { ERR="manifest too large"; return 1; }
|
[ "$(wc -c < "$OFFER_MANIFEST")" -le 4096 ] || { ERR="manifest too large"; return 1; }
|
||||||
sigbin="$WORK/sig.bin"
|
sigbin="$WORK/sig.bin"
|
||||||
base64 -d "$OFFER_SIG" > "$sigbin" 2>/dev/null || { ERR="bad signature encoding"; return 1; }
|
base64 -d "$OFFER_SIG" > "$sigbin" 2>/dev/null || { ERR="bad signature encoding"; return 1; }
|
||||||
openssl dgst -sha256 -verify "$PUBKEY" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; }
|
key="$PUBKEY"
|
||||||
|
[ -s "$ADOPTED" ] && key="$ADOPTED"
|
||||||
|
openssl dgst -sha256 -verify "$key" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; }
|
||||||
|
# 密钥延续:这份清单由当前密钥签名,里面的 next_key(SPKI DER 的 base64)就是以后要认的密钥;写不成合法的公钥就整份拒绝
|
||||||
|
M_NEXT=""
|
||||||
|
next="$(field next_key)"
|
||||||
|
if [ -n "$next" ]; then
|
||||||
|
printf '%s' "$next" | base64 -d > "$WORK/next.der" 2>/dev/null \
|
||||||
|
&& openssl pkey -pubin -inform DER -in "$WORK/next.der" -outform PEM -out "$WORK/next.pem" >/dev/null 2>&1 \
|
||||||
|
&& [ -s "$WORK/next.pem" ] || { ERR="next_key is not a public key"; return 1; }
|
||||||
|
M_NEXT="$WORK/next.pem"
|
||||||
|
fi
|
||||||
[ "$(field component)" = "$COMPONENT" ] || { ERR="manifest is for another component"; return 1; }
|
[ "$(field component)" = "$COMPONENT" ] || { ERR="manifest is for another component"; return 1; }
|
||||||
[ "$(field image)" = "$IMAGE_REPO" ] || { ERR="manifest names another repository"; return 1; }
|
[ "$(field image)" = "$IMAGE_REPO" ] || { ERR="manifest names another repository"; return 1; }
|
||||||
if [ -n "$CHANNEL" ]; then
|
if [ -n "$CHANNEL" ]; then
|
||||||
@@ -183,6 +195,14 @@ verify_offer() {
|
|||||||
[ "$M_SEQ" -gt "$(installed_seq)" ] || { ERR="stale manifest (seq $M_SEQ <= $(installed_seq))"; return 1; }
|
[ "$M_SEQ" -gt "$(installed_seq)" ] || { ERR="stale manifest (seq $M_SEQ <= $(installed_seq))"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 验过签的清单带了 next_key 就改认它(写临时文件再换名;已经是它就不动)
|
||||||
|
adopt_next_key() {
|
||||||
|
[ -n "$M_NEXT" ] || return 0
|
||||||
|
if [ -s "$ADOPTED" ] && cmp -s "$M_NEXT" "$ADOPTED"; then return 0; fi
|
||||||
|
cp "$M_NEXT" "$ADOPTED.new" && mv -f "$ADOPTED.new" "$ADOPTED"
|
||||||
|
echo "release key replaced by the one in the signed manifest"
|
||||||
|
}
|
||||||
|
|
||||||
available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '\042\134')" "$(printf '%s' "$M_TAG" | tr -d '\042\134')" "$M_DIGEST" "$M_SEQ"; }
|
available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '\042\134')" "$(printf '%s' "$M_TAG" | tr -d '\042\134')" "$M_DIGEST" "$M_SEQ"; }
|
||||||
|
|
||||||
do_check() {
|
do_check() {
|
||||||
@@ -195,6 +215,7 @@ do_check() {
|
|||||||
esac
|
esac
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
adopt_next_key
|
||||||
if [ "$M_DIGEST" = "$(current_digest)" ]; then
|
if [ "$M_DIGEST" = "$(current_digest)" ]; then
|
||||||
write_status up_to_date null ""
|
write_status up_to_date null ""
|
||||||
return 0
|
return 0
|
||||||
@@ -215,6 +236,7 @@ do_apply() {
|
|||||||
fi
|
fi
|
||||||
fetch_offer
|
fetch_offer
|
||||||
verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; }
|
verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; }
|
||||||
|
adopt_next_key
|
||||||
if [ "$COMPONENT" = backend ]; then
|
if [ "$COMPONENT" = backend ]; then
|
||||||
case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac
|
case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac
|
||||||
[ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; }
|
[ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAuth } from './auth'
|
|||||||
import { BackendUpdateNotice } from './BackendUpdateNotice'
|
import { BackendUpdateNotice } from './BackendUpdateNotice'
|
||||||
import { ElevationBar } from './ElevationBar'
|
import { ElevationBar } from './ElevationBar'
|
||||||
import { PasskeyNotice } from './PasskeyNotice'
|
import { PasskeyNotice } from './PasskeyNotice'
|
||||||
|
import { PasskeyOnly } from './PasskeyOnly'
|
||||||
import { AgentDetail } from './pages/AgentDetail'
|
import { AgentDetail } from './pages/AgentDetail'
|
||||||
import { Agents } from './pages/Agents'
|
import { Agents } from './pages/Agents'
|
||||||
import { Probes } from './pages/Probes'
|
import { Probes } from './pages/Probes'
|
||||||
@@ -242,6 +243,8 @@ export function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!me) return <Login />
|
if (!me) return <Login />
|
||||||
|
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||||||
|
if (me.kind === 'session' && me.auth_method === 'password') return <PasskeyOnly />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { KeyRound, LogOut } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '@shared/api/client'
|
||||||
|
import { getBase } from '@shared/api/source'
|
||||||
|
import type { Page, PasskeyDto } from '@shared/api/types'
|
||||||
|
import { passkeySupported } from '@shared/api/webauthn'
|
||||||
|
import { useAuth } from './auth'
|
||||||
|
import { PasskeyAddForm } from './pages/PasskeyAdd'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用密码登录的会话没有任何管理能力,只能做一件事:添加 Passkey。
|
||||||
|
* 之后要用 Passkey 登录才能管理节点、令牌和设置——密码泄露最多只能让人给你的账号加一个 Passkey 的机会(还要你已有的 Passkey 批准第二个)。
|
||||||
|
*/
|
||||||
|
export function PasskeyOnly() {
|
||||||
|
const { me, logout } = useAuth()
|
||||||
|
const [count, setCount] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api
|
||||||
|
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
|
||||||
|
.then((list) => setCount(list.items.length))
|
||||||
|
.catch(() => setCount(null))
|
||||||
|
}
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
const remote = !!getBase()
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||||
|
<div>
|
||||||
|
<h1>MoShiro</h1>
|
||||||
|
<p className="muted">
|
||||||
|
{me?.username},你是用密码登录的。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="notice">用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。</div>
|
||||||
|
{remote ? (
|
||||||
|
<div className="notice warn">这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。</div>
|
||||||
|
) : !passkeySupported() ? (
|
||||||
|
<div className="notice warn">这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。</div>
|
||||||
|
) : count === 0 || count === null ? (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
<b>添加你的第一个 Passkey</b>
|
||||||
|
</div>
|
||||||
|
<PasskeyAddForm compact onAdded={load} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="notice">你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button type="button" className="btn primary" onClick={() => void logout()}>
|
||||||
|
<LogOut size={16} />
|
||||||
|
退出,用 Passkey 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { fmtAgo, fmtTime } from '@shared/format'
|
|||||||
import { useQuery } from '@shared/hooks'
|
import { useQuery } from '@shared/hooks'
|
||||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||||
import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||||
|
import { OwnerPicker } from './OwnerPicker'
|
||||||
import { JoinTokenCreated } from './JoinTokenCreated'
|
import { JoinTokenCreated } from './JoinTokenCreated'
|
||||||
|
|
||||||
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
|
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
|
||||||
@@ -33,6 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
const [maxUses, setMaxUses] = useState(1)
|
const [maxUses, setMaxUses] = useState(1)
|
||||||
const [hours, setHours] = useState(24)
|
const [hours, setHours] = useState(24)
|
||||||
const [created, setCreated] = useState<CreatedJoinTokenDto>()
|
const [created, setCreated] = useState<CreatedJoinTokenDto>()
|
||||||
|
const [controllers, setControllers] = useState<string[] | null>(null)
|
||||||
|
|
||||||
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
|
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
auto_approve: autoApprove,
|
auto_approve: autoApprove,
|
||||||
max_uses: maxUses,
|
max_uses: maxUses,
|
||||||
expires_in_hours: hours,
|
expires_in_hours: hours,
|
||||||
|
controllers: controllers ?? undefined,
|
||||||
})
|
})
|
||||||
setCreated(token)
|
setCreated(token)
|
||||||
onCreated()
|
onCreated()
|
||||||
@@ -69,7 +72,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ marginBottom: 16 }}>
|
<p className="muted" style={{ marginBottom: 16 }}>
|
||||||
添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。
|
添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。
|
||||||
</p>
|
</p>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="wide">
|
<div className="wide">
|
||||||
@@ -83,11 +86,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="可用次数">
|
<Field label="可用次数">
|
||||||
<input className="input" type="number" min={1} max={100} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
|
<input className="input" type="number" min={1} max={100000} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="有效期(小时)">
|
<Field label="有效期(小时)">
|
||||||
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<div className="wide">
|
||||||
|
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
|
||||||
|
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
<div className="wide">
|
<div className="wide">
|
||||||
<label className="switch">
|
<label className="switch">
|
||||||
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
|
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog:
|
|||||||
const allOn = all.length > 0 && all.every((id) => value.has(id))
|
const allOn = all.length > 0 && all.every((id) => value.has(id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack" style={{ gap: 10 }}>
|
<div>
|
||||||
|
<div className="cap-toolbar stack" style={{ gap: 8 }}>
|
||||||
<div className="row wrap" style={{ gap: 6 }}>
|
<div className="row wrap" style={{ gap: 6 }}>
|
||||||
<span className="muted">预设:</span>
|
<span className="muted">预设:</span>
|
||||||
{catalog.presets.map((p) => (
|
{catalog.presets.map((p) => (
|
||||||
@@ -113,22 +114,24 @@ export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog:
|
|||||||
{PRESETS[p.id] ?? p.id}
|
{PRESETS[p.id] ?? p.id}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<input className="input" style={{ maxWidth: 200, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
|
<input className="input" style={{ maxWidth: 220, marginLeft: 'auto' }} placeholder="搜索能力" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="row wrap" style={{ gap: 12 }}>
|
<div className="row wrap" style={{ gap: 12 }}>
|
||||||
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
|
<Check checked={allOn} partial={value.size > 0} onChange={(on) => set(on ? all : [])} label={<b>全选</b>} />
|
||||||
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
|
<button type="button" className="btn small text" onClick={() => set(all.filter((id) => !value.has(id)))}>
|
||||||
反选
|
反选
|
||||||
</button>
|
</button>
|
||||||
<span className="muted">已选 {value.size} 项</span>
|
<span className="muted">已选 {value.size} / {all.length} 项</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cap-grid">
|
||||||
{catalog.groups.map((g) => {
|
{catalog.groups.map((g) => {
|
||||||
const rows = g.capabilities.filter((c) => visible(c.id))
|
const rows = g.capabilities.filter((c) => visible(c.id))
|
||||||
if (rows.length === 0) return null
|
if (rows.length === 0) return null
|
||||||
const own = g.capabilities.filter((c) => selectable(c.id))
|
const own = g.capabilities.filter((c) => selectable(c.id))
|
||||||
const on = own.filter((c) => value.has(c.id))
|
const on = own.filter((c) => value.has(c.id))
|
||||||
return (
|
return (
|
||||||
<div key={g.id} className="card" style={{ padding: 10 }}>
|
<div key={g.id} className="cap-group">
|
||||||
<Check
|
<Check
|
||||||
checked={own.length > 0 && on.length === own.length}
|
checked={own.length > 0 && on.length === own.length}
|
||||||
partial={on.length > 0}
|
partial={on.length > 0}
|
||||||
@@ -136,29 +139,22 @@ export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog:
|
|||||||
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
|
onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))}
|
||||||
label={<b>{GROUPS[g.id] ?? g.id}</b>}
|
label={<b>{GROUPS[g.id] ?? g.id}</b>}
|
||||||
/>
|
/>
|
||||||
<div className="stack" style={{ gap: 4, marginTop: 6, paddingLeft: 22 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
{rows.map((c) => (
|
{rows.map((c) => (
|
||||||
<div key={c.id} className="row wrap" style={{ gap: 8 }}>
|
<div key={c.id} className="cap-row">
|
||||||
<Check
|
<div className="row" style={{ gap: 8 }}>
|
||||||
checked={value.has(c.id)}
|
<Check checked={value.has(c.id)} disabled={!selectable(c.id)} onChange={(next) => toggle(c.id, next)} label={NAMES[c.id]?.[0] ?? c.id} />
|
||||||
disabled={!selectable(c.id)}
|
|
||||||
onChange={(next) => toggle(c.id, next)}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
{NAMES[c.id]?.[0] ?? c.id}
|
|
||||||
<span className="mono faint" style={{ marginLeft: 6 }}>
|
|
||||||
{c.id}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{c.trust ? (
|
{c.trust ? (
|
||||||
<Chip tone="warn">
|
<Chip tone="warn">
|
||||||
<KeyRound size={12} /> 信任类
|
<KeyRound size={12} /> 信任类
|
||||||
</Chip>
|
</Chip>
|
||||||
) : null}
|
) : null}
|
||||||
{c.reserved ? <Chip>预留</Chip> : null}
|
{c.reserved ? <Chip>预留</Chip> : null}
|
||||||
{NAMES[c.id]?.[1] ? <span className="faint">{NAMES[c.id]![1]}</span> : null}
|
</div>
|
||||||
|
<div className="cap-meta faint">
|
||||||
|
<span className="mono">{c.id}</span>
|
||||||
|
{NAMES[c.id]?.[1] ?? ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +162,7 @@ export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog:
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CreatedJoinTokenDto } from '@shared/api/types'
|
import type { CreatedJoinTokenDto } from '@shared/api/types'
|
||||||
import { fmtTime } from '@shared/format'
|
import { fmtTime } from '@shared/format'
|
||||||
import { CommandTabs, CopyButton, Dialog, Field, guessPlatform } from '@shared/ui'
|
import { CommandTabs, Dialog, Field, guessPlatform } from '@shared/ui'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份,一行命令装好。
|
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份,一行命令装好。
|
||||||
@@ -19,10 +19,10 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。</div>
|
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
|
||||||
<Field
|
<Field
|
||||||
label={`在要加入的机器上运行(${kind})`}
|
label={`在要加入的机器上运行(${kind})`}
|
||||||
hint="需要 Docker 或 Podman。命令里已经填好面板地址和一次性代码,节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
|
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
|
||||||
>
|
>
|
||||||
<CommandTabs
|
<CommandTabs
|
||||||
storageKey="moshiro.install_tab"
|
storageKey="moshiro.install_tab"
|
||||||
@@ -34,12 +34,6 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="代码">
|
|
||||||
<pre className="code">{created.plaintext}</pre>
|
|
||||||
<div>
|
|
||||||
<CopyButton text={created.plaintext} label="复制代码" />
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
||||||
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
|
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '@shared/api/client'
|
||||||
|
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选这个节点的属主:允许授权它 extension 会话(web ssh 等,以后)的 passkey。只是选,不需要触碰 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
|
||||||
|
api
|
||||||
|
.get<TrustStateDto>('/api/admin/trust')
|
||||||
|
.then((t) => {
|
||||||
|
if (!alive) return
|
||||||
|
setTrust(t)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!trust) return null
|
||||||
|
if (!trust.initialized) {
|
||||||
|
return <div className="notice warn">还没有建立信任根:这个节点装上后不会固定属主,也不会校验面板的操作员集合。先到「系统 → 信任」用 Passkey 建立。</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = new Set(value ?? [])
|
||||||
|
const name = (id: string) => own.find((p) => p.id === id)?.name ?? `其他账号的 Passkey(${id.slice(0, 8)}…)`
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 4 }}>
|
||||||
|
{(trust.operators ?? []).map((id) => (
|
||||||
|
<label key={id} className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chosen.has(id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(chosen)
|
||||||
|
if (e.target.checked) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
onChange([...next])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{name(id)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { NodeTag, useNodeInfos } from '@shared/nodes'
|
|||||||
import { IPV6_STATES } from '@shared/ipVersions'
|
import { IPV6_STATES } from '@shared/ipVersions'
|
||||||
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
||||||
import { JoinTokenCreated } from './JoinTokenCreated'
|
import { JoinTokenCreated } from './JoinTokenCreated'
|
||||||
|
import { OwnerPicker } from './OwnerPicker'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
|
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
|
||||||
@@ -96,6 +97,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
const [hours, setHours] = useState(24)
|
const [hours, setHours] = useState(24)
|
||||||
const [created, setCreated] = useState<CreatedJoinTokenDto>()
|
const [created, setCreated] = useState<CreatedJoinTokenDto>()
|
||||||
|
const [controllers, setControllers] = useState<string[] | null>(null)
|
||||||
|
|
||||||
if (created) return <JoinTokenCreated created={created} kind="Probe" onClose={onClose} />
|
if (created) return <JoinTokenCreated created={created} kind="Probe" onClose={onClose} />
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
auto_approve: true,
|
auto_approve: true,
|
||||||
max_uses: 1,
|
max_uses: 1,
|
||||||
expires_in_hours: hours,
|
expires_in_hours: hours,
|
||||||
|
controllers: controllers ?? undefined,
|
||||||
})
|
})
|
||||||
setCreated(token)
|
setCreated(token)
|
||||||
onCreated()
|
onCreated()
|
||||||
@@ -143,6 +146,11 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
<Field label="有效期(小时)">
|
<Field label="有效期(小时)">
|
||||||
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<div className="wide">
|
||||||
|
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
|
||||||
|
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { useQuery } from '@shared/hooks'
|
|||||||
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||||
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
|
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
|
||||||
import { PasskeyAddForm } from './PasskeyAdd'
|
import { PasskeyAddForm } from './PasskeyAdd'
|
||||||
|
import { Trust } from './Trust'
|
||||||
|
|
||||||
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'audit' | 'events' | 'raw' | 'analysis'
|
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | 'events' | 'raw' | 'analysis'
|
||||||
|
|
||||||
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
|
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
|
||||||
|
|
||||||
@@ -406,6 +407,7 @@ function Tokens() {
|
|||||||
{creating ? (
|
{creating ? (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="创建 API 令牌"
|
title="创建 API 令牌"
|
||||||
|
wide
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -832,6 +834,7 @@ export function System() {
|
|||||||
{ value: 'versions' as const, label: '版本' },
|
{ value: 'versions' as const, label: '版本' },
|
||||||
{ value: 'tokens' as const, label: 'API 令牌' },
|
{ value: 'tokens' as const, label: 'API 令牌' },
|
||||||
{ value: 'users' as const, label: '用户' },
|
{ value: 'users' as const, label: '用户' },
|
||||||
|
{ value: 'trust' as const, label: '信任' },
|
||||||
{ value: 'audit' as const, label: '审计' },
|
{ value: 'audit' as const, label: '审计' },
|
||||||
{ value: 'events' as const, label: '事件' },
|
{ value: 'events' as const, label: '事件' },
|
||||||
{ value: 'raw' as const, label: '原始数据' },
|
{ value: 'raw' as const, label: '原始数据' },
|
||||||
@@ -840,7 +843,7 @@ export function System() {
|
|||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'trust' ? <Trust /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ShieldCheck } from 'lucide-react'
|
||||||
|
import { api } from '@shared/api/client'
|
||||||
|
import type { TrustStateDto } from '@shared/api/types'
|
||||||
|
import { signStatement } from '@shared/api/webauthn'
|
||||||
|
import { useQuery } from '@shared/hooks'
|
||||||
|
import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
|
||||||
|
* 操作员集合,面板自己改不了,也签不出新的版本。
|
||||||
|
*/
|
||||||
|
export function Trust() {
|
||||||
|
const trust = useQuery(() => api.get<TrustStateDto>('/api/admin/trust'), [])
|
||||||
|
const { busy, run } = useAction()
|
||||||
|
const toast = useToast()
|
||||||
|
const state = trust.data
|
||||||
|
|
||||||
|
const sign = async () => {
|
||||||
|
if (!state?.proposal) return
|
||||||
|
const proposal = state.proposal
|
||||||
|
const ok = await run(async () => {
|
||||||
|
const signed = await signStatement(proposal.statement)
|
||||||
|
await api.post('/api/admin/trust', signed)
|
||||||
|
})
|
||||||
|
if (ok) {
|
||||||
|
toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 和 Probe 了。', false, { title: '已用 Passkey 签名', ms: 8000 })
|
||||||
|
trust.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<ErrorNotice error={trust.error} />
|
||||||
|
{!state ? (
|
||||||
|
<Spinner />
|
||||||
|
) : !state.initialized ? (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<b>还没有建立信任根</b>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
|
||||||
|
这一步会把账号下已有的 Passkey 登记为操作员,并需要你触碰两次 Passkey(确认身份、给集合签名)。
|
||||||
|
</p>
|
||||||
|
{state.proposal ? (
|
||||||
|
<div>
|
||||||
|
<button type="button" className="btn primary" disabled={busy} onClick={() => void sign()}>
|
||||||
|
用 Passkey 建立信任根
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="notice warn">先在「用户 → 我的 Passkey」添加一个 Passkey,才能建立信任根。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<b>信任根已建立</b>
|
||||||
|
<span className="muted">操作员集合第 {state.version} 版</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ marginBottom: 4 }}>
|
||||||
|
创世指纹(安装命令里的 MOSHIRO_GENESIS,节点把它固定下来)
|
||||||
|
</div>
|
||||||
|
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{state.genesis}
|
||||||
|
</pre>
|
||||||
|
<CopyButton text={state.genesis ?? ''} label="复制指纹" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ marginBottom: 4 }}>
|
||||||
|
服务器身份密钥(面板用它向节点证明自己;登记进操作员集合之后,节点只认带这把密钥签名的服务器,链路用应用层加密)
|
||||||
|
{state.server_key_listed ? ':已登记' : ':还没有登记,需要签名更新操作员集合'}
|
||||||
|
</div>
|
||||||
|
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{state.server_key}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ marginBottom: 4 }}>
|
||||||
|
安装脚本的 SHA-256(命令会先核对它再执行){state.install_matches_release === true ? ':与 CI 签名的发布清单一致' : state.install_matches_release === false ? ':与 CI 签名的发布清单不一致' : ':发布清单里还没有记录'}
|
||||||
|
</div>
|
||||||
|
{state.install_matches_release === false ? <div className="notice bad">这个面板提供的安装脚本和 CI 构建时记下的不一样,先不要用它装节点,去发布页核对。</div> : null}
|
||||||
|
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>install.sh {state.install_sh_sha256}</pre>
|
||||||
|
<pre className="code" style={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>install.ps1 {state.install_ps1_sha256}</pre>
|
||||||
|
</div>
|
||||||
|
{state.in_sync ? (
|
||||||
|
<p className="muted">操作员集合和账号下的 Passkey 一致。</p>
|
||||||
|
) : (
|
||||||
|
<div className="notice warn stack">
|
||||||
|
<div>
|
||||||
|
账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把<b>现有操作员的 Passkey</b>签一个新版本,节点才会采用。
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>注意</b>:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。
|
||||||
|
</div>
|
||||||
|
{state.proposal ? (
|
||||||
|
<div>
|
||||||
|
<button type="button" className="btn primary" disabled={busy} onClick={() => void sign()}>
|
||||||
|
签名更新到第 {state.proposal.version} 版
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+270
-14
@@ -867,6 +867,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/trust": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign */
|
||||||
|
get: operations["trust_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window) */
|
||||||
|
post: operations["trust_commit"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/api-tokens": {
|
"/api/admin/api-tokens": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1116,7 +1134,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** One-line installer for Linux and macOS nodes (agent or probe, decided by the code) */
|
/** The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release) */
|
||||||
get: operations["install_sh"];
|
get: operations["install_sh"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
@@ -1126,6 +1144,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/install/manifest": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** The signed release manifest of the component an install code is for (exact signed bytes) */
|
||||||
|
get: operations["install_manifest"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/install/manifest.sig": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Base64 DER ECDSA P-256 signature of the manifest */
|
||||||
|
get: operations["install_manifest_signature"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/install.ps1": {
|
"/install.ps1": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1133,7 +1185,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** One-line installer for Windows nodes (agent or probe, decided by the code) */
|
/** The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release) */
|
||||||
get: operations["install_ps1"];
|
get: operations["install_ps1"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
@@ -1160,6 +1212,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/agent/v1/enroll/trust": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** The operator set chain and the grant of a join token, for the node to verify before it enrolls */
|
||||||
|
post: operations["fleet_enroll_trust"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1349,6 +1418,7 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
expires_in_hours: null | number | string;
|
expires_in_hours: null | number | string;
|
||||||
role?: null | string;
|
role?: null | string;
|
||||||
|
controllers?: null | string[];
|
||||||
};
|
};
|
||||||
CreateTaskRequest: {
|
CreateTaskRequest: {
|
||||||
scope: string;
|
scope: string;
|
||||||
@@ -1619,6 +1689,7 @@ export interface components {
|
|||||||
user_id: null | string;
|
user_id: null | string;
|
||||||
username: null | string;
|
username: null | string;
|
||||||
csrf_token: null | string;
|
csrf_token: null | string;
|
||||||
|
auth_method?: null | string;
|
||||||
};
|
};
|
||||||
MetricDto: {
|
MetricDto: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1999,6 +2070,13 @@ export interface components {
|
|||||||
default: string;
|
default: string;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
SignedStatementDto: {
|
||||||
|
statement?: string;
|
||||||
|
credential_id?: string;
|
||||||
|
authenticator_data?: string;
|
||||||
|
client_data_json?: string;
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
StartTestRequestDto: {
|
StartTestRequestDto: {
|
||||||
plan: string;
|
plan: string;
|
||||||
params: null | Record<string, never>;
|
params: null | Record<string, never>;
|
||||||
@@ -2085,6 +2163,32 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
finished_at: unknown;
|
finished_at: unknown;
|
||||||
};
|
};
|
||||||
|
TrustProposalDto: {
|
||||||
|
statement: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
version: number | string;
|
||||||
|
challenge: string;
|
||||||
|
};
|
||||||
|
TrustStateDto: {
|
||||||
|
initialized: boolean;
|
||||||
|
genesis: null | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
version: number | string;
|
||||||
|
in_sync: boolean;
|
||||||
|
proposal: null | components["schemas"]["TrustProposalDto"];
|
||||||
|
/** @default */
|
||||||
|
install_url: string;
|
||||||
|
operators?: null | string[];
|
||||||
|
/** @default */
|
||||||
|
install_sh_sha256: string;
|
||||||
|
/** @default */
|
||||||
|
install_ps1_sha256: string;
|
||||||
|
install_matches_release?: null | boolean;
|
||||||
|
/** @default */
|
||||||
|
server_key: string;
|
||||||
|
/** @default false */
|
||||||
|
server_key_listed: boolean;
|
||||||
|
};
|
||||||
UserDto: {
|
UserDto: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -5103,6 +5207,104 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
trust_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TrustStateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
trust_commit: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SignedStatementDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TrustStateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_tokens_list: {
|
api_tokens_list: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5899,6 +6101,53 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
install_sh: {
|
install_sh: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
install_manifest: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
install_manifest_signature: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -5929,9 +6178,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
install_ps1: {
|
install_ps1: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: never;
|
||||||
code?: string;
|
|
||||||
};
|
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -5945,15 +6192,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
fleet_enroll: {
|
fleet_enroll: {
|
||||||
@@ -5974,4 +6212,22 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
fleet_enroll_trust: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,8 @@ export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
|||||||
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
||||||
export type PasskeyDto = Narrow<S['PasskeyDto']>
|
export type PasskeyDto = Narrow<S['PasskeyDto']>
|
||||||
export type ElevationDto = Narrow<S['ElevationDto']>
|
export type ElevationDto = Narrow<S['ElevationDto']>
|
||||||
|
export type TrustStateDto = Narrow<S['TrustStateDto']>
|
||||||
|
export type SignedStatementDto = Narrow<S['SignedStatementDto']>
|
||||||
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
||||||
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
|
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
|
||||||
export type MeDto = Narrow<S['MeDto']>
|
export type MeDto = Narrow<S['MeDto']>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
|
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types'
|
||||||
|
|
||||||
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
|
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
|
||||||
export async function conditionalSupported(): Promise<boolean> {
|
export async function conditionalSupported(): Promise<boolean> {
|
||||||
@@ -58,6 +58,30 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 passkey 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。
|
||||||
|
* 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。
|
||||||
|
*/
|
||||||
|
export async function signStatement(statement: string): Promise<SignedStatementDto> {
|
||||||
|
const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement)))
|
||||||
|
let credential: PublicKeyCredential | null
|
||||||
|
try {
|
||||||
|
credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
if (!credential) throw new Error('没有选择 passkey')
|
||||||
|
const response = credential.response as AuthenticatorAssertionResponse
|
||||||
|
return {
|
||||||
|
statement,
|
||||||
|
credential_id: credential.id,
|
||||||
|
authenticator_data: toText(response.authenticatorData),
|
||||||
|
client_data_json: toText(response.clientDataJSON),
|
||||||
|
signature: toText(response.signature),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
|
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
|
||||||
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
|
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
|
||||||
|
|
||||||
|
|||||||
@@ -478,6 +478,67 @@ textarea {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 宽对话框:标题和按钮固定,中间滚动(内容很长的表单,比如选能力) */
|
||||||
|
.dialog.wide {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.wide .dialog-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.wide .actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 能力选择器:工具栏吸顶,分组排成多列,每项两行(名称,代码和说明) */
|
||||||
|
.cap-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--app-card);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-group {
|
||||||
|
border: 1px solid var(--app-outline-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-row {
|
||||||
|
padding: 5px 0 5px 22px;
|
||||||
|
border-top: 1px solid var(--app-outline-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-meta {
|
||||||
|
margin: 1px 0 0 26px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-meta .mono {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog .actions {
|
.dialog .actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function Field({ label, hint, children }: { label: string; hint?: string;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
|
export function Dialog({ title, onClose, children, actions, wide }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode; wide?: boolean }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -69,9 +69,9 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o
|
|||||||
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div className={`dialog${wide ? ' wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
{children}
|
{wide ? <div className="dialog-body">{children}</div> : children}
|
||||||
{actions ? <div className="actions">{actions}</div> : null}
|
{actions ? <div className="actions">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
Reference in new issue
Block a user