feat!: probes generate their own keys and enroll with a join token; node and peer keys are separate [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
1ff847e45a
commit
a9f562441f
51 files changed
+505
-749
No files matched your search
@@ -2,7 +2,8 @@ using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
||||
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
|
||||
|
||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||
@@ -12,6 +13,14 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
{
|
||||
public const string AgentRole = "agent";
|
||||
public const string ProbeRole = "probe";
|
||||
|
||||
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
|
||||
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
|
||||
|
||||
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
|
||||
|
||||
private readonly IJoinTokenStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
@@ -26,6 +35,11 @@ public sealed class JoinTokenService
|
||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string>();
|
||||
if (request.Role is not (AgentRole or ProbeRole))
|
||||
{
|
||||
errors["role"] = "must be agent or probe";
|
||||
}
|
||||
|
||||
if (request.MaxUses is < 1 or > 1000)
|
||||
{
|
||||
errors["max_uses"] = "must be between 1 and 1000";
|
||||
@@ -54,13 +68,18 @@ public sealed class JoinTokenService
|
||||
string plaintext = TokenHash.NewToken("jt");
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
JoinTokenInfo info = await _store.CreateAsync(
|
||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove,
|
||||
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, ct);
|
||||
|
||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh
|
||||
string shell = request.Role == ProbeRole
|
||||
? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name <node-name>"
|
||||
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
||||
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
||||
string powershell = request.Role == ProbeRole
|
||||
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
|
||||
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
||||
|
||||
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
|
||||
{
|
||||
if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
// Probe:新的对等面公钥要进每个 Agent 的名单,Probe 自己也要拿到 Agent 列表
|
||||
await _service.AfterAgentSetChangedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
AgentEnrollMeta meta = new();
|
||||
if (context.ProductMeta is { } raw)
|
||||
{
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
|
||||
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
|
||||
|
||||
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥;<c>Server</c> 是 Probe 要连的面板地址。</summary>
|
||||
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits);
|
||||
|
||||
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
||||
|
||||
@@ -21,8 +14,9 @@ public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<Pro
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
|
||||
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。
|
||||
/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。
|
||||
/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。
|
||||
/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。
|
||||
/// 一条线路是一对(Probe,Agent)。
|
||||
/// </summary>
|
||||
public sealed partial class ProbeService
|
||||
@@ -46,26 +40,6 @@ public sealed partial class ProbeService
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, string server, CancellationToken ct)
|
||||
{
|
||||
string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim();
|
||||
if (!NamePattern().IsMatch(finalName))
|
||||
{
|
||||
return ValidationError.Fail<ProbeIdentityFile>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.NameConflict, $"a probe named {finalName} already exists");
|
||||
}
|
||||
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
|
||||
await _probes.SaveAsync(probe, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
return Result<ProbeIdentityFile>.Ok(BuildFile(probe, identity, server));
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||
@@ -98,27 +72,6 @@ public sealed partial class ProbeService
|
||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||
}
|
||||
|
||||
/// <summary>换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。</summary>
|
||||
public async ValueTask<Result<ProbeIdentityFile>> RotateAsync(string id, string server, CancellationToken ct)
|
||||
{
|
||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
||||
}
|
||||
|
||||
if (probe.RevokedAtMs is not null)
|
||||
{
|
||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead");
|
||||
}
|
||||
|
||||
using NodeIdentity identity = NodeIdentity.Generate();
|
||||
ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki };
|
||||
await _probes.SaveAsync(updated, ct);
|
||||
_gateway.NotifyAllTaskSetsChanged();
|
||||
await DisconnectEverywhereAsync(id, ct);
|
||||
return Result<ProbeIdentityFile>.Ok(BuildFile(updated, identity, server));
|
||||
}
|
||||
|
||||
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
|
||||
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
|
||||
{
|
||||
@@ -164,14 +117,6 @@ public sealed partial class ProbeService
|
||||
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
|
||||
}
|
||||
|
||||
private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct)
|
||||
{
|
||||
foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId))
|
||||
{
|
||||
await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
|
||||
{
|
||||
var connections = new List<ProbeConnection>();
|
||||
@@ -187,9 +132,6 @@ public sealed partial class ProbeService
|
||||
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private static ProbeIdentityFile BuildFile(ProbeRecord probe, NodeIdentity identity, string server) =>
|
||||
new(new ProbeId(probe.Id), Convert.ToBase64String(identity.ExportPkcs8()), server, new ProbeLimits(MaxTestMbps: 100, MaxTotalPps: 20_000));
|
||||
|
||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||
private static partial Regex NamePattern();
|
||||
}
|
||||
@@ -64,6 +64,11 @@ public sealed record JoinTokenInfo(
|
||||
int Uses,
|
||||
long ExpiresAtMs,
|
||||
long? RevokedAtMs,
|
||||
long CreatedAtMs);
|
||||
long CreatedAtMs,
|
||||
string Kind = "");
|
||||
|
||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null);
|
||||
/// <summary>
|
||||
/// <see cref="PublicKey"/> 是连 Backend 的身份公钥,<see cref="PeerPublicKey"/> 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥;
|
||||
/// Agent 的 <c>peers</c> 里放的是后者。
|
||||
/// </summary>
|
||||
public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null);
|
||||
@@ -51,7 +51,7 @@ public interface IGeoLookup
|
||||
|
||||
public interface IJoinTokenStore
|
||||
{
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
|
||||
ValueTask<JoinTokenInfo> CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList<string> labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct);
|
||||
|
||||
ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct);
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ public sealed class TaskPlanner : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && 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.PublicKey) });
|
||||
document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) });
|
||||
}
|
||||
|
||||
string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet)));
|
||||
|
||||
Reference in new issue
Block a user